refactor(tdd): split oversized first-party files with part-based modules and LOC guard

This commit is contained in:
Haitao Pan 2026-03-28 10:30:45 +08:00
parent 5426505cda
commit bec99b6d5d
56 changed files with 46047 additions and 45908 deletions

View File

@ -11,8 +11,6 @@ import (
"io"
"net/http"
"os"
"os/exec"
"sort"
"strings"
"sync"
"time"
@ -784,473 +782,3 @@ func (s *acpServer) closeSession(sessionID string) bool {
}
return true
}
func detectACPProviders() []string {
candidates := []struct {
provider string
envKey string
binary string
}{
{provider: "codex", envKey: "ACP_CODEX_BIN", binary: "codex"},
{provider: "opencode", envKey: "ACP_OPENCODE_BIN", binary: "opencode"},
{provider: "claude", envKey: "ACP_CLAUDE_BIN", binary: "claude"},
{provider: "gemini", envKey: "ACP_GEMINI_BIN", binary: "gemini"},
}
providers := make([]string, 0, len(candidates))
for _, candidate := range candidates {
binary := strings.TrimSpace(envOrDefault(candidate.envKey, candidate.binary))
if binary == "" {
continue
}
if _, err := exec.LookPath(binary); err == nil {
providers = append(providers, candidate.provider)
}
}
sort.Strings(providers)
return providers
}
func runProviderCommand(
ctx context.Context,
provider,
model,
prompt,
workingDirectory string,
) (string, error) {
command, args := resolveProviderCommand(provider, model, prompt, workingDirectory)
if command == "" {
return "", fmt.Errorf("unsupported provider: %s", provider)
}
cmd := exec.CommandContext(ctx, command, args...)
if strings.TrimSpace(workingDirectory) != "" {
cmd.Dir = strings.TrimSpace(workingDirectory)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.Canceled) {
return "", errors.New("run canceled")
}
message := strings.TrimSpace(stderr.String())
if message == "" {
message = err.Error()
}
return "", fmt.Errorf("%s run failed: %s", provider, message)
}
output := strings.TrimSpace(stdout.String())
if output == "" {
output = strings.TrimSpace(stderr.String())
}
if output == "" {
return "", fmt.Errorf("%s returned empty output", provider)
}
return output, nil
}
func resolveProviderCommand(provider, model, prompt, cwd string) (string, []string) {
switch strings.TrimSpace(strings.ToLower(provider)) {
case "codex":
binary := strings.TrimSpace(envOrDefault("ACP_CODEX_BIN", "codex"))
args := []string{"exec", "--skip-git-repo-check", "--color", "never"}
if strings.TrimSpace(cwd) != "" {
args = append(args, "-C", strings.TrimSpace(cwd))
}
if strings.TrimSpace(model) != "" {
args = append(args, "-m", strings.TrimSpace(model))
}
args = append(args, prompt)
return binary, args
case "opencode":
binary := strings.TrimSpace(envOrDefault("ACP_OPENCODE_BIN", "opencode"))
args := []string{"run", "--format", "default"}
if strings.TrimSpace(cwd) != "" {
args = append(args, "--dir", strings.TrimSpace(cwd))
}
if strings.TrimSpace(model) != "" {
args = append(args, "-m", strings.TrimSpace(model))
}
args = append(args, prompt)
return binary, args
case "claude":
binary := strings.TrimSpace(envOrDefault("ACP_CLAUDE_BIN", "claude"))
if strings.TrimSpace(model) == "" {
return binary, []string{"-p", prompt}
}
return binary, []string{"--model", strings.TrimSpace(model), "-p", prompt}
case "gemini":
binary := strings.TrimSpace(envOrDefault("ACP_GEMINI_BIN", "gemini"))
if strings.TrimSpace(model) == "" {
return binary, []string{"-p", prompt}
}
return binary, []string{"--model", strings.TrimSpace(model), "-p", prompt}
default:
return "", nil
}
}
func augmentPromptWithAttachments(prompt string, params map[string]any) string {
attachmentsRaw := listArg(params, "attachments")
if len(attachmentsRaw) == 0 {
return prompt
}
lines := make([]string, 0, len(attachmentsRaw))
for _, raw := range attachmentsRaw {
entry, ok := raw.(map[string]any)
if !ok {
continue
}
name := strings.TrimSpace(stringArg(entry, "name", "attachment"))
path := strings.TrimSpace(stringArg(entry, "path", ""))
if path == "" {
continue
}
lines = append(lines, fmt.Sprintf("- %s: %s", name, path))
}
if len(lines) == 0 {
return prompt
}
var builder strings.Builder
builder.WriteString("User-selected local attachments:\n")
builder.WriteString(strings.Join(lines, "\n"))
builder.WriteString("\n\n")
builder.WriteString(prompt)
return builder.String()
}
func composeHistoryPrompt(history []string) string {
if len(history) == 0 {
return ""
}
var builder strings.Builder
for index, turn := range history {
builder.WriteString(fmt.Sprintf("## User Turn %d\n", index+1))
builder.WriteString(turn)
builder.WriteString("\n\n")
}
return strings.TrimSpace(builder.String())
}
func callOpenAICompatibleCtx(
ctx context.Context,
baseURL,
apiKey,
model string,
messages []map[string]string,
) (string, error) {
payload := map[string]any{
"model": model,
"messages": messages,
"max_tokens": 4096,
"stream": false,
}
body, _ := json.Marshal(payload)
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
strings.TrimRight(baseURL, "/")+"/chat/completions",
bytes.NewReader(body),
)
if err != nil {
return "", err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{Timeout: 120 * time.Second}
response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return "", fmt.Errorf("api error %d: %s", response.StatusCode, strings.TrimSpace(string(responseBody)))
}
var decoded map[string]any
if err := json.Unmarshal(responseBody, &decoded); err != nil {
return "", err
}
choices, _ := decoded["choices"].([]any)
if len(choices) == 0 {
return "", errors.New("missing choices in response")
}
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
content := strings.TrimSpace(fmt.Sprint(message["content"]))
if content == "" || content == "<nil>" {
return "", errors.New("empty response content")
}
return content, nil
}
func decodeRpcRequest(payload []byte) (rpcRequest, error) {
var request rpcRequest
if err := json.Unmarshal(payload, &request); err != nil {
return rpcRequest{}, fmt.Errorf("invalid json: %w", err)
}
if strings.TrimSpace(request.Method) == "" {
return rpcRequest{}, errors.New("missing method")
}
if request.Params == nil {
request.Params = map[string]any{}
}
return request, nil
}
func writeSSE(w http.ResponseWriter, payload map[string]any) {
encoded, _ := json.Marshal(payload)
_, _ = fmt.Fprintf(w, "data: %s\n\n", encoded)
}
func resultEnvelope(id any, result map[string]any) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": result,
}
}
func errorEnvelope(id any, code int, message string) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{
"code": code,
"message": message,
},
}
}
func notificationEnvelope(method string, params map[string]any) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"method": method,
"params": params,
}
}
func errorResponse(id any, code int, message string) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{
"code": code,
"message": message,
},
}
}
func toolTextResult(id any, content string) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": map[string]any{
"content": []map[string]any{
{"type": "text", "text": content},
},
},
}
}
func toolErrorResult(id any, err error) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": map[string]any{
"content": []map[string]any{
{"type": "text", "text": fmt.Sprintf("Error: %v", err)},
},
"isError": true,
},
}
}
func handleChatTool(arguments map[string]any) (string, error) {
apiKey := strings.TrimSpace(envOrDefault("LLM_API_KEY", ""))
if apiKey == "" {
return "", errors.New("LLM_API_KEY environment variable not set")
}
baseURL := normalizeBaseURL(envOrDefault("LLM_BASE_URL", "https://api.openai.com/v1"))
model := stringArg(arguments, "model", envOrDefault("LLM_MODEL", "gpt-4o"))
prompt := strings.TrimSpace(stringArg(arguments, "prompt", ""))
if prompt == "" {
return "", errors.New("prompt is required")
}
system := strings.TrimSpace(stringArg(arguments, "system", ""))
messages := make([]map[string]string, 0, 2)
if system != "" {
messages = append(messages, map[string]string{"role": "system", "content": system})
}
messages = append(messages, map[string]string{"role": "user", "content": prompt})
return callOpenAICompatible(baseURL, apiKey, model, messages)
}
func handleClaudeReviewTool(arguments map[string]any) (string, error) {
prompt := strings.TrimSpace(stringArg(arguments, "prompt", ""))
if prompt == "" {
return "", errors.New("prompt is required")
}
model := strings.TrimSpace(stringArg(arguments, "model", envOrDefault("CLAUDE_REVIEW_MODEL", "")))
system := strings.TrimSpace(stringArg(arguments, "system", envOrDefault("CLAUDE_REVIEW_SYSTEM", "")))
tools := strings.TrimSpace(stringArg(arguments, "tools", envOrDefault("CLAUDE_REVIEW_TOOLS", "")))
timeout := intArg(envOrDefault("CLAUDE_REVIEW_TIMEOUT_SEC", "600"), 600)
return runClaudeReview(prompt, model, system, tools, time.Duration(timeout)*time.Second)
}
func callOpenAICompatible(baseURL, apiKey, model string, messages []map[string]string) (string, error) {
return callOpenAICompatibleCtx(context.Background(), baseURL, apiKey, model, messages)
}
func runClaudeReview(prompt, model, system, tools string, timeout time.Duration) (string, error) {
claudeBin := strings.TrimSpace(envOrDefault("CLAUDE_BIN", "claude"))
resolved, err := exec.LookPath(claudeBin)
if err != nil {
return "", fmt.Errorf("Claude CLI not found: %s", claudeBin)
}
args := []string{"-p", prompt, "--output-format", "json", "--permission-mode", "plan"}
if model != "" {
args = append(args, "--model", model)
}
if system != "" {
args = append(args, "--system-prompt", system)
}
if tools != "" {
args = append(args, "--tools", tools)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, resolved, args...)
cmd.Stdin = nil
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return "", fmt.Errorf("Claude review timed out after %s", timeout)
}
message := strings.TrimSpace(stderr.String())
if message == "" {
message = err.Error()
}
return "", fmt.Errorf("Claude review failed: %s", message)
}
payload, err := parseClaudeJSON(stdout.String())
if err != nil {
message := strings.TrimSpace(stderr.String())
if message != "" {
return "", fmt.Errorf("%v. stderr: %s", err, message)
}
return "", err
}
if isError, _ := payload["is_error"].(bool); isError {
return "", fmt.Errorf("%v", payload["result"])
}
response := strings.TrimSpace(fmt.Sprint(payload["result"]))
if response == "" || response == "<nil>" {
return "", errors.New("Claude review returned empty output")
}
return response, nil
}
func parseClaudeJSON(raw string) (map[string]any, error) {
lines := strings.Split(raw, "\n")
for i := len(lines) - 1; i >= 0; i-- {
candidate := strings.TrimSpace(lines[i])
if candidate == "" {
continue
}
var payload map[string]any
if err := json.Unmarshal([]byte(candidate), &payload); err == nil {
return payload, nil
}
}
return nil, errors.New("Claude CLI did not return JSON output")
}
func normalizeBaseURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "https://api.openai.com/v1"
}
if strings.HasSuffix(trimmed, "/v1") {
return trimmed
}
return strings.TrimRight(trimmed, "/") + "/v1"
}
func envOrDefault(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
func stringArg(arguments map[string]any, key, fallback string) string {
if arguments == nil {
return fallback
}
value, ok := arguments[key]
if !ok {
return fallback
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" {
return fallback
}
return text
}
func listArg(arguments map[string]any, key string) []any {
if arguments == nil {
return nil
}
raw, ok := arguments[key]
if !ok || raw == nil {
return nil
}
if values, ok := raw.([]any); ok {
return values
}
if values, ok := raw.([]interface{}); ok {
return values
}
return nil
}
func intArg(raw string, fallback int) int {
var parsed int
if _, err := fmt.Sscanf(raw, "%d", &parsed); err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func boolArg(raw string, fallback bool) bool {
trimmed := strings.TrimSpace(strings.ToLower(raw))
if trimmed == "" {
return fallback
}
switch trimmed {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}

486
go/go_core/main_tools.go Normal file
View File

@ -0,0 +1,486 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"sort"
"strings"
"time"
)
func detectACPProviders() []string {
candidates := []struct {
provider string
envKey string
binary string
}{
{provider: "codex", envKey: "ACP_CODEX_BIN", binary: "codex"},
{provider: "opencode", envKey: "ACP_OPENCODE_BIN", binary: "opencode"},
{provider: "claude", envKey: "ACP_CLAUDE_BIN", binary: "claude"},
{provider: "gemini", envKey: "ACP_GEMINI_BIN", binary: "gemini"},
}
providers := make([]string, 0, len(candidates))
for _, candidate := range candidates {
binary := strings.TrimSpace(envOrDefault(candidate.envKey, candidate.binary))
if binary == "" {
continue
}
if _, err := exec.LookPath(binary); err == nil {
providers = append(providers, candidate.provider)
}
}
sort.Strings(providers)
return providers
}
func runProviderCommand(
ctx context.Context,
provider,
model,
prompt,
workingDirectory string,
) (string, error) {
command, args := resolveProviderCommand(provider, model, prompt, workingDirectory)
if command == "" {
return "", fmt.Errorf("unsupported provider: %s", provider)
}
cmd := exec.CommandContext(ctx, command, args...)
if strings.TrimSpace(workingDirectory) != "" {
cmd.Dir = strings.TrimSpace(workingDirectory)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.Canceled) {
return "", errors.New("run canceled")
}
message := strings.TrimSpace(stderr.String())
if message == "" {
message = err.Error()
}
return "", fmt.Errorf("%s run failed: %s", provider, message)
}
output := strings.TrimSpace(stdout.String())
if output == "" {
output = strings.TrimSpace(stderr.String())
}
if output == "" {
return "", fmt.Errorf("%s returned empty output", provider)
}
return output, nil
}
func resolveProviderCommand(provider, model, prompt, cwd string) (string, []string) {
switch strings.TrimSpace(strings.ToLower(provider)) {
case "codex":
binary := strings.TrimSpace(envOrDefault("ACP_CODEX_BIN", "codex"))
args := []string{"exec", "--skip-git-repo-check", "--color", "never"}
if strings.TrimSpace(cwd) != "" {
args = append(args, "-C", strings.TrimSpace(cwd))
}
if strings.TrimSpace(model) != "" {
args = append(args, "-m", strings.TrimSpace(model))
}
args = append(args, prompt)
return binary, args
case "opencode":
binary := strings.TrimSpace(envOrDefault("ACP_OPENCODE_BIN", "opencode"))
args := []string{"run", "--format", "default"}
if strings.TrimSpace(cwd) != "" {
args = append(args, "--dir", strings.TrimSpace(cwd))
}
if strings.TrimSpace(model) != "" {
args = append(args, "-m", strings.TrimSpace(model))
}
args = append(args, prompt)
return binary, args
case "claude":
binary := strings.TrimSpace(envOrDefault("ACP_CLAUDE_BIN", "claude"))
if strings.TrimSpace(model) == "" {
return binary, []string{"-p", prompt}
}
return binary, []string{"--model", strings.TrimSpace(model), "-p", prompt}
case "gemini":
binary := strings.TrimSpace(envOrDefault("ACP_GEMINI_BIN", "gemini"))
if strings.TrimSpace(model) == "" {
return binary, []string{"-p", prompt}
}
return binary, []string{"--model", strings.TrimSpace(model), "-p", prompt}
default:
return "", nil
}
}
func augmentPromptWithAttachments(prompt string, params map[string]any) string {
attachmentsRaw := listArg(params, "attachments")
if len(attachmentsRaw) == 0 {
return prompt
}
lines := make([]string, 0, len(attachmentsRaw))
for _, raw := range attachmentsRaw {
entry, ok := raw.(map[string]any)
if !ok {
continue
}
name := strings.TrimSpace(stringArg(entry, "name", "attachment"))
path := strings.TrimSpace(stringArg(entry, "path", ""))
if path == "" {
continue
}
lines = append(lines, fmt.Sprintf("- %s: %s", name, path))
}
if len(lines) == 0 {
return prompt
}
var builder strings.Builder
builder.WriteString("User-selected local attachments:\n")
builder.WriteString(strings.Join(lines, "\n"))
builder.WriteString("\n\n")
builder.WriteString(prompt)
return builder.String()
}
func composeHistoryPrompt(history []string) string {
if len(history) == 0 {
return ""
}
var builder strings.Builder
for index, turn := range history {
builder.WriteString(fmt.Sprintf("## User Turn %d\n", index+1))
builder.WriteString(turn)
builder.WriteString("\n\n")
}
return strings.TrimSpace(builder.String())
}
func callOpenAICompatibleCtx(
ctx context.Context,
baseURL,
apiKey,
model string,
messages []map[string]string,
) (string, error) {
payload := map[string]any{
"model": model,
"messages": messages,
"max_tokens": 4096,
"stream": false,
}
body, _ := json.Marshal(payload)
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
strings.TrimRight(baseURL, "/")+"/chat/completions",
bytes.NewReader(body),
)
if err != nil {
return "", err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{Timeout: 120 * time.Second}
response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return "", fmt.Errorf("api error %d: %s", response.StatusCode, strings.TrimSpace(string(responseBody)))
}
var decoded map[string]any
if err := json.Unmarshal(responseBody, &decoded); err != nil {
return "", err
}
choices, _ := decoded["choices"].([]any)
if len(choices) == 0 {
return "", errors.New("missing choices in response")
}
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
content := strings.TrimSpace(fmt.Sprint(message["content"]))
if content == "" || content == "<nil>" {
return "", errors.New("empty response content")
}
return content, nil
}
func decodeRpcRequest(payload []byte) (rpcRequest, error) {
var request rpcRequest
if err := json.Unmarshal(payload, &request); err != nil {
return rpcRequest{}, fmt.Errorf("invalid json: %w", err)
}
if strings.TrimSpace(request.Method) == "" {
return rpcRequest{}, errors.New("missing method")
}
if request.Params == nil {
request.Params = map[string]any{}
}
return request, nil
}
func writeSSE(w http.ResponseWriter, payload map[string]any) {
encoded, _ := json.Marshal(payload)
_, _ = fmt.Fprintf(w, "data: %s\n\n", encoded)
}
func resultEnvelope(id any, result map[string]any) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": result,
}
}
func errorEnvelope(id any, code int, message string) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{
"code": code,
"message": message,
},
}
}
func notificationEnvelope(method string, params map[string]any) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"method": method,
"params": params,
}
}
func errorResponse(id any, code int, message string) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{
"code": code,
"message": message,
},
}
}
func toolTextResult(id any, content string) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": map[string]any{
"content": []map[string]any{
{"type": "text", "text": content},
},
},
}
}
func toolErrorResult(id any, err error) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": map[string]any{
"content": []map[string]any{
{"type": "text", "text": fmt.Sprintf("Error: %v", err)},
},
"isError": true,
},
}
}
func handleChatTool(arguments map[string]any) (string, error) {
apiKey := strings.TrimSpace(envOrDefault("LLM_API_KEY", ""))
if apiKey == "" {
return "", errors.New("LLM_API_KEY environment variable not set")
}
baseURL := normalizeBaseURL(envOrDefault("LLM_BASE_URL", "https://api.openai.com/v1"))
model := stringArg(arguments, "model", envOrDefault("LLM_MODEL", "gpt-4o"))
prompt := strings.TrimSpace(stringArg(arguments, "prompt", ""))
if prompt == "" {
return "", errors.New("prompt is required")
}
system := strings.TrimSpace(stringArg(arguments, "system", ""))
messages := make([]map[string]string, 0, 2)
if system != "" {
messages = append(messages, map[string]string{"role": "system", "content": system})
}
messages = append(messages, map[string]string{"role": "user", "content": prompt})
return callOpenAICompatible(baseURL, apiKey, model, messages)
}
func handleClaudeReviewTool(arguments map[string]any) (string, error) {
prompt := strings.TrimSpace(stringArg(arguments, "prompt", ""))
if prompt == "" {
return "", errors.New("prompt is required")
}
model := strings.TrimSpace(stringArg(arguments, "model", envOrDefault("CLAUDE_REVIEW_MODEL", "")))
system := strings.TrimSpace(stringArg(arguments, "system", envOrDefault("CLAUDE_REVIEW_SYSTEM", "")))
tools := strings.TrimSpace(stringArg(arguments, "tools", envOrDefault("CLAUDE_REVIEW_TOOLS", "")))
timeout := intArg(envOrDefault("CLAUDE_REVIEW_TIMEOUT_SEC", "600"), 600)
return runClaudeReview(prompt, model, system, tools, time.Duration(timeout)*time.Second)
}
func callOpenAICompatible(baseURL, apiKey, model string, messages []map[string]string) (string, error) {
return callOpenAICompatibleCtx(context.Background(), baseURL, apiKey, model, messages)
}
func runClaudeReview(prompt, model, system, tools string, timeout time.Duration) (string, error) {
claudeBin := strings.TrimSpace(envOrDefault("CLAUDE_BIN", "claude"))
resolved, err := exec.LookPath(claudeBin)
if err != nil {
return "", fmt.Errorf("Claude CLI not found: %s", claudeBin)
}
args := []string{"-p", prompt, "--output-format", "json", "--permission-mode", "plan"}
if model != "" {
args = append(args, "--model", model)
}
if system != "" {
args = append(args, "--system-prompt", system)
}
if tools != "" {
args = append(args, "--tools", tools)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, resolved, args...)
cmd.Stdin = nil
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return "", fmt.Errorf("Claude review timed out after %s", timeout)
}
message := strings.TrimSpace(stderr.String())
if message == "" {
message = err.Error()
}
return "", fmt.Errorf("Claude review failed: %s", message)
}
payload, err := parseClaudeJSON(stdout.String())
if err != nil {
message := strings.TrimSpace(stderr.String())
if message != "" {
return "", fmt.Errorf("%v. stderr: %s", err, message)
}
return "", err
}
if isError, _ := payload["is_error"].(bool); isError {
return "", fmt.Errorf("%v", payload["result"])
}
response := strings.TrimSpace(fmt.Sprint(payload["result"]))
if response == "" || response == "<nil>" {
return "", errors.New("Claude review returned empty output")
}
return response, nil
}
func parseClaudeJSON(raw string) (map[string]any, error) {
lines := strings.Split(raw, "\n")
for i := len(lines) - 1; i >= 0; i-- {
candidate := strings.TrimSpace(lines[i])
if candidate == "" {
continue
}
var payload map[string]any
if err := json.Unmarshal([]byte(candidate), &payload); err == nil {
return payload, nil
}
}
return nil, errors.New("Claude CLI did not return JSON output")
}
func normalizeBaseURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "https://api.openai.com/v1"
}
if strings.HasSuffix(trimmed, "/v1") {
return trimmed
}
return strings.TrimRight(trimmed, "/") + "/v1"
}
func envOrDefault(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
func stringArg(arguments map[string]any, key, fallback string) string {
if arguments == nil {
return fallback
}
value, ok := arguments[key]
if !ok {
return fallback
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" {
return fallback
}
return text
}
func listArg(arguments map[string]any, key string) []any {
if arguments == nil {
return nil
}
raw, ok := arguments[key]
if !ok || raw == nil {
return nil
}
if values, ok := raw.([]any); ok {
return values
}
if values, ok := raw.([]interface{}); ok {
return values
}
return nil
}
func intArg(raw string, fallback int) int {
var parsed int
if _, err := fmt.Sscanf(raw, "%d", &parsed); err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func boolArg(raw string, fallback bool) bool {
trimmed := strings.TrimSpace(strings.ToLower(raw))
if trimmed == "" {
return fallback
}
switch trimmed {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,165 @@
part of 'runtime_controllers.dart';
class DerivedTasksController extends ChangeNotifier {
List<DerivedTaskItem> _queue = const <DerivedTaskItem>[];
List<DerivedTaskItem> _running = const <DerivedTaskItem>[];
List<DerivedTaskItem> _history = const <DerivedTaskItem>[];
List<DerivedTaskItem> _failed = const <DerivedTaskItem>[];
List<DerivedTaskItem> _scheduled = const <DerivedTaskItem>[];
List<DerivedTaskItem> get queue => _queue;
List<DerivedTaskItem> get running => _running;
List<DerivedTaskItem> get history => _history;
List<DerivedTaskItem> get failed => _failed;
List<DerivedTaskItem> get scheduled => _scheduled;
int get totalCount =>
_queue.length + _running.length + _history.length + _failed.length;
void recompute({
required List<GatewaySessionSummary> sessions,
required List<GatewayCronJobSummary> cronJobs,
required String currentSessionKey,
required bool hasPendingRun,
required String activeAgentName,
}) {
final sorted = sessions.toList(growable: false)
..sort(
(left, right) =>
(right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0),
);
final queue = <DerivedTaskItem>[];
final running = <DerivedTaskItem>[];
final history = <DerivedTaskItem>[];
final failed = <DerivedTaskItem>[];
for (final session in sorted) {
final item = DerivedTaskItem(
id: session.key,
title: session.label,
owner: activeAgentName,
status: _statusForSession(
session: session,
currentSessionKey: currentSessionKey,
hasPendingRun: hasPendingRun,
),
surface: session.surface ?? session.kind ?? 'Assistant',
startedAtLabel: _timeLabel(session.updatedAtMs),
durationLabel: _durationLabel(session.updatedAtMs),
summary:
session.lastMessagePreview ?? session.subject ?? 'Session activity',
sessionKey: session.key,
);
switch (item.status) {
case 'Running':
running.add(item);
case 'Failed':
failed.add(item);
case 'Queued':
queue.add(item);
default:
history.add(item);
}
}
_queue = queue;
_running = running;
_history = history;
_failed = failed;
_scheduled = cronJobs
.map(
(job) => DerivedTaskItem(
id: job.id,
title: job.name,
owner: job.agentId?.trim().isNotEmpty == true
? job.agentId!
: activeAgentName,
status: job.enabled ? 'Scheduled' : 'Disabled',
surface: 'Cron',
startedAtLabel: _timeLabel(job.nextRunAtMs?.toDouble()),
durationLabel: job.scheduleLabel,
summary:
job.description ??
job.lastError ??
job.lastStatus ??
'Scheduled automation',
sessionKey: 'cron:${job.id}',
),
)
.toList(growable: false);
notifyListeners();
}
String _statusForSession({
required GatewaySessionSummary session,
required String currentSessionKey,
required bool hasPendingRun,
}) {
if (session.abortedLastRun == true) {
return 'Failed';
}
if (hasPendingRun && matchesSessionKey(session.key, currentSessionKey)) {
return 'Running';
}
if ((session.lastMessagePreview ?? '').isEmpty) {
return 'Queued';
}
return 'Open';
}
String _timeLabel(double? timestampMs) {
if (timestampMs == null) {
return 'Unknown';
}
final date = DateTime.fromMillisecondsSinceEpoch(timestampMs.toInt());
return '${date.month}/${date.day} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
String _durationLabel(double? timestampMs) {
if (timestampMs == null) {
return 'n/a';
}
final delta = DateTime.now().difference(
DateTime.fromMillisecondsSinceEpoch(timestampMs.toInt()),
);
if (delta.inMinutes < 1) {
return 'just now';
}
if (delta.inHours < 1) {
return '${delta.inMinutes}m ago';
}
if (delta.inDays < 1) {
return '${delta.inHours}h ago';
}
return '${delta.inDays}d ago';
}
}
String normalizeMainSessionKey(String? value) {
final trimmed = value?.trim() ?? '';
return trimmed.isEmpty ? 'main' : trimmed;
}
String makeAgentSessionKey({required String agentId, required String baseKey}) {
final trimmedAgent = agentId.trim();
final trimmedBase = baseKey.trim();
if (trimmedAgent.isEmpty) {
return normalizeMainSessionKey(trimmedBase);
}
return 'agent:$trimmedAgent:${normalizeMainSessionKey(trimmedBase)}';
}
bool matchesSessionKey(String incoming, String current) {
final left = incoming.trim().toLowerCase();
final right = current.trim().toLowerCase();
if (left == right) {
return true;
}
return (left == 'agent:main:main' && right == 'main') ||
(left == 'main' && right == 'agent:main:main');
}
String encodePrettyJson(Object value) {
const encoder = JsonEncoder.withIndent(' ');
return encoder.convert(value);
}
String _ephemeralId() => DateTime.now().microsecondsSinceEpoch.toString();

View File

@ -0,0 +1,329 @@
part of 'runtime_controllers.dart';
class InstancesController extends ChangeNotifier {
InstancesController(this._runtime);
final GatewayRuntime _runtime;
List<GatewayInstanceSummary> _items = const <GatewayInstanceSummary>[];
bool _loading = false;
String? _error;
List<GatewayInstanceSummary> get items => _items;
bool get loading => _loading;
String? get error => _error;
Future<void> refresh() async {
if (!_runtime.isConnected) {
_items = const <GatewayInstanceSummary>[];
_error = null;
notifyListeners();
return;
}
_loading = true;
_error = null;
notifyListeners();
try {
_items = await _runtime.listInstances();
} catch (error) {
_error = error.toString();
} finally {
_loading = false;
notifyListeners();
}
}
}
class SkillsController extends ChangeNotifier {
SkillsController(this._runtime);
final GatewayRuntime _runtime;
List<GatewaySkillSummary> _items = const <GatewaySkillSummary>[];
bool _loading = false;
String? _error;
List<GatewaySkillSummary> get items => _items;
bool get loading => _loading;
String? get error => _error;
Future<void> refresh({String? agentId}) async {
if (!_runtime.isConnected) {
_items = const <GatewaySkillSummary>[];
_error = null;
notifyListeners();
return;
}
_loading = true;
_error = null;
notifyListeners();
try {
_items = await _runtime.listSkills(agentId: agentId);
} catch (error) {
_error = error.toString();
} finally {
_loading = false;
notifyListeners();
}
}
}
class ConnectorsController extends ChangeNotifier {
ConnectorsController(this._runtime);
final GatewayRuntime _runtime;
List<GatewayConnectorSummary> _items = const <GatewayConnectorSummary>[];
bool _loading = false;
String? _error;
List<GatewayConnectorSummary> get items => _items;
bool get loading => _loading;
String? get error => _error;
Future<void> refresh() async {
if (!_runtime.isConnected) {
_items = const <GatewayConnectorSummary>[];
_error = null;
notifyListeners();
return;
}
_loading = true;
_error = null;
notifyListeners();
try {
_items = await _runtime.listConnectors();
} catch (error) {
_error = error.toString();
} finally {
_loading = false;
notifyListeners();
}
}
}
class ModelsController extends ChangeNotifier {
ModelsController(this._runtime, this._settingsController);
final GatewayRuntime _runtime;
final SettingsController _settingsController;
List<GatewayModelSummary> _items = const <GatewayModelSummary>[];
bool _loading = false;
String? _error;
List<GatewayModelSummary> get items => _items;
bool get loading => _loading;
String? get error => _error;
void restoreFromSettings(AiGatewayProfile profile) {
final models = _modelsFromProfile(profile);
if (models.length == _items.length &&
models.every(
(item) => _items.any((current) => current.id == item.id),
)) {
return;
}
_items = models;
notifyListeners();
}
Future<void> refresh() async {
_loading = true;
_error = null;
notifyListeners();
try {
final profile = _settingsController.snapshot.aiGateway;
if (profile.baseUrl.trim().isNotEmpty) {
final synced = await _settingsController.syncAiGatewayCatalog(profile);
_items = _modelsFromProfile(synced);
} else if (_runtime.isConnected) {
_items = await _runtime.listModels();
} else {
_items = _modelsFromProfile(profile);
}
} catch (error) {
_error = error.toString();
} finally {
_loading = false;
notifyListeners();
}
}
List<GatewayModelSummary> _modelsFromProfile(AiGatewayProfile profile) {
final selected = profile.selectedModels
.where(profile.availableModels.contains)
.toList(growable: false);
final candidates = selected.isNotEmpty
? selected
: profile.availableModels.take(5).toList(growable: false);
return candidates
.map(
(item) => GatewayModelSummary(
id: item,
name: item,
provider: 'LLM API',
contextWindow: null,
maxOutputTokens: null,
),
)
.toList(growable: false);
}
}
class CronJobsController extends ChangeNotifier {
CronJobsController(this._runtime);
final GatewayRuntime _runtime;
List<GatewayCronJobSummary> _items = const <GatewayCronJobSummary>[];
bool _loading = false;
String? _error;
List<GatewayCronJobSummary> get items => _items;
bool get loading => _loading;
String? get error => _error;
Future<void> refresh() async {
if (!_runtime.isConnected) {
_items = const <GatewayCronJobSummary>[];
_error = null;
notifyListeners();
return;
}
_loading = true;
_error = null;
notifyListeners();
try {
_items = await _runtime.listCronJobs();
} catch (error) {
_error = error.toString();
} finally {
_loading = false;
notifyListeners();
}
}
}
class DevicesController extends ChangeNotifier {
DevicesController(this._runtime);
final GatewayRuntime _runtime;
GatewayDevicePairingList _items = const GatewayDevicePairingList.empty();
bool _loading = false;
String? _error;
GatewayDevicePairingList get items => _items;
bool get loading => _loading;
String? get error => _error;
Future<void> refresh({bool quiet = false}) async {
if (!_runtime.isConnected) {
_items = const GatewayDevicePairingList.empty();
if (!quiet) {
_error = null;
}
notifyListeners();
return;
}
if (_loading) {
return;
}
_loading = true;
if (!quiet) {
_error = null;
}
notifyListeners();
try {
_items = await _runtime.listDevicePairing();
} catch (error) {
if (!quiet) {
_error = error.toString();
}
} finally {
_loading = false;
notifyListeners();
}
}
Future<void> approve(String requestId) async {
_error = null;
notifyListeners();
try {
await _runtime.approveDevicePairing(requestId);
await refresh(quiet: true);
} catch (error) {
_error = error.toString();
notifyListeners();
}
}
Future<void> reject(String requestId) async {
_error = null;
notifyListeners();
try {
await _runtime.rejectDevicePairing(requestId);
await refresh(quiet: true);
} catch (error) {
_error = error.toString();
notifyListeners();
}
}
Future<void> remove(String deviceId) async {
_error = null;
notifyListeners();
try {
await _runtime.removePairedDevice(deviceId);
await refresh(quiet: true);
} catch (error) {
_error = error.toString();
notifyListeners();
}
}
Future<String?> rotateToken({
required String deviceId,
required String role,
List<String> scopes = const <String>[],
}) async {
_error = null;
notifyListeners();
try {
final token = await _runtime.rotateDeviceToken(
deviceId: deviceId,
role: role,
scopes: scopes,
);
await refresh(quiet: true);
return token;
} catch (error) {
_error = error.toString();
notifyListeners();
return null;
}
}
Future<void> revokeToken({
required String deviceId,
required String role,
}) async {
_error = null;
notifyListeners();
try {
await _runtime.revokeDeviceToken(deviceId: deviceId, role: role);
await refresh(quiet: true);
} catch (error) {
_error = error.toString();
notifyListeners();
}
}
void clear() {
_items = const GatewayDevicePairingList.empty();
_error = null;
_loading = false;
notifyListeners();
}
}

View File

@ -0,0 +1,345 @@
part of 'runtime_controllers.dart';
class _AiGatewayResponseException implements Exception {
const _AiGatewayResponseException({
required this.statusCode,
required this.message,
});
final int statusCode;
final String message;
}
class GatewayAgentsController extends ChangeNotifier {
GatewayAgentsController(this._runtime);
final GatewayRuntime _runtime;
List<GatewayAgentSummary> _agents = const <GatewayAgentSummary>[];
String _selectedAgentId = '';
bool _loading = false;
String? _error;
List<GatewayAgentSummary> get agents => _agents;
String get selectedAgentId => _selectedAgentId;
bool get loading => _loading;
String? get error => _error;
GatewayAgentSummary? get selectedAgent {
final selected = _selectedAgentId.trim();
if (selected.isEmpty) {
return null;
}
for (final agent in _agents) {
if (agent.id == selected) {
return agent;
}
}
return null;
}
String get activeAgentName => selectedAgent?.name ?? 'Main';
void restoreSelection(String agentId) {
_selectedAgentId = agentId.trim();
notifyListeners();
}
void selectAgent(String? agentId) {
_selectedAgentId = agentId?.trim() ?? '';
notifyListeners();
}
Future<void> refresh() async {
if (!_runtime.isConnected) {
_agents = const <GatewayAgentSummary>[];
_error = null;
notifyListeners();
return;
}
_loading = true;
_error = null;
notifyListeners();
try {
_agents = await _runtime.listAgents();
if (_selectedAgentId.isNotEmpty &&
!_agents.any((item) => item.id == _selectedAgentId)) {
_selectedAgentId = '';
}
} catch (error) {
_error = error.toString();
} finally {
_loading = false;
notifyListeners();
}
}
}
class GatewaySessionsController extends ChangeNotifier {
GatewaySessionsController(this._runtime);
final GatewayRuntime _runtime;
List<GatewaySessionSummary> _sessions = const <GatewaySessionSummary>[];
String _currentSessionKey = 'main';
String _mainSessionBaseKey = 'main';
String _selectedAgentId = '';
String _defaultAgentId = '';
bool _loading = false;
String? _error;
List<GatewaySessionSummary> get sessions => _sessions;
String get currentSessionKey => _currentSessionKey;
bool get loading => _loading;
String? get error => _error;
String get mainSessionBaseKey => _mainSessionBaseKey;
void configure({
required String mainSessionKey,
required String selectedAgentId,
required String defaultAgentId,
}) {
_mainSessionBaseKey = normalizeMainSessionKey(mainSessionKey);
_selectedAgentId = selectedAgentId.trim();
_defaultAgentId = defaultAgentId.trim();
final preferred = preferredSessionKey;
if (_currentSessionKey.trim().isEmpty ||
_currentSessionKey == 'main' ||
_currentSessionKey == _mainSessionBaseKey ||
_currentSessionKey.startsWith('agent:')) {
_currentSessionKey = preferred;
}
notifyListeners();
}
String get preferredSessionKey {
final selected = _selectedAgentId.trim();
final defaultAgent = _defaultAgentId.trim();
final base = normalizeMainSessionKey(_mainSessionBaseKey);
if (selected.isEmpty ||
(defaultAgent.isNotEmpty && selected == defaultAgent)) {
return base;
}
return makeAgentSessionKey(agentId: selected, baseKey: base);
}
Future<void> refresh() async {
if (!_runtime.isConnected) {
_sessions = const <GatewaySessionSummary>[];
_error = null;
notifyListeners();
return;
}
_loading = true;
_error = null;
notifyListeners();
try {
_sessions = await _runtime.listSessions(limit: 50);
if (!_sessions.any(
(item) => matchesSessionKey(item.key, _currentSessionKey),
)) {
_currentSessionKey = preferredSessionKey;
}
} catch (error) {
_error = error.toString();
} finally {
_loading = false;
notifyListeners();
}
}
Future<void> switchSession(String sessionKey) async {
final trimmed = sessionKey.trim();
if (trimmed.isEmpty || trimmed == _currentSessionKey) {
return;
}
_currentSessionKey = trimmed;
notifyListeners();
}
}
class GatewayChatController extends ChangeNotifier {
GatewayChatController(this._runtime);
final GatewayRuntime _runtime;
List<GatewayChatMessage> _messages = const <GatewayChatMessage>[];
String _sessionKey = 'main';
bool _loading = false;
bool _sending = false;
bool _aborting = false;
String? _error;
String? _streamingAssistantText;
final Set<String> _pendingRuns = <String>{};
List<GatewayChatMessage> get messages => _messages;
String get sessionKey => _sessionKey;
bool get loading => _loading;
bool get sending => _sending;
bool get aborting => _aborting;
String? get error => _error;
String? get streamingAssistantText => _streamingAssistantText;
bool get hasPendingRun => _pendingRuns.isNotEmpty;
String? get activeRunId => _pendingRuns.isEmpty ? null : _pendingRuns.first;
Future<void> loadSession(String sessionKey) async {
final next = sessionKey.trim().isEmpty ? 'main' : sessionKey.trim();
_sessionKey = next;
if (!_runtime.isConnected) {
_messages = const <GatewayChatMessage>[];
_streamingAssistantText = null;
_error = null;
notifyListeners();
return;
}
_loading = true;
_error = null;
notifyListeners();
try {
_messages = await _runtime.loadHistory(next);
_streamingAssistantText = null;
} catch (error) {
_error = error.toString();
} finally {
_loading = false;
notifyListeners();
}
}
Future<void> sendMessage({
required String sessionKey,
required String message,
required String thinking,
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
String? agentId,
Map<String, dynamic>? metadata,
}) async {
final trimmed = message.trim();
if ((trimmed.isEmpty && attachments.isEmpty) || !_runtime.isConnected) {
return;
}
_sessionKey = sessionKey.trim().isEmpty ? 'main' : sessionKey.trim();
_sending = true;
_error = null;
_streamingAssistantText = null;
_messages = List<GatewayChatMessage>.from(_messages)
..add(
GatewayChatMessage(
id: _ephemeralId(),
role: 'user',
text: trimmed.isEmpty ? 'See attached.' : trimmed,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
notifyListeners();
try {
final runId = await _runtime.sendChat(
sessionKey: _sessionKey,
message: trimmed.isEmpty ? 'See attached.' : trimmed,
thinking: thinking,
attachments: attachments,
agentId: agentId,
metadata: metadata,
);
_pendingRuns.add(runId);
} catch (error) {
_error = error.toString();
} finally {
_sending = false;
notifyListeners();
}
}
Future<void> abortRun() async {
if (_pendingRuns.isEmpty || !_runtime.isConnected) {
return;
}
_aborting = true;
notifyListeners();
try {
final runIds = _pendingRuns.toList(growable: false);
for (final runId in runIds) {
await _runtime.abortChat(sessionKey: _sessionKey, runId: runId);
}
} catch (error) {
_error = error.toString();
} finally {
_aborting = false;
notifyListeners();
}
}
void handleEvent(GatewayPushEvent event) {
if (event.event == 'chat') {
_handleChatEvent(asMap(event.payload));
return;
}
if (event.event == 'agent') {
_handleAgentEvent(asMap(event.payload));
}
}
void clear() {
_messages = const <GatewayChatMessage>[];
_pendingRuns.clear();
_streamingAssistantText = null;
_error = null;
notifyListeners();
}
void _handleChatEvent(Map<String, dynamic> payload) {
final runId = stringValue(payload['runId']);
final state = stringValue(payload['state']) ?? '';
final incomingSessionKey =
stringValue(payload['sessionKey']) ?? _sessionKey;
final isOurRun = runId != null && _pendingRuns.contains(runId);
if (!matchesSessionKey(incomingSessionKey, _sessionKey) && !isOurRun) {
return;
}
final message = asMap(payload['message']);
final role = (stringValue(message['role']) ?? '').toLowerCase();
final text = extractMessageText(message);
if (role == 'assistant' &&
text.isNotEmpty &&
(state == 'delta' || state == 'final')) {
_streamingAssistantText = text;
}
if (state == 'error') {
_error = stringValue(payload['errorMessage']) ?? 'Chat failed';
}
if (state == 'final' || state == 'aborted' || state == 'error') {
if (runId != null) {
_pendingRuns.remove(runId);
} else {
_pendingRuns.clear();
}
unawaited(loadSession(_sessionKey));
notifyListeners();
return;
}
notifyListeners();
}
void _handleAgentEvent(Map<String, dynamic> payload) {
final runId = stringValue(payload['runId']);
if (runId == null || !_pendingRuns.contains(runId)) {
return;
}
final stream = stringValue(payload['stream']);
final data = asMap(payload['data']);
if (stream == 'assistant') {
final nextText = stringValue(data['text']) ?? extractMessageText(data);
if (nextText.isNotEmpty) {
_streamingAssistantText = nextText;
notifyListeners();
}
}
}
}

View File

@ -0,0 +1,929 @@
part of 'runtime_controllers.dart';
class SettingsController extends ChangeNotifier {
SettingsController(this._store);
final SecureConfigStore _store;
bool _disposed = false;
final List<StreamSubscription<FileSystemEvent>> _settingsWatchSubscriptions =
<StreamSubscription<FileSystemEvent>>[];
Timer? _settingsReloadDebounce;
Timer? _settingsPollTimer;
SettingsSnapshot _snapshot = SettingsSnapshot.defaults();
String _lastSnapshotJson = SettingsSnapshot.defaults().toJsonString();
String _lastSettingsFileStamp = '';
Map<String, String> _secureRefs = const <String, String>{};
List<SecretAuditEntry> _auditTrail = const <SecretAuditEntry>[];
String _ollamaStatus = 'Idle';
String _vaultStatus = 'Idle';
String _aiGatewayStatus = 'Idle';
SettingsSnapshot get snapshot => _snapshot;
Map<String, String> get secureRefs => _secureRefs;
List<SecretAuditEntry> get auditTrail => _auditTrail;
String get ollamaStatus => _ollamaStatus;
String get vaultStatus => _vaultStatus;
String get aiGatewayStatus => _aiGatewayStatus;
@override
void notifyListeners() {
if (_disposed) {
return;
}
super.notifyListeners();
}
@override
void dispose() {
_disposed = true;
_settingsReloadDebounce?.cancel();
_settingsPollTimer?.cancel();
for (final subscription in _settingsWatchSubscriptions) {
unawaited(subscription.cancel());
}
_settingsWatchSubscriptions.clear();
super.dispose();
}
Future<void> initialize() async {
_snapshot = await _store.loadSettingsSnapshot();
_lastSnapshotJson = _snapshot.toJsonString();
await _reloadDerivedState();
await _startSettingsWatcher();
await _refreshSettingsFileStamp();
_startSettingsPolling();
notifyListeners();
}
Future<void> refreshDerivedState() async {
await _reloadDerivedState();
notifyListeners();
}
Future<void> saveSnapshot(SettingsSnapshot snapshot) async {
_snapshot = snapshot;
_lastSnapshotJson = _snapshot.toJsonString();
await _store.saveSettingsSnapshot(snapshot);
await _refreshSettingsFileStamp();
await _reloadDerivedState();
notifyListeners();
}
Future<void> resetSnapshot(SettingsSnapshot snapshot) async {
_snapshot = snapshot;
_lastSnapshotJson = _snapshot.toJsonString();
await _refreshSettingsFileStamp();
await _reloadDerivedState();
notifyListeners();
}
Future<void> saveGatewaySecrets({
int? profileIndex,
required String token,
required String password,
}) async {
final trimmedToken = token.trim();
final trimmedPassword = password.trim();
if (trimmedToken.isNotEmpty) {
await _store.saveGatewayToken(trimmedToken, profileIndex: profileIndex);
await appendAudit(
SecretAuditEntry(
timeLabel: _timeLabel(),
action: 'Updated',
provider: 'Gateway',
target: _gatewaySecretTarget('gateway_token', profileIndex),
module: 'Assistant',
status: 'Success',
),
);
}
if (trimmedPassword.isNotEmpty) {
await _store.saveGatewayPassword(
trimmedPassword,
profileIndex: profileIndex,
);
await appendAudit(
SecretAuditEntry(
timeLabel: _timeLabel(),
action: 'Updated',
provider: 'Gateway',
target: _gatewaySecretTarget('gateway_password', profileIndex),
module: 'Assistant',
status: 'Success',
),
);
}
await _reloadDerivedState();
notifyListeners();
}
Future<void> clearGatewaySecrets({
int? profileIndex,
bool token = false,
bool password = false,
}) async {
if (token) {
await _store.clearGatewayToken(profileIndex: profileIndex);
await appendAudit(
SecretAuditEntry(
timeLabel: _timeLabel(),
action: 'Cleared',
provider: 'Gateway',
target: _gatewaySecretTarget('gateway_token', profileIndex),
module: 'Assistant',
status: 'Success',
),
);
}
if (password) {
await _store.clearGatewayPassword(profileIndex: profileIndex);
await appendAudit(
SecretAuditEntry(
timeLabel: _timeLabel(),
action: 'Cleared',
provider: 'Gateway',
target: _gatewaySecretTarget('gateway_password', profileIndex),
module: 'Assistant',
status: 'Success',
),
);
}
await _reloadDerivedState();
notifyListeners();
}
Future<String> loadGatewayToken({int? profileIndex}) async {
return (await _store.loadGatewayToken(
profileIndex: profileIndex,
))?.trim() ??
'';
}
Future<String> loadGatewayPassword({int? profileIndex}) async {
return (await _store.loadGatewayPassword(
profileIndex: profileIndex,
))?.trim() ??
'';
}
bool hasStoredGatewayTokenForProfile(int profileIndex) =>
_secureRefs.containsKey(SecretStore.gatewayTokenRefKey(profileIndex)) ||
_secureRefs.containsKey('gateway_token');
bool hasStoredGatewayPasswordForProfile(int profileIndex) =>
_secureRefs.containsKey(
SecretStore.gatewayPasswordRefKey(profileIndex),
) ||
_secureRefs.containsKey('gateway_password');
String? storedGatewayTokenMaskForProfile(int profileIndex) =>
_secureRefs[SecretStore.gatewayTokenRefKey(profileIndex)] ??
_secureRefs['gateway_token'];
String? storedGatewayPasswordMaskForProfile(int profileIndex) =>
_secureRefs[SecretStore.gatewayPasswordRefKey(profileIndex)] ??
_secureRefs['gateway_password'];
Future<void> saveOllamaCloudApiKey(String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return;
}
await _store.saveOllamaCloudApiKey(trimmed);
await appendAudit(
SecretAuditEntry(
timeLabel: _timeLabel(),
action: 'Updated',
provider: 'Ollama Cloud',
target: _snapshot.ollamaCloud.apiKeyRef,
module: 'Settings',
status: 'Success',
),
);
await _reloadDerivedState();
notifyListeners();
}
Future<String> loadOllamaCloudApiKey() async {
return (await _store.loadOllamaCloudApiKey())?.trim() ?? '';
}
Future<void> saveVaultToken(String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return;
}
await _store.saveVaultToken(trimmed);
await appendAudit(
SecretAuditEntry(
timeLabel: _timeLabel(),
action: 'Updated',
provider: 'Vault',
target: _snapshot.vault.tokenRef,
module: 'Secrets',
status: 'Success',
),
);
await _reloadDerivedState();
notifyListeners();
}
Future<String> loadVaultToken() async {
return (await _store.loadVaultToken())?.trim() ?? '';
}
Future<void> saveAiGatewayApiKey(String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return;
}
await _store.saveAiGatewayApiKey(trimmed);
await appendAudit(
SecretAuditEntry(
timeLabel: _timeLabel(),
action: 'Updated',
provider: 'LLM API',
target: _snapshot.aiGateway.apiKeyRef,
module: 'Settings',
status: 'Success',
),
);
await _reloadDerivedState();
notifyListeners();
}
Future<String> loadAiGatewayApiKey() async {
return (await _store.loadAiGatewayApiKey())?.trim() ?? '';
}
Future<void> appendAudit(SecretAuditEntry entry) async {
await _store.appendAudit(entry);
_auditTrail = await _store.loadAuditTrail();
notifyListeners();
}
Future<String> testOllamaConnection({required bool cloud}) async {
return testOllamaConnectionDraft(
cloud: cloud,
localConfig: _snapshot.ollamaLocal,
cloudConfig: _snapshot.ollamaCloud,
);
}
Future<String> testOllamaConnectionDraft({
required bool cloud,
required OllamaLocalConfig localConfig,
required OllamaCloudConfig cloudConfig,
String apiKeyOverride = '',
}) async {
final base = cloud
? cloudConfig.baseUrl.trim()
: localConfig.endpoint.trim();
if (base.isEmpty) {
final message = 'Missing endpoint';
_ollamaStatus = message;
notifyListeners();
return message;
}
final cloudApiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await _store.loadOllamaCloudApiKey())?.trim() ?? '';
try {
final uri = Uri.parse(
cloud ? base : '$base${base.endsWith('/') ? '' : '/'}api/tags',
);
final response = await _simpleGet(
uri,
headers: cloud
? <String, String>{
if (cloudApiKey.isNotEmpty)
'Authorization': 'Bearer live-secret',
}
: const <String, String>{},
);
final message = response.statusCode < 500
? 'Reachable (${response.statusCode})'
: 'Unhealthy (${response.statusCode})';
_ollamaStatus = message;
notifyListeners();
return message;
} catch (error) {
final message = 'Failed: $error';
_ollamaStatus = message;
notifyListeners();
return message;
}
}
Future<String> testVaultConnection() async {
return testVaultConnectionDraft(_snapshot.vault);
}
Future<String> testVaultConnectionDraft(
VaultConfig profile, {
String tokenOverride = '',
}) async {
final address = profile.address.trim();
if (address.isEmpty) {
const message = 'Missing address';
_vaultStatus = message;
notifyListeners();
return message;
}
try {
final uri = Uri.parse(
'$address${address.endsWith('/') ? '' : '/'}v1/sys/health',
);
final headers = <String, String>{
if (profile.namespace.trim().isNotEmpty)
'X-Vault-Namespace': profile.namespace.trim(),
};
final token = tokenOverride.trim().isNotEmpty
? tokenOverride.trim()
: (await _store.loadVaultToken())?.trim() ?? '';
if (token.trim().isNotEmpty) {
headers['X-Vault-Token'] = token.trim();
}
final response = await _simpleGet(uri, headers: headers);
final message = response.statusCode < 500
? 'Reachable (${response.statusCode})'
: 'Unhealthy (${response.statusCode})';
_vaultStatus = message;
notifyListeners();
return message;
} catch (error) {
final message = 'Failed: $error';
_vaultStatus = message;
notifyListeners();
return message;
}
}
Future<AiGatewayProfile> syncAiGatewayCatalog(
AiGatewayProfile profile, {
String apiKeyOverride = '',
}) async {
final normalizedBaseUrl = _normalizeAiGatewayBaseUrl(profile.baseUrl);
if (normalizedBaseUrl == null) {
final next = profile.copyWith(
syncState: 'invalid',
syncMessage: 'Missing LLM API Endpoint',
);
_aiGatewayStatus = next.syncMessage;
_snapshot = _snapshot.copyWith(aiGateway: next);
await _store.saveSettingsSnapshot(_snapshot);
notifyListeners();
return next;
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await _store.loadAiGatewayApiKey())?.trim() ?? '';
if (apiKey.isEmpty) {
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
syncState: 'invalid',
syncMessage: 'Missing LLM API Token',
);
_aiGatewayStatus = next.syncMessage;
_snapshot = _snapshot.copyWith(aiGateway: next);
await _store.saveSettingsSnapshot(_snapshot);
notifyListeners();
return next;
}
try {
final models = await loadAiGatewayModels(
profile: profile.copyWith(baseUrl: normalizedBaseUrl.toString()),
apiKeyOverride: apiKey,
);
final availableModels = models
.map((item) => item.id)
.toList(growable: false);
final retainedSelected = profile.selectedModels
.where(availableModels.contains)
.toList(growable: false);
final selectedModels = retainedSelected.isNotEmpty
? retainedSelected
: availableModels.take(5).toList(growable: false);
final currentDefaultModel = _snapshot.defaultModel.trim();
final resolvedDefaultModel = selectedModels.contains(currentDefaultModel)
? currentDefaultModel
: selectedModels.isNotEmpty
? selectedModels.first
: availableModels.isNotEmpty
? availableModels.first
: '';
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
availableModels: availableModels,
selectedModels: selectedModels,
syncState: 'ready',
syncMessage: 'Loaded ${availableModels.length} model(s)',
);
_aiGatewayStatus = 'Ready (${availableModels.length})';
_snapshot = _snapshot.copyWith(
aiGateway: next,
defaultModel: resolvedDefaultModel,
);
await _store.saveSettingsSnapshot(_snapshot);
await _reloadDerivedState();
notifyListeners();
return next;
} catch (error) {
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
syncState: 'error',
syncMessage: _networkErrorLabel(error),
);
_aiGatewayStatus = next.syncMessage;
_snapshot = _snapshot.copyWith(aiGateway: next);
await _store.saveSettingsSnapshot(_snapshot);
notifyListeners();
return next;
}
}
Future<AiGatewayConnectionCheck> testAiGatewayConnection(
AiGatewayProfile profile, {
String apiKeyOverride = '',
}) async {
final normalizedBaseUrl = _normalizeAiGatewayBaseUrl(profile.baseUrl);
if (normalizedBaseUrl == null) {
return const AiGatewayConnectionCheck(
state: 'invalid',
message: 'Missing LLM API Endpoint',
endpoint: '',
modelCount: 0,
);
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await _store.loadAiGatewayApiKey())?.trim() ?? '';
final endpoint = _aiGatewayModelsUri(normalizedBaseUrl).toString();
if (apiKey.isEmpty) {
return AiGatewayConnectionCheck(
state: 'invalid',
message: 'Missing LLM API Token',
endpoint: endpoint,
modelCount: 0,
);
}
try {
final models = await _requestAiGatewayModels(
uri: _aiGatewayModelsUri(normalizedBaseUrl),
apiKey: apiKey,
);
if (models.isEmpty) {
return AiGatewayConnectionCheck(
state: 'empty',
message: 'Authenticated but no models were returned',
endpoint: endpoint,
modelCount: 0,
);
}
return AiGatewayConnectionCheck(
state: 'ready',
message: 'Authenticated · ${models.length} model(s) available',
endpoint: endpoint,
modelCount: models.length,
);
} catch (error) {
return AiGatewayConnectionCheck(
state: 'error',
message: _networkErrorLabel(error),
endpoint: endpoint,
modelCount: 0,
);
}
}
Future<List<GatewayModelSummary>> loadAiGatewayModels({
AiGatewayProfile? profile,
String apiKeyOverride = '',
}) async {
final activeProfile = profile ?? _snapshot.aiGateway;
final normalizedBaseUrl = _normalizeAiGatewayBaseUrl(activeProfile.baseUrl);
if (normalizedBaseUrl == null) {
return const <GatewayModelSummary>[];
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await _store.loadAiGatewayApiKey())?.trim() ?? '';
if (apiKey.isEmpty) {
return const <GatewayModelSummary>[];
}
return _requestAiGatewayModels(
uri: _aiGatewayModelsUri(normalizedBaseUrl),
apiKey: apiKey,
);
}
List<SecretReferenceEntry> buildSecretReferences() {
final entries = <SecretReferenceEntry>[
..._secureRefs.entries.map(
(entry) => SecretReferenceEntry(
name: entry.key,
provider: _providerNameForSecret(entry.key),
module: _moduleForSecret(entry.key),
maskedValue: entry.value,
status: 'In Use',
),
),
SecretReferenceEntry(
name: _snapshot.aiGateway.name,
provider: 'LLM API',
module: 'Settings',
maskedValue: _snapshot.aiGateway.baseUrl.trim().isEmpty
? 'Not set'
: _snapshot.aiGateway.baseUrl,
status: _snapshot.aiGateway.syncState,
),
];
return entries;
}
Future<void> _reloadDerivedState() async {
final refs = await _store.loadSecureRefs();
_secureRefs = {
for (final entry in refs.entries)
entry.key: SecureConfigStore.maskValue(entry.value),
};
_auditTrail = await _store.loadAuditTrail();
}
String _providerNameForSecret(String key) {
if (key.contains('vault')) {
return 'Vault';
}
if (key.contains('ollama')) {
return 'Ollama Cloud';
}
if (key.contains('ai_gateway')) {
return 'LLM API';
}
if (key.contains('gateway')) {
return 'Gateway';
}
return 'Local Store';
}
String _moduleForSecret(String key) {
if (key.contains('gateway')) {
return key.contains('device_token') ? 'Devices' : 'Assistant';
}
if (key.contains('ollama')) {
return 'Settings';
}
if (key.contains('ai_gateway')) {
return 'Settings';
}
if (key.contains('vault')) {
return 'Secrets';
}
return 'Workspace';
}
Uri? _normalizeAiGatewayBaseUrl(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
return null;
}
final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed';
final uri = Uri.tryParse(candidate);
if (uri == null || uri.host.trim().isEmpty) {
return null;
}
final pathSegments = uri.pathSegments.where((item) => item.isNotEmpty);
return uri.replace(
pathSegments: pathSegments.isEmpty ? const <String>['v1'] : pathSegments,
query: null,
fragment: null,
);
}
Uri _aiGatewayModelsUri(Uri baseUrl) {
final pathSegments = baseUrl.pathSegments
.where((item) => item.isNotEmpty)
.toList(growable: true);
if (pathSegments.isEmpty) {
pathSegments.add('v1');
}
if (pathSegments.last != 'models') {
pathSegments.add('models');
}
return baseUrl.replace(
pathSegments: pathSegments,
query: null,
fragment: null,
);
}
Future<List<GatewayModelSummary>> _requestAiGatewayModels({
required Uri uri,
required String apiKey,
}) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 6);
try {
final request = await client
.getUrl(uri)
.timeout(const Duration(seconds: 6));
request.headers.set(HttpHeaders.acceptHeader, 'application/json');
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey');
request.headers.set('x-api-key', apiKey);
final response = await request.close().timeout(
const Duration(seconds: 6),
);
final body = await response.transform(utf8.decoder).join();
if (response.statusCode < 200 || response.statusCode >= 300) {
throw _AiGatewayResponseException(
statusCode: response.statusCode,
message: _aiGatewayHttpErrorLabel(
response.statusCode,
_extractAiGatewayErrorDetail(body),
),
);
}
final decoded = jsonDecode(_extractFirstJsonDocument(body));
final rawModels = decoded is Map<String, dynamic>
? [
...asList(decoded['data']),
if (asList(decoded['data']).isEmpty) ...asList(decoded['models']),
]
: const <Object>[];
final seen = <String>{};
final items = <GatewayModelSummary>[];
for (final item in rawModels) {
final map = asMap(item);
final modelId =
stringValue(map['id']) ?? stringValue(map['name']) ?? '';
if (modelId.trim().isEmpty || !seen.add(modelId)) {
continue;
}
items.add(
GatewayModelSummary(
id: modelId,
name: stringValue(map['name']) ?? modelId,
provider:
stringValue(map['provider']) ??
stringValue(map['owned_by']) ??
'LLM API',
contextWindow:
intValue(map['contextWindow']) ??
intValue(map['context_window']),
maxOutputTokens:
intValue(map['maxOutputTokens']) ??
intValue(map['max_output_tokens']),
),
);
}
return items;
} finally {
client.close(force: true);
}
}
String _networkErrorLabel(Object error) {
if (error is _AiGatewayResponseException) {
return error.message;
}
if (error is SocketException) {
return 'Unable to reach the LLM API';
}
if (error is HandshakeException) {
return 'TLS handshake failed';
}
if (error is TimeoutException) {
return 'Connection timed out';
}
if (error is FormatException) {
return 'LLM API returned invalid JSON';
}
return 'Failed: $error';
}
String _aiGatewayHttpErrorLabel(int statusCode, String detail) {
final base = switch (statusCode) {
400 => 'Bad request (400)',
401 => 'Authentication failed (401)',
403 => 'Access denied (403)',
404 => 'Model catalog endpoint not found (404)',
429 => 'Rate limited by LLM API (429)',
>= 500 => 'LLM API unavailable ($statusCode)',
_ => 'LLM API responded $statusCode',
};
return detail.isEmpty ? base : '$base · $detail';
}
String _extractAiGatewayErrorDetail(String body) {
if (body.trim().isEmpty) {
return '';
}
try {
final decoded = jsonDecode(_extractFirstJsonDocument(body));
final map = asMap(decoded);
final error = asMap(map['error']);
return (stringValue(error['message']) ??
stringValue(map['message']) ??
stringValue(map['detail']) ??
'')
.trim();
} on FormatException {
return '';
}
}
String _extractFirstJsonDocument(String body) {
final trimmed = body.trimLeft();
if (trimmed.isEmpty) {
throw const FormatException('Empty response body');
}
final start = trimmed.indexOf(RegExp(r'[\{\[]'));
if (start < 0) {
throw const FormatException('Missing JSON document');
}
var depth = 0;
var inString = false;
var escaped = false;
for (var index = start; index < trimmed.length; index++) {
final char = trimmed[index];
if (escaped) {
escaped = false;
continue;
}
if (char == r'\') {
escaped = true;
continue;
}
if (char == '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (char == '{' || char == '[') {
depth += 1;
} else if (char == '}' || char == ']') {
depth -= 1;
if (depth == 0) {
return trimmed.substring(start, index + 1);
}
}
}
throw const FormatException('Unterminated JSON document');
}
Future<HttpClientResponse> _simpleGet(
Uri uri, {
required Map<String, String> headers,
}) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 4);
try {
final request = await client
.getUrl(uri)
.timeout(const Duration(seconds: 4));
for (final entry in headers.entries) {
request.headers.set(entry.key, entry.value);
}
return await request.close().timeout(const Duration(seconds: 4));
} finally {
client.close(force: true);
}
}
String _timeLabel() {
final now = DateTime.now();
return '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
}
String _gatewaySecretTarget(String base, int? profileIndex) {
if (profileIndex == null) {
return base;
}
return '$base.$profileIndex';
}
Future<void> _startSettingsWatcher() async {
for (final subscription in _settingsWatchSubscriptions) {
await subscription.cancel();
}
_settingsWatchSubscriptions.clear();
final files = await _store.resolvedSettingsFiles();
final directories = await _store.resolvedSettingsWatchDirectories();
void scheduleReload() {
_settingsReloadDebounce?.cancel();
_settingsReloadDebounce = Timer(
const Duration(milliseconds: 160),
() => unawaited(_reloadSettingsFromDiskIfChanged()),
);
}
for (final file in files) {
try {
if (await file.exists()) {
_settingsWatchSubscriptions.add(
file.watch().listen((_) {
scheduleReload();
}),
);
}
} catch (_) {
// Best effort only. Directory watch below remains as a fallback.
}
}
for (final directory in directories) {
try {
if (!await directory.exists()) {
await directory.create(recursive: true);
}
_settingsWatchSubscriptions.add(
directory.watch().listen((_) {
scheduleReload();
}),
);
} catch (_) {
// Best effort only. Missing watch support should not block runtime.
}
}
}
Future<void> _reloadSettingsFromDiskIfChanged() async {
if (_disposed) {
return;
}
final nextStamp = await _resolveStableSettingsFileStamp();
if (nextStamp == _lastSettingsFileStamp) {
return;
}
final reload = await _store.reloadSettingsSnapshotResult();
if (!reload.applied) {
return;
}
_lastSettingsFileStamp = nextStamp;
final next = reload.snapshot;
final nextJson = next.toJsonString();
if (nextJson == _lastSnapshotJson) {
return;
}
_snapshot = next;
_lastSnapshotJson = nextJson;
await _reloadDerivedState();
notifyListeners();
}
void _startSettingsPolling() {
_settingsPollTimer?.cancel();
_settingsPollTimer = Timer.periodic(const Duration(seconds: 1), (_) {
unawaited(_pollSettingsFileChanges());
});
}
Future<void> _pollSettingsFileChanges() async {
if (_disposed) {
return;
}
final previousStamp = _lastSettingsFileStamp;
final nextStamp = await _computeSettingsFileStamp();
if (nextStamp == previousStamp) {
return;
}
await _reloadSettingsFromDiskIfChanged();
}
Future<void> _refreshSettingsFileStamp() async {
_lastSettingsFileStamp = await _computeSettingsFileStamp();
}
Future<String> _resolveStableSettingsFileStamp() async {
var current = await _computeSettingsFileStamp();
for (var attempt = 0; attempt < 4; attempt++) {
await Future<void>.delayed(const Duration(milliseconds: 120));
final next = await _computeSettingsFileStamp();
if (next == current) {
return next;
}
current = next;
}
return current;
}
Future<String> _computeSettingsFileStamp() async {
final files = await _store.resolvedSettingsFiles();
final buffer = StringBuffer();
for (final file in files) {
buffer.write(file.path);
if (await file.exists()) {
final stat = await file.stat();
buffer
..write(':')
..write(stat.modified.millisecondsSinceEpoch)
..write(':')
..write(stat.size);
} else {
buffer.write(':missing');
}
buffer.write('|');
}
return buffer.toString();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,592 @@
part of 'runtime_models.dart';
class GatewayConnectionProfile {
const GatewayConnectionProfile({
required this.mode,
required this.useSetupCode,
required this.setupCode,
required this.host,
required this.port,
required this.tls,
required this.selectedAgentId,
});
final RuntimeConnectionMode mode;
final bool useSetupCode;
final String setupCode;
final String host;
final int port;
final bool tls;
final String selectedAgentId;
factory GatewayConnectionProfile.defaults() {
return GatewayConnectionProfile.defaultsRemote();
}
factory GatewayConnectionProfile.defaultsLocal() {
return const GatewayConnectionProfile(
mode: RuntimeConnectionMode.local,
useSetupCode: false,
setupCode: '',
host: '127.0.0.1',
port: 18789,
tls: false,
selectedAgentId: '',
);
}
factory GatewayConnectionProfile.defaultsRemote() {
return const GatewayConnectionProfile(
mode: RuntimeConnectionMode.remote,
useSetupCode: false,
setupCode: '',
host: 'openclaw.svc.plus',
port: 443,
tls: true,
selectedAgentId: '',
);
}
factory GatewayConnectionProfile.emptySlot({required int index}) {
return const GatewayConnectionProfile(
mode: RuntimeConnectionMode.unconfigured,
useSetupCode: false,
setupCode: '',
host: '',
port: 443,
tls: true,
selectedAgentId: '',
);
}
GatewayConnectionProfile copyWith({
RuntimeConnectionMode? mode,
bool? useSetupCode,
String? setupCode,
String? host,
int? port,
bool? tls,
String? selectedAgentId,
}) {
final normalized = _normalizeGatewayManualEndpoint(
host: host ?? this.host,
port: port ?? this.port,
tls: tls ?? this.tls,
);
return GatewayConnectionProfile(
mode: mode ?? this.mode,
useSetupCode: useSetupCode ?? this.useSetupCode,
setupCode: setupCode ?? this.setupCode,
host: normalized.host,
port: normalized.port,
tls: normalized.tls,
selectedAgentId: selectedAgentId ?? this.selectedAgentId,
);
}
Map<String, dynamic> toJson() {
return {
'mode': mode.name,
'useSetupCode': useSetupCode,
'setupCode': setupCode,
'host': host,
'port': port,
'tls': tls,
'selectedAgentId': selectedAgentId,
};
}
factory GatewayConnectionProfile.fromJson(Map<String, dynamic> json) {
final defaults = GatewayConnectionProfile.defaults();
final normalized = _normalizeGatewayManualEndpoint(
host: json['host'] as String? ?? defaults.host,
port: json['port'] as int? ?? defaults.port,
tls: json['tls'] as bool? ?? defaults.tls,
);
return GatewayConnectionProfile(
mode: RuntimeConnectionModeCopy.fromJsonValue(json['mode'] as String?),
useSetupCode: json['useSetupCode'] as bool? ?? false,
setupCode: json['setupCode'] as String? ?? '',
host: normalized.host,
port: normalized.port,
tls: normalized.tls,
selectedAgentId: json['selectedAgentId'] as String? ?? '',
);
}
}
const int kGatewayProfileListLength = 5;
const int kGatewayLocalProfileIndex = 0;
const int kGatewayRemoteProfileIndex = 1;
const int kGatewayCustomProfileStartIndex = 2;
List<GatewayConnectionProfile> normalizeGatewayProfiles({
Iterable<GatewayConnectionProfile>? profiles,
}) {
final defaults = List<GatewayConnectionProfile>.generate(
kGatewayProfileListLength,
(index) => switch (index) {
kGatewayLocalProfileIndex => GatewayConnectionProfile.defaultsLocal(),
kGatewayRemoteProfileIndex => GatewayConnectionProfile.defaultsRemote(),
_ => GatewayConnectionProfile.emptySlot(index: index),
},
growable: false,
);
final incoming =
profiles?.toList(growable: false) ?? const <GatewayConnectionProfile>[];
final normalized = <GatewayConnectionProfile>[];
for (var index = 0; index < kGatewayProfileListLength; index += 1) {
final fallback = defaults[index];
final current = index < incoming.length ? incoming[index] : fallback;
if (index == kGatewayLocalProfileIndex) {
normalized.add(
current.copyWith(
mode: RuntimeConnectionMode.local,
useSetupCode: false,
setupCode: '',
host: current.host.trim().isEmpty ? fallback.host : current.host,
port: current.port > 0 ? current.port : fallback.port,
tls: false,
),
);
continue;
}
if (index == kGatewayRemoteProfileIndex) {
final useDefaultRemoteEndpoint =
current.host.trim().isEmpty || current.port <= 0;
normalized.add(
current.copyWith(
mode: RuntimeConnectionMode.remote,
host: useDefaultRemoteEndpoint ? fallback.host : current.host,
port: useDefaultRemoteEndpoint ? fallback.port : current.port,
tls: useDefaultRemoteEndpoint ? fallback.tls : current.tls,
),
);
continue;
}
final slotMode = switch (current.mode) {
RuntimeConnectionMode.local => RuntimeConnectionMode.local,
RuntimeConnectionMode.remote => RuntimeConnectionMode.remote,
RuntimeConnectionMode.unconfigured =>
current.host.trim().isNotEmpty
? RuntimeConnectionMode.remote
: RuntimeConnectionMode.unconfigured,
};
normalized.add(
current.copyWith(
mode: slotMode,
useSetupCode: slotMode == RuntimeConnectionMode.local
? false
: current.useSetupCode,
setupCode: slotMode == RuntimeConnectionMode.local
? ''
: current.setupCode,
port: current.port > 0
? current.port
: slotMode == RuntimeConnectionMode.local
? 18789
: 443,
tls: slotMode == RuntimeConnectionMode.local ? false : current.tls,
),
);
}
return List<GatewayConnectionProfile>.unmodifiable(normalized);
}
List<GatewayConnectionProfile> replaceGatewayProfileAt(
List<GatewayConnectionProfile> profiles,
int index,
GatewayConnectionProfile profile,
) {
final normalizedProfiles = normalizeGatewayProfiles(profiles: profiles);
final next = List<GatewayConnectionProfile>.from(normalizedProfiles);
final clampedIndex = index.clamp(0, kGatewayProfileListLength - 1);
next[clampedIndex] = profile;
return normalizeGatewayProfiles(profiles: next);
}
({String host, int port, bool tls}) _normalizeGatewayManualEndpoint({
required String host,
required int port,
required bool tls,
}) {
final trimmedHost = host.trim();
if (trimmedHost.isEmpty) {
return (host: trimmedHost, port: port, tls: tls);
}
final normalizedInput = trimmedHost.contains('://')
? trimmedHost
: '${tls ? 'https' : 'http'}://$trimmedHost:${port > 0 ? port : (tls ? 443 : 18789)}';
final uri = Uri.tryParse(normalizedInput);
final normalizedHost = uri?.host.trim() ?? trimmedHost;
if (normalizedHost.isEmpty) {
return (host: trimmedHost, port: port, tls: tls);
}
final scheme = uri?.scheme.trim().toLowerCase() ?? (tls ? 'https' : 'http');
final normalizedTls = switch (scheme) {
'ws' || 'http' => false,
_ => true,
};
final normalizedPort = uri?.hasPort == true
? uri!.port
: normalizedTls
? 443
: 18789;
return (
host: normalizedHost,
port: normalizedPort > 0 ? normalizedPort : port,
tls: normalizedTls,
);
}
class OllamaLocalConfig {
const OllamaLocalConfig({
required this.endpoint,
required this.defaultModel,
required this.autoDiscover,
});
final String endpoint;
final String defaultModel;
final bool autoDiscover;
factory OllamaLocalConfig.defaults() {
return const OllamaLocalConfig(
endpoint: 'http://127.0.0.1:11434',
defaultModel: 'qwen2.5-coder:latest',
autoDiscover: true,
);
}
OllamaLocalConfig copyWith({
String? endpoint,
String? defaultModel,
bool? autoDiscover,
}) {
return OllamaLocalConfig(
endpoint: endpoint ?? this.endpoint,
defaultModel: defaultModel ?? this.defaultModel,
autoDiscover: autoDiscover ?? this.autoDiscover,
);
}
Map<String, dynamic> toJson() {
return {
'endpoint': endpoint,
'defaultModel': defaultModel,
'autoDiscover': autoDiscover,
};
}
factory OllamaLocalConfig.fromJson(Map<String, dynamic> json) {
return OllamaLocalConfig(
endpoint:
json['endpoint'] as String? ?? OllamaLocalConfig.defaults().endpoint,
defaultModel:
json['defaultModel'] as String? ??
OllamaLocalConfig.defaults().defaultModel,
autoDiscover: json['autoDiscover'] as bool? ?? true,
);
}
}
class OllamaCloudConfig {
const OllamaCloudConfig({
required this.baseUrl,
required this.organization,
required this.workspace,
required this.defaultModel,
required this.apiKeyRef,
});
final String baseUrl;
final String organization;
final String workspace;
final String defaultModel;
final String apiKeyRef;
factory OllamaCloudConfig.defaults() {
return const OllamaCloudConfig(
baseUrl: 'https://ollama.com',
organization: '',
workspace: '',
defaultModel: 'gpt-oss:120b',
apiKeyRef: 'ollama_cloud_api_key',
);
}
OllamaCloudConfig copyWith({
String? baseUrl,
String? organization,
String? workspace,
String? defaultModel,
String? apiKeyRef,
}) {
return OllamaCloudConfig(
baseUrl: baseUrl ?? this.baseUrl,
organization: organization ?? this.organization,
workspace: workspace ?? this.workspace,
defaultModel: defaultModel ?? this.defaultModel,
apiKeyRef: apiKeyRef ?? this.apiKeyRef,
);
}
Map<String, dynamic> toJson() {
return {
'baseUrl': baseUrl,
'organization': organization,
'workspace': workspace,
'defaultModel': defaultModel,
'apiKeyRef': apiKeyRef,
};
}
factory OllamaCloudConfig.fromJson(Map<String, dynamic> json) {
return OllamaCloudConfig(
baseUrl:
json['baseUrl'] as String? ?? OllamaCloudConfig.defaults().baseUrl,
organization: json['organization'] as String? ?? '',
workspace: json['workspace'] as String? ?? '',
defaultModel:
json['defaultModel'] as String? ??
OllamaCloudConfig.defaults().defaultModel,
apiKeyRef:
json['apiKeyRef'] as String? ??
OllamaCloudConfig.defaults().apiKeyRef,
);
}
}
class VaultConfig {
const VaultConfig({
required this.address,
required this.namespace,
required this.authMode,
required this.tokenRef,
});
final String address;
final String namespace;
final String authMode;
final String tokenRef;
factory VaultConfig.defaults() {
return const VaultConfig(
address: 'http://127.0.0.1:8200',
namespace: 'default',
authMode: 'token',
tokenRef: 'vault_token',
);
}
VaultConfig copyWith({
String? address,
String? namespace,
String? authMode,
String? tokenRef,
}) {
return VaultConfig(
address: address ?? this.address,
namespace: namespace ?? this.namespace,
authMode: authMode ?? this.authMode,
tokenRef: tokenRef ?? this.tokenRef,
);
}
Map<String, dynamic> toJson() {
return {
'address': address,
'namespace': namespace,
'authMode': authMode,
'tokenRef': tokenRef,
};
}
factory VaultConfig.fromJson(Map<String, dynamic> json) {
return VaultConfig(
address: json['address'] as String? ?? VaultConfig.defaults().address,
namespace:
json['namespace'] as String? ?? VaultConfig.defaults().namespace,
authMode: json['authMode'] as String? ?? VaultConfig.defaults().authMode,
tokenRef: json['tokenRef'] as String? ?? VaultConfig.defaults().tokenRef,
);
}
}
class AiGatewayProfile {
const AiGatewayProfile({
required this.name,
required this.baseUrl,
required this.apiKeyRef,
required this.availableModels,
required this.selectedModels,
required this.syncState,
required this.syncMessage,
});
final String name;
final String baseUrl;
final String apiKeyRef;
final List<String> availableModels;
final List<String> selectedModels;
final String syncState;
final String syncMessage;
factory AiGatewayProfile.defaults() {
return const AiGatewayProfile(
name: 'LLM API',
baseUrl: '',
apiKeyRef: 'ai_gateway_api_key',
availableModels: <String>[],
selectedModels: <String>[],
syncState: 'idle',
syncMessage: 'Ready to sync models',
);
}
AiGatewayProfile copyWith({
String? name,
String? baseUrl,
String? apiKeyRef,
List<String>? availableModels,
List<String>? selectedModels,
String? syncState,
String? syncMessage,
}) {
return AiGatewayProfile(
name: name ?? this.name,
baseUrl: baseUrl ?? this.baseUrl,
apiKeyRef: apiKeyRef ?? this.apiKeyRef,
availableModels: availableModels ?? this.availableModels,
selectedModels: selectedModels ?? this.selectedModels,
syncState: syncState ?? this.syncState,
syncMessage: syncMessage ?? this.syncMessage,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'baseUrl': baseUrl,
'apiKeyRef': apiKeyRef,
'availableModels': availableModels,
'selectedModels': selectedModels,
'syncState': syncState,
'syncMessage': syncMessage,
};
}
factory AiGatewayProfile.fromJson(Map<String, dynamic> json) {
List<String> normalizeList(Object? value) {
if (value is! List) {
return const <String>[];
}
return value
.map((item) => item.toString().trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
}
final defaults = AiGatewayProfile.defaults();
final availableModels = normalizeList(json['availableModels']);
final selectedModels = normalizeList(json['selectedModels'])
.where(
(item) => availableModels.isEmpty || availableModels.contains(item),
)
.toList(growable: false);
final legacyFilePath = json['filePath'] as String?;
final legacyBaseUrl =
legacyFilePath != null && legacyFilePath.trim().startsWith('http')
? legacyFilePath.trim()
: null;
return AiGatewayProfile(
name: json['name'] as String? ?? defaults.name,
baseUrl: json['baseUrl'] as String? ?? legacyBaseUrl ?? defaults.baseUrl,
apiKeyRef: json['apiKeyRef'] as String? ?? defaults.apiKeyRef,
availableModels: availableModels,
selectedModels: selectedModels,
syncState: json['syncState'] as String? ?? defaults.syncState,
syncMessage: json['syncMessage'] as String? ?? defaults.syncMessage,
);
}
}
class AiGatewayConnectionCheck {
const AiGatewayConnectionCheck({
required this.state,
required this.message,
required this.endpoint,
required this.modelCount,
});
final String state;
final String message;
final String endpoint;
final int modelCount;
bool get success => state == 'ready' || state == 'empty';
}
enum WebSessionPersistenceMode { browser, remote }
extension WebSessionPersistenceModeCopy on WebSessionPersistenceMode {
String get label => switch (this) {
WebSessionPersistenceMode.browser => appText('浏览器本地缓存', 'Browser cache'),
WebSessionPersistenceMode.remote => appText(
'远端 Session API',
'Remote session API',
),
};
static WebSessionPersistenceMode fromJsonValue(String? value) {
return WebSessionPersistenceMode.values.firstWhere(
(item) => item.name == value,
orElse: () => WebSessionPersistenceMode.browser,
);
}
}
class WebSessionPersistenceConfig {
const WebSessionPersistenceConfig({
required this.mode,
required this.remoteBaseUrl,
});
final WebSessionPersistenceMode mode;
final String remoteBaseUrl;
factory WebSessionPersistenceConfig.defaults() {
return const WebSessionPersistenceConfig(
mode: WebSessionPersistenceMode.browser,
remoteBaseUrl: '',
);
}
bool get usesRemoteApi =>
mode == WebSessionPersistenceMode.remote &&
remoteBaseUrl.trim().isNotEmpty;
WebSessionPersistenceConfig copyWith({
WebSessionPersistenceMode? mode,
String? remoteBaseUrl,
}) {
return WebSessionPersistenceConfig(
mode: mode ?? this.mode,
remoteBaseUrl: remoteBaseUrl ?? this.remoteBaseUrl,
);
}
Map<String, dynamic> toJson() {
return {'mode': mode.name, 'remoteBaseUrl': remoteBaseUrl};
}
factory WebSessionPersistenceConfig.fromJson(Map<String, dynamic> json) {
final defaults = WebSessionPersistenceConfig.defaults();
return WebSessionPersistenceConfig(
mode: WebSessionPersistenceModeCopy.fromJsonValue(
json['mode'] as String?,
),
remoteBaseUrl: json['remoteBaseUrl'] as String? ?? defaults.remoteBaseUrl,
);
}
}

View File

@ -0,0 +1,308 @@
part of 'runtime_models.dart';
enum RuntimeConnectionMode { unconfigured, local, remote }
extension RuntimeConnectionModeCopy on RuntimeConnectionMode {
String get label => switch (this) {
RuntimeConnectionMode.unconfigured => appText('未配置', 'Unconfigured'),
RuntimeConnectionMode.local => appText('本地', 'Local'),
RuntimeConnectionMode.remote => appText('远程', 'Remote'),
};
static RuntimeConnectionMode fromJsonValue(String? value) {
return RuntimeConnectionMode.values.firstWhere(
(item) => item.name == value,
orElse: () => RuntimeConnectionMode.unconfigured,
);
}
}
enum RuntimeConnectionStatus { offline, connecting, connected, error }
extension RuntimeConnectionStatusCopy on RuntimeConnectionStatus {
String get label => switch (this) {
RuntimeConnectionStatus.offline => appText('离线', 'Offline'),
RuntimeConnectionStatus.connecting => appText('连接中', 'Connecting'),
RuntimeConnectionStatus.connected => appText('已连接', 'Connected'),
RuntimeConnectionStatus.error => appText('错误', 'Error'),
};
}
enum AssistantExecutionTarget { singleAgent, local, remote }
extension AssistantExecutionTargetCopy on AssistantExecutionTarget {
String get label => switch (this) {
AssistantExecutionTarget.singleAgent => appText('单机智能体', 'Single Agent'),
AssistantExecutionTarget.local => appText(
'本地 OpenClaw Gateway',
'Local OpenClaw Gateway',
),
AssistantExecutionTarget.remote => appText(
'远程 OpenClaw Gateway',
'Remote OpenClaw Gateway',
),
};
String get promptValue => switch (this) {
AssistantExecutionTarget.singleAgent => 'single-agent',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',
};
static AssistantExecutionTarget fromJsonValue(String? value) {
final normalized = value?.trim() ?? '';
switch (normalized) {
case 'singleAgent':
case 'aiGatewayOnly':
case 'single-agent':
case 'ai-gateway-only':
return AssistantExecutionTarget.singleAgent;
case 'local':
return AssistantExecutionTarget.local;
case 'remote':
return AssistantExecutionTarget.remote;
default:
return AssistantExecutionTarget.local;
}
}
}
String normalizeSingleAgentProviderId(String value) {
final trimmed = value.trim().toLowerCase();
if (trimmed.isEmpty) {
return '';
}
final normalizedWhitespace = trimmed.replaceAll(RegExp(r'\s+'), '-');
final buffer = StringBuffer();
var previousWasSeparator = false;
var hasOutput = false;
for (final rune in normalizedWhitespace.runes) {
final char = String.fromCharCode(rune);
final isAlphaNumeric =
(rune >= 97 && rune <= 122) || (rune >= 48 && rune <= 57);
final isSeparator = char == '-' || char == '_' || char == '.';
if (isAlphaNumeric) {
buffer.write(char);
previousWasSeparator = false;
hasOutput = true;
continue;
}
if (isSeparator && !previousWasSeparator && hasOutput) {
buffer.write('-');
previousWasSeparator = true;
}
}
return buffer.toString().replaceAll(RegExp(r'^[-_.]+|[-_.]+$'), '');
}
String _singleAgentProviderFallbackLabel(String providerId) {
final normalized = normalizeSingleAgentProviderId(providerId);
if (normalized.isEmpty) {
return 'Custom Agent';
}
return normalized
.split(RegExp(r'[-_.]+'))
.where((item) => item.isNotEmpty)
.map((item) => '${item[0].toUpperCase()}${item.substring(1)}')
.join(' ');
}
String _singleAgentProviderFallbackBadge({
required String providerId,
required String label,
}) {
final normalized = normalizeSingleAgentProviderId(providerId);
final known = <String, String>{
'auto': 'A',
'codex': 'C',
'opencode': 'O',
'claude': 'Cl',
'gemini': 'G',
};
final explicit = known[normalized];
if (explicit != null) {
return explicit;
}
final stripped = label.replaceAll(RegExp(r'\s+'), '');
if (stripped.isEmpty) {
return '?';
}
final length = stripped.length >= 2 ? 2 : 1;
return stripped.substring(0, length).toUpperCase();
}
const Set<String> kSupportedExternalAcpEndpointSchemes = <String>{
'ws',
'wss',
'http',
'https',
};
bool isSupportedExternalAcpEndpoint(String endpoint) {
final trimmed = endpoint.trim();
if (trimmed.isEmpty) {
return false;
}
final uri = Uri.tryParse(trimmed);
final scheme = uri?.scheme.trim().toLowerCase() ?? '';
return kSupportedExternalAcpEndpointSchemes.contains(scheme);
}
class SingleAgentProvider {
const SingleAgentProvider({
required this.providerId,
required this.label,
required this.badge,
this.source = SingleAgentProviderSource.externalExtension,
});
static const SingleAgentProvider auto = SingleAgentProvider(
providerId: 'auto',
label: 'Auto',
badge: 'A',
);
static const SingleAgentProvider codex = SingleAgentProvider(
providerId: 'codex',
label: 'Codex',
badge: 'C',
source: SingleAgentProviderSource.builtInReserved,
);
static const SingleAgentProvider opencode = SingleAgentProvider(
providerId: 'opencode',
label: 'OpenCode',
badge: 'O',
);
static const SingleAgentProvider claude = SingleAgentProvider(
providerId: 'claude',
label: 'Claude',
badge: 'Cl',
);
static const SingleAgentProvider gemini = SingleAgentProvider(
providerId: 'gemini',
label: 'Gemini',
badge: 'G',
);
final String providerId;
final String label;
final String badge;
final SingleAgentProviderSource source;
bool get isAuto => providerId == auto.providerId;
bool get isBuiltInReserved =>
source == SingleAgentProviderSource.builtInReserved;
bool get isExternalExtension =>
source == SingleAgentProviderSource.externalExtension;
SingleAgentProvider copyWith({
String? providerId,
String? label,
String? badge,
SingleAgentProviderSource? source,
}) {
final resolvedProviderId = normalizeSingleAgentProviderId(
providerId ?? this.providerId,
);
final resolvedLabel = (label ?? this.label).trim();
final resolvedBadge = (badge ?? this.badge).trim();
return SingleAgentProvider(
providerId: resolvedProviderId,
label: resolvedLabel.isEmpty
? _singleAgentProviderFallbackLabel(resolvedProviderId)
: resolvedLabel,
badge: resolvedBadge.isEmpty
? _singleAgentProviderFallbackBadge(
providerId: resolvedProviderId,
label: resolvedLabel,
)
: resolvedBadge,
source: source ?? this.source,
);
}
static SingleAgentProvider fromJsonValue(
String? value, {
String? label,
String? badge,
}) {
final normalized = normalizeSingleAgentProviderId(value ?? '');
final base = switch (normalized) {
'codex' => codex,
'opencode' => opencode,
'claude' => claude,
'gemini' => gemini,
'auto' || '' => auto,
_ => SingleAgentProvider(
providerId: normalized,
label: _singleAgentProviderFallbackLabel(normalized),
badge: _singleAgentProviderFallbackBadge(
providerId: normalized,
label: _singleAgentProviderFallbackLabel(normalized),
),
),
};
return base.copyWith(label: label, badge: badge);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is SingleAgentProvider && other.providerId == providerId);
@override
int get hashCode => providerId.hashCode;
}
extension SingleAgentProviderCopy on SingleAgentProvider {
static SingleAgentProvider fromJsonValue(
String? value, {
String? label,
String? badge,
}) => SingleAgentProvider.fromJsonValue(value, label: label, badge: badge);
}
enum SingleAgentProviderSource { externalExtension, builtInReserved }
SingleAgentProvider normalizeSingleAgentProviderSelection(
SingleAgentProvider provider,
) {
if (provider.isBuiltInReserved) {
return SingleAgentProvider.opencode;
}
return provider;
}
List<SingleAgentProvider> normalizeSingleAgentProviderList(
Iterable<SingleAgentProvider> providers,
) {
final normalized = <SingleAgentProvider>[];
final seen = <String>{};
for (final provider in providers) {
final resolved = normalizeSingleAgentProviderSelection(provider);
if (seen.add(resolved.providerId)) {
normalized.add(resolved);
}
}
return normalized;
}
const List<SingleAgentProvider> kPresetExternalAcpProviders =
<SingleAgentProvider>[SingleAgentProvider.opencode];
const List<SingleAgentProvider> kKnownSingleAgentProviders =
<SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.claude,
SingleAgentProvider.gemini,
];
const Set<String> kLegacyExternalAcpProviderIds = <String>{
'claude',
'gemini',
'codex',
};

View File

@ -0,0 +1,351 @@
part of 'runtime_models.dart';
class GatewayChatAttachmentPayload {
const GatewayChatAttachmentPayload({
required this.type,
required this.mimeType,
required this.fileName,
required this.content,
});
final String type;
final String mimeType;
final String fileName;
final String content;
Map<String, dynamic> toJson() {
return {
'type': type,
'mimeType': mimeType,
'fileName': fileName,
'content': content,
};
}
}
class GatewayInstanceSummary {
const GatewayInstanceSummary({
required this.id,
required this.host,
required this.ip,
required this.version,
required this.platform,
required this.deviceFamily,
required this.modelIdentifier,
required this.lastInputSeconds,
required this.mode,
required this.reason,
required this.text,
required this.timestampMs,
});
final String id;
final String? host;
final String? ip;
final String? version;
final String? platform;
final String? deviceFamily;
final String? modelIdentifier;
final int? lastInputSeconds;
final String? mode;
final String? reason;
final String text;
final double timestampMs;
}
class GatewaySkillSummary {
const GatewaySkillSummary({
required this.name,
required this.description,
required this.source,
required this.skillKey,
required this.primaryEnv,
required this.eligible,
required this.disabled,
required this.missingBins,
required this.missingEnv,
required this.missingConfig,
});
final String name;
final String description;
final String source;
final String skillKey;
final String? primaryEnv;
final bool eligible;
final bool disabled;
final List<String> missingBins;
final List<String> missingEnv;
final List<String> missingConfig;
}
class GatewayConnectorSummary {
const GatewayConnectorSummary({
required this.id,
required this.label,
required this.detailLabel,
required this.accountName,
required this.configured,
required this.enabled,
required this.running,
required this.connected,
required this.status,
required this.lastError,
required this.meta,
});
final String id;
final String label;
final String detailLabel;
final String? accountName;
final bool configured;
final bool enabled;
final bool running;
final bool connected;
final String status;
final String? lastError;
final List<String> meta;
}
class GatewayModelSummary {
const GatewayModelSummary({
required this.id,
required this.name,
required this.provider,
required this.contextWindow,
required this.maxOutputTokens,
});
final String id;
final String name;
final String provider;
final int? contextWindow;
final int? maxOutputTokens;
}
class GatewayCronJobSummary {
const GatewayCronJobSummary({
required this.id,
required this.name,
required this.description,
required this.enabled,
required this.agentId,
required this.scheduleLabel,
required this.nextRunAtMs,
required this.lastRunAtMs,
required this.lastStatus,
required this.lastError,
});
final String id;
final String name;
final String? description;
final bool enabled;
final String? agentId;
final String scheduleLabel;
final int? nextRunAtMs;
final int? lastRunAtMs;
final String? lastStatus;
final String? lastError;
}
class GatewayDevicePairingList {
const GatewayDevicePairingList({required this.pending, required this.paired});
final List<GatewayPendingDevice> pending;
final List<GatewayPairedDevice> paired;
const GatewayDevicePairingList.empty()
: pending = const <GatewayPendingDevice>[],
paired = const <GatewayPairedDevice>[];
}
class GatewayPendingDevice {
const GatewayPendingDevice({
required this.requestId,
required this.deviceId,
required this.displayName,
required this.role,
required this.scopes,
required this.remoteIp,
required this.isRepair,
required this.requestedAtMs,
});
final String requestId;
final String deviceId;
final String? displayName;
final String? role;
final List<String> scopes;
final String? remoteIp;
final bool isRepair;
final int? requestedAtMs;
String get label {
final display = displayName?.trim() ?? '';
return display.isEmpty ? deviceId : display;
}
}
class GatewayPairedDevice {
const GatewayPairedDevice({
required this.deviceId,
required this.displayName,
required this.roles,
required this.scopes,
required this.remoteIp,
required this.tokens,
required this.createdAtMs,
required this.approvedAtMs,
required this.currentDevice,
});
final String deviceId;
final String? displayName;
final List<String> roles;
final List<String> scopes;
final String? remoteIp;
final List<GatewayDeviceTokenSummary> tokens;
final int? createdAtMs;
final int? approvedAtMs;
final bool currentDevice;
String get label {
final display = displayName?.trim() ?? '';
return display.isEmpty ? deviceId : display;
}
}
class GatewayDeviceTokenSummary {
const GatewayDeviceTokenSummary({
required this.role,
required this.scopes,
required this.createdAtMs,
required this.rotatedAtMs,
required this.revokedAtMs,
required this.lastUsedAtMs,
});
final String role;
final List<String> scopes;
final int? createdAtMs;
final int? rotatedAtMs;
final int? revokedAtMs;
final int? lastUsedAtMs;
bool get revoked => revokedAtMs != null;
}
class SecretReferenceEntry {
const SecretReferenceEntry({
required this.name,
required this.provider,
required this.module,
required this.maskedValue,
required this.status,
});
final String name;
final String provider;
final String module;
final String maskedValue;
final String status;
}
class SecretAuditEntry {
const SecretAuditEntry({
required this.timeLabel,
required this.action,
required this.provider,
required this.target,
required this.module,
required this.status,
});
final String timeLabel;
final String action;
final String provider;
final String target;
final String module;
final String status;
Map<String, dynamic> toJson() {
return {
'timeLabel': timeLabel,
'action': action,
'provider': provider,
'target': target,
'module': module,
'status': status,
};
}
factory SecretAuditEntry.fromJson(Map<String, dynamic> json) {
return SecretAuditEntry(
timeLabel: json['timeLabel'] as String? ?? '',
action: json['action'] as String? ?? '',
provider: json['provider'] as String? ?? '',
target: json['target'] as String? ?? '',
module: json['module'] as String? ?? '',
status: json['status'] as String? ?? '',
);
}
}
class DerivedTaskItem {
const DerivedTaskItem({
required this.id,
required this.title,
required this.owner,
required this.status,
required this.surface,
required this.startedAtLabel,
required this.durationLabel,
required this.summary,
required this.sessionKey,
});
final String id;
final String title;
final String owner;
final String status;
final String surface;
final String startedAtLabel;
final String durationLabel;
final String summary;
final String sessionKey;
}
class LocalDeviceIdentity {
const LocalDeviceIdentity({
required this.deviceId,
required this.publicKeyBase64Url,
required this.privateKeyBase64Url,
required this.createdAtMs,
});
final String deviceId;
final String publicKeyBase64Url;
final String privateKeyBase64Url;
final int createdAtMs;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'deviceId': deviceId,
'publicKeyBase64Url': publicKeyBase64Url,
'privateKeyBase64Url': privateKeyBase64Url,
'createdAtMs': createdAtMs,
};
}
factory LocalDeviceIdentity.fromJson(Map<String, dynamic> json) {
return LocalDeviceIdentity(
deviceId: json['deviceId'] as String? ?? '',
publicKeyBase64Url: json['publicKeyBase64Url'] as String? ?? '',
privateKeyBase64Url: json['privateKeyBase64Url'] as String? ?? '',
createdAtMs: (json['createdAtMs'] as num?)?.toInt() ?? 0,
);
}
}
/// Agent

