Files
1Panel/agent/utils/terminal/ai_interceptor.go

274 lines
6.7 KiB
Go

package terminal
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
)
const lineClearControl = 21
type aiInputInterceptor struct {
config terminalai.GeneratorConfig
timeout time.Duration
shell string
lang string
prefix string
version uint64
mu sync.Mutex
currentLine string
recentCommands []string
riskCommands []string
}
func newAIInputInterceptor(shell string, lang 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),
lang: strings.TrimSpace(lang),
prefix: settings.Prefix,
riskCommands: append([]string(nil), settings.RiskCommands...),
version: terminalai.CurrentTerminalRuntimeVersion(),
}
}
func (i *aiInputInterceptor) refreshSettings() error {
if !i.needsRefresh() {
return nil
}
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...)
i.version = terminalai.CurrentTerminalRuntimeVersion()
return nil
}
func (i *aiInputInterceptor) SetCurrentLine(line string) {
if i == nil {
return
}
i.mu.Lock()
defer i.mu.Unlock()
i.currentLine = normalizeCurrentLine(line, i.prefix)
}
func (i *aiInputInterceptor) needsRefresh() bool {
if i == nil {
return false
}
currentVersion := terminalai.CurrentTerminalRuntimeVersion()
i.mu.Lock()
defer i.mu.Unlock()
return i.version != currentVersion
}
func (i *aiInputInterceptor) HandleEnter(onStart func(), onDone func(string), onError func(string)) (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()
currentLine := i.currentLine
i.currentLine = ""
recentCommands := append([]string(nil), i.recentCommands...)
i.mu.Unlock()
if !matchesAIPrefix(currentLine, i.prefix) {
if currentLine != "" {
i.pushRecentCommand(currentLine)
}
return "", false
}
prompt := strings.TrimSpace(strings.TrimPrefix(currentLine, i.prefix))
if prompt == "" {
return "", true
}
if onStart != nil {
onStart()
}
start := time.Now()
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)
if onError != nil {
onError(i18n.GetMsgWithMapAndLang(i.lang, "TerminalAIRequestFailed", map[string]interface{}{
"err": err.Error(),
}))
}
return "", true
}
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)
if onError != nil {
onError(i18n.GetMsgWithMapAndLang(i.lang, "TerminalAIRequestFailed", map[string]interface{}{
"err": err.Error(),
}))
}
return "", true
}
if onDone != nil {
onDone(i18n.GetMsgWithMapAndLang(i.lang, "TerminalAIReadyToExecute", map[string]interface{}{
"duration": formatAIDuration(time.Since(start)),
"tokens": resolveTotalTokens(resp.Usage),
}))
}
if i.isRiskCommand(resp.Command) {
return "# " + i18n.GetMsgWithMapAndLang(i.lang, "TerminalAIBlockedRiskyCommand", map[string]interface{}{
"command": resp.Command,
}), true
}
return resp.Command, strings.TrimSpace(resp.Command) != ""
}
func resolveTotalTokens(usage terminalai.ResponseUsage) int {
if usage.TotalTokens > 0 {
return usage.TotalTokens
}
return usage.PromptTokens + usage.CompletionTokens
}
func formatAIDuration(duration time.Duration) string {
if duration < time.Millisecond {
return duration.String()
}
return duration.Round(time.Millisecond).String()
}
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 {
return strings.TrimSpace(raw)
}
func matchesAIPrefix(line, prefix string) bool {
line = strings.TrimSpace(line)
prefix = strings.TrimSpace(prefix)
if line == "" || prefix == "" {
return false
}
return line == prefix || strings.HasPrefix(line, prefix+" ")
}
func normalizeCurrentLine(raw, prefix string) string {
line := sanitizeInputLine(raw)
prefix = strings.TrimSpace(prefix)
if line == "" || prefix == "" {
return line
}
if matchesAIPrefix(line, prefix) {
return line
}
prefixIdx := strings.Index(line, prefix)
if prefixIdx < 0 {
return line
}
for _, marker := range []string{"# ", "$ ", "% ", "> "} {
if idx := strings.LastIndex(line[:prefixIdx], marker); idx >= 0 {
promptPart := strings.TrimSpace(line[:idx+len(marker)-1])
if looksLikePromptPrefix(promptPart) {
return strings.TrimSpace(line[idx+len(marker):])
}
}
}
return line
}
func looksLikePromptPrefix(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
if strings.ContainsAny(value, "'\"`") {
return false
}
return true
}
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
}