feat(deps): implement dependency auto-completion and interactive installation CLI (#147)

This commit is contained in:
duorameng
2026-07-10 08:45:26 +08:00
parent f0967abc6f
commit 3354f3d5ed
11 changed files with 442 additions and 7 deletions
+2
View File
@@ -2,6 +2,7 @@ package cmd
import (
"github.com/engigu/baihu-panel/cmd/builtininstall"
"github.com/engigu/baihu-panel/cmd/depinstall"
"github.com/engigu/baihu-panel/cmd/reposync"
"github.com/engigu/baihu-panel/cmd/resetpwd"
"github.com/engigu/baihu-panel/cmd/restore"
@@ -25,5 +26,6 @@ var Handlers = map[string]CommandHandler{
"version": version.Run,
"-v": version.Run,
"-V": version.Run,
"depinstall": depinstall.Run,
// "migrate": migrate.Run,
}
+183
View File
@@ -0,0 +1,183 @@
package depinstall
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/engigu/baihu-panel/cmd/clibase"
"github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/models"
"github.com/engigu/baihu-panel/internal/services"
"github.com/engigu/baihu-panel/internal/services/deps"
"github.com/engigu/baihu-panel/internal/utils"
)
// Run 依赖自动补全命令入口
func Run(args []string) {
if len(args) == 0 {
fmt.Println("用法: baihu depinstall <log_id>")
return
}
logID := args[0]
// 初始化基础环境和数据库连接
if err := clibase.InitContext(true); err != nil {
fmt.Printf(">> 初始化环境失败: %v\n", err)
return
}
var log models.TaskLog
if err := database.DB.Where("id = ?", logID).First(&log).Error; err != nil {
fmt.Printf(">> 未找到指定的任务日志 (ID: %s): %v\n", logID, err)
return
}
var task models.Task
if err := database.DB.Where("id = ?", log.TaskID).First(&task).Error; err != nil {
fmt.Printf(">> 未找到对应的任务 (TaskID: %s): %v\n", log.TaskID, err)
return
}
logOutput, err := utils.DecompressFromBase64(string(log.Output))
if err != nil {
fmt.Printf(">> 解压日志失败: %v\n", err)
return
}
// 找出任务配置的语言
taskLangs := task.GetLanguages()
if len(taskLangs) == 0 {
fmt.Println(">> 提示: 当前任务未配置具体语言环境,请手动指定语言类型(例如 python3, node 等):")
reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
fmt.Println(">> 已取消补全。")
return
}
taskLangs = append(taskLangs, map[string]string{
"name": input,
"version": "",
})
}
var allDetected []string
langToPkgMap := make(map[string][]string)
for _, langMap := range taskLangs {
langName := langMap["name"]
if langName == "" {
continue
}
detected, found := deps.DetectMissingDependencies(langName, logOutput)
if found {
langToPkgMap[langName] = detected
allDetected = append(allDetected, detected...)
}
}
reader := bufio.NewReader(os.Stdin)
// 如果没有检测到任何缺失的包,允许用户手动输入
if len(allDetected) == 0 {
fmt.Println(">> 分析完毕: 未从最近一次的任务运行日志中检测到缺失依赖模式。")
fmt.Println(">> 您可以手动输入想要安装的依赖包名称(多个包用空格分隔,若不安装请直接回车退出):")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
fmt.Println(">> 已退出依赖补全。")
return
}
// 默认分配到任务的第一个语言环境
defaultLang := taskLangs[0]["name"]
langToPkgMap[defaultLang] = strings.Fields(input)
allDetected = append(allDetected, langToPkgMap[defaultLang]...)
} else {
fmt.Println(">> 分析结果: 从运行日志中检测到以下缺失依赖包:")
for langName, pkgs := range langToPkgMap {
fmt.Printf(" [%s]: %s\n", langName, strings.Join(pkgs, ", "))
}
fmt.Println(">> 是否确认自动安装上述依赖包?(y/N):")
confirm, _ := reader.ReadString('\n')
confirm = strings.TrimSpace(strings.ToLower(confirm))
if confirm != "y" && confirm != "yes" {
fmt.Println(">> 用户已取消安装操作。")
return
}
}
fmt.Println("==================================================================")
fmt.Println(">> 开始执行依赖安装,请稍候...")
fmt.Println("==================================================================")
var failedPkgs []string
depService := services.NewDependencyService()
for langName, pkgs := range langToPkgMap {
var langVersion string
for _, lm := range taskLangs {
if lm["name"] == langName {
langVersion = lm["version"]
break
}
}
m := deps.GetManager(langName)
if m == nil {
fmt.Printf(">> 错误: 不支持的语言类型: %s\n", langName)
failedPkgs = append(failedPkgs, pkgs...)
continue
}
for _, pkg := range pkgs {
dep := &models.Dependency{
Name: pkg,
Language: langName,
LangVersion: langVersion,
}
cmdStr, err := m.GetInstallCommand(dep)
if err != nil {
fmt.Printf(">> 无法生成 %s 包 [%s] 的安装命令: %v\n", langName, pkg, err)
failedPkgs = append(failedPkgs, pkg)
continue
}
// 去除命令末尾的 success/failed echo 重定向,因为我们需要捕获退出状态并在控制台展示原始流程
if idx := strings.Index(cmdStr, " && echo"); idx != -1 {
cmdStr = cmdStr[:idx]
}
fmt.Printf(">> 正在安装 [%s] -> 执行指令: %s\n", pkg, cmdStr)
execCmd := utils.NewShellCommandCmd(cmdStr)
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
execCmd.Stdin = os.Stdin
runErr := execCmd.Run()
if runErr != nil {
fmt.Printf(">> 【失败】依赖包 [%s] 安装出错。\n\n", pkg)
failedPkgs = append(failedPkgs, pkg)
} else {
fmt.Printf(">> 【成功】依赖包 [%s] 安装成功!\n\n", pkg)
// 成功后记录到依赖表
_ = depService.Create(dep)
}
}
}
fmt.Println("==================================================================")
if len(failedPkgs) > 0 {
fmt.Printf(">> 依赖补全已结束。其中以下依赖包安装失败,请用户自行判断/手动处理:\n")
for _, fp := range failedPkgs {
fmt.Printf(" - %s\n", fp)
}
} else {
fmt.Println(">> 恭喜!所有依赖包安装成功!")
}
fmt.Println("==================================================================")
}
+4
View File
@@ -32,6 +32,10 @@ var Commands = []CommandInfo{
Name: "task",
Description: "系统级任务的列表查询、触发运行、启停控制及状态查看",
},
{
Name: "depinstall",
Description: "一键补全指定任务执行日志中的缺失依赖包",
},
{
Name: "version",
Description: "查看当前系统版本号 (同 -v, -V)",
@@ -1,6 +1,8 @@
package controllers
import (
"fmt"
"os"
"strings"
"github.com/engigu/baihu-panel/internal/models"
@@ -397,3 +399,23 @@ func (c *DependencyController) ParseAndImport(ctx *gin.Context) {
"command": cmd,
})
}
// GetDepInstallCommand 获取自动补全的命令,返回给前端执行
func (c *DependencyController) GetDepInstallCommand(ctx *gin.Context) {
logID := ctx.Query("log_id")
if logID == "" {
utils.BadRequest(ctx, "参数错误: log_id 不能为空")
return
}
execPath, err := os.Executable()
if err != nil {
execPath = "baihu" // 兜底
}
// 构造命令,比如: "F:\workspace\baihu-panel\baihu.exe" depinstall <log_id>
cmdStr := fmt.Sprintf("%q depinstall %s", execPath, logID)
utils.Success(ctx, gin.H{
"command": cmdStr,
})
}
+1
View File
@@ -213,6 +213,7 @@ func registerDependencyRoutes(g *gin.RouterGroup, c *Controllers) {
deps.POST("/batch-install-cmd", c.Dependency.GetBatchInstallCommand)
deps.POST("/import", c.Dependency.ParseAndImport)
deps.GET("/installed", c.Dependency.GetInstalled)
deps.GET("/install-suggest-cmd", c.Dependency.GetDepInstallCommand)
}
}
+103
View File
@@ -0,0 +1,103 @@
package deps
import (
"regexp"
"strings"
)
// Detector 依赖检测器接口
type Detector interface {
Detect(logContent string) []string
}
// PythonDetector Python 依赖检测器
type PythonDetector struct{}
func (d *PythonDetector) Detect(logContent string) []string {
var pkgs []string
seen := make(map[string]bool)
pythonRegex1 := regexp.MustCompile(`ModuleNotFoundError: No module named '([^']+)'`)
pythonRegex2 := regexp.MustCompile(`No module named ([a-zA-Z0-9_\-]+)`)
matches := pythonRegex1.FindAllStringSubmatch(logContent, -1)
for _, m := range matches {
if len(m) > 1 {
name := strings.TrimSpace(m[1])
if name != "" && !seen[name] {
seen[name] = true
pkgs = append(pkgs, name)
}
}
}
matches2 := pythonRegex2.FindAllStringSubmatch(logContent, -1)
for _, m := range matches2 {
if len(m) > 1 {
name := strings.TrimSpace(m[1])
if name != "" && !seen[name] {
seen[name] = true
pkgs = append(pkgs, name)
}
}
}
return pkgs
}
// NodeDetector Node.js 依赖检测器
type NodeDetector struct{}
func (d *NodeDetector) Detect(logContent string) []string {
var pkgs []string
seen := make(map[string]bool)
nodeRegex1 := regexp.MustCompile(`Error: Cannot find module '([^']+)'`)
nodeRegex2 := regexp.MustCompile(`Cannot find module '([^']+)'`)
matches := nodeRegex1.FindAllStringSubmatch(logContent, -1)
for _, m := range matches {
if len(m) > 1 {
name := strings.TrimSpace(m[1])
if name != "" && !seen[name] {
seen[name] = true
pkgs = append(pkgs, name)
}
}
}
matches2 := nodeRegex2.FindAllStringSubmatch(logContent, -1)
for _, m := range matches2 {
if len(m) > 1 {
name := strings.TrimSpace(m[1])
if name != "" && !seen[name] {
seen[name] = true
pkgs = append(pkgs, name)
}
}
}
return pkgs
}
var languageDetectors = map[string]Detector{
"python": &PythonDetector{},
"python3": &PythonDetector{},
"node": &NodeDetector{},
"js": &NodeDetector{},
"ts": &NodeDetector{},
"bun": &NodeDetector{},
}
// DetectMissingDependencies 从日志内容中检测缺失的依赖包名
func DetectMissingDependencies(language, logContent string) ([]string, bool) {
lang := strings.ToLower(language)
var det Detector
for key, d := range languageDetectors {
if strings.Contains(lang, key) {
det = d
break
}
}
if det == nil {
return nil, false
}
pkgs := det.Detect(logContent)
return pkgs, len(pkgs) > 0
}
+62
View File
@@ -0,0 +1,62 @@
package deps
import (
"testing"
)
func TestDetectMissingDependencies(t *testing.T) {
tests := []struct {
name string
language string
logContent string
wantPkgs []string
wantFound bool
}{
{
name: "Python ModuleNotFoundError",
language: "python3",
logContent: "Traceback (most recent call last):\n File \"main.py\", line 1, in <module>\n import requests\nModuleNotFoundError: No module named 'requests'",
wantPkgs: []string{"requests"},
wantFound: true,
},
{
name: "Python No module named",
language: "python",
logContent: "ImportError: No module named yaml",
wantPkgs: []string{"yaml"},
wantFound: true,
},
{
name: "Node Error Cannot find module",
language: "node",
logContent: "Error: Cannot find module 'axios'\nRequire stack:\n- /app/index.js",
wantPkgs: []string{"axios"},
wantFound: true,
},
{
name: "No match",
language: "python",
logContent: "Success running script",
wantPkgs: nil,
wantFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotPkgs, gotFound := DetectMissingDependencies(tt.language, tt.logContent)
if gotFound != tt.wantFound {
t.Errorf("DetectMissingDependencies() gotFound = %v, want %v", gotFound, tt.wantFound)
}
if len(gotPkgs) != len(tt.wantPkgs) {
t.Errorf("DetectMissingDependencies() gotPkgs = %v, want %v", gotPkgs, tt.wantPkgs)
return
}
for i, p := range gotPkgs {
if p != tt.wantPkgs[i] {
t.Errorf("DetectMissingDependencies() gotPkgs[%d] = %v, want %v", i, p, tt.wantPkgs[i])
}
}
})
}
}
+2 -1
View File
@@ -360,7 +360,8 @@ export const api = {
const query = new URLSearchParams({ language })
if (lang_version) query.set('lang_version', lang_version)
return request<Dependency[]>(`/deps/installed?${query}`)
}
},
getInstallSuggestCmd: (logID: string) => request<{ command: string }>(`/deps/install-suggest-cmd?log_id=${logID}`)
},
agents: {
list: () => request<Agent[]>('/agents'),
+1 -1
View File
@@ -318,7 +318,7 @@ async function save() {
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="max-w-[95vw] sm:max-w-[700px] xl:max-w-[950px] p-0 overflow-hidden border-none bg-background shadow-2xl transition-all duration-300" style="text-rendering: optimizeLegibility;" @openAutoFocus.prevent>
<DialogContent class="max-w-[95vw] sm:max-w-[700px] xl:max-w-[950px] p-0 overflow-hidden border-none bg-background shadow-2xl transition-all duration-300" style="text-rendering: optimizeLegibility;" @openAutoFocus.prevent @pointerDownOutside.prevent>
<div class="flex flex-col max-h-[85vh]">
<DialogHeader class="px-5 sm:px-6 pr-20 pt-6 pb-2 shrink-0">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-2">
+11 -4
View File
@@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import DirTreeSelect from '@/components/DirTreeSelect.vue'
import { Plus, X, ChevronDown, Search, AlertCircle, Terminal, Zap, Lock, Variable } from 'lucide-vue-next'
import { Plus, X, ChevronDown, Search, AlertCircle, Terminal, Zap, Lock, Variable, Wrench } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { api, type Task, type EnvVar, type Agent } from '@/api'
@@ -283,7 +283,7 @@ async function save() {
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="max-w-[95vw] sm:max-w-[600px] xl:max-w-[850px] p-0 overflow-hidden border-none bg-background shadow-2xl transition-all duration-300" style="text-rendering: optimizeLegibility;" @openAutoFocus.prevent>
<DialogContent class="max-w-[95vw] sm:max-w-[600px] xl:max-w-[850px] p-0 overflow-hidden border-none bg-background shadow-2xl transition-all duration-300" style="text-rendering: optimizeLegibility;" @openAutoFocus.prevent @pointerDownOutside.prevent>
<div class="flex flex-col max-h-[85vh]">
<DialogHeader class="px-6 pr-12 pt-6 pb-2 shrink-0 border-b border-muted/50">
<DialogTitle class="text-xl font-bold py-2">
@@ -354,8 +354,15 @@ async function save() {
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-3">
<div class="sm:col-span-1" />
<div class="sm:col-span-3">
<div class="flex items-center gap-2.5 p-3 rounded-xl bg-amber-500/5 border border-amber-500/10 text-amber-600 dark:text-amber-400 text-[11px] leading-relaxed font-medium">
<AlertCircle class="h-4 w-4 shrink-0 text-amber-500" /><p>请先在<b>语言依赖</b>中安装所需的运行时执行脚本时将自动注入该环境</p>
<div class="flex flex-col gap-2 p-3 rounded-xl bg-amber-500/5 border border-amber-500/10 text-amber-600 dark:text-amber-400 text-[11px] leading-relaxed font-medium">
<div class="flex items-center gap-2.5">
<AlertCircle class="h-4 w-4 shrink-0 text-amber-500" />
<p>请先在<b>语言依赖</b>中安装所需的运行时执行脚本时将自动注入该环境</p>
</div>
<div class="flex items-center gap-2.5 mt-1 border-t border-amber-500/10 pt-1.5">
<Wrench class="h-4 w-4 shrink-0 text-amber-500" />
<p><b>依赖自动补全</b>若执行报错提示缺失包 requests 可在任务列表的<b>操作菜单</b>中点击<b>补全依赖</b>进行一键安装</p>
</div>
</div>
</div>
</div>
+51 -1
View File
@@ -2,15 +2,17 @@
import { ref, onMounted, computed, watch, onUnmounted } from 'vue'
import { Button } from '@/components/ui/button'
import BaihuDialog from '@/components/ui/BaihuDialog.vue'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import Pagination from '@/components/Pagination.vue'
import TaskDialog from './TaskDialog.vue'
import RepoDialog from './RepoDialog.vue'
import LogViewer from '@/views/history/LogViewer.vue'
import { Plus, Play, Pencil, Trash2, Search, ScrollText, GitBranch, Terminal, Server, Monitor, X, Loader2, RefreshCw, Wifi, WifiOff, Zap, ZapOff, Copy, Tag, ChevronDown, Pin, PinOff, MoreHorizontal, CalendarClock } from 'lucide-vue-next'
import { Plus, Play, Pencil, Trash2, Search, ScrollText, GitBranch, Terminal, Server, Monitor, X, Loader2, RefreshCw, Wifi, WifiOff, Zap, ZapOff, Copy, Tag, ChevronDown, Pin, PinOff, MoreHorizontal, CalendarClock, Wrench } from 'lucide-vue-next'
import TagInput from '@/components/TagInput.vue'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import XTerminal from '@/components/XTerminal.vue'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
DropdownMenu,
@@ -416,6 +418,31 @@ async function viewLogs(taskId: string) {
}
}
const showTerminalDialog = ref(false)
const terminalCmd = ref('')
async function handleTaskDepInstall(task: Task) {
try {
const res = await api.logs.list({ task_id: task.id, page: 1, page_size: 1 })
if (res.data && res.data.length > 0) {
const latestLog = res.data[0]
if (latestLog) {
const cmdRes = await api.deps.getInstallSuggestCmd(latestLog.id)
if (cmdRes && cmdRes.command) {
terminalCmd.value = cmdRes.command
showTerminalDialog.value = true
}
} else {
toast.error('未找到该任务的运行日志,无法分析依赖')
}
} else {
toast.error('该任务尚未运行,无日志可分析')
}
} catch (err: any) {
toast.error(err.message || '获取依赖安装命令失败')
}
}
// 视图管理
const taskViews = ref<any[]>([])
const newViewName = ref('')
@@ -797,6 +824,10 @@ watch(() => route.query.agent_id, (newVal: any) => {
<PinOff v-else class="h-3.5 w-3.5 mr-2 text-primary" />
<span>{{ task.pin_type === 'top' ? '取消置顶' : '置顶任务' }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="handleTaskDepInstall(task)">
<Wrench class="h-3.5 w-3.5 mr-2 text-amber-500" />
<span>补全依赖</span>
</DropdownMenuItem>
<DropdownMenuItem @click="duplicateTask(task)">
<Copy class="h-3.5 w-3.5 mr-2" />
<span>复制任务</span>
@@ -881,6 +912,10 @@ watch(() => route.query.agent_id, (newVal: any) => {
<PinOff v-else class="h-3.5 w-3.5 mr-2 text-primary" />
<span>{{ task.pin_type === 'top' ? '取消置顶' : '置顶任务' }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="handleTaskDepInstall(task)">
<Wrench class="h-3.5 w-3.5 mr-2 text-amber-500" />
<span>补全依赖</span>
</DropdownMenuItem>
<DropdownMenuItem @click="duplicateTask(task)">
<Copy class="h-3.5 w-3.5 mr-2" />
<span>复制任务</span>
@@ -1070,5 +1105,20 @@ watch(() => route.query.agent_id, (newVal: any) => {
</Button>
</template>
</BaihuDialog>
<!-- 依赖补全终端 -->
<Dialog v-model:open="showTerminalDialog">
<DialogContent class="w-[calc(100%-1rem)] sm:max-w-[90vw] lg:max-w-4xl h-[60vh] sm:h-[80vh] flex flex-col p-0 overflow-hidden bg-[#1e1e1e] border-none shadow-2xl [&>button]:hidden">
<div class="flex items-center justify-between px-3 py-2 border-b border-[#3c3c3c]">
<span class="text-xs sm:text-sm font-medium text-gray-300">依赖补全终端</span>
<Button variant="ghost" size="icon" class="h-6 w-6 text-gray-400 hover:text-white" @click="showTerminalDialog = false">
<X class="h-4 w-4" />
</Button>
</div>
<div class="flex-1 overflow-hidden">
<XTerminal v-if="showTerminalDialog" :initial-command="terminalCmd" />
</div>
</DialogContent>
</Dialog>
</div>
</template>