From 5dcdd436ebc5168faa3fcb39ec38b7e2fb9e2a00 Mon Sep 17 00:00:00 2001 From: whale Date: Sun, 5 Jul 2026 22:03:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E6=96=B0=E5=A2=9E=E5=AD=90?= =?UTF-8?q?=E8=B5=9B=E9=A2=98=E5=9B=9B=E7=BD=91=E9=A1=B5=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=20gitlink-cli=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 server 子命令启动 HTTP 服务(:8080),提供 /api/scenarios、/api/run、/api/result/{key}、/api/health;前端含仪表盘(index)、独立结果页(result)、热点追踪页(hotspot),6 科研场景一键运行与可视化。在 cmd/root.go 注册子命令。 Co-Authored-By: Claude --- cmd/root.go | 2 + cmd/server/server.go | 293 ++++++++++++++++ cmd/server/static/hotspot.html | 485 +++++++++++++++++++++++++++ cmd/server/static/index.html | 594 +++++++++++++++++++++++++++++++++ cmd/server/static/result.html | 504 ++++++++++++++++++++++++++++ 5 files changed, 1878 insertions(+) create mode 100644 cmd/server/server.go create mode 100644 cmd/server/static/hotspot.html create mode 100644 cmd/server/static/index.html create mode 100644 cmd/server/static/result.html diff --git a/cmd/root.go b/cmd/root.go index 75f8532..96a39c7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( doctorCmd "github.com/gitlink-org/gitlink-cli/cmd/doctor" internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" "github.com/gitlink-org/gitlink-cli/internal/i18n" + serverCmd "github.com/gitlink-org/gitlink-cli/cmd/server" "github.com/gitlink-org/gitlink-cli/shortcuts" ) @@ -58,6 +59,7 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { rootCmd.AddCommand(apiCmd.NewAPICmd(tr)) rootCmd.AddCommand(configCmd.NewConfigCmd(tr)) rootCmd.AddCommand(doctorCmd.NewDoctorCmd(tr)) + rootCmd.AddCommand(serverCmd.NewServerCmd()) rootCmd.AddCommand(newVersionCmd(version, tr)) shortcuts.RegisterAll(rootCmd, tr) diff --git a/cmd/server/server.go b/cmd/server/server.go new file mode 100644 index 0000000..9ac4298 --- /dev/null +++ b/cmd/server/server.go @@ -0,0 +1,293 @@ +// Package server 提供子赛题四的「网页终端」演示服务(子赛题四·Phase 7)。 +// +// 在云服务器上 `gitlink-cli server` 启动一个轻量 HTTP 服务:前端按 7 个科研场景按钮 +// 触发后端运行对应的 Python 算法脚本(scripts/research/*.py),并把 JSON / Mermaid / +// 报告等产物回显/渲染。所有数据仍由 gitlink-cli 获取,本服务只做命令编排与产物转发。 +// +// 安全:仅接受 7 个固定场景 + owner/repo/keyword 结构化参数(非任意 shell); +// 若设置 --token / DEMO_TOKEN 环境变量,则请求需带 X-Demo-Token 头匹配。 +package server + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/spf13/cobra" +) + +//go:embed static/* +var staticFS embed.FS + +const defaultPort = 8080 + +// 场景定义:前端按钮 ↔ 后端 Python 脚本。 +type scenarioDef struct { + Key string `json:"key"` + Label string `json:"label"` + Desc string `json:"desc"` + Script string `json:"script"` + Needs []string `json:"needs"` // 需要的输入: "owner","repo","keyword" + Timeout int `json:"timeout_s"` +} + +var scenarios = []scenarioDef{ + {Key: "s1", Label: "S1 仓库洞悉", Desc: "科研项目演进谱系 + 创新点", Script: "lineage.py", Needs: []string{"owner", "repo"}, Timeout: 180}, + {Key: "s2", Label: "S2 知识图谱", Desc: "科研领域知识图谱(networkx)", Script: "graph_build.py", Needs: []string{"keyword"}, Timeout: 240}, + {Key: "s3", Label: "S3 合规复现", Desc: "许可证/密钥/复现性检查", Script: "repro.py", Needs: []string{"owner", "repo"}, Timeout: 120}, + {Key: "s4", Label: "S4 协作匹配", Desc: "学者×缺口 智能匹配", Script: "match.py", Needs: []string{"owner", "repo"}, Timeout: 180}, + {Key: "s5", Label: "S5 进度预警", Desc: "周报 + 风险预警", Script: "report.py", Needs: []string{"owner", "repo"}, Timeout: 180}, + {Key: "s6", Label: "S6 成果可视化", Desc: "交互图表(plotly)", Script: "visual.py", Needs: []string{"owner", "repo"}, Timeout: 240}, + {Key: "hotspot", Label: "🔥 热点追踪", Desc: "科研热点全景观测:飙升项目+活跃讨论+主题热度+学者团队", Script: "hotspot.py", Needs: []string{"keyword"}, Timeout: 300}, +} + +type Options struct { + Port int + ResearchDir string // scripts/research 目录 + WorkDir string // 产物输出根目录 + Token string // 可选鉴权 token +} + +func NewServerCmd() *cobra.Command { + opts := Options{Port: defaultPort, ResearchDir: "scripts/research", WorkDir: "research-output"} + cmd := &cobra.Command{ + Use: "server", + Short: "启动子赛题四网页终端(HTTP 演示服务)", + RunE: func(cmd *cobra.Command, args []string) error { + return Run(opts) + }, + } + cmd.Flags().IntVarP(&opts.Port, "port", "p", defaultPort, "监听端口") + cmd.Flags().StringVar(&opts.ResearchDir, "research-dir", "scripts/research", "scripts/research 目录") + cmd.Flags().StringVar(&opts.WorkDir, "work-dir", "research-output", "产物输出根目录") + cmd.Flags().StringVar(&opts.Token, "token", "", "可选鉴权 token(亦可用 DEMO_TOKEN 环境变量)") + return cmd +} + +// Run 启动 HTTP 服务(阻塞)。 +func Run(opts Options) error { + if t := os.Getenv("DEMO_TOKEN"); t != "" && opts.Token == "" { + opts.Token = t + } + _ = os.MkdirAll(opts.WorkDir, 0o755) + + mux := http.NewServeMux() + h := &handler{opts: opts} + + mux.HandleFunc("GET /api/scenarios", h.handleScenarios) + mux.HandleFunc("POST /api/run", h.handleRun) + mux.HandleFunc("GET /api/result/{key}", h.handleResult) + mux.HandleFunc("GET /api/health", h.handleHealth) + + sub, err := fs.Sub(staticFS, "static") + if err != nil { + return fmt.Errorf("static fs: %w", err) + } + mux.Handle("GET /", http.FileServer(http.FS(sub))) + + addr := fmt.Sprintf(":%d", opts.Port) + fmt.Fprintf(os.Stderr, "子赛题四 网页终端已启动: http://localhost%s\n", addr) + fmt.Fprintf(os.Stderr, " research-dir=%s work-dir=%s auth=%v\n", opts.ResearchDir, opts.WorkDir, opts.Token != "") + srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second} + return srv.ListenAndServe() +} + +type handler struct { + opts Options + mu sync.Mutex // 串行化场景执行,避免并发打爆 GitLink API +} + +func (h *handler) authed(r *http.Request) bool { + if h.opts.Token == "" { + return true + } + return r.Header.Get("X-Demo-Token") == h.opts.Token +} + +func (h *handler) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{"ok": true, "scenarios": len(scenarios)}) +} + +func (h *handler) handleScenarios(w http.ResponseWriter, r *http.Request) { + if !h.authed(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + writeJSON(w, map[string]any{"ok": true, "scenarios": scenarios}) +} + +// handleResult 返回某场景最近一次运行的产物(供 result.html 独立结果页按 key 读取, +// URL 可刷新/分享,便于演示讲解)。无需鉴权串行锁——只读已落盘产物。 +func (h *handler) handleResult(w http.ResponseWriter, r *http.Request) { + if !h.authed(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + key := r.PathValue("key") + sc, ok := findScenario(key) + if !ok { + writeJSON(w, map[string]any{"ok": false, "error": "unknown scenario: " + key}) + return + } + outDir := filepath.Join(h.opts.WorkDir, sc.Key) + writeJSON(w, map[string]any{ + "ok": true, + "scenario": sc.Key, + "label": sc.Label, + "desc": sc.Desc, + "artifacts": readArtifacts(outDir), + }) +} + +type runRequest struct { + Scenario string `json:"scenario"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Keyword string `json:"keyword"` +} + +func (h *handler) handleRun(w http.ResponseWriter, r *http.Request) { + if !h.authed(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var req runRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": "bad request: " + err.Error()}) + return + } + sc, ok := findScenario(req.Scenario) + if !ok { + writeJSON(w, map[string]any{"ok": false, "error": "unknown scenario: " + req.Scenario}) + return + } + for _, need := range sc.Needs { + if (need == "keyword" && req.Keyword == "") || + (need == "owner" && req.Owner == "") || + (need == "repo" && req.Repo == "") { + writeJSON(w, map[string]any{"ok": false, "error": "missing parameter: " + need}) + return + } + } + + // 串行执行:一次只跑一个场景,保护 GitLink API。 + h.mu.Lock() + defer h.mu.Unlock() + + scriptPath := filepath.Join(h.opts.ResearchDir, sc.Script) + outDir := filepath.Join(h.opts.WorkDir, sc.Key) + _ = os.MkdirAll(outDir, 0o755) + + argv := []string{scriptPath, "--out", outDir} + if contains(sc.Needs, "owner") { + argv = append(argv, "--owner", req.Owner, "--repo", req.Repo) + } + if contains(sc.Needs, "keyword") { + argv = append(argv, "--keywords", req.Keyword) + } + + // python3 优先,回退 python + py, err := pythonBin() + if err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + cmd := exec.Command(py, argv...) + cmd.Env = os.Environ() + start := time.Now() + out, err := cmd.CombinedOutput() + dur := time.Since(start) + resp := map[string]any{ + "ok": err == nil, + "scenario": sc.Key, + "command": py + " " + strings.Join(argv, " "), + "duration": dur.Truncate(time.Millisecond).String(), + "stdout": string(out), + "out_dir": outDir, + } + if err != nil { + resp["error"] = err.Error() + } + // 附带读取关键产物(json + 第一个 mmd + report.md),便于前端直接渲染 + resp["artifacts"] = readArtifacts(outDir) + writeJSON(w, resp) +} + +func findScenario(key string) (scenarioDef, bool) { + for _, s := range scenarios { + if s.Key == key || strings.EqualFold(s.Key, key) { + return s, true + } + } + return scenarioDef{}, false +} + +func readArtifacts(dir string) map[string]string { + out := map[string]string{} + // json 产物(取第一个 *.json) + if entries, err := os.ReadDir(dir); err == nil { + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + switch { + case strings.HasSuffix(name, ".json"): + b, _ := os.ReadFile(filepath.Join(dir, name)) + out["json"] = string(b) + case strings.HasSuffix(name, ".mmd"): + b, _ := os.ReadFile(filepath.Join(dir, name)) + out["mermaid"] = string(b) + case name == "visual.html": + b, _ := os.ReadFile(filepath.Join(dir, name)) + out["html"] = string(b) + case strings.HasSuffix(name, ".md"): + // 第一份 .md 报告(report.md / weekly_report.md / compliance_report.md) + if _, ok := out["report"]; !ok { + b, _ := os.ReadFile(filepath.Join(dir, name)) + out["report"] = string(b) + } + } + } + } + return out +} + +func pythonBin() (string, error) { + // 候选按 Linux 习惯 python3 优先,再 python / py(Windows)。 + // 必须实测能产出:Windows 的 WindowsApps\python3.exe 是 Store 桩,对 -c 也可能 exit 0 但不真正执行, + // 故用「stdout 必须含 PYOK」来拦截桩。 + for _, name := range []string{"python3", "python", "py"} { + path, err := exec.LookPath(name) + if err != nil { + continue + } + if out, err := exec.Command(path, "-c", "print('PYOK')").Output(); err == nil && + strings.Contains(string(out), "PYOK") { + return path, nil + } + } + return "", fmt.Errorf("python 未安装;容器需内置 python3 并 pip install -r scripts/research/requirements.txt") +} + +func contains(xs []string, s string) bool { + for _, x := range xs { + if x == s { + return true + } + } + return false +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(v) +} diff --git a/cmd/server/static/hotspot.html b/cmd/server/static/hotspot.html new file mode 100644 index 0000000..e38f902 --- /dev/null +++ b/cmd/server/static/hotspot.html @@ -0,0 +1,485 @@ + + + + + +科研热点追踪 · GitLink Research Atlas + + + + + + +
+
+ ← 仪表盘 +
+

科研热点追踪

+
输入领域/关键词,勾选追踪角度,扫描 GitLink 科研仓库 → 飙升项目 · 活跃讨论 · 主题热度 · 学者团队
+
+
+ + +
+
+ 领域 / 关键词 + + +
+
+ 示例 +
+ 深度学习computer vision + 自然语言处理强化学习 + knowledge graph联邦学习 + LLM agent自动驾驶 +
+
+
+ 追踪角度 +
+ 🔥 飙升项目 + 💬 活跃讨论 + 📊 热门主题 + 👥 核心学者·团队 +
+ 仓库上限 + +
+
就绪 · 点"开始追踪"扫描 GitLink
+
+ + + + + +
+
输入关键词并选择角度,点 开始追踪 —— 服务器将通过 gitlink-cli 实时搜索并构建该领域的科研热点全景。
+
+
+ + + + diff --git a/cmd/server/static/index.html b/cmd/server/static/index.html new file mode 100644 index 0000000..3ab56a9 --- /dev/null +++ b/cmd/server/static/index.html @@ -0,0 +1,594 @@ + + + + + +GitLink Research Atlas · 科研代码图谱智能体 + + + + + + + +
+ + +
+
+
G
+
+
GitLink Research Atlas
+
科研代码图谱智能体
+
+
+ + 🔥 热点追踪 + AI Agent online + +
+ + +
+ + + + + +
+
+
+
Knowledge Graph

科研知识图谱

+ +
+
+ + +
+
+
+
+
+ + + + +
+ + +
+
+ atlas ❯ + gitlink-cli research --repo mindspore-Ecosystem/mindspore --agent atlas + ready +
+ +
+ + + + diff --git a/cmd/server/static/result.html b/cmd/server/static/result.html new file mode 100644 index 0000000..5aeed02 --- /dev/null +++ b/cmd/server/static/result.html @@ -0,0 +1,504 @@ + + + + + +GitLink Research Atlas · 结果详情 + + + + + + + +
+
正在加载结果…
+
+ + + +