From 965da167fe608cfab0bcc814651533826b1b9716 Mon Sep 17 00:00:00 2001 From: ssongliu Date: Wed, 18 Mar 2026 13:36:14 +0800 Subject: [PATCH] feat: add ai terminal settings --- agent/utils/terminal/ai/client.go | 271 +++++++++++++++ agent/utils/terminal/ai/command_generator.go | 170 +++++++++ agent/utils/terminal/ai/config_runtime.go | 228 +++++++++++++ agent/utils/terminal/ai_interceptor.go | 234 +++++++++++++ agent/utils/terminal/ws_local_session.go | 16 +- agent/utils/terminal/ws_session.go | 10 + core/app/dto/setting.go | 4 + core/app/service/setting.go | 12 + core/init/migration/migrate.go | 1 + core/init/migration/migrations/init.go | 19 ++ frontend/src/api/interface/setting.ts | 4 + frontend/src/lang/modules/en.ts | 16 + frontend/src/lang/modules/es-es.ts | 15 + frontend/src/lang/modules/ja.ts | 15 + frontend/src/lang/modules/ko.ts | 15 + frontend/src/lang/modules/ms.ts | 15 + frontend/src/lang/modules/pt-br.ts | 15 + frontend/src/lang/modules/ru.ts | 15 + frontend/src/lang/modules/tr.ts | 15 + frontend/src/lang/modules/zh-Hant.ts | 11 + frontend/src/lang/modules/zh.ts | 11 + .../src/views/terminal/setting/ai/helper.ts | 44 +++ .../src/views/terminal/setting/ai/index.vue | 322 ++++++++++++++++++ frontend/src/views/terminal/setting/index.vue | 213 ++++++++++-- 24 files changed, 1658 insertions(+), 33 deletions(-) create mode 100644 agent/utils/terminal/ai/client.go create mode 100644 agent/utils/terminal/ai/command_generator.go create mode 100644 agent/utils/terminal/ai/config_runtime.go create mode 100644 agent/utils/terminal/ai_interceptor.go create mode 100644 frontend/src/views/terminal/setting/ai/helper.ts create mode 100644 frontend/src/views/terminal/setting/ai/index.vue diff --git a/agent/utils/terminal/ai/client.go b/agent/utils/terminal/ai/client.go new file mode 100644 index 000000000000..0465db64a50b --- /dev/null +++ b/agent/utils/terminal/ai/client.go @@ -0,0 +1,271 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider" +) + +const ( + defaultTimeout = 30 * time.Second + defaultUserAgent = "1panel-terminal-ai/1.0" +) + +type Client interface { + ChatCompletion(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) +} + +type ClientConfig struct { + Provider string + BaseURL string + APIKey string + Model string + Timeout time.Duration + HTTPClient *http.Client +} + +type GeneratorConfig struct { + Provider string + BaseURL string + APIKey string + Model string +} + +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type ChatCompletionRequest struct { + Messages []ChatMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` +} + +type ChatCompletionResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Content string `json:"content"` + RawText string `json:"rawText"` + Usage ResponseUsage `json:"usage"` +} + +type ResponseUsage struct { + PromptTokens int `json:"promptTokens"` + CompletionTokens int `json:"completionTokens"` + TotalTokens int `json:"totalTokens"` +} + +type openAICompatibleClient struct { + config ClientConfig + httpClient *http.Client +} + +func NewClient(cfg ClientConfig) (Client, error) { + providerKey := strings.ToLower(strings.TrimSpace(cfg.Provider)) + if providerKey == "" { + providerKey = "custom" + } + if strings.TrimSpace(cfg.Model) == "" { + return nil, fmt.Errorf("model is required") + } + if strings.TrimSpace(cfg.APIKey) == "" && providerKey != "ollama" { + return nil, fmt.Errorf("api key is required") + } + baseURL := normalizeBaseURL(providerKey, cfg.BaseURL) + if baseURL == "" { + return nil, fmt.Errorf("base url is required") + } + cfg.Provider = providerKey + cfg.BaseURL = baseURL + if cfg.Timeout <= 0 { + cfg.Timeout = defaultTimeout + } + client := cfg.HTTPClient + if client == nil { + client = &http.Client{Timeout: cfg.Timeout} + } + return &openAICompatibleClient{ + config: cfg, + httpClient: client, + }, nil +} + +type ClientOption func(*ClientConfig) + +func WithBaseURL(baseURL string) ClientOption { + return func(cfg *ClientConfig) { + cfg.BaseURL = baseURL + } +} + +func WithTimeout(timeout time.Duration) ClientOption { + return func(cfg *ClientConfig) { + cfg.Timeout = timeout + } +} + +func WithHTTPClient(client *http.Client) ClientOption { + return func(cfg *ClientConfig) { + cfg.HTTPClient = client + } +} + +func (c *openAICompatibleClient) ChatCompletion(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) { + if len(req.Messages) == 0 { + return nil, fmt.Errorf("messages are required") + } + payload := openAIChatCompletionRequest{ + Model: normalizeModelID(c.config.Model), + Messages: req.Messages, + MaxTokens: req.MaxTokens, + Temperature: req.Temperature, + TopP: req.TopP, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, buildChatCompletionsURL(c.config.BaseURL), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.config.APIKey)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("User-Agent", defaultUserAgent) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + if resp.StatusCode >= http.StatusBadRequest { + return nil, parseProviderError(resp.StatusCode, respBody) + } + + var completionResp openAIChatCompletionResponse + if err := json.Unmarshal(respBody, &completionResp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + content := extractAssistantContent(completionResp) + return &ChatCompletionResponse{ + ID: completionResp.ID, + Model: completionResp.Model, + Content: content, + RawText: strings.TrimSpace(string(respBody)), + Usage: ResponseUsage{ + PromptTokens: completionResp.Usage.PromptTokens, + CompletionTokens: completionResp.Usage.CompletionTokens, + TotalTokens: completionResp.Usage.TotalTokens, + }, + }, nil +} + +type openAIChatCompletionRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` +} + +type openAIChatCompletionResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Choices []struct { + Message ChatMessage `json:"message"` + Text string `json:"text"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` +} + +type openAIErrorResponse struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + Code string `json:"code"` + } `json:"error"` +} + +func normalizeBaseURL(provider, rawBaseURL string) string { + baseURL := strings.TrimSpace(rawBaseURL) + if baseURL == "" { + defaultBaseURL, ok := providercatalog.DefaultBaseURL(provider) + if ok { + baseURL = defaultBaseURL + } + } + return strings.TrimRight(baseURL, "/") +} + +func buildChatCompletionsURL(baseURL string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if strings.HasSuffix(baseURL, "/chat/completions") { + return baseURL + } + parsed, err := url.Parse(baseURL) + if err != nil { + return baseURL + "/chat/completions" + } + switch { + case strings.HasSuffix(parsed.Path, "/v1"): + parsed.Path += "/chat/completions" + case strings.HasSuffix(parsed.Path, "/v1beta"): + parsed.Path += "/chat/completions" + default: + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1/chat/completions" + } + return strings.TrimRight(parsed.String(), "/") +} + +func normalizeModelID(model string) string { + model = strings.TrimSpace(model) + if parts := strings.SplitN(model, "/", 2); len(parts) == 2 { + return parts[1] + } + return model +} + +func extractAssistantContent(resp openAIChatCompletionResponse) string { + if len(resp.Choices) == 0 { + return "" + } + if content := strings.TrimSpace(resp.Choices[0].Message.Content); content != "" { + return content + } + return strings.TrimSpace(resp.Choices[0].Text) +} + +func parseProviderError(statusCode int, body []byte) error { + var errResp openAIErrorResponse + if err := json.Unmarshal(body, &errResp); err == nil && strings.TrimSpace(errResp.Error.Message) != "" { + if strings.TrimSpace(errResp.Error.Code) != "" { + return fmt.Errorf("provider returned %d: %s (%s)", statusCode, errResp.Error.Message, errResp.Error.Code) + } + return fmt.Errorf("provider returned %d: %s", statusCode, errResp.Error.Message) + } + message := strings.TrimSpace(string(body)) + if message == "" { + message = http.StatusText(statusCode) + } + return fmt.Errorf("provider returned %d: %s", statusCode, message) +} diff --git a/agent/utils/terminal/ai/command_generator.go b/agent/utils/terminal/ai/command_generator.go new file mode 100644 index 000000000000..ee0770d6e42d --- /dev/null +++ b/agent/utils/terminal/ai/command_generator.go @@ -0,0 +1,170 @@ +package ai + +import ( + "context" + "fmt" + "strings" +) + +type CommandGenerator struct { + client Client +} + +type CommandGenerateRequest struct { + Input string + Shell string + WorkingDir string + OS string + RecentCommands []string + DirectoryHints []string +} + +type CommandGenerateResponse struct { + Command string + Model string + Provider string + RawText string + Usage ResponseUsage +} + +func NewCommandGeneratorFromConfig(cfg GeneratorConfig) (*CommandGenerator, error) { + client, err := NewClient(ClientConfig{ + Provider: cfg.Provider, + BaseURL: cfg.BaseURL, + APIKey: cfg.APIKey, + Model: cfg.Model, + }) + if err != nil { + return nil, err + } + return NewCommandGenerator(client) +} + +func NewCommandGenerator(client Client) (*CommandGenerator, error) { + if client == nil { + return nil, fmt.Errorf("client is required") + } + return &CommandGenerator{client: client}, nil +} + +func (g *CommandGenerator) Generate(ctx context.Context, req CommandGenerateRequest) (*CommandGenerateResponse, error) { + if strings.TrimSpace(req.Input) == "" { + return nil, fmt.Errorf("input is required") + } + + resp, err := g.client.ChatCompletion(ctx, ChatCompletionRequest{ + Messages: []ChatMessage{ + {Role: "system", Content: buildCommandSystemPrompt()}, + {Role: "user", Content: buildCommandUserPrompt(req)}, + }, + }) + if err != nil { + return nil, err + } + + command := sanitizeCommand(resp.Content) + if command == "" { + return nil, fmt.Errorf("model returned empty command") + } + + return &CommandGenerateResponse{ + Command: command, + Model: resp.Model, + Provider: providerNameFromModel(resp.Model), + RawText: resp.RawText, + Usage: resp.Usage, + }, nil +} + +func buildCommandSystemPrompt() string { + return strings.Join([]string{ + "You are a shell command generator.", + "Return exactly one command suitable for direct execution in the user's shell.", + "Do not include markdown, code fences, explanations, numbering, comments, or backticks.", + "If multiple commands are required, join them with shell operators in a single line.", + "Prefer safe, non-destructive commands unless the user explicitly asks for destructive behavior.", + "Preserve the user's language when filenames or arguments are ambiguous, but output only the command.", + }, "\n") +} + +func buildCommandUserPrompt(req CommandGenerateRequest) string { + var sections []string + sections = append(sections, "Task:\n"+strings.TrimSpace(req.Input)) + + var env []string + if shell := strings.TrimSpace(req.Shell); shell != "" { + env = append(env, "Shell: "+shell) + } + if wd := strings.TrimSpace(req.WorkingDir); wd != "" { + env = append(env, "Working directory: "+wd) + } + if osName := strings.TrimSpace(req.OS); osName != "" { + env = append(env, "Operating system: "+osName) + } + if len(env) > 0 { + sections = append(sections, "Environment:\n"+strings.Join(env, "\n")) + } + + if block := formatBulletBlock(req.DirectoryHints); block != "" { + sections = append(sections, "Directory hints:\n"+block) + } + if block := formatBulletBlock(req.RecentCommands); block != "" { + sections = append(sections, "Recent commands:\n"+block) + } + + sections = append(sections, "Output requirement:\nReturn one shell command only.") + return strings.Join(sections, "\n\n") +} + +func formatBulletBlock(values []string) string { + var lines []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + lines = append(lines, "- "+value) + } + return strings.Join(lines, "\n") +} + +func sanitizeCommand(raw string) string { + command := strings.TrimSpace(raw) + if command == "" { + return "" + } + command = strings.TrimPrefix(command, "```sh") + command = strings.TrimPrefix(command, "```bash") + command = strings.TrimPrefix(command, "```zsh") + command = strings.TrimPrefix(command, "```shell") + command = strings.TrimPrefix(command, "```") + command = strings.TrimSuffix(command, "```") + command = strings.TrimSpace(command) + + lines := strings.Split(command, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(strings.ToLower(line), "command:") { + line = strings.TrimSpace(line[len("command:"):]) + } + return strings.Trim(line, "` ") + } + return "" +} + +func providerNameFromModel(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + if parts := strings.SplitN(model, "/", 2); len(parts) == 2 { + return parts[0] + } + return "" +} diff --git a/agent/utils/terminal/ai/config_runtime.go b/agent/utils/terminal/ai/config_runtime.go new file mode 100644 index 000000000000..5ef459383493 --- /dev/null +++ b/agent/utils/terminal/ai/config_runtime.go @@ -0,0 +1,228 @@ +package ai + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/1Panel-dev/1Panel/agent/app/model" + providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider" + "github.com/1Panel-dev/1Panel/agent/app/repo" + "github.com/1Panel-dev/1Panel/agent/global" + "gorm.io/gorm" +) + +var agentAccountRepo = repo.NewIAgentAccountRepo() + +type TerminalRuntimeSettings struct { + AccountID uint + Prefix string + RiskCommands []string +} + +var defaultRiskCommands = []string{ + "rm -rf", + "mkfs", + "dd if=", + "curl | sh", + "wget | sh", + "chmod -R 777 /", + "shutdown", + "reboot", + "poweroff", + "init 0", + ":(){ :|:& };:", +} + +func ResolveGeneratorConfig(accountID uint) (GeneratorConfig, time.Duration, error) { + account, err := loadAgentAccount(accountID) + if err != nil { + return GeneratorConfig{}, 0, err + } + + provider := strings.ToLower(strings.TrimSpace(account.Provider)) + if provider == "" { + return GeneratorConfig{}, 0, fmt.Errorf("agent account provider is required") + } + model := strings.TrimSpace(account.Model) + if model == "" { + model = defaultModelForProvider(provider) + } + baseURL := strings.TrimSpace(account.BaseURL) + if baseURL == "" { + if defaultURL, ok := providercatalog.DefaultBaseURL(provider); ok { + baseURL = defaultURL + } + } + apiKey := strings.TrimSpace(account.APIKey) + if apiKey == "" { + apiKey = lookupProviderAPIKey(provider) + } + if apiKey == "" && provider != "ollama" { + return GeneratorConfig{}, 0, fmt.Errorf("agent account api key is required") + } + return GeneratorConfig{ + Provider: provider, + BaseURL: baseURL, + APIKey: strings.TrimSpace(apiKey), + Model: model, + }, 30 * time.Second, nil +} + +func lookupProviderAPIKey(provider string) string { + envKey := providercatalog.EnvKey(provider) + if envKey == "" { + return strings.TrimSpace(os.Getenv("LLM_API_KEY")) + } + return strings.TrimSpace(os.Getenv(envKey)) +} + +func defaultModelForProvider(provider string) string { + meta, ok := providercatalog.Get(provider) + if !ok || len(meta.Models) == 0 { + if strings.EqualFold(strings.TrimSpace(provider), "deepseek") { + return "deepseek-chat" + } + return "" + } + return meta.Models[0].ID +} + +func ResolveGeneratorConfigFromCoreSettings() (GeneratorConfig, uint, time.Duration, error) { + status, err := loadCoreSettingValue("AIStatus") + if err != nil && !os.IsNotExist(err) { + return GeneratorConfig{}, 0, 0, err + } + if !strings.EqualFold(strings.TrimSpace(status), "Enable") { + return GeneratorConfig{}, 0, 0, os.ErrNotExist + } + accountValue, err := loadCoreSettingValue("AIAccountID") + if err != nil { + return GeneratorConfig{}, 0, 0, err + } + accountID, err := strconv.ParseUint(strings.TrimSpace(accountValue), 10, 64) + if err != nil || accountID == 0 { + return GeneratorConfig{}, 0, 0, os.ErrNotExist + } + config, timeout, err := ResolveGeneratorConfig(uint(accountID)) + return config, uint(accountID), timeout, err +} + +func LoadTerminalRuntimeSettings() (TerminalRuntimeSettings, GeneratorConfig, time.Duration, error) { + config, accountID, timeout, err := ResolveGeneratorConfigFromCoreSettings() + if err != nil { + return TerminalRuntimeSettings{}, GeneratorConfig{}, 0, err + } + prefix, err := loadCoreSettingValue("AIPrefix") + if err != nil && !os.IsNotExist(err) { + return TerminalRuntimeSettings{}, GeneratorConfig{}, 0, err + } + if strings.TrimSpace(prefix) == "" { + prefix = "#" + } + riskCommands, err := loadRiskCommands() + if err != nil { + return TerminalRuntimeSettings{}, GeneratorConfig{}, 0, err + } + return TerminalRuntimeSettings{ + AccountID: accountID, + Prefix: strings.TrimSpace(prefix), + RiskCommands: riskCommands, + }, config, timeout, nil +} + +func loadAgentAccount(accountID uint) (*model.AgentAccount, error) { + if accountID > 0 { + account, err := agentAccountRepo.GetFirst(repo.WithByID(accountID)) + if err != nil { + return nil, err + } + if !account.Verified && !providercatalog.SkipVerification(account.Provider) { + return nil, fmt.Errorf("agent account %d is not verified", account.ID) + } + return account, nil + } + + accounts, err := agentAccountRepo.List(repo.WithOrderDesc("created_at")) + if err != nil { + return nil, err + } + account := selectAgentAccount(accounts) + if account == nil { + return nil, os.ErrNotExist + } + return account, nil +} + +func selectAgentAccount(accounts []model.AgentAccount) *model.AgentAccount { + for idx := range accounts { + account := accounts[idx] + if strings.TrimSpace(account.Provider) == "" { + continue + } + if !account.Verified && !providercatalog.SkipVerification(account.Provider) { + continue + } + if strings.TrimSpace(account.APIKey) == "" && !strings.EqualFold(strings.TrimSpace(account.Provider), "ollama") { + continue + } + return &accounts[idx] + } + return nil +} + +func loadCoreSettingValue(key string) (string, error) { + var setting model.Setting + if err := global.CoreDB.Where("key = ?", key).First(&setting).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", os.ErrNotExist + } + return "", err + } + return setting.Value, nil +} + +func loadRiskCommands() ([]string, error) { + value, err := loadCoreSettingValue("AIRiskCommands") + if err != nil { + if os.IsNotExist(err) { + return append([]string(nil), defaultRiskCommands...), nil + } + return nil, err + } + if strings.TrimSpace(value) == "" { + return append([]string(nil), defaultRiskCommands...), nil + } + var commands []string + if err := json.Unmarshal([]byte(value), &commands); err != nil { + return nil, err + } + return normalizeRiskCommands(commands), nil +} + +func normalizeRiskCommands(commands []string) []string { + if len(commands) == 0 { + return append([]string(nil), defaultRiskCommands...) + } + seen := make(map[string]struct{}, len(commands)) + result := make([]string, 0, len(commands)) + for _, command := range commands { + command = strings.TrimSpace(command) + if command == "" { + continue + } + if _, ok := seen[command]; ok { + continue + } + seen[command] = struct{}{} + result = append(result, command) + } + if len(result) == 0 { + return append([]string(nil), defaultRiskCommands...) + } + return result +} diff --git a/agent/utils/terminal/ai_interceptor.go b/agent/utils/terminal/ai_interceptor.go new file mode 100644 index 000000000000..1e0aabf46a67 --- /dev/null +++ b/agent/utils/terminal/ai_interceptor.go @@ -0,0 +1,234 @@ +package terminal + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + "unicode" + "unicode/utf8" + + "github.com/1Panel-dev/1Panel/agent/global" + terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai" +) + +const lineClearControl = 21 + +type aiInputInterceptor struct { + config terminalai.GeneratorConfig + timeout time.Duration + shell string + prefix string + + mu sync.Mutex + currentLine []byte + recentCommands []string + riskCommands []string + inEscapeSeq bool +} + +func newAIInputInterceptor(shell string) *aiInputInterceptor { + settings, config, timeout, err := terminalai.LoadTerminalRuntimeSettings() + if err != nil { + if !os.IsNotExist(err) { + global.LOG.Warnf("load terminal ai config failed: %v", err) + } + return nil + } + if strings.TrimSpace(config.APIKey) == "" && !strings.EqualFold(strings.TrimSpace(config.Provider), "ollama") { + return nil + } + return &aiInputInterceptor{ + config: config, + timeout: timeout, + shell: strings.TrimSpace(shell), + prefix: settings.Prefix, + riskCommands: append([]string(nil), settings.RiskCommands...), + } +} + +func (i *aiInputInterceptor) refreshSettings() error { + settings, config, timeout, err := terminalai.LoadTerminalRuntimeSettings() + if err != nil { + return err + } + i.mu.Lock() + defer i.mu.Unlock() + i.config = config + i.timeout = timeout + i.prefix = settings.Prefix + i.riskCommands = append([]string(nil), settings.RiskCommands...) + return nil +} + +func (i *aiInputInterceptor) HandleEnter() (string, bool) { + if i == nil { + return "", false + } + if err := i.refreshSettings(); err != nil { + if !os.IsNotExist(err) { + global.LOG.Warnf("refresh terminal ai config failed: %v", err) + } + return "", false + } + i.mu.Lock() + line := sanitizeInputLine(string(i.currentLine)) + i.currentLine = nil + i.inEscapeSeq = false + recentCommands := append([]string(nil), i.recentCommands...) + i.mu.Unlock() + + if !strings.HasPrefix(line, i.prefix) { + if line != "" { + i.pushRecentCommand(line) + } + return "", false + } + prompt := strings.TrimSpace(strings.TrimPrefix(line, i.prefix)) + if prompt == "" { + return "", false + } + + ctx, cancel := context.WithTimeout(context.Background(), i.timeout) + defer cancel() + generator, err := terminalai.NewCommandGeneratorFromConfig(i.config) + if err != nil { + global.LOG.Errorf("create terminal ai generator failed: %v", err) + return "", false + } + resp, err := generator.Generate(ctx, terminalai.CommandGenerateRequest{ + Input: prompt, + Shell: firstNonEmpty(i.shell, filepath.Base(strings.TrimSpace(os.Getenv("SHELL")))), + OS: runtime.GOOS, + RecentCommands: recentCommands, + }) + if err != nil { + global.LOG.Errorf("generate terminal ai command failed: %v", err) + return "", false + } + if i.isRiskCommand(resp.Command) { + return ": # blocked risky command: " + resp.Command, true + } + return resp.Command, strings.TrimSpace(resp.Command) != "" +} + +func (i *aiInputInterceptor) TrackInput(data []byte) { + if i == nil || len(data) == 0 { + return + } + i.mu.Lock() + defer i.mu.Unlock() + for _, b := range data { + if i.inEscapeSeq { + if isEscapeSequenceTerminator(b) { + i.inEscapeSeq = false + } + continue + } + switch b { + case '\r', '\n': + i.currentLine = nil + case 0x08, 0x7f: + i.currentLine = trimLastRuneBytes(i.currentLine) + case lineClearControl: + i.currentLine = nil + case 0x1b: + // Ignore ANSI escape sequences such as arrow keys and bracketed paste markers. + i.inEscapeSeq = true + default: + if b < 0x20 && b != '\t' { + continue + } + i.currentLine = append(i.currentLine, b) + } + } +} + +func (i *aiInputInterceptor) pushRecentCommand(command string) { + if i == nil { + return + } + command = strings.TrimSpace(command) + if command == "" || strings.HasPrefix(command, i.prefix) { + return + } + i.mu.Lock() + defer i.mu.Unlock() + i.recentCommands = append([]string{command}, i.recentCommands...) + if len(i.recentCommands) > 8 { + i.recentCommands = i.recentCommands[:8] + } +} + +func isEnterInput(data []byte) bool { + if len(data) == 1 && (data[0] == '\r' || data[0] == '\n') { + return true + } + return len(data) == 2 && data[0] == '\r' && data[1] == '\n' +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func sanitizeInputLine(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + var builder strings.Builder + builder.Grow(len(raw)) + for _, r := range raw { + switch { + case unicode.IsControl(r) && r != '\t' && r != ' ': + continue + case unicode.In(r, unicode.Cf): + continue + case r == '\u00a0' || r == '\u2007' || r == '\u202f' || r == '\u3000': + builder.WriteRune(' ') + default: + builder.WriteRune(r) + } + } + return strings.TrimSpace(builder.String()) +} + +func trimLastRuneBytes(data []byte) []byte { + if len(data) == 0 { + return data + } + _, size := utf8.DecodeLastRune(data) + if size <= 0 || size > len(data) { + return data[:len(data)-1] + } + return data[:len(data)-size] +} + +func isEscapeSequenceTerminator(b byte) bool { + return b >= 0x40 && b <= 0x7e +} + +func (i *aiInputInterceptor) isRiskCommand(command string) bool { + command = strings.ToLower(strings.TrimSpace(command)) + if command == "" { + return false + } + for _, riskCommand := range i.riskCommands { + riskCommand = strings.ToLower(strings.TrimSpace(riskCommand)) + if riskCommand == "" { + continue + } + if strings.Contains(command, riskCommand) { + return true + } + } + return false +} diff --git a/agent/utils/terminal/ws_local_session.go b/agent/utils/terminal/ws_local_session.go index da7c1ab367b0..230ece3b623c 100644 --- a/agent/utils/terminal/ws_local_session.go +++ b/agent/utils/terminal/ws_local_session.go @@ -14,8 +14,9 @@ type LocalWsSession struct { slave *LocalCommand wsConn *websocket.Conn - allowCtrlC bool - writeMutex sync.Mutex + allowCtrlC bool + writeMutex sync.Mutex + aiInterceptor *aiInputInterceptor } func NewLocalWsSession(cols, rows int, wsConn *websocket.Conn, slave *LocalCommand, allowCtrlC bool) (*LocalWsSession, error) { @@ -27,7 +28,8 @@ func NewLocalWsSession(cols, rows int, wsConn *websocket.Conn, slave *LocalComma slave: slave, wsConn: wsConn, - allowCtrlC: allowCtrlC, + allowCtrlC: allowCtrlC, + aiInterceptor: newAIInputInterceptor(""), }, nil } @@ -108,6 +110,14 @@ func (sws *LocalWsSession) receiveWsMsg(exitCh chan bool) { if err != nil { global.LOG.Errorf("websock cmd string base64 decoding failed, err: %v", err) } + if isEnterInput(decodeBytes) { + if generated, ok := sws.aiInterceptor.HandleEnter(); ok { + sws.sendWebsocketInputCommandToSshSessionStdinPipe(append([]byte{lineClearControl}, []byte(generated)...)) + continue + } + } else { + sws.aiInterceptor.TrackInput(decodeBytes) + } sws.sendWebsocketInputCommandToSshSessionStdinPipe(decodeBytes) case WsMsgHeartbeat: err = wsConn.WriteMessage(websocket.TextMessage, wsData) diff --git a/agent/utils/terminal/ws_session.go b/agent/utils/terminal/ws_session.go index c5fd9dc60c4a..6f6a7c993573 100644 --- a/agent/utils/terminal/ws_session.go +++ b/agent/utils/terminal/ws_session.go @@ -57,6 +57,7 @@ type LogicSshWsSession struct { wsConn *websocket.Conn isAdmin bool IsFlagged bool + aiInterceptor *aiInputInterceptor } func NewLogicSshWsSession(cols, rows int, sshClient *ssh.Client, wsConn *websocket.Conn, initCmd string) (*LogicSshWsSession, error) { @@ -100,6 +101,7 @@ func NewLogicSshWsSession(cols, rows int, sshClient *ssh.Client, wsConn *websock wsConn: wsConn, isAdmin: true, IsFlagged: false, + aiInterceptor: newAIInputInterceptor(""), }, nil } @@ -151,6 +153,14 @@ func (sws *LogicSshWsSession) receiveWsMsg(exitCh chan bool) { if err != nil { global.LOG.Errorf("websock cmd string base64 decoding failed, err: %v", err) } + if isEnterInput(decodeBytes) { + if generated, ok := sws.aiInterceptor.HandleEnter(); ok { + sws.sendWebsocketInputCommandToSshSessionStdinPipe(append([]byte{lineClearControl}, []byte(generated)...)) + continue + } + } else { + sws.aiInterceptor.TrackInput(decodeBytes) + } sws.sendWebsocketInputCommandToSshSessionStdinPipe(decodeBytes) case WsMsgHeartbeat: err = wsConn.WriteMessage(websocket.TextMessage, wsData) diff --git a/core/app/dto/setting.go b/core/app/dto/setting.go index 8b1e79099fb9..2010dd7adc15 100644 --- a/core/app/dto/setting.go +++ b/core/app/dto/setting.go @@ -235,6 +235,10 @@ type TerminalInfo struct { CursorStyle string `json:"cursorStyle"` Scrollback string `json:"scrollback"` ScrollSensitivity string `json:"scrollSensitivity"` + AIStatus string `json:"aiStatus"` + AIAccountID string `json:"aiAccountId"` + AIPrefix string `json:"aiPrefix"` + AIRiskCommands string `json:"aiRiskCommands"` } type AppstoreUpdate struct { diff --git a/core/app/service/setting.go b/core/app/service/setting.go index 8842a945f802..ef09ebe4ec35 100644 --- a/core/app/service/setting.go +++ b/core/app/service/setting.go @@ -558,6 +558,18 @@ func (u *SettingService) UpdateTerminal(req dto.TerminalInfo) error { if err := settingRepo.UpdateOrCreate("ScrollSensitivity", req.ScrollSensitivity); err != nil { return err } + if err := settingRepo.UpdateOrCreate("AIStatus", req.AIStatus); err != nil { + return err + } + if err := settingRepo.UpdateOrCreate("AIAccountID", req.AIAccountID); err != nil { + return err + } + if err := settingRepo.UpdateOrCreate("AIPrefix", req.AIPrefix); err != nil { + return err + } + if err := settingRepo.UpdateOrCreate("AIRiskCommands", req.AIRiskCommands); err != nil { + return err + } return nil } diff --git a/core/init/migration/migrate.go b/core/init/migration/migrate.go index 86d498085ab8..bc3b508f1c73 100644 --- a/core/init/migration/migrate.go +++ b/core/init/migration/migrate.go @@ -35,6 +35,7 @@ func Init() { migrations.AddEditionSetting, migrations.UpdateAiLocalModelMenuTitle, migrations.AddDocSourceSetting, + migrations.AddAITerminalSettings, }) if err := m.Migrate(); err != nil { global.LOG.Error(err) diff --git a/core/init/migration/migrations/init.go b/core/init/migration/migrations/init.go index d00d7d424f74..227276418ae4 100644 --- a/core/init/migration/migrations/init.go +++ b/core/init/migration/migrations/init.go @@ -275,6 +275,25 @@ var InitTerminalSetting = &gormigrate.Migration{ }, } +var AddAITerminalSettings = &gormigrate.Migration{ + ID: "20260318-add-ai-terminal-settings", + Migrate: func(tx *gorm.DB) error { + if err := tx.Create(&model.Setting{Key: "AIStatus", Value: constant.StatusDisable}).Error; err != nil { + return err + } + if err := tx.Create(&model.Setting{Key: "AIAccountID", Value: ""}).Error; err != nil { + return err + } + if err := tx.Create(&model.Setting{Key: "AIPrefix", Value: "#"}).Error; err != nil { + return err + } + if err := tx.Create(&model.Setting{Key: "AIRiskCommands", Value: "[\"rm -rf\",\"mkfs\",\"dd if=\",\"curl | sh\",\"wget | sh\",\"chmod -R 777 /\",\"shutdown\",\"reboot\",\"poweroff\",\"init 0\",\":(){ :|:& };:\"]"}).Error; err != nil { + return err + } + return nil + }, +} + var InitHost = &gormigrate.Migration{ ID: "20240816-init-host", Migrate: func(tx *gorm.DB) error { diff --git a/frontend/src/api/interface/setting.ts b/frontend/src/api/interface/setting.ts index 0a3d00fdc440..a8f99e8c4e31 100644 --- a/frontend/src/api/interface/setting.ts +++ b/frontend/src/api/interface/setting.ts @@ -81,6 +81,10 @@ export namespace Setting { cursorStyle: string; scrollback: string; scrollSensitivity: string; + aiStatus: string; + aiAccountId: string; + aiPrefix: string; + aiRiskCommands: string; } export interface SettingUpdate { key: string; diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 7a97b4365cef..3cadd4c7f5a9 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -1371,6 +1371,22 @@ const message = { cursorBar: 'Bar', scrollback: 'Scrollback', scrollSensitivity: 'Scroll Sensitivity', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'When enabled, entering a line starting with # and pressing Enter will use the selected AgentAccount to generate a command and fill the current line.', + aiAccountRequired: 'Please select an available AI account first.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with {0} and you press Enter, {1} ({2}) will generate AI commands.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: 'Are you sure you want to save the current terminal configuration?', }, toolbox: { diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 9522b122bf5c..0e602644cf48 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -1410,6 +1410,21 @@ const message = { cursorBar: 'Barra', scrollback: 'Scrollback', scrollSensitivity: 'Sensibilidad de scroll', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'When enabled, entering a line starting with # and pressing Enter will use the selected AgentAccount to generate a command and fill the current line.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with {0} and you press Enter, {1} ({2}) will generate AI commands.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: '¿Está seguro de que desea guardar la configuración actual de la terminal?', }, toolbox: { diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 31e0fdd7ac9b..8e95660a6c8e 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -1381,6 +1381,21 @@ const message = { cursorBar: 'バー', scrollback: 'スクロールバック', scrollSensitivity: 'スクロール感度', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'When enabled, entering a line starting with # and pressing Enter will use the selected AgentAccount to generate a command and fill the current line.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with {0} and you press Enter, {1} ({2}) will generate AI commands.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: '現在のターミナル設定を保存してもよろしいですか?', }, toolbox: { diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 26dd15b29739..b5b48129a2da 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -1355,6 +1355,21 @@ const message = { cursorBar: '막대', scrollback: '스크롤백', scrollSensitivity: '스크롤 감도', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'When enabled, entering a line starting with # and pressing Enter will use the selected AgentAccount to generate a command and fill the current line.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with {0} and you press Enter, {1} ({2}) will generate AI commands.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: '현재 터미널 설정을 저장하시겠습니까?', }, toolbox: { diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 553323e127bb..ad46481fd89b 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -1395,6 +1395,21 @@ const message = { cursorBar: 'Bar', scrollback: 'Skrol balik', scrollSensitivity: 'Kepekaan skrol', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'When enabled, entering a line starting with # and pressing Enter will use the selected AgentAccount to generate a command and fill the current line.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with {0} and you press Enter, {1} ({2}) will generate AI commands.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: 'Adakah anda pasti mahu menyimpan konfigurasi terminal semasa?', }, toolbox: { diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index d7e9eb5c8588..d8231ad720fe 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -1403,6 +1403,21 @@ const message = { cursorBar: 'Barra', scrollback: 'Scrollback', scrollSensitivity: 'Sensibilidade de rolagem', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'When enabled, entering a line starting with # and pressing Enter will use the selected AgentAccount to generate a command and fill the current line.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with {0} and you press Enter, {1} ({2}) will generate AI commands.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: 'Tem certeza de que deseja salvar a configuração atual do terminal?', }, toolbox: { diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 1430d5f59ee5..998457ef0028 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -1388,6 +1388,21 @@ const message = { cursorBar: 'Полоса', scrollback: 'Буфер прокрутки', scrollSensitivity: 'Чувствительность прокрутки', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'When enabled, entering a line starting with # and pressing Enter will use the selected AgentAccount to generate a command and fill the current line.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with {0} and you press Enter, {1} ({2}) will generate AI commands.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: 'Вы уверены, что хотите сохранить текущую конфигурацию терминала?', }, toolbox: { diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index a9a9613dfa8b..9eabb0bcbfec 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -1392,6 +1392,21 @@ const message = { cursorBar: 'Çubuk', scrollback: 'Geri Kaydırma', scrollSensitivity: 'Kaydırma Hassasiyeti', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'When enabled, entering a line starting with # and pressing Enter will use the selected AgentAccount to generate a command and fill the current line.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with {0} and you press Enter, {1} ({2}) will generate AI commands.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: 'Mevcut terminal yapılandırmasını kaydetmek istediğinizden emin misiniz?', }, toolbox: { diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index a42053858e67..77017dcf7572 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -1296,6 +1296,17 @@ const message = { cursorBar: '條形', scrollback: '滾動行數', scrollSensitivity: '滾動速度', + aiStatus: 'AI 終端', + aiSettings: 'AI 終端設定', + aiAccountHelper: '開啟後,將使用所選模型帳號生成命令並回填當前行', + aiPrefix: '觸發前綴', + aiPrefixHelper: '以該前綴開頭並按下 Enter 時,會觸發 AI 指令生成,例如 # 或 //ai', + aiRiskCommands: '風險命令攔截', + aiRiskCommandsHelper: '命中以下片段的生成命令會被攔截,並以註解形式回填,支援增刪改', + aiAddRiskCommand: '新增風險命令', + aiRemoveRiskCommand: '刪除', + aiSummary: '以 {0} 前綴開頭並按下 Enter 時,會觸發 AI 指令生成,{1}({2})支援', + aiPrefixAsciiVisible: '僅支援 ASCII 可見字元,不支援空格、中文或全形符號', saveHelper: '是否確認儲存目前終端設定?', }, toolbox: { diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 2366e9efb6bf..473868a4d6de 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -1305,6 +1305,17 @@ const message = { cursorBar: '条形', scrollback: '滚动行数', scrollSensitivity: '滚动速度', + aiStatus: 'AI 终端', + aiSettings: 'AI 终端设置', + aiAccountHelper: '开启后,将使用所选模型账号生成命令并回填当前行', + aiPrefix: '触发前缀', + aiPrefixHelper: '以该前缀开头并回车时,会触发 AI 命令生成,例如 # 或 //ai', + aiRiskCommands: '风险命令拦截', + aiRiskCommandsHelper: '命中以下片段的生成命令会被拦截,并以注释形式回填,支持增删改', + aiAddRiskCommand: '新增风险命令', + aiRemoveRiskCommand: '删除', + aiSummary: '以 {0} 前缀开头并回车时,会触发 AI 命令生成,{1}({2})支持', + aiPrefixAsciiVisible: '仅支持 ASCII 可见字符,不支持空格、中文或全角符号', saveHelper: '是否确认保存当前终端配置?', }, toolbox: { diff --git a/frontend/src/views/terminal/setting/ai/helper.ts b/frontend/src/views/terminal/setting/ai/helper.ts new file mode 100644 index 000000000000..86b776bb8561 --- /dev/null +++ b/frontend/src/views/terminal/setting/ai/helper.ts @@ -0,0 +1,44 @@ +export const DEFAULT_AI_PREFIX = '#'; + +export const DEFAULT_AI_RISK_COMMANDS = [ + 'rm -rf', + 'mkfs', + 'dd if=', + 'curl | sh', + 'wget | sh', + 'chmod -R 777 /', + 'shutdown', + 'reboot', + 'poweroff', + 'init 0', + ':(){ :|:& };:', +]; + +export const parseRiskCommands = (value: string): string[] => { + if (!value) { + return [...DEFAULT_AI_RISK_COMMANDS]; + } + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) { + return [...DEFAULT_AI_RISK_COMMANDS]; + } + return parsed.map((item) => String(item).trim()).filter((item) => item.length > 0); + } catch { + return [...DEFAULT_AI_RISK_COMMANDS]; + } +}; + +export const normalizeRiskCommands = (riskCommands: string[]): string[] => { + const seen = new Set(); + const result: string[] = []; + for (const command of riskCommands) { + const normalized = command.trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + return result.length > 0 ? result : [...DEFAULT_AI_RISK_COMMANDS]; +}; diff --git a/frontend/src/views/terminal/setting/ai/index.vue b/frontend/src/views/terminal/setting/ai/index.vue new file mode 100644 index 000000000000..0b20b905ebd1 --- /dev/null +++ b/frontend/src/views/terminal/setting/ai/index.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/frontend/src/views/terminal/setting/index.vue b/frontend/src/views/terminal/setting/index.vue index d22c510b3037..0e9a810574bb 100644 --- a/frontend/src/views/terminal/setting/index.vue +++ b/frontend/src/views/terminal/setting/index.vue @@ -111,7 +111,22 @@ {{ $t('commons.button.reset') }} {{ $t('commons.button.save') }} + + + + + + @@ -146,8 +161,16 @@