View File

@ -0,0 +1,830 @@
part of 'runtime_models.dart';
enum MultiAgentRole {
architect, // /
engineer, //
testerDoc, // worker/review
}
enum MultiAgentFramework { native, aris }
extension MultiAgentFrameworkCopy on MultiAgentFramework {
String get label => switch (this) {
MultiAgentFramework.native => appText('原生多 Agent', 'Native Multi-Agent'),
MultiAgentFramework.aris => appText('ARIS 框架', 'ARIS Framework'),
};
static MultiAgentFramework fromJsonValue(String? value) {
return MultiAgentFramework.values.firstWhere(
(item) => item.name == value,
orElse: () => MultiAgentFramework.native,
);
}
}
extension MultiAgentRoleCopy on MultiAgentRole {
String get label => switch (this) {
MultiAgentRole.architect => 'Architect调度/文档)',
MultiAgentRole.engineer => 'Lead Engineer主程',
MultiAgentRole.testerDoc => 'Worker/ReviewWorker 池)',
};
String get description => switch (this) {
MultiAgentRole.architect => '负责需求收口、接受标准、文档与协作调度',
MultiAgentRole.engineer => '负责主实现、关键改动、集成收口',
MultiAgentRole.testerDoc => '负责并行 worker、复审、回归和补充说明',
};
}
enum AiGatewayInjectionPolicy { disabled, launchScoped, appManagedDefault }
extension AiGatewayInjectionPolicyCopy on AiGatewayInjectionPolicy {
String get label => switch (this) {
AiGatewayInjectionPolicy.disabled => appText('禁用', 'Disabled'),
AiGatewayInjectionPolicy.launchScoped => appText(
'仅当前协作运行',
'Launch scoped',
),
AiGatewayInjectionPolicy.appManagedDefault => appText(
'XWorkmate 默认',
'XWorkmate default',
),
};
static AiGatewayInjectionPolicy fromJsonValue(String? value) {
return AiGatewayInjectionPolicy.values.firstWhere(
(item) => item.name == value,
orElse: () => AiGatewayInjectionPolicy.appManagedDefault,
);
}
}
/// Agent Worker
class AgentWorkerConfig {
const AgentWorkerConfig({
required this.role,
required this.cliTool,
required this.model,
required this.enabled,
this.maxRetries = 2,
});
final MultiAgentRole role;
final String cliTool; // e.g. 'claude' | 'codex' | 'opencode' | 'gemini'
final String model;
final bool enabled;
final int maxRetries;
AgentWorkerConfig copyWith({
MultiAgentRole? role,
String? cliTool,
String? model,
bool? enabled,
int? maxRetries,
}) {
return AgentWorkerConfig(
role: role ?? this.role,
cliTool: cliTool ?? this.cliTool,
model: model ?? this.model,
enabled: enabled ?? this.enabled,
maxRetries: maxRetries ?? this.maxRetries,
);
}
}
class ManagedSkillEntry {
const ManagedSkillEntry({
required this.key,
required this.label,
required this.source,
required this.selected,
});
final String key;
final String label;
final String source;
final bool selected;
ManagedSkillEntry copyWith({
String? key,
String? label,
String? source,
bool? selected,
}) {
return ManagedSkillEntry(
key: key ?? this.key,
label: label ?? this.label,
source: source ?? this.source,
selected: selected ?? this.selected,
);
}
Map<String, dynamic> toJson() {
return {'key': key, 'label': label, 'source': source, 'selected': selected};
}
factory ManagedSkillEntry.fromJson(Map<String, dynamic> json) {
return ManagedSkillEntry(
key: json['key'] as String? ?? '',
label: json['label'] as String? ?? '',
source: json['source'] as String? ?? '',
selected: json['selected'] as bool? ?? false,
);
}
}
class ManagedMcpServerEntry {
const ManagedMcpServerEntry({
required this.id,
required this.name,
required this.transport,
required this.command,
required this.url,
required this.args,
required this.envKeys,
required this.enabled,
});
final String id;
final String name;
final String transport;
final String command;
final String url;
final List<String> args;
final List<String> envKeys;
final bool enabled;
ManagedMcpServerEntry copyWith({
String? id,
String? name,
String? transport,
String? command,
String? url,
List<String>? args,
List<String>? envKeys,
bool? enabled,
}) {
return ManagedMcpServerEntry(
id: id ?? this.id,
name: name ?? this.name,
transport: transport ?? this.transport,
command: command ?? this.command,
url: url ?? this.url,
args: args ?? this.args,
envKeys: envKeys ?? this.envKeys,
enabled: enabled ?? this.enabled,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'transport': transport,
'command': command,
'url': url,
'args': args,
'envKeys': envKeys,
'enabled': enabled,
};
}
factory ManagedMcpServerEntry.fromJson(Map<String, dynamic> json) {
final rawArgs = json['args'];
final rawEnvKeys = json['envKeys'];
return ManagedMcpServerEntry(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
transport: json['transport'] as String? ?? 'stdio',
command: json['command'] as String? ?? '',
url: json['url'] as String? ?? '',
args: rawArgs is List
? rawArgs.map((item) => item.toString()).toList(growable: false)
: const <String>[],
envKeys: rawEnvKeys is List
? rawEnvKeys.map((item) => item.toString()).toList(growable: false)
: const <String>[],
enabled: json['enabled'] as bool? ?? true,
);
}
}
class ManagedMountTargetState {
const ManagedMountTargetState({
required this.targetId,
required this.label,
required this.available,
required this.supportsSkills,
required this.supportsMcp,
required this.supportsAiGatewayInjection,
required this.discoveryState,
required this.syncState,
required this.discoveredSkillCount,
required this.discoveredMcpCount,
required this.managedMcpCount,
required this.detail,
});
final String targetId;
final String label;
final bool available;
final bool supportsSkills;
final bool supportsMcp;
final bool supportsAiGatewayInjection;
final String discoveryState;
final String syncState;
final int discoveredSkillCount;
final int discoveredMcpCount;
final int managedMcpCount;
final String detail;
ManagedMountTargetState copyWith({
String? targetId,
String? label,
bool? available,
bool? supportsSkills,
bool? supportsMcp,
bool? supportsAiGatewayInjection,
String? discoveryState,
String? syncState,
int? discoveredSkillCount,
int? discoveredMcpCount,
int? managedMcpCount,
String? detail,
}) {
return ManagedMountTargetState(
targetId: targetId ?? this.targetId,
label: label ?? this.label,
available: available ?? this.available,
supportsSkills: supportsSkills ?? this.supportsSkills,
supportsMcp: supportsMcp ?? this.supportsMcp,
supportsAiGatewayInjection:
supportsAiGatewayInjection ?? this.supportsAiGatewayInjection,
discoveryState: discoveryState ?? this.discoveryState,
syncState: syncState ?? this.syncState,
discoveredSkillCount: discoveredSkillCount ?? this.discoveredSkillCount,
discoveredMcpCount: discoveredMcpCount ?? this.discoveredMcpCount,
managedMcpCount: managedMcpCount ?? this.managedMcpCount,
detail: detail ?? this.detail,
);
}
Map<String, dynamic> toJson() {
return {
'targetId': targetId,
'label': label,
'available': available,
'supportsSkills': supportsSkills,
'supportsMcp': supportsMcp,
'supportsAiGatewayInjection': supportsAiGatewayInjection,
'discoveryState': discoveryState,
'syncState': syncState,
'discoveredSkillCount': discoveredSkillCount,
'discoveredMcpCount': discoveredMcpCount,
'managedMcpCount': managedMcpCount,
'detail': detail,
};
}
factory ManagedMountTargetState.fromJson(Map<String, dynamic> json) {
return ManagedMountTargetState(
targetId: json['targetId'] as String? ?? '',
label: json['label'] as String? ?? '',
available: json['available'] as bool? ?? false,
supportsSkills: json['supportsSkills'] as bool? ?? false,
supportsMcp: json['supportsMcp'] as bool? ?? false,
supportsAiGatewayInjection:
json['supportsAiGatewayInjection'] as bool? ?? false,
discoveryState: json['discoveryState'] as String? ?? 'idle',
syncState: json['syncState'] as String? ?? 'idle',
discoveredSkillCount: json['discoveredSkillCount'] as int? ?? 0,
discoveredMcpCount: json['discoveredMcpCount'] as int? ?? 0,
managedMcpCount: json['managedMcpCount'] as int? ?? 0,
detail: json['detail'] as String? ?? '',
);
}
factory ManagedMountTargetState.placeholder({
required String targetId,
required String label,
required bool supportsSkills,
required bool supportsMcp,
required bool supportsAiGatewayInjection,
}) {
return ManagedMountTargetState(
targetId: targetId,
label: label,
available: false,
supportsSkills: supportsSkills,
supportsMcp: supportsMcp,
supportsAiGatewayInjection: supportsAiGatewayInjection,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
);
}
static List<ManagedMountTargetState> defaults() {
return const <ManagedMountTargetState>[
ManagedMountTargetState(
targetId: 'aris',
label: 'ARIS',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: false,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'codex',
label: 'Codex',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'claude',
label: 'Claude',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'gemini',
label: 'Gemini',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'opencode',
label: 'OpenCode',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'openclaw',
label: 'OpenClaw',
available: false,
supportsSkills: true,
supportsMcp: false,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
];
}
}
/// Agent
class MultiAgentConfig {
const MultiAgentConfig({
required this.enabled,
required this.autoSync,
required this.framework,
required this.arisEnabled,
required this.arisMode,
required this.arisBundleVersion,
required this.arisCompatStatus,
required this.architect,
required this.engineer,
required this.tester,
required this.ollamaEndpoint,
required this.maxIterations,
required this.minAcceptableScore,
required this.timeoutSeconds,
required this.aiGatewayInjectionPolicy,
required this.managedSkills,
required this.managedMcpServers,
required this.mountTargets,
});
final bool enabled;
final bool autoSync;
final MultiAgentFramework framework;
final bool arisEnabled;
final String arisMode;
final String arisBundleVersion;
final String arisCompatStatus;
final AgentWorkerConfig architect;
final AgentWorkerConfig engineer;
final AgentWorkerConfig tester;
final String ollamaEndpoint;
final int maxIterations;
final int minAcceptableScore;
final int timeoutSeconds;
final AiGatewayInjectionPolicy aiGatewayInjectionPolicy;
final List<ManagedSkillEntry> managedSkills;
final List<ManagedMcpServerEntry> managedMcpServers;
final List<ManagedMountTargetState> mountTargets;
/// Architect 便访
bool get architectEnabled => architect.enabled;
String get architectTool => architect.cliTool;
String get architectModel => architect.model;
/// Engineer 便访
String get engineerTool => engineer.cliTool;
String get engineerModel => engineer.model;
/// Tester 便访
String get testerTool => tester.cliTool;
String get testerModel => tester.model;
bool get usesAris => arisEnabled || framework == MultiAgentFramework.aris;
factory MultiAgentConfig.defaults() {
return MultiAgentConfig(
enabled: false,
autoSync: true,
framework: MultiAgentFramework.native,
arisEnabled: false,
arisMode: 'full',
arisBundleVersion: '',
arisCompatStatus: 'idle',
architect: const AgentWorkerConfig(
role: MultiAgentRole.architect,
cliTool: 'claude',
model: 'kimi-k2.5:cloud',
enabled: true,
),
engineer: const AgentWorkerConfig(
role: MultiAgentRole.engineer,
cliTool: 'codex',
model: 'minimax-m2.7:cloud',
enabled: true,
),
tester: const AgentWorkerConfig(
role: MultiAgentRole.testerDoc,
cliTool: 'opencode',
model: 'glm-5:cloud',
enabled: true,
),
ollamaEndpoint: 'http://127.0.0.1:11434',
maxIterations: 3,
minAcceptableScore: 7,
timeoutSeconds: 120,
aiGatewayInjectionPolicy: AiGatewayInjectionPolicy.appManagedDefault,
managedSkills: const <ManagedSkillEntry>[],
managedMcpServers: const <ManagedMcpServerEntry>[],
mountTargets: const <ManagedMountTargetState>[
ManagedMountTargetState(
targetId: 'aris',
label: 'ARIS',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: false,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'codex',
label: 'Codex',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'claude',
label: 'Claude',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'gemini',
label: 'Gemini',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'opencode',
label: 'OpenCode',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'openclaw',
label: 'OpenClaw',
available: false,
supportsSkills: true,
supportsMcp: false,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
],
);
}
MultiAgentConfig copyWith({
bool? enabled,
bool? autoSync,
MultiAgentFramework? framework,
bool? arisEnabled,
String? arisMode,
String? arisBundleVersion,
String? arisCompatStatus,
AgentWorkerConfig? architect,
AgentWorkerConfig? engineer,
AgentWorkerConfig? tester,
String? ollamaEndpoint,
int? maxIterations,
int? minAcceptableScore,
int? timeoutSeconds,
AiGatewayInjectionPolicy? aiGatewayInjectionPolicy,
List<ManagedSkillEntry>? managedSkills,
List<ManagedMcpServerEntry>? managedMcpServers,
List<ManagedMountTargetState>? mountTargets,
}) {
return MultiAgentConfig(
enabled: enabled ?? this.enabled,
autoSync: autoSync ?? this.autoSync,
framework: framework ?? this.framework,
arisEnabled: arisEnabled ?? this.arisEnabled,
arisMode: arisMode ?? this.arisMode,
arisBundleVersion: arisBundleVersion ?? this.arisBundleVersion,
arisCompatStatus: arisCompatStatus ?? this.arisCompatStatus,
architect: architect ?? this.architect,
engineer: engineer ?? this.engineer,
tester: tester ?? this.tester,
ollamaEndpoint: ollamaEndpoint ?? this.ollamaEndpoint,
maxIterations: maxIterations ?? this.maxIterations,
minAcceptableScore: minAcceptableScore ?? this.minAcceptableScore,
timeoutSeconds: timeoutSeconds ?? this.timeoutSeconds,
aiGatewayInjectionPolicy:
aiGatewayInjectionPolicy ?? this.aiGatewayInjectionPolicy,
managedSkills: managedSkills ?? this.managedSkills,
managedMcpServers: managedMcpServers ?? this.managedMcpServers,
mountTargets: mountTargets ?? this.mountTargets,
);
}
Map<String, dynamic> toJson() {
return {
'enabled': enabled,
'autoSync': autoSync,
'framework': framework.name,
'arisEnabled': arisEnabled,
'arisMode': arisMode,
'arisBundleVersion': arisBundleVersion,
'arisCompatStatus': arisCompatStatus,
'architect': {
'role': architect.role.name,
'cliTool': architect.cliTool,
'model': architect.model,
'enabled': architect.enabled,
'maxRetries': architect.maxRetries,
},
'engineer': {
'role': engineer.role.name,
'cliTool': engineer.cliTool,
'model': engineer.model,
'enabled': engineer.enabled,
'maxRetries': engineer.maxRetries,
},
'tester': {
'role': tester.role.name,
'cliTool': tester.cliTool,
'model': tester.model,
'enabled': tester.enabled,
'maxRetries': tester.maxRetries,
},
'ollamaEndpoint': ollamaEndpoint,
'maxIterations': maxIterations,
'minAcceptableScore': minAcceptableScore,
'timeoutSeconds': timeoutSeconds,
'aiGatewayInjectionPolicy': aiGatewayInjectionPolicy.name,
'managedSkills': managedSkills.map((item) => item.toJson()).toList(),
'managedMcpServers': managedMcpServers
.map((item) => item.toJson())
.toList(),
'mountTargets': mountTargets.map((item) => item.toJson()).toList(),
};
}
factory MultiAgentConfig.fromJson(Map<String, dynamic> json) {
final defaults = MultiAgentConfig.defaults();
final architectJson = json['architect'] as Map<String, dynamic>? ?? {};
final engineerJson = json['engineer'] as Map<String, dynamic>? ?? {};
final testerJson = json['tester'] as Map<String, dynamic>? ?? {};
final rawManagedSkills = json['managedSkills'];
final rawManagedMcpServers = json['managedMcpServers'];
final rawMountTargets = json['mountTargets'];
AgentWorkerConfig parseWorker(
Map<String, dynamic> m,
MultiAgentRole role,
String defaultTool,
) {
return AgentWorkerConfig(
role: role,
cliTool: m['cliTool'] as String? ?? defaultTool,
model: m['model'] as String? ?? '',
enabled: m['enabled'] as bool? ?? true,
maxRetries: m['maxRetries'] as int? ?? 2,
);
}
return MultiAgentConfig(
enabled: json['enabled'] as bool? ?? false,
autoSync: json['autoSync'] as bool? ?? defaults.autoSync,
framework: MultiAgentFrameworkCopy.fromJsonValue(
json['framework'] as String?,
),
arisEnabled: json['arisEnabled'] as bool? ?? defaults.arisEnabled,
arisMode: json['arisMode'] as String? ?? defaults.arisMode,
arisBundleVersion:
json['arisBundleVersion'] as String? ?? defaults.arisBundleVersion,
arisCompatStatus:
json['arisCompatStatus'] as String? ?? defaults.arisCompatStatus,
architect: parseWorker(
architectJson,
MultiAgentRole.architect,
defaults.architect.cliTool,
),
engineer: parseWorker(
engineerJson,
MultiAgentRole.engineer,
defaults.engineer.cliTool,
),
tester: parseWorker(
testerJson,
MultiAgentRole.testerDoc,
defaults.tester.cliTool,
),
ollamaEndpoint:
json['ollamaEndpoint'] as String? ?? defaults.ollamaEndpoint,
maxIterations: json['maxIterations'] as int? ?? defaults.maxIterations,
minAcceptableScore:
json['minAcceptableScore'] as int? ?? defaults.minAcceptableScore,
timeoutSeconds: json['timeoutSeconds'] as int? ?? defaults.timeoutSeconds,
aiGatewayInjectionPolicy: AiGatewayInjectionPolicyCopy.fromJsonValue(
json['aiGatewayInjectionPolicy'] as String?,
),
managedSkills: rawManagedSkills is List
? rawManagedSkills
.whereType<Map>()
.map(
(item) =>
ManagedSkillEntry.fromJson(item.cast<String, dynamic>()),
)
.toList(growable: false)
: defaults.managedSkills,
managedMcpServers: rawManagedMcpServers is List
? rawManagedMcpServers
.whereType<Map>()
.map(
(item) => ManagedMcpServerEntry.fromJson(
item.cast<String, dynamic>(),
),
)
.toList(growable: false)
: defaults.managedMcpServers,
mountTargets: rawMountTargets is List
? rawMountTargets
.whereType<Map>()
.map(
(item) => ManagedMountTargetState.fromJson(
item.cast<String, dynamic>(),
),
)
.toList(growable: false)
: defaults.mountTargets,
);
}
}
class MultiAgentRunEvent {
const MultiAgentRunEvent({
required this.type,
required this.title,
required this.message,
required this.pending,
required this.error,
this.role,
this.iteration,
this.score,
this.data = const <String, dynamic>{},
});
final String type;
final String title;
final String message;
final bool pending;
final bool error;
final String? role;
final int? iteration;
final int? score;
final Map<String, dynamic> data;
Map<String, dynamic> toJson() {
return {
'type': type,
'title': title,
'message': message,
'pending': pending,
'error': error,
if (role != null) 'role': role,
if (iteration != null) 'iteration': iteration,
if (score != null) 'score': score,
'data': data,
};
}
factory MultiAgentRunEvent.fromJson(Map<String, dynamic> json) {
return MultiAgentRunEvent(
type: json['type'] as String? ?? 'status',
title: json['title'] as String? ?? '',
message: json['message'] as String? ?? '',
pending: json['pending'] as bool? ?? false,
error: json['error'] as bool? ?? false,
role: json['role'] as String?,
iteration: (json['iteration'] as num?)?.toInt(),
score: (json['score'] as num?)?.toInt(),
data:
(json['data'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{},
);
}
}

View File

@ -0,0 +1,752 @@
part of 'runtime_models.dart';
class ExternalAcpEndpointProfile {
const ExternalAcpEndpointProfile({
required this.providerKey,
required this.label,
required this.badge,
required this.endpoint,
required this.enabled,
});
final String providerKey;
final String label;
final String badge;
final String endpoint;
final bool enabled;
factory ExternalAcpEndpointProfile.defaultsForProvider(
SingleAgentProvider provider,
) {
return ExternalAcpEndpointProfile(
providerKey: provider.providerId,
label: provider.label,
badge: provider.badge,
endpoint: '',
enabled: true,
);
}
ExternalAcpEndpointProfile copyWith({
String? providerKey,
String? label,
String? badge,
String? endpoint,
bool? enabled,
}) {
return ExternalAcpEndpointProfile(
providerKey: normalizeSingleAgentProviderId(
providerKey ?? this.providerKey,
),
label: (label ?? this.label).trim(),
badge: (badge ?? this.badge).trim(),
endpoint: (endpoint ?? this.endpoint).trim(),
enabled: enabled ?? this.enabled,
);
}
SingleAgentProvider? get builtinProvider {
final normalized = providerKey.trim().toLowerCase();
for (final provider in kKnownSingleAgentProviders) {
if (provider.providerId == normalized) {
return provider;
}
}
return null;
}
bool get isPreset =>
kPresetExternalAcpProviders.any((item) => item.providerId == providerKey);
SingleAgentProvider toProvider() {
final builtin = builtinProvider;
return SingleAgentProvider.fromJsonValue(
providerKey,
label: label,
badge: badge,
).copyWith(
source: builtin?.source ?? SingleAgentProviderSource.externalExtension,
);
}
Map<String, dynamic> toJson() {
return {
'providerKey': providerKey,
'label': label,
'badge': badge,
'endpoint': endpoint,
'enabled': enabled,
};
}
factory ExternalAcpEndpointProfile.fromJson(Map<String, dynamic> json) {
final providerKey = normalizeSingleAgentProviderId(
json['providerKey']?.toString() ?? '',
);
final builtin = SingleAgentProviderCopy.fromJsonValue(providerKey);
final fallbackLabel = builtin.isAuto ? providerKey : builtin.label;
final label = json['label']?.toString().trim().isNotEmpty == true
? json['label'].toString().trim()
: fallbackLabel;
return ExternalAcpEndpointProfile(
providerKey: providerKey,
label: label,
badge: json['badge']?.toString().trim().isNotEmpty == true
? json['badge'].toString().trim()
: _singleAgentProviderFallbackBadge(
providerId: providerKey,
label: label,
),
endpoint: json['endpoint']?.toString().trim() ?? '',
enabled: json['enabled'] as bool? ?? true,
);
}
}
List<ExternalAcpEndpointProfile> normalizeExternalAcpEndpoints({
Iterable<ExternalAcpEndpointProfile>? profiles,
}) {
final incoming =
profiles?.toList(growable: false) ?? const <ExternalAcpEndpointProfile>[];
final byKey = <String, ExternalAcpEndpointProfile>{};
final migratedCustomProfiles = <ExternalAcpEndpointProfile>[];
var customSuffix = 1;
String nextCustomKey() {
while (true) {
final key = 'custom-agent-$customSuffix';
customSuffix += 1;
if (!byKey.containsKey(key) &&
!migratedCustomProfiles.any((item) => item.providerKey == key)) {
return key;
}
}
}
bool isLegacyCustomPlaceholder(ExternalAcpEndpointProfile profile) {
final key = profile.providerKey.trim().toLowerCase();
if (!key.startsWith('custom-agent-') ||
profile.endpoint.trim().isNotEmpty) {
return false;
}
final label = profile.label.trim();
final badge = profile.badge.trim();
return (label == SingleAgentProvider.claude.label &&
badge == SingleAgentProvider.claude.badge) ||
(label == SingleAgentProvider.gemini.label &&
badge == SingleAgentProvider.gemini.badge);
}
for (final item in incoming) {
final key = item.providerKey.trim().toLowerCase();
if (key.isEmpty || byKey.containsKey(key)) {
continue;
}
if (kLegacyExternalAcpProviderIds.contains(key)) {
if (item.endpoint.trim().isEmpty) {
continue;
}
migratedCustomProfiles.add(item.copyWith(providerKey: nextCustomKey()));
continue;
}
if (isLegacyCustomPlaceholder(item)) {
continue;
}
byKey[key] = item.copyWith(providerKey: key);
}
final normalized = <ExternalAcpEndpointProfile>[
for (final provider in kPresetExternalAcpProviders)
byKey.remove(provider.providerId) ??
ExternalAcpEndpointProfile.defaultsForProvider(provider),
...migratedCustomProfiles,
...byKey.values,
];
return List<ExternalAcpEndpointProfile>.unmodifiable(normalized);
}
List<ExternalAcpEndpointProfile> replaceExternalAcpEndpointForProvider(
List<ExternalAcpEndpointProfile> profiles,
SingleAgentProvider provider,
ExternalAcpEndpointProfile profile,
) {
final normalized = normalizeExternalAcpEndpoints(profiles: profiles);
final next = List<ExternalAcpEndpointProfile>.from(normalized);
final index = next.indexWhere(
(item) => item.providerKey.trim().toLowerCase() == provider.providerId,
);
final resolved = profile.copyWith(
providerKey: provider.providerId,
label: profile.label.trim().isEmpty ? provider.label : profile.label,
badge: profile.badge.trim().isEmpty ? provider.badge : profile.badge,
);
if (index == -1) {
next.add(resolved);
} else {
next[index] = resolved;
}
return normalizeExternalAcpEndpoints(profiles: next);
}
ExternalAcpEndpointProfile buildCustomExternalAcpEndpointProfile(
Iterable<ExternalAcpEndpointProfile> profiles, {
required String label,
required String endpoint,
}) {
final normalizedProfiles = normalizeExternalAcpEndpoints(profiles: profiles);
var suffix = normalizedProfiles.length + 1;
String providerKey() => 'custom-agent-$suffix';
final existingKeys = normalizedProfiles
.map((item) => item.providerKey)
.toSet();
while (existingKeys.contains(providerKey())) {
suffix += 1;
}
final normalizedLabel = label.trim().isEmpty
? 'Custom ACP Endpoint $suffix'
: label.trim();
return ExternalAcpEndpointProfile(
providerKey: providerKey(),
label: normalizedLabel,
badge: _singleAgentProviderFallbackBadge(
providerId: providerKey(),
label: normalizedLabel,
),
endpoint: endpoint.trim(),
enabled: true,
);
}
String normalizeAuthorizedSkillDirectoryPath(String path) {
var trimmed = path.trim();
if (trimmed.isEmpty) {
return trimmed;
}
trimmed = trimmed.replaceFirst(RegExp(r'[\\/]+$'), '');
trimmed = trimmed.replaceFirst(
RegExp(r'([\\/])SKILL\.md$', caseSensitive: false),
'',
);
if (trimmed.length <= 1) {
return trimmed;
}
return trimmed.replaceFirst(RegExp(r'[\\/]+$'), '');
}
class AuthorizedSkillDirectory {
const AuthorizedSkillDirectory({required this.path, this.bookmark = ''});
final String path;
final String bookmark;
AuthorizedSkillDirectory copyWith({String? path, String? bookmark}) {
return AuthorizedSkillDirectory(
path: normalizeAuthorizedSkillDirectoryPath(path ?? this.path),
bookmark: bookmark ?? this.bookmark,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'path': path,
if (bookmark.trim().isNotEmpty) 'bookmark': bookmark,
};
}
factory AuthorizedSkillDirectory.fromJson(Map<String, dynamic> json) {
return AuthorizedSkillDirectory(
path: normalizeAuthorizedSkillDirectoryPath(
json['path']?.toString() ?? '',
),
bookmark: json['bookmark']?.toString().trim() ?? '',
);
}
}
List<AuthorizedSkillDirectory> normalizeAuthorizedSkillDirectories({
Iterable<AuthorizedSkillDirectory>? directories,
}) {
final incoming =
directories?.toList(growable: false) ??
const <AuthorizedSkillDirectory>[];
final normalized = <AuthorizedSkillDirectory>[];
final seen = <String>{};
for (final item in incoming) {
final path = normalizeAuthorizedSkillDirectoryPath(item.path);
if (path.isEmpty || !seen.add(path)) {
continue;
}
normalized.add(
AuthorizedSkillDirectory(path: path, bookmark: item.bookmark.trim()),
);
}
normalized.sort((left, right) => left.path.compareTo(right.path));
return List<AuthorizedSkillDirectory>.unmodifiable(normalized);
}
class AssistantThreadConnectionState {
const AssistantThreadConnectionState({
required this.executionTarget,
required this.status,
required this.primaryLabel,
required this.detailLabel,
required this.ready,
required this.pairingRequired,
required this.gatewayTokenMissing,
required this.lastError,
});
final AssistantExecutionTarget executionTarget;
final RuntimeConnectionStatus status;
final String primaryLabel;
final String detailLabel;
final bool ready;
final bool pairingRequired;
final bool gatewayTokenMissing;
final String? lastError;
bool get isSingleAgent =>
executionTarget == AssistantExecutionTarget.singleAgent;
bool get connected => ready;
bool get connecting =>
!isSingleAgent && status == RuntimeConnectionStatus.connecting;
}
enum AssistantMessageViewMode { rendered, raw }
extension AssistantMessageViewModeCopy on AssistantMessageViewMode {
String get label => switch (this) {
AssistantMessageViewMode.rendered => appText('渲染', 'Rendered'),
AssistantMessageViewMode.raw => 'RAW',
};
static AssistantMessageViewMode fromJsonValue(String? value) {
return AssistantMessageViewMode.values.firstWhere(
(item) => item.name == value,
orElse: () => AssistantMessageViewMode.rendered,
);
}
}
enum WorkspaceRefKind { localPath, remotePath, objectStore }
extension WorkspaceRefKindCopy on WorkspaceRefKind {
static WorkspaceRefKind fromJsonValue(String? value) {
return WorkspaceRefKind.values.firstWhere(
(item) => item.name == value,
orElse: () => WorkspaceRefKind.localPath,
);
}
}
enum AssistantPermissionLevel { defaultAccess, fullAccess }
extension AssistantPermissionLevelCopy on AssistantPermissionLevel {
String get label => switch (this) {
AssistantPermissionLevel.defaultAccess => appText('默认权限', 'Default Access'),
AssistantPermissionLevel.fullAccess => appText('完全访问权限', 'Full Access'),
};
String get promptValue => switch (this) {
AssistantPermissionLevel.defaultAccess => 'default',
AssistantPermissionLevel.fullAccess => 'full-access',
};
static AssistantPermissionLevel fromJsonValue(String? value) {
return AssistantPermissionLevel.values.firstWhere(
(item) => item.name == value,
orElse: () => AssistantPermissionLevel.defaultAccess,
);
}
}
enum CodeAgentRuntimeMode { builtIn, externalCli }
extension CodeAgentRuntimeModeCopy on CodeAgentRuntimeMode {
String get label => switch (this) {
CodeAgentRuntimeMode.externalCli => appText(
'外部 Codex CLI',
'External Codex CLI',
),
CodeAgentRuntimeMode.builtIn => appText('内置 Codex', 'Built-in Codex'),
};
static CodeAgentRuntimeMode fromJsonValue(String? value) {
return CodeAgentRuntimeMode.values.firstWhere(
(item) => item.name == value,
orElse: () => CodeAgentRuntimeMode.externalCli,
);
}
}
enum VpnMode { tunnel, proxy }
extension VpnModeCopy on VpnMode {
String get label => switch (this) {
VpnMode.tunnel => appText('隧道', 'Tunnel'),
VpnMode.proxy => appText('代理', 'Proxy'),
};
static VpnMode fromJsonValue(String? value) {
return VpnMode.values.firstWhere(
(item) => item.name == value,
orElse: () => VpnMode.proxy,
);
}
}
enum DesktopEnvironment { unknown, gnome, kde }
extension DesktopEnvironmentCopy on DesktopEnvironment {
String get label => switch (this) {
DesktopEnvironment.unknown => appText('未知桌面', 'Unknown Desktop'),
DesktopEnvironment.gnome => 'GNOME',
DesktopEnvironment.kde => 'KDE Plasma',
};
static DesktopEnvironment fromJsonValue(String? value) {
return DesktopEnvironment.values.firstWhere(
(item) => item.name == value,
orElse: () => DesktopEnvironment.unknown,
);
}
}
class LinuxDesktopConfig {
const LinuxDesktopConfig({
required this.preferredMode,
required this.vpnConnectionName,
required this.proxyHost,
required this.proxyPort,
required this.trayEnabled,
});
final VpnMode preferredMode;
final String vpnConnectionName;
final String proxyHost;
final int proxyPort;
final bool trayEnabled;
factory LinuxDesktopConfig.defaults() {
return const LinuxDesktopConfig(
preferredMode: VpnMode.proxy,
vpnConnectionName: 'XWorkmate Tunnel',
proxyHost: '127.0.0.1',
proxyPort: 7890,
trayEnabled: true,
);
}
LinuxDesktopConfig copyWith({
VpnMode? preferredMode,
String? vpnConnectionName,
String? proxyHost,
int? proxyPort,
bool? trayEnabled,
}) {
return LinuxDesktopConfig(
preferredMode: preferredMode ?? this.preferredMode,
vpnConnectionName: vpnConnectionName ?? this.vpnConnectionName,
proxyHost: proxyHost ?? this.proxyHost,
proxyPort: proxyPort ?? this.proxyPort,
trayEnabled: trayEnabled ?? this.trayEnabled,
);
}
Map<String, dynamic> toJson() {
return {
'preferredMode': preferredMode.name,
'vpnConnectionName': vpnConnectionName,
'proxyHost': proxyHost,
'proxyPort': proxyPort,
'trayEnabled': trayEnabled,
};
}
factory LinuxDesktopConfig.fromJson(Map<String, dynamic> json) {
final defaults = LinuxDesktopConfig.defaults();
return LinuxDesktopConfig(
preferredMode: VpnModeCopy.fromJsonValue(
json['preferredMode'] as String?,
),
vpnConnectionName:
json['vpnConnectionName'] as String? ?? defaults.vpnConnectionName,
proxyHost: json['proxyHost'] as String? ?? defaults.proxyHost,
proxyPort: json['proxyPort'] as int? ?? defaults.proxyPort,
trayEnabled: json['trayEnabled'] as bool? ?? defaults.trayEnabled,
);
}
}
class SystemProxyState {
const SystemProxyState({
required this.enabled,
required this.host,
required this.port,
required this.backend,
required this.lastAppliedMode,
});
final bool enabled;
final String host;
final int port;
final String backend;
final VpnMode lastAppliedMode;
factory SystemProxyState.defaults({LinuxDesktopConfig? config}) {
final resolvedConfig = config ?? LinuxDesktopConfig.defaults();
return SystemProxyState(
enabled: resolvedConfig.preferredMode == VpnMode.proxy,
host: resolvedConfig.proxyHost,
port: resolvedConfig.proxyPort,
backend: '',
lastAppliedMode: resolvedConfig.preferredMode,
);
}
SystemProxyState copyWith({
bool? enabled,
String? host,
int? port,
String? backend,
VpnMode? lastAppliedMode,
}) {
return SystemProxyState(
enabled: enabled ?? this.enabled,
host: host ?? this.host,
port: port ?? this.port,
backend: backend ?? this.backend,
lastAppliedMode: lastAppliedMode ?? this.lastAppliedMode,
);
}
Map<String, dynamic> toJson() {
return {
'enabled': enabled,
'host': host,
'port': port,
'backend': backend,
'lastAppliedMode': lastAppliedMode.name,
};
}
factory SystemProxyState.fromJson(
Map<String, dynamic> json, {
LinuxDesktopConfig? config,
}) {
final defaults = SystemProxyState.defaults(config: config);
return SystemProxyState(
enabled: json['enabled'] as bool? ?? defaults.enabled,
host: json['host'] as String? ?? defaults.host,
port: json['port'] as int? ?? defaults.port,
backend: json['backend'] as String? ?? defaults.backend,
lastAppliedMode: VpnModeCopy.fromJsonValue(
json['lastAppliedMode'] as String?,
),
);
}
}
class TunnelSessionState {
const TunnelSessionState({
required this.available,
required this.connected,
required this.connectionName,
required this.backend,
required this.lastError,
});
final bool available;
final bool connected;
final String connectionName;
final String backend;
final String lastError;
factory TunnelSessionState.defaults({LinuxDesktopConfig? config}) {
final resolvedConfig = config ?? LinuxDesktopConfig.defaults();
return TunnelSessionState(
available: false,
connected: false,
connectionName: resolvedConfig.vpnConnectionName,
backend: '',
lastError: '',
);
}
TunnelSessionState copyWith({
bool? available,
bool? connected,
String? connectionName,
String? backend,
String? lastError,
}) {
return TunnelSessionState(
available: available ?? this.available,
connected: connected ?? this.connected,
connectionName: connectionName ?? this.connectionName,
backend: backend ?? this.backend,
lastError: lastError ?? this.lastError,
);
}
Map<String, dynamic> toJson() {
return {
'available': available,
'connected': connected,
'connectionName': connectionName,
'backend': backend,
'lastError': lastError,
};
}
factory TunnelSessionState.fromJson(
Map<String, dynamic> json, {
LinuxDesktopConfig? config,
}) {
final defaults = TunnelSessionState.defaults(config: config);
return TunnelSessionState(
available: json['available'] as bool? ?? defaults.available,
connected: json['connected'] as bool? ?? defaults.connected,
connectionName:
json['connectionName'] as String? ?? defaults.connectionName,
backend: json['backend'] as String? ?? defaults.backend,
lastError: json['lastError'] as String? ?? defaults.lastError,
);
}
}
class DesktopIntegrationState {
const DesktopIntegrationState({
required this.isSupported,
required this.environment,
required this.mode,
required this.trayAvailable,
required this.trayEnabled,
required this.autostartEnabled,
required this.networkManagerAvailable,
required this.systemProxy,
required this.tunnel,
required this.statusMessage,
});
final bool isSupported;
final DesktopEnvironment environment;
final VpnMode mode;
final bool trayAvailable;
final bool trayEnabled;
final bool autostartEnabled;
final bool networkManagerAvailable;
final SystemProxyState systemProxy;
final TunnelSessionState tunnel;
final String statusMessage;
factory DesktopIntegrationState.loading() {
final config = LinuxDesktopConfig.defaults();
return DesktopIntegrationState(
isSupported: true,
environment: DesktopEnvironment.unknown,
mode: config.preferredMode,
trayAvailable: false,
trayEnabled: config.trayEnabled,
autostartEnabled: false,
networkManagerAvailable: false,
systemProxy: SystemProxyState.defaults(config: config),
tunnel: TunnelSessionState.defaults(config: config),
statusMessage: '',
);
}
factory DesktopIntegrationState.unsupported({
LinuxDesktopConfig? config,
String message = '',
}) {
final resolvedConfig = config ?? LinuxDesktopConfig.defaults();
return DesktopIntegrationState(
isSupported: false,
environment: DesktopEnvironment.unknown,
mode: resolvedConfig.preferredMode,
trayAvailable: false,
trayEnabled: false,
autostartEnabled: false,
networkManagerAvailable: false,
systemProxy: SystemProxyState.defaults(config: resolvedConfig),
tunnel: TunnelSessionState.defaults(config: resolvedConfig),
statusMessage: message,
);
}
DesktopIntegrationState copyWith({
bool? isSupported,
DesktopEnvironment? environment,
VpnMode? mode,
bool? trayAvailable,
bool? trayEnabled,
bool? autostartEnabled,
bool? networkManagerAvailable,
SystemProxyState? systemProxy,
TunnelSessionState? tunnel,
String? statusMessage,
}) {
return DesktopIntegrationState(
isSupported: isSupported ?? this.isSupported,
environment: environment ?? this.environment,
mode: mode ?? this.mode,
trayAvailable: trayAvailable ?? this.trayAvailable,
trayEnabled: trayEnabled ?? this.trayEnabled,
autostartEnabled: autostartEnabled ?? this.autostartEnabled,
networkManagerAvailable:
networkManagerAvailable ?? this.networkManagerAvailable,
systemProxy: systemProxy ?? this.systemProxy,
tunnel: tunnel ?? this.tunnel,
statusMessage: statusMessage ?? this.statusMessage,
);
}
Map<String, dynamic> toJson() {
return {
'isSupported': isSupported,
'environment': environment.name,
'mode': mode.name,
'trayAvailable': trayAvailable,
'trayEnabled': trayEnabled,
'autostartEnabled': autostartEnabled,
'networkManagerAvailable': networkManagerAvailable,
'systemProxy': systemProxy.toJson(),
'tunnel': tunnel.toJson(),
'statusMessage': statusMessage,
};
}
factory DesktopIntegrationState.fromJson(
Map<String, dynamic> json, {
LinuxDesktopConfig? fallbackConfig,
}) {
final config = fallbackConfig ?? LinuxDesktopConfig.defaults();
return DesktopIntegrationState(
isSupported: json['isSupported'] as bool? ?? true,
environment: DesktopEnvironmentCopy.fromJsonValue(
json['environment'] as String?,
),
mode: VpnModeCopy.fromJsonValue(json['mode'] as String?),
trayAvailable: json['trayAvailable'] as bool? ?? false,
trayEnabled: json['trayEnabled'] as bool? ?? config.trayEnabled,
autostartEnabled: json['autostartEnabled'] as bool? ?? false,
networkManagerAvailable:
json['networkManagerAvailable'] as bool? ?? false,
systemProxy: SystemProxyState.fromJson(
(json['systemProxy'] as Map?)?.cast<String, dynamic>() ?? const {},
config: config,
),
tunnel: TunnelSessionState.fromJson(
(json['tunnel'] as Map?)?.cast<String, dynamic>() ?? const {},
config: config,
),
statusMessage: json['statusMessage'] as String? ?? '',
);
}
}

View File

@ -0,0 +1,648 @@
part of 'runtime_models.dart';
class GatewayConnectionSnapshot {
const GatewayConnectionSnapshot({
required this.status,
required this.mode,
required this.statusText,
required this.serverName,
required this.remoteAddress,
required this.mainSessionKey,
required this.lastError,
required this.lastErrorCode,
required this.lastErrorDetailCode,
required this.lastConnectedAtMs,
required this.deviceId,
required this.authRole,
required this.authScopes,
required this.connectAuthMode,
required this.connectAuthFields,
required this.connectAuthSources,
required this.hasSharedAuth,
required this.hasDeviceToken,
required this.healthPayload,
required this.statusPayload,
});
final RuntimeConnectionStatus status;
final RuntimeConnectionMode mode;
final String statusText;
final String? serverName;
final String? remoteAddress;
final String? mainSessionKey;
final String? lastError;
final String? lastErrorCode;
final String? lastErrorDetailCode;
final int? lastConnectedAtMs;
final String? deviceId;
final String? authRole;
final List<String> authScopes;
final String? connectAuthMode;
final List<String> connectAuthFields;
final List<String> connectAuthSources;
final bool hasSharedAuth;
final bool hasDeviceToken;
final Map<String, dynamic>? healthPayload;
final Map<String, dynamic>? statusPayload;
factory GatewayConnectionSnapshot.initial({
RuntimeConnectionMode mode = RuntimeConnectionMode.unconfigured,
}) {
return GatewayConnectionSnapshot(
status: RuntimeConnectionStatus.offline,
mode: mode,
statusText: 'Offline',
serverName: null,
remoteAddress: null,
mainSessionKey: null,
lastError: null,
lastErrorCode: null,
lastErrorDetailCode: null,
lastConnectedAtMs: null,
deviceId: null,
authRole: null,
authScopes: const <String>[],
connectAuthMode: null,
connectAuthFields: const <String>[],
connectAuthSources: const <String>[],
hasSharedAuth: false,
hasDeviceToken: false,
healthPayload: null,
statusPayload: null,
);
}
GatewayConnectionSnapshot copyWith({
RuntimeConnectionStatus? status,
RuntimeConnectionMode? mode,
String? statusText,
String? serverName,
String? remoteAddress,
String? mainSessionKey,
String? lastError,
String? lastErrorCode,
String? lastErrorDetailCode,
int? lastConnectedAtMs,
String? deviceId,
String? authRole,
List<String>? authScopes,
String? connectAuthMode,
List<String>? connectAuthFields,
List<String>? connectAuthSources,
bool? hasSharedAuth,
bool? hasDeviceToken,
Map<String, dynamic>? healthPayload,
Map<String, dynamic>? statusPayload,
bool clearServerName = false,
bool clearRemoteAddress = false,
bool clearMainSessionKey = false,
bool clearLastError = false,
bool clearLastErrorCode = false,
bool clearLastErrorDetailCode = false,
}) {
return GatewayConnectionSnapshot(
status: status ?? this.status,
mode: mode ?? this.mode,
statusText: statusText ?? this.statusText,
serverName: clearServerName ? null : (serverName ?? this.serverName),
remoteAddress: clearRemoteAddress
? null
: (remoteAddress ?? this.remoteAddress),
mainSessionKey: clearMainSessionKey
? null
: (mainSessionKey ?? this.mainSessionKey),
lastError: clearLastError ? null : (lastError ?? this.lastError),
lastErrorCode: clearLastErrorCode
? null
: (lastErrorCode ?? this.lastErrorCode),
lastErrorDetailCode: clearLastErrorDetailCode
? null
: (lastErrorDetailCode ?? this.lastErrorDetailCode),
lastConnectedAtMs: lastConnectedAtMs ?? this.lastConnectedAtMs,
deviceId: deviceId ?? this.deviceId,
authRole: authRole ?? this.authRole,
authScopes: authScopes ?? this.authScopes,
connectAuthMode: connectAuthMode ?? this.connectAuthMode,
connectAuthFields: connectAuthFields ?? this.connectAuthFields,
connectAuthSources: connectAuthSources ?? this.connectAuthSources,
hasSharedAuth: hasSharedAuth ?? this.hasSharedAuth,
hasDeviceToken: hasDeviceToken ?? this.hasDeviceToken,
healthPayload: healthPayload ?? this.healthPayload,
statusPayload: statusPayload ?? this.statusPayload,
);
}
bool get pairingRequired {
final detailCode = lastErrorDetailCode?.trim().toUpperCase();
final errorCode = lastErrorCode?.trim().toUpperCase();
final errorText = lastError?.toLowerCase() ?? '';
return status != RuntimeConnectionStatus.connected &&
(detailCode == 'PAIRING_REQUIRED' ||
errorCode == 'NOT_PAIRED' ||
errorText.contains('pairing required'));
}
bool get gatewayTokenMissing {
final detailCode = lastErrorDetailCode?.trim().toUpperCase();
final errorText = lastError?.toLowerCase() ?? '';
return detailCode == 'AUTH_TOKEN_MISSING' ||
errorText.contains('gateway token missing');
}
String get connectAuthSummary {
final mode = connectAuthMode?.trim() ?? 'none';
final fields = connectAuthFields.isEmpty
? 'none'
: connectAuthFields.join(', ');
final sources = connectAuthSources.isEmpty
? 'none'
: connectAuthSources.join(' · ');
return '$mode | fields: $fields | sources: $sources';
}
}
class RuntimePackageInfo {
const RuntimePackageInfo({
required this.appName,
required this.packageName,
required this.version,
required this.buildNumber,
});
final String appName;
final String packageName;
final String version;
final String buildNumber;
}
class RuntimeDeviceInfo {
const RuntimeDeviceInfo({
required this.platform,
required this.platformVersion,
required this.deviceFamily,
required this.modelIdentifier,
});
final String platform;
final String platformVersion;
final String deviceFamily;
final String modelIdentifier;
String get platformLabel {
final version = platformVersion.trim();
if (version.isEmpty) {
return platform;
}
return '$platform $version';
}
}
class RuntimeLogEntry {
const RuntimeLogEntry({
required this.timestampMs,
required this.level,
required this.category,
required this.message,
});
final int timestampMs;
final String level;
final String category;
final String message;
String get timeLabel {
final date = DateTime.fromMillisecondsSinceEpoch(timestampMs);
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}:${date.second.toString().padLeft(2, '0')}';
}
String get line => '[$timeLabel] ${level.toUpperCase()} $category $message';
}
class GatewayAgentSummary {
const GatewayAgentSummary({
required this.id,
required this.name,
required this.emoji,
required this.theme,
});
final String id;
final String name;
final String emoji;
final String theme;
}
class GatewaySessionSummary {
const GatewaySessionSummary({
required this.key,
required this.kind,
required this.displayName,
required this.surface,
required this.subject,
required this.room,
required this.space,
required this.updatedAtMs,
required this.sessionId,
required this.systemSent,
required this.abortedLastRun,
required this.thinkingLevel,
required this.verboseLevel,
required this.inputTokens,
required this.outputTokens,
required this.totalTokens,
required this.model,
required this.contextTokens,
required this.derivedTitle,
required this.lastMessagePreview,
});
final String key;
final String? kind;
final String? displayName;
final String? surface;
final String? subject;
final String? room;
final String? space;
final double? updatedAtMs;
final String? sessionId;
final bool? systemSent;
final bool? abortedLastRun;
final String? thinkingLevel;
final String? verboseLevel;
final int? inputTokens;
final int? outputTokens;
final int? totalTokens;
final String? model;
final int? contextTokens;
final String? derivedTitle;
final String? lastMessagePreview;
String get label {
final candidates = [derivedTitle, displayName, subject, room, space, key];
return candidates.firstWhere(
(item) => item != null && item.trim().isNotEmpty,
orElse: () => key,
)!;
}
}
class GatewayChatMessage {
const GatewayChatMessage({
required this.id,
required this.role,
required this.text,
required this.timestampMs,
required this.toolCallId,
required this.toolName,
required this.stopReason,
required this.pending,
required this.error,
});
final String id;
final String role;
final String text;
final double? timestampMs;
final String? toolCallId;
final String? toolName;
final String? stopReason;
final bool pending;
final bool error;
Map<String, dynamic> toJson() {
return {
'id': id,
'role': role,
'text': text,
'timestampMs': timestampMs,
'toolCallId': toolCallId,
'toolName': toolName,
'stopReason': stopReason,
'pending': pending,
'error': error,
};
}
factory GatewayChatMessage.fromJson(Map<String, dynamic> json) {
double? asDouble(Object? value) {
if (value is num) {
return value.toDouble();
}
return double.tryParse(value?.toString() ?? '');
}
return GatewayChatMessage(
id: json['id']?.toString() ?? '',
role: json['role']?.toString() ?? 'assistant',
text: json['text']?.toString() ?? '',
timestampMs: asDouble(json['timestampMs']),
toolCallId: json['toolCallId']?.toString(),
toolName: json['toolName']?.toString(),
stopReason: json['stopReason']?.toString(),
pending: json['pending'] as bool? ?? false,
error: json['error'] as bool? ?? false,
);
}
GatewayChatMessage copyWith({
String? id,
String? role,
String? text,
double? timestampMs,
String? toolCallId,
String? toolName,
String? stopReason,
bool? pending,
bool? error,
}) {
return GatewayChatMessage(
id: id ?? this.id,
role: role ?? this.role,
text: text ?? this.text,
timestampMs: timestampMs ?? this.timestampMs,
toolCallId: toolCallId ?? this.toolCallId,
toolName: toolName ?? this.toolName,
stopReason: stopReason ?? this.stopReason,
pending: pending ?? this.pending,
error: error ?? this.error,
);
}
}
class AssistantThreadSkillEntry {
const AssistantThreadSkillEntry({
required this.key,
required this.label,
required this.description,
this.source = '',
required this.sourcePath,
this.scope = '',
required this.sourceLabel,
});
final String key;
final String label;
final String description;
final String source;
final String sourcePath;
final String scope;
final String sourceLabel;
AssistantThreadSkillEntry copyWith({
String? key,
String? label,
String? description,
String? source,
String? sourcePath,
String? scope,
String? sourceLabel,
}) {
return AssistantThreadSkillEntry(
key: key ?? this.key,
label: label ?? this.label,
description: description ?? this.description,
source: source ?? this.source,
sourcePath: sourcePath ?? this.sourcePath,
scope: scope ?? this.scope,
sourceLabel: sourceLabel ?? this.sourceLabel,
);
}
Map<String, dynamic> toJson() {
return {
'key': key,
'label': label,
'description': description,
'source': source,
'sourcePath': sourcePath,
'scope': scope,
'sourceLabel': sourceLabel,
};
}
factory AssistantThreadSkillEntry.fromJson(Map<String, dynamic> json) {
return AssistantThreadSkillEntry(
key: json['key']?.toString() ?? '',
label: json['label']?.toString() ?? '',
description: json['description']?.toString() ?? '',
source: json['source']?.toString() ?? '',
sourcePath: json['sourcePath']?.toString() ?? '',
scope: json['scope']?.toString() ?? '',
sourceLabel: json['sourceLabel']?.toString() ?? '',
);
}
}
class AssistantThreadRecord {
const AssistantThreadRecord({
required this.sessionKey,
required this.messages,
required this.updatedAtMs,
required this.title,
required this.archived,
required this.executionTarget,
required this.messageViewMode,
this.importedSkills = const <AssistantThreadSkillEntry>[],
this.selectedSkillKeys = const <String>[],
this.assistantModelId = '',
this.singleAgentProvider = SingleAgentProvider.auto,
this.gatewayEntryState,
this.workspaceRef = '',
this.workspaceRefKind = WorkspaceRefKind.localPath,
});
final String sessionKey;
final List<GatewayChatMessage> messages;
final double? updatedAtMs;
final String title;
final bool archived;
final AssistantExecutionTarget? executionTarget;
final AssistantMessageViewMode messageViewMode;
final List<AssistantThreadSkillEntry> importedSkills;
final List<String> selectedSkillKeys;
final String assistantModelId;
final SingleAgentProvider singleAgentProvider;
final String? gatewayEntryState;
final String workspaceRef;
final WorkspaceRefKind workspaceRefKind;
AssistantThreadRecord copyWith({
String? sessionKey,
List<GatewayChatMessage>? messages,
double? updatedAtMs,
String? title,
bool? archived,
AssistantExecutionTarget? executionTarget,
bool clearExecutionTarget = false,
AssistantMessageViewMode? messageViewMode,
List<AssistantThreadSkillEntry>? importedSkills,
List<String>? selectedSkillKeys,
String? assistantModelId,
SingleAgentProvider? singleAgentProvider,
String? gatewayEntryState,
bool clearGatewayEntryState = false,
String? workspaceRef,
WorkspaceRefKind? workspaceRefKind,
}) {
return AssistantThreadRecord(
sessionKey: sessionKey ?? this.sessionKey,
messages: messages ?? this.messages,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
title: title ?? this.title,
archived: archived ?? this.archived,
executionTarget: clearExecutionTarget
? null
: (executionTarget ?? this.executionTarget),
messageViewMode: messageViewMode ?? this.messageViewMode,
importedSkills: importedSkills ?? this.importedSkills,
selectedSkillKeys: selectedSkillKeys ?? this.selectedSkillKeys,
assistantModelId: assistantModelId ?? this.assistantModelId,
singleAgentProvider: singleAgentProvider ?? this.singleAgentProvider,
gatewayEntryState: clearGatewayEntryState
? null
: (gatewayEntryState ?? this.gatewayEntryState),
workspaceRef: workspaceRef ?? this.workspaceRef,
workspaceRefKind: workspaceRefKind ?? this.workspaceRefKind,
);
}
Map<String, dynamic> toJson() {
return {
'sessionKey': sessionKey,
'messages': messages.map((item) => item.toJson()).toList(growable: false),
'updatedAtMs': updatedAtMs,
'title': title,
'archived': archived,
'executionTarget': executionTarget?.name,
'messageViewMode': messageViewMode.name,
'importedSkills': importedSkills
.map((item) => item.toJson())
.toList(growable: false),
'selectedSkillKeys': selectedSkillKeys,
'assistantModelId': assistantModelId,
'singleAgentProvider': singleAgentProvider.providerId,
'gatewayEntryState': gatewayEntryState,
'workspaceRef': workspaceRef,
'workspaceRefKind': workspaceRefKind.name,
};
}
factory AssistantThreadRecord.fromJson(Map<String, dynamic> json) {
double? asDouble(Object? value) {
if (value is num) {
return value.toDouble();
}
return double.tryParse(value?.toString() ?? '');
}
final rawMessages = json['messages'];
final messages = rawMessages is List
? rawMessages
.whereType<Map>()
.map(
(item) =>
GatewayChatMessage.fromJson(item.cast<String, dynamic>()),
)
.toList(growable: false)
: const <GatewayChatMessage>[];
List<AssistantThreadSkillEntry> normalizeSkillEntries(Object? value) {
if (value is! List) {
return const <AssistantThreadSkillEntry>[];
}
final entries = <AssistantThreadSkillEntry>[];
final seen = <String>{};
for (final item in value.whereType<Map>()) {
final entry = AssistantThreadSkillEntry.fromJson(
item.cast<String, dynamic>(),
);
final normalizedKey = entry.key.trim();
if (normalizedKey.isEmpty || !seen.add(normalizedKey)) {
continue;
}
entries.add(entry);
}
return entries;
}
List<String> normalizeSkillKeys(Object? value) {
if (value is! List) {
return const <String>[];
}
final keys = <String>[];
final seen = <String>{};
for (final item in value) {
final normalized = item?.toString().trim() ?? '';
if (normalized.isEmpty || !seen.add(normalized)) {
continue;
}
keys.add(normalized);
}
return keys;
}
String? normalizeGatewayEntryState(Object? value) {
final normalized = value?.toString().trim() ?? '';
if (normalized.isEmpty) {
return null;
}
if (normalized == 'ai-gateway-only') {
return 'single-agent';
}
return normalized;
}
WorkspaceRefKind normalizeWorkspaceRefKind(
Object? value, {
required AssistantExecutionTarget? executionTarget,
required String workspaceRef,
}) {
final raw = value?.toString().trim();
if (raw != null && raw.isNotEmpty) {
return WorkspaceRefKindCopy.fromJsonValue(raw);
}
if (workspaceRef.startsWith('object://')) {
return WorkspaceRefKind.objectStore;
}
if (executionTarget == AssistantExecutionTarget.remote) {
return WorkspaceRefKind.remotePath;
}
return WorkspaceRefKind.localPath;
}
// Keep tolerating legacy payloads that still contain discoveredSkills,
// but do not map the retired field back into the runtime model.
normalizeSkillEntries(json['discoveredSkills']);
final executionTarget = json['executionTarget'] == null
? null
: AssistantExecutionTargetCopy.fromJsonValue(
json['executionTarget']?.toString(),
);
final workspaceRef = json['workspaceRef']?.toString() ?? '';
return AssistantThreadRecord(
sessionKey: json['sessionKey']?.toString() ?? '',
messages: messages,
updatedAtMs: asDouble(json['updatedAtMs']),
title: json['title']?.toString() ?? '',
archived: json['archived'] as bool? ?? false,
executionTarget: executionTarget,
messageViewMode: AssistantMessageViewModeCopy.fromJsonValue(
json['messageViewMode']?.toString(),
),
importedSkills: normalizeSkillEntries(json['importedSkills']),
selectedSkillKeys: normalizeSkillKeys(json['selectedSkillKeys']),
assistantModelId: json['assistantModelId']?.toString() ?? '',
singleAgentProvider: SingleAgentProviderCopy.fromJsonValue(
json['singleAgentProvider']?.toString(),
),
gatewayEntryState: normalizeGatewayEntryState(json['gatewayEntryState']),
workspaceRef: workspaceRef,
workspaceRefKind: normalizeWorkspaceRefKind(
json['workspaceRefKind'],
executionTarget: executionTarget,
workspaceRef: workspaceRef,
),
);
}
}

View File

@ -0,0 +1,545 @@
part of 'runtime_models.dart';
class SettingsSnapshot {
const SettingsSnapshot({
required this.appLanguage,
required this.appActive,
required this.launchAtLogin,
required this.showDockIcon,
required this.workspacePath,
required this.remoteProjectRoot,
required this.cliPath,
required this.codeAgentRuntimeMode,
required this.codexCliPath,
required this.defaultModel,
required this.defaultProvider,
required this.gatewayProfiles,
required this.externalAcpEndpoints,
required this.authorizedSkillDirectories,
required this.ollamaLocal,
required this.ollamaCloud,
required this.vault,
required this.aiGateway,
required this.webSessionPersistence,
required this.multiAgent,
required this.experimentalCanvas,
required this.experimentalBridge,
required this.experimentalDebug,
required this.accountBaseUrl,
required this.accountUsername,
required this.accountWorkspace,
required this.accountLocalMode,
required this.linuxDesktop,
required this.assistantExecutionTarget,
required this.assistantPermissionLevel,
required this.assistantNavigationDestinations,
required this.assistantCustomTaskTitles,
required this.assistantArchivedTaskKeys,
required this.assistantLastSessionKey,
});
final AppLanguage appLanguage;
final bool appActive;
final bool launchAtLogin;
final bool showDockIcon;
final String workspacePath;
final String remoteProjectRoot;
final String cliPath;
final CodeAgentRuntimeMode codeAgentRuntimeMode;
final String codexCliPath;
final String defaultModel;
final String defaultProvider;
final List<GatewayConnectionProfile> gatewayProfiles;
final List<ExternalAcpEndpointProfile> externalAcpEndpoints;
final List<AuthorizedSkillDirectory> authorizedSkillDirectories;
final OllamaLocalConfig ollamaLocal;
final OllamaCloudConfig ollamaCloud;
final VaultConfig vault;
final AiGatewayProfile aiGateway;
final WebSessionPersistenceConfig webSessionPersistence;
final MultiAgentConfig multiAgent;
final bool experimentalCanvas;
final bool experimentalBridge;
final bool experimentalDebug;
final String accountBaseUrl;
final String accountUsername;
final String accountWorkspace;
final bool accountLocalMode;
final LinuxDesktopConfig linuxDesktop;
final AssistantExecutionTarget assistantExecutionTarget;
final AssistantPermissionLevel assistantPermissionLevel;
final List<AssistantFocusEntry> assistantNavigationDestinations;
final Map<String, String> assistantCustomTaskTitles;
final List<String> assistantArchivedTaskKeys;
final String assistantLastSessionKey;
factory SettingsSnapshot.defaults() {
return SettingsSnapshot(
appLanguage: AppLanguage.zh,
appActive: true,
launchAtLogin: false,
showDockIcon: true,
workspacePath: '/opt/data',
remoteProjectRoot: '/opt/data/workspace',
cliPath: 'openclaw',
codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli,
codexCliPath: '',
defaultModel: '',
defaultProvider: 'gateway',
gatewayProfiles: normalizeGatewayProfiles(),
externalAcpEndpoints: normalizeExternalAcpEndpoints(),
authorizedSkillDirectories: normalizeAuthorizedSkillDirectories(),
ollamaLocal: OllamaLocalConfig.defaults(),
ollamaCloud: OllamaCloudConfig.defaults(),
vault: VaultConfig.defaults(),
aiGateway: AiGatewayProfile.defaults(),
webSessionPersistence: WebSessionPersistenceConfig.defaults(),
multiAgent: MultiAgentConfig.defaults(),
experimentalCanvas: false,
experimentalBridge: false,
experimentalDebug: false,
accountBaseUrl: 'https://accounts.svc.plus',
accountUsername: '',
accountWorkspace: 'Default Workspace',
accountLocalMode: true,
linuxDesktop: LinuxDesktopConfig.defaults(),
assistantExecutionTarget: AssistantExecutionTarget.local,
assistantPermissionLevel: AssistantPermissionLevel.defaultAccess,
assistantNavigationDestinations: kAssistantNavigationDestinationDefaults,
assistantCustomTaskTitles: const <String, String>{},
assistantArchivedTaskKeys: const <String>[],
assistantLastSessionKey: '',
);
}
SettingsSnapshot copyWith({
AppLanguage? appLanguage,
bool? appActive,
bool? launchAtLogin,
bool? showDockIcon,
String? workspacePath,
String? remoteProjectRoot,
String? cliPath,
CodeAgentRuntimeMode? codeAgentRuntimeMode,
String? codexCliPath,
String? defaultModel,
String? defaultProvider,
List<GatewayConnectionProfile>? gatewayProfiles,
List<ExternalAcpEndpointProfile>? externalAcpEndpoints,
List<AuthorizedSkillDirectory>? authorizedSkillDirectories,
OllamaLocalConfig? ollamaLocal,
OllamaCloudConfig? ollamaCloud,
VaultConfig? vault,
AiGatewayProfile? aiGateway,
WebSessionPersistenceConfig? webSessionPersistence,
MultiAgentConfig? multiAgent,
bool? experimentalCanvas,
bool? experimentalBridge,
bool? experimentalDebug,
String? accountBaseUrl,
String? accountUsername,
String? accountWorkspace,
bool? accountLocalMode,
LinuxDesktopConfig? linuxDesktop,
AssistantExecutionTarget? assistantExecutionTarget,
AssistantPermissionLevel? assistantPermissionLevel,
List<AssistantFocusEntry>? assistantNavigationDestinations,
Map<String, String>? assistantCustomTaskTitles,
List<String>? assistantArchivedTaskKeys,
String? assistantLastSessionKey,
}) {
final resolvedGatewayProfiles = gatewayProfiles != null
? normalizeGatewayProfiles(profiles: gatewayProfiles)
: this.gatewayProfiles;
final resolvedExternalAcpEndpoints = externalAcpEndpoints != null
? normalizeExternalAcpEndpoints(profiles: externalAcpEndpoints)
: this.externalAcpEndpoints;
final resolvedAuthorizedSkillDirectories =
authorizedSkillDirectories != null
? normalizeAuthorizedSkillDirectories(
directories: authorizedSkillDirectories,
)
: this.authorizedSkillDirectories;
return SettingsSnapshot(
appLanguage: appLanguage ?? this.appLanguage,
appActive: appActive ?? this.appActive,
launchAtLogin: launchAtLogin ?? this.launchAtLogin,
showDockIcon: showDockIcon ?? this.showDockIcon,
workspacePath: workspacePath ?? this.workspacePath,
remoteProjectRoot: remoteProjectRoot ?? this.remoteProjectRoot,
cliPath: cliPath ?? this.cliPath,
codeAgentRuntimeMode: codeAgentRuntimeMode ?? this.codeAgentRuntimeMode,
codexCliPath: codexCliPath ?? this.codexCliPath,
defaultModel: defaultModel ?? this.defaultModel,
defaultProvider: defaultProvider ?? this.defaultProvider,
gatewayProfiles: resolvedGatewayProfiles,
externalAcpEndpoints: resolvedExternalAcpEndpoints,
authorizedSkillDirectories: resolvedAuthorizedSkillDirectories,
ollamaLocal: ollamaLocal ?? this.ollamaLocal,
ollamaCloud: ollamaCloud ?? this.ollamaCloud,
vault: vault ?? this.vault,
aiGateway: aiGateway ?? this.aiGateway,
webSessionPersistence:
webSessionPersistence ?? this.webSessionPersistence,
multiAgent: multiAgent ?? this.multiAgent,
experimentalCanvas: experimentalCanvas ?? this.experimentalCanvas,
experimentalBridge: experimentalBridge ?? this.experimentalBridge,
experimentalDebug: experimentalDebug ?? this.experimentalDebug,
accountBaseUrl: accountBaseUrl ?? this.accountBaseUrl,
accountUsername: accountUsername ?? this.accountUsername,
accountWorkspace: accountWorkspace ?? this.accountWorkspace,
accountLocalMode: accountLocalMode ?? this.accountLocalMode,
linuxDesktop: linuxDesktop ?? this.linuxDesktop,
assistantExecutionTarget:
assistantExecutionTarget ?? this.assistantExecutionTarget,
assistantPermissionLevel:
assistantPermissionLevel ?? this.assistantPermissionLevel,
assistantNavigationDestinations:
assistantNavigationDestinations ??
this.assistantNavigationDestinations,
assistantCustomTaskTitles:
assistantCustomTaskTitles ?? this.assistantCustomTaskTitles,
assistantArchivedTaskKeys:
assistantArchivedTaskKeys ?? this.assistantArchivedTaskKeys,
assistantLastSessionKey:
assistantLastSessionKey ?? this.assistantLastSessionKey,
);
}
Map<String, dynamic> toJson() {
return {
'appLanguage': appLanguage.name,
'appActive': appActive,
'launchAtLogin': launchAtLogin,
'showDockIcon': showDockIcon,
'workspacePath': workspacePath,
'remoteProjectRoot': remoteProjectRoot,
'cliPath': cliPath,
'codeAgentRuntimeMode': codeAgentRuntimeMode.name,
'codexCliPath': codexCliPath,
'defaultModel': defaultModel,
'defaultProvider': defaultProvider,
'gatewayProfiles': gatewayProfiles
.map((item) => item.toJson())
.toList(growable: false),
'externalAcpEndpoints': externalAcpEndpoints
.map((item) => item.toJson())
.toList(growable: false),
'authorizedSkillDirectories': authorizedSkillDirectories
.map((item) => item.toJson())
.toList(growable: false),
'ollamaLocal': ollamaLocal.toJson(),
'ollamaCloud': ollamaCloud.toJson(),
'vault': vault.toJson(),
'aiGateway': aiGateway.toJson(),
'webSessionPersistence': webSessionPersistence.toJson(),
'multiAgent': multiAgent.toJson(),
'experimentalCanvas': experimentalCanvas,
'experimentalBridge': experimentalBridge,
'experimentalDebug': experimentalDebug,
'accountBaseUrl': accountBaseUrl,
'accountUsername': accountUsername,
'accountWorkspace': accountWorkspace,
'accountLocalMode': accountLocalMode,
'linuxDesktop': linuxDesktop.toJson(),
'assistantExecutionTarget': assistantExecutionTarget.name,
'assistantPermissionLevel': assistantPermissionLevel.name,
'assistantNavigationDestinations': assistantNavigationDestinations
.map((item) => item.name)
.toList(growable: false),
'assistantCustomTaskTitles': assistantCustomTaskTitles,
'assistantArchivedTaskKeys': assistantArchivedTaskKeys,
'assistantLastSessionKey': assistantLastSessionKey,
};
}
factory SettingsSnapshot.fromJson(Map<String, dynamic> json) {
Map<String, String> normalizeTaskTitles(Object? value) {
if (value is! Map) {
return const <String, String>{};
}
final normalized = <String, String>{};
value.forEach((key, title) {
final normalizedKey = key.toString().trim();
final normalizedTitle = title.toString().trim();
if (normalizedKey.isEmpty || normalizedTitle.isEmpty) {
return;
}
normalized[normalizedKey] = normalizedTitle;
});
return normalized;
}
List<String> normalizeTaskKeys(Object? value) {
if (value is! List) {
return const <String>[];
}
final normalized = <String>[];
final seen = <String>{};
for (final item in value) {
final normalizedKey = item?.toString().trim() ?? '';
if (normalizedKey.isEmpty || !seen.add(normalizedKey)) {
continue;
}
normalized.add(normalizedKey);
}
return normalized;
}
final rawAssistantNavigationDestinations =
json['assistantNavigationDestinations'];
final assistantNavigationDestinations =
rawAssistantNavigationDestinations is List
? normalizeAssistantNavigationDestinations(
rawAssistantNavigationDestinations
.map(
(item) =>
AssistantFocusEntryCopy.fromJsonValue(item?.toString()),
)
.whereType<AssistantFocusEntry>(),
)
: kAssistantNavigationDestinationDefaults;
final gatewayProfiles = normalizeGatewayProfiles(
profiles: ((json['gatewayProfiles'] as List?) ?? const <Object>[])
.whereType<Map>()
.map(
(item) =>
GatewayConnectionProfile.fromJson(item.cast<String, dynamic>()),
),
);
final externalAcpEndpoints = normalizeExternalAcpEndpoints(
profiles: ((json['externalAcpEndpoints'] as List?) ?? const <Object>[])
.whereType<Map>()
.map(
(item) => ExternalAcpEndpointProfile.fromJson(
item.cast<String, dynamic>(),
),
),
);
final authorizedSkillDirectories = normalizeAuthorizedSkillDirectories(
directories:
((json['authorizedSkillDirectories'] as List?) ?? const <Object>[])
.whereType<Map>()
.map(
(item) => AuthorizedSkillDirectory.fromJson(
item.cast<String, dynamic>(),
),
),
);
return SettingsSnapshot(
appLanguage: AppLanguageCopy.fromJsonValue(
json['appLanguage'] as String?,
),
appActive: json['appActive'] as bool? ?? true,
launchAtLogin: json['launchAtLogin'] as bool? ?? false,
showDockIcon: json['showDockIcon'] as bool? ?? true,
workspacePath:
json['workspacePath'] as String? ??
SettingsSnapshot.defaults().workspacePath,
remoteProjectRoot:
json['remoteProjectRoot'] as String? ??
SettingsSnapshot.defaults().remoteProjectRoot,
cliPath:
json['cliPath'] as String? ?? SettingsSnapshot.defaults().cliPath,
codeAgentRuntimeMode: CodeAgentRuntimeModeCopy.fromJsonValue(
json['codeAgentRuntimeMode'] as String?,
),
codexCliPath:
json['codexCliPath'] as String? ??
SettingsSnapshot.defaults().codexCliPath,
defaultModel:
json['defaultModel'] as String? ??
SettingsSnapshot.defaults().defaultModel,
defaultProvider:
json['defaultProvider'] as String? ??
SettingsSnapshot.defaults().defaultProvider,
gatewayProfiles: gatewayProfiles,
externalAcpEndpoints: externalAcpEndpoints,
authorizedSkillDirectories: authorizedSkillDirectories,
ollamaLocal: OllamaLocalConfig.fromJson(
(json['ollamaLocal'] as Map?)?.cast<String, dynamic>() ?? const {},
),
ollamaCloud: OllamaCloudConfig.fromJson(
(json['ollamaCloud'] as Map?)?.cast<String, dynamic>() ?? const {},
),
vault: VaultConfig.fromJson(
(json['vault'] as Map?)?.cast<String, dynamic>() ?? const {},
),
aiGateway: AiGatewayProfile.fromJson(
(json['aiGateway'] as Map?)?.cast<String, dynamic>() ??
(json['apisix'] as Map?)?.cast<String, dynamic>() ??
const {},
),
webSessionPersistence: WebSessionPersistenceConfig.fromJson(
(json['webSessionPersistence'] as Map?)?.cast<String, dynamic>() ??
const {},
),
multiAgent: MultiAgentConfig.fromJson(
(json['multiAgent'] as Map?)?.cast<String, dynamic>() ?? const {},
),
experimentalCanvas: json['experimentalCanvas'] as bool? ?? false,
experimentalBridge: json['experimentalBridge'] as bool? ?? false,
experimentalDebug: json['experimentalDebug'] as bool? ?? false,
accountBaseUrl:
json['accountBaseUrl'] as String? ??
SettingsSnapshot.defaults().accountBaseUrl,
accountUsername: json['accountUsername'] as String? ?? '',
accountWorkspace:
json['accountWorkspace'] as String? ??
SettingsSnapshot.defaults().accountWorkspace,
accountLocalMode: json['accountLocalMode'] as bool? ?? true,
linuxDesktop: LinuxDesktopConfig.fromJson(
(json['linuxDesktop'] as Map?)?.cast<String, dynamic>() ?? const {},
),
assistantExecutionTarget: AssistantExecutionTargetCopy.fromJsonValue(
json['assistantExecutionTarget'] as String?,
),
assistantPermissionLevel: AssistantPermissionLevelCopy.fromJsonValue(
json['assistantPermissionLevel'] as String?,
),
assistantNavigationDestinations: assistantNavigationDestinations,
assistantCustomTaskTitles: normalizeTaskTitles(
json['assistantCustomTaskTitles'],
),
assistantArchivedTaskKeys: normalizeTaskKeys(
json['assistantArchivedTaskKeys'],
),
assistantLastSessionKey: json['assistantLastSessionKey'] as String? ?? '',
);
}
static SettingsSnapshot fromJsonString(String? raw) {
if (raw == null || raw.trim().isEmpty) {
return SettingsSnapshot.defaults();
}
try {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return SettingsSnapshot.fromJson(decoded);
} catch (_) {
return SettingsSnapshot.defaults();
}
}
String toJsonString() => jsonEncode(toJson());
GatewayConnectionProfile get primaryLocalGatewayProfile =>
gatewayProfiles[kGatewayLocalProfileIndex];
GatewayConnectionProfile get primaryRemoteGatewayProfile =>
gatewayProfiles[kGatewayRemoteProfileIndex];
GatewayConnectionProfile? gatewayProfileForExecutionTarget(
AssistantExecutionTarget target,
) {
return switch (target) {
AssistantExecutionTarget.singleAgent => null,
AssistantExecutionTarget.local => primaryLocalGatewayProfile,
AssistantExecutionTarget.remote => primaryRemoteGatewayProfile,
};
}
SettingsSnapshot copyWithGatewayProfileAt(
int index,
GatewayConnectionProfile profile,
) {
return copyWith(
gatewayProfiles: replaceGatewayProfileAt(gatewayProfiles, index, profile),
);
}
SettingsSnapshot copyWithGatewayProfileForExecutionTarget(
AssistantExecutionTarget target,
GatewayConnectionProfile profile,
) {
final index = switch (target) {
AssistantExecutionTarget.local => kGatewayLocalProfileIndex,
AssistantExecutionTarget.remote => kGatewayRemoteProfileIndex,
AssistantExecutionTarget.singleAgent => null,
};
if (index == null) {
return this;
}
return copyWithGatewayProfileAt(index, profile);
}
ExternalAcpEndpointProfile externalAcpEndpointForProvider(
SingleAgentProvider provider,
) {
return externalAcpEndpointForProviderId(provider.providerId) ??
ExternalAcpEndpointProfile.defaultsForProvider(provider);
}
ExternalAcpEndpointProfile? externalAcpEndpointForProviderId(
String providerId,
) {
final normalized = normalizeSingleAgentProviderId(providerId);
if (normalized.isEmpty) {
return null;
}
for (final item in externalAcpEndpoints) {
if (item.providerKey == normalized) {
return item;
}
}
if (kLegacyExternalAcpProviderIds.contains(normalized)) {
final canonical = SingleAgentProvider.fromJsonValue(normalized);
for (final item in externalAcpEndpoints) {
if (!item.isPreset &&
item.label.trim() == canonical.label &&
item.badge.trim() == canonical.badge) {
return item;
}
}
}
return null;
}
SingleAgentProvider resolveSingleAgentProvider(SingleAgentProvider provider) {
final normalizedSelection = normalizeSingleAgentProviderSelection(provider);
if (normalizedSelection.isAuto) {
return SingleAgentProvider.auto;
}
final profile = externalAcpEndpointForProviderId(
normalizedSelection.providerId,
);
if (profile != null) {
return profile.toProvider();
}
return normalizedSelection;
}
SingleAgentProvider singleAgentProviderForId(String providerId) {
final resolved = normalizeSingleAgentProviderId(providerId);
if (resolved.isEmpty || resolved == SingleAgentProvider.auto.providerId) {
return SingleAgentProvider.auto;
}
final normalizedSelection = normalizeSingleAgentProviderSelection(
SingleAgentProvider.fromJsonValue(resolved),
);
final profile = externalAcpEndpointForProviderId(
normalizedSelection.providerId,
);
if (profile != null) {
return profile.toProvider();
}
return normalizedSelection;
}
List<SingleAgentProvider> get availableSingleAgentProviders =>
normalizeSingleAgentProviderList(
externalAcpEndpoints.map((item) => item.toProvider()),
);
SettingsSnapshot copyWithExternalAcpEndpointForProvider(
SingleAgentProvider provider,
ExternalAcpEndpointProfile profile,
) {
return copyWith(
externalAcpEndpoints: replaceExternalAcpEndpointForProvider(
externalAcpEndpoints,
provider,
profile,
),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('wave1 oversized files should stay within 800 lines', () {
const maxLines = 800;
const targets = <String>[
// Wave 1
'lib/runtime/runtime_models.dart',
'lib/runtime/runtime_controllers.dart',
'lib/app/app_controller_desktop.dart',
'lib/app/app_controller_web.dart',
'lib/runtime/gateway_runtime.dart',
'lib/runtime/multi_agent_orchestrator.dart',
'test/features/assistant_page_suite.dart',
// Wave 2
'lib/features/settings/settings_page.dart',
'lib/features/assistant/assistant_page.dart',
'lib/features/assistant/assistant_page_components.part.dart',
'lib/web/web_workspace_pages.dart',
'lib/web/web_assistant_page.dart',
'lib/web/web_settings_page.dart',
'lib/features/mobile/mobile_shell.dart',
// Wave 3
'lib/runtime/direct_single_agent_app_server_client.dart',
'test/runtime/app_controller_thread_skills_suite.dart',
'go/go_core/main.go',
'test/runtime/app_controller_ai_gateway_chat_suite.dart',
'test/runtime/secure_config_store_suite.dart',
'lib/app/ui_feature_manifest.dart',
'test/runtime/app_controller_execution_target_switch_suite.dart',
'lib/widgets/assistant_focus_panel.dart',
'lib/web/web_focus_panel.dart',
];
final violations = <String>[];
for (final path in targets) {
final file = File(path);
expect(file.existsSync(), isTrue, reason: 'missing file: $path');
final lines = file.readAsLinesSync().length;
if (lines > maxLines) {
violations.add('$path has $lines lines (limit: $maxLines)');
}
}
expect(
violations,
isEmpty,
reason: violations.isEmpty ? null : violations.join('\n'),
);
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff