Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add shared credits page and command

Lyric c1f07586 8fe41179

+1992 -303
+198
assets/credits/data.json
··· 1 + { 2 + "open_source": [ 3 + { 4 + "id": "cobra", 5 + "name": "Cobra", 6 + "link": "https://github.com/spf13/cobra", 7 + "license": "Apache-2.0", 8 + "summary": "Builds the CLI command tree, flag parsing, and help output." 9 + }, 10 + { 11 + "id": "viper", 12 + "name": "Viper", 13 + "link": "https://github.com/spf13/viper", 14 + "license": "MIT", 15 + "summary": "Loads config files, environment variables, and runtime settings." 16 + }, 17 + { 18 + "id": "gorilla_websocket", 19 + "name": "Gorilla WebSocket", 20 + "link": "https://github.com/gorilla/websocket", 21 + "license": "BSD-2-Clause", 22 + "summary": "Handles WebSocket connections for channel runtimes such as Slack." 23 + }, 24 + { 25 + "id": "uniai", 26 + "name": "UniAI", 27 + "link": "https://github.com/quailyquaily/uniai", 28 + "license": "Apache-2.0", 29 + "summary": "Provides a shared client layer across multiple LLM providers." 30 + }, 31 + { 32 + "id": "goldmark", 33 + "name": "Goldmark", 34 + "link": "https://github.com/yuin/goldmark", 35 + "license": "MIT", 36 + "summary": "Converts Markdown into controlled HTML in agent-facing flows." 37 + }, 38 + { 39 + "id": "wails", 40 + "name": "Wails", 41 + "link": "https://github.com/wailsapp/wails", 42 + "license": "MIT", 43 + "summary": "Powers the desktop shell and application packaging path." 44 + }, 45 + { 46 + "id": "vue", 47 + "name": "Vue", 48 + "link": "https://github.com/vuejs/core/tree/main/packages/vue", 49 + "license": "MIT", 50 + "summary": "Drives the Console SPA component, state, and rendering model." 51 + }, 52 + { 53 + "id": "quail_ui", 54 + "name": "Quail UI", 55 + "link": "https://github.com/quailyquaily/quail-ui/", 56 + "license": "Apache-2.0", 57 + "summary": "Provides the Console component, styling, and icon system." 58 + }, 59 + { 60 + "id": "overtype", 61 + "name": "OverType", 62 + "link": "https://github.com/panphora/overtype", 63 + "license": "MIT", 64 + "summary": "Provides the Markdown editor used in the Console chat input." 65 + }, 66 + { 67 + "id": "unified", 68 + "name": "unified", 69 + "link": "https://github.com/unifiedjs/unified", 70 + "license": "MIT", 71 + "summary": "Runs the Markdown renderer processing pipeline." 72 + }, 73 + { 74 + "id": "remark_parse", 75 + "name": "remark-parse", 76 + "link": "https://github.com/remarkjs/remark/tree/main/packages/remark-parse", 77 + "license": "MIT", 78 + "summary": "Parses Markdown source text into a syntax tree." 79 + }, 80 + { 81 + "id": "remark_gfm", 82 + "name": "remark-gfm", 83 + "link": "https://github.com/remarkjs/remark-gfm", 84 + "license": "MIT", 85 + "summary": "Adds GFM features such as tables and task lists." 86 + }, 87 + { 88 + "id": "remark_math", 89 + "name": "remark-math", 90 + "link": "https://github.com/remarkjs/remark-math/tree/main/packages/remark-math", 91 + "license": "MIT", 92 + "summary": "Parses block and inline math syntax." 93 + }, 94 + { 95 + "id": "remark_rehype", 96 + "name": "remark-rehype", 97 + "link": "https://github.com/remarkjs/remark-rehype", 98 + "license": "MIT", 99 + "summary": "Converts the Markdown syntax tree into an HTML syntax tree." 100 + }, 101 + { 102 + "id": "rehype_raw", 103 + "name": "rehype-raw", 104 + "link": "https://github.com/rehypejs/rehype-raw", 105 + "license": "MIT", 106 + "summary": "Brings raw HTML inside Markdown into the same processing chain." 107 + }, 108 + { 109 + "id": "rehype_stringify", 110 + "name": "rehype-stringify", 111 + "link": "https://github.com/rehypejs/rehype/tree/main/packages/rehype-stringify", 112 + "license": "MIT", 113 + "summary": "Serializes the processed HTML syntax tree into final HTML." 114 + }, 115 + { 116 + "id": "rehype_katex", 117 + "name": "rehype-katex", 118 + "link": "https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex", 119 + "license": "MIT", 120 + "summary": "Turns math nodes into KaTeX HTML output." 121 + }, 122 + { 123 + "id": "dompurify", 124 + "name": "DOMPurify", 125 + "link": "https://github.com/cure53/DOMPurify", 126 + "license": "MPL-2.0 OR Apache-2.0", 127 + "summary": "Sanitizes final HTML before it is rendered." 128 + }, 129 + { 130 + "id": "mermaid", 131 + "name": "Mermaid", 132 + "link": "https://github.com/mermaid-js/mermaid", 133 + "license": "MIT", 134 + "summary": "Renders Mermaid diagram blocks in Markdown." 135 + }, 136 + { 137 + "id": "katex", 138 + "name": "KaTeX", 139 + "link": "https://github.com/KaTeX/KaTeX", 140 + "license": "MIT", 141 + "summary": "Renders mathematical formulas." 142 + }, 143 + { 144 + "id": "shiki", 145 + "name": "Shiki", 146 + "link": "https://github.com/shikijs/shiki", 147 + "license": "MIT", 148 + "summary": "Provides code syntax highlighting." 149 + }, 150 + { 151 + "id": "shiki_stream", 152 + "name": "Shiki Stream", 153 + "link": "https://github.com/antfu/shiki-stream", 154 + "license": "MIT", 155 + "summary": "Supports streamed syntax-highlighting token output." 156 + }, 157 + { 158 + "id": "viz_js", 159 + "name": "Viz.js", 160 + "link": "https://github.com/mdaines/viz-js", 161 + "license": "MIT", 162 + "summary": "Renders Graphviz and DOT diagrams." 163 + }, 164 + { 165 + "id": "antv_infographic", 166 + "name": "AntV Infographic", 167 + "link": "https://github.com/antvis/Infographic", 168 + "license": "MIT", 169 + "summary": "Renders infographic custom blocks." 170 + } 171 + ], 172 + "contributors": [ 173 + { 174 + "id": "lyricat", 175 + "name": "Lyric Wai", 176 + "link": "https://github.com/lyricat", 177 + "summary": "Appears in the current Git history with 724 commits under Lyric and Lyric Wai." 178 + }, 179 + { 180 + "id": "yiplee", 181 + "name": "yiplee", 182 + "link": "https://github.com/yiplee", 183 + "summary": "Appears in the current Git history with 37 commits." 184 + }, 185 + { 186 + "id": "iamcheyan", 187 + "name": "CHEYAN", 188 + "link": "https://github.com/iamcheyan", 189 + "summary": "Appears in the current Git history with 11 commits under Cheyan and CHEYAN." 190 + }, 191 + { 192 + "id": "cw35", 193 + "name": "cw35", 194 + "link": "https://github.com/cw35", 195 + "summary": "Appears in the current Git history with 2 commits." 196 + } 197 + ] 198 + }
+5
assets/embed.go
··· 11 11 // 12 12 //go:embed config/** 13 13 var ConfigFS embed.FS 14 + 15 + // CreditsFS contains embedded credits data shipped with mistermorph (under assets/credits). 16 + // 17 + //go:embed credits/** 18 + var CreditsFS embed.FS
+65
cmd/mistermorph/consolecmd/credits.go
··· 1 + package consolecmd 2 + 3 + import ( 4 + "net/http" 5 + 6 + sharedcredits "github.com/quailyquaily/mistermorph/internal/credits" 7 + ) 8 + 9 + type creditsOpenSourcePayload struct { 10 + ID string `json:"id"` 11 + Name string `json:"name"` 12 + Link string `json:"link"` 13 + License string `json:"license"` 14 + Summary string `json:"summary"` 15 + } 16 + 17 + type creditsContributorPayload struct { 18 + ID string `json:"id"` 19 + Name string `json:"name"` 20 + Link string `json:"link"` 21 + Summary string `json:"summary"` 22 + } 23 + 24 + type creditsPayload struct { 25 + OpenSource []creditsOpenSourcePayload `json:"open_source"` 26 + Contributors []creditsContributorPayload `json:"contributors"` 27 + } 28 + 29 + func (s *server) handleCredits(w http.ResponseWriter, r *http.Request) { 30 + if r.Method != http.MethodGet { 31 + writeError(w, http.StatusMethodNotAllowed, "method not allowed") 32 + return 33 + } 34 + data, err := sharedcredits.Load() 35 + if err != nil { 36 + writeError(w, http.StatusInternalServerError, err.Error()) 37 + return 38 + } 39 + writeJSON(w, http.StatusOK, buildCreditsPayload(data)) 40 + } 41 + 42 + func buildCreditsPayload(data sharedcredits.Data) creditsPayload { 43 + out := creditsPayload{ 44 + OpenSource: make([]creditsOpenSourcePayload, 0, len(data.OpenSource)), 45 + Contributors: make([]creditsContributorPayload, 0, len(data.Contributors)), 46 + } 47 + for _, item := range data.OpenSource { 48 + out.OpenSource = append(out.OpenSource, creditsOpenSourcePayload{ 49 + ID: item.ID, 50 + Name: item.Name, 51 + Link: item.Link, 52 + License: item.License, 53 + Summary: item.Summary, 54 + }) 55 + } 56 + for _, item := range data.Contributors { 57 + out.Contributors = append(out.Contributors, creditsContributorPayload{ 58 + ID: item.ID, 59 + Name: item.Name, 60 + Link: item.Link, 61 + Summary: item.Summary, 62 + }) 63 + } 64 + return out 65 + }
+44
cmd/mistermorph/consolecmd/credits_test.go
··· 1 + package consolecmd 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/httptest" 7 + "testing" 8 + ) 9 + 10 + func TestHandleCreditsGet(t *testing.T) { 11 + req := httptest.NewRequest(http.MethodGet, "/api/settings/credits", nil) 12 + rec := httptest.NewRecorder() 13 + 14 + (&server{}).handleCredits(rec, req) 15 + 16 + if rec.Code != http.StatusOK { 17 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 18 + } 19 + 20 + var body creditsPayload 21 + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { 22 + t.Fatalf("json.Unmarshal() error = %v", err) 23 + } 24 + if len(body.OpenSource) == 0 { 25 + t.Fatalf("OpenSource = empty") 26 + } 27 + if len(body.Contributors) == 0 { 28 + t.Fatalf("Contributors = empty") 29 + } 30 + if body.OpenSource[0].ID == "" || body.OpenSource[0].Name == "" { 31 + t.Fatalf("OpenSource[0] = %+v", body.OpenSource[0]) 32 + } 33 + } 34 + 35 + func TestHandleCreditsMethodNotAllowed(t *testing.T) { 36 + req := httptest.NewRequest(http.MethodPost, "/api/settings/credits", nil) 37 + rec := httptest.NewRecorder() 38 + 39 + (&server{}).handleCredits(rec, req) 40 + 41 + if rec.Code != http.StatusMethodNotAllowed { 42 + t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) 43 + } 44 + }
+1
cmd/mistermorph/consolecmd/serve.go
··· 397 397 mux.HandleFunc(apiPrefix+"/settings/agent/models", s.withAuth(s.handleAgentSettingsModels)) 398 398 mux.HandleFunc(apiPrefix+"/settings/agent/test", s.withAuth(s.handleAgentSettingsTest)) 399 399 mux.HandleFunc(apiPrefix+"/settings/console", s.withAuth(s.handleConsoleSettings)) 400 + mux.HandleFunc(apiPrefix+"/settings/credits", s.withAuth(s.handleCredits)) 400 401 mux.HandleFunc(apiPrefix+"/proxy", s.withAuth(s.handleProxy)) 401 402 mux.HandleFunc(apiPrefix+"/stream/ticket", s.withAuth(s.handleStreamTicket)) 402 403 mux.HandleFunc(apiPrefix+"/stream/ws", s.handleStreamWebSocket)
+64
cmd/mistermorph/credits.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "strings" 7 + 8 + "github.com/quailyquaily/mistermorph/internal/clifmt" 9 + sharedcredits "github.com/quailyquaily/mistermorph/internal/credits" 10 + "github.com/spf13/cobra" 11 + ) 12 + 13 + func newCreditsCmd() *cobra.Command { 14 + return &cobra.Command{ 15 + Use: "credits", 16 + Short: "Show open source and contributor credits", 17 + RunE: func(cmd *cobra.Command, args []string) error { 18 + data, err := sharedcredits.Load() 19 + if err != nil { 20 + return err 21 + } 22 + printCredits(cmd.OutOrStdout(), data) 23 + return nil 24 + }, 25 + } 26 + } 27 + 28 + func printCredits(out io.Writer, data sharedcredits.Data) { 29 + if out == nil { 30 + return 31 + } 32 + printOpenSourceCredits(out, data.OpenSource) 33 + if len(data.Contributors) > 0 { 34 + fmt.Fprintln(out) 35 + printContributorCredits(out, data.Contributors) 36 + } 37 + } 38 + 39 + func printOpenSourceCredits(out io.Writer, items []sharedcredits.OpenSourceEntry) { 40 + fmt.Fprintln(out, clifmt.Headerf("Open Source (%d)", len(items))) 41 + if len(items) == 0 { 42 + fmt.Fprintln(out, clifmt.Warn("No open source entries.")) 43 + return 44 + } 45 + for _, item := range items { 46 + fmt.Fprintln(out, clifmt.Success(item.Name)) 47 + fmt.Fprintf(out, " License: %s\n", strings.TrimSpace(item.License)) 48 + fmt.Fprintf(out, " Link: %s\n", strings.TrimSpace(item.Link)) 49 + fmt.Fprintf(out, " %s\n\n", strings.TrimSpace(item.Summary)) 50 + } 51 + } 52 + 53 + func printContributorCredits(out io.Writer, items []sharedcredits.ContributorEntry) { 54 + fmt.Fprintln(out, clifmt.Headerf("Contributors (%d)", len(items))) 55 + if len(items) == 0 { 56 + fmt.Fprintln(out, clifmt.Warn("No contributor entries.")) 57 + return 58 + } 59 + for _, item := range items { 60 + fmt.Fprintln(out, clifmt.Success(item.Name)) 61 + fmt.Fprintf(out, " Link: %s\n", strings.TrimSpace(item.Link)) 62 + fmt.Fprintf(out, " %s\n\n", strings.TrimSpace(item.Summary)) 63 + } 64 + }
+30
cmd/mistermorph/credits_test.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "strings" 6 + "testing" 7 + ) 8 + 9 + func TestCreditsCommand(t *testing.T) { 10 + cmd := newCreditsCmd() 11 + var out bytes.Buffer 12 + cmd.SetOut(&out) 13 + cmd.SetErr(&out) 14 + 15 + if err := cmd.Execute(); err != nil { 16 + t.Fatalf("Execute() error = %v", err) 17 + } 18 + 19 + got := out.String() 20 + for _, want := range []string{ 21 + "Open Source", 22 + "Cobra", 23 + "Contributors", 24 + "Quaily", 25 + } { 26 + if !strings.Contains(got, want) { 27 + t.Fatalf("output missing %q:\n%s", want, got) 28 + } 29 + } 30 + }
+1
cmd/mistermorph/root.go
··· 193 193 })) 194 194 cmd.AddCommand(newToolsCmd(registryResolver.Registry)) 195 195 cmd.AddCommand(newBenchmarkCmd()) 196 + cmd.AddCommand(newCreditsCmd()) 196 197 cmd.AddCommand(skillscmd.New()) 197 198 cmd.AddCommand(consolecmd.New()) 198 199 cmd.AddCommand(newInstallCmd())
+407
docs/feat/feat_20260421_credits.md
··· 1 + --- 2 + date: 2026-04-21 3 + title: Credits in CLI and Console 4 + status: in_progress 5 + --- 6 + 7 + # Credits in CLI and Console 8 + 9 + ## 1) 目标 10 + 11 + 这次需求只解决一个直接问题: 12 + 13 + - 产品里需要一份统一的 `credits` 清单,CLI 和 Console 都能展示同一份内容 14 + 15 + 这里的 `credits` 是上层概念,不等于 `licenses`。 16 + 17 + 当前这份清单首版就包含两类内容: 18 + 19 + - 主要开源项目 20 + - 主要贡献者 21 + 22 + 这不是为了预埋一个很远的扩展点。当前仓库已经有多个贡献者,所以 contributors section 不是以后再说,而是现在就该纳入。 23 + 24 + 这份清单要同时服务两处入口: 25 + 26 + - 新增一个 CLI 命令,展示 credits 27 + - 在 Console 的 Settings 下新增一个子页面,展示同一份内容 28 + 29 + 重点不是自动扫描一切依赖,也不是做成一个复杂的档案系统。重点只是把产品需要公开说明的事实,放进一份简单、稳定、可复用的数据里。 30 + 31 + ## 2) 非目标 32 + 33 + 这版不做下面这些事: 34 + 35 + - 不做全量 SBOM 36 + - 不做所有传递依赖的枚举 37 + - 不在 CLI 或 Web 里展示完整 LICENSE 正文 38 + - 不做运行时动态扫描 `go.mod`、`pnpm-lock.yaml` 或 `node_modules` 39 + - 不把文档站、构建链、部署链工具都塞进首版清单 40 + - 不在这期里解释各协议的法律义务;这里只展示事实信息 41 + - 不做多语言 credits 描述;首版只保留一套固定展示文案 42 + - 不自动从 GitHub contributors graph 或 Git 历史实时生成 contributors 43 + - 不做贡献者打分、排序算法、社交资料聚合 44 + 45 + ## 3) 顶层概念 46 + 47 + ### 3.1 为什么不能继续叫 `licenses` 48 + 49 + 如果这份内容里还要包含主要贡献者,下面这些命名都会马上失真: 50 + 51 + - `mistermorph licenses` 52 + - `/settings/open-source` 53 + - `/api/settings/open-source` 54 + - `assets/open_source/projects.json` 55 + 56 + 这些名字都在表达“这里只放第三方开源项目和协议”。贡献者不是这个语义。 57 + 58 + 所以顶层概念应该统一成: 59 + 60 + - `credits` 61 + 62 + 开源项目和主要贡献者只是 `credits` 下面的两个 section。 63 + 64 + ### 3.2 为什么不需要更复杂的模型 65 + 66 + 这个需求的本质很简单: 67 + 68 + - 有两组公开信息 69 + - 两端共用一份数据 70 + - 用固定顺序展示出来 71 + 72 + 所以不需要: 73 + 74 + - 把所有条目压成一维数组再靠 `type` 区分 75 + - 设计一套复杂的继承或多态模型 76 + - 额外引入排序规则、筛选规则、评分规则 77 + 78 + 顶层保持两个数组就够了: 79 + 80 + - `open_source` 81 + - `contributors` 82 + 83 + ## 4) 首版纳入范围 84 + 85 + ### 4.1 开源项目 86 + 87 + 首版只纳入和产品能力面直接相关的主要开源项目: 88 + 89 + - `go.mod` 里的主要直接依赖 90 + - `web/console` 的主要直接依赖 91 + - `web/markdown-renderer` 的主要直接依赖,且产物会进入 Console 92 + 93 + 首版明确不纳入: 94 + 95 + - `vite`、`vitepress`、`wrangler`、`esbuild` 这类构建、文档、发布工具 96 + - 只在测试里出现、但不进入产品能力面的库 97 + - 价值很低的通用小依赖 98 + 99 + ### 4.2 主要贡献者 100 + 101 + 首版 contributors 名单以当前仓库 Git 历史为候选集合,再做最小人工筛选。 102 + 103 + 这里保持简单: 104 + 105 + - 以当前仓库 Git author 为候选集合 106 + - 明显属于同一人的 author name / email 允许手工合并 107 + - 只有很小的文档修正或零碎改动的人,不必进入 contributors 108 + - GitHub profile URL 允许手工映射 109 + - 不在运行时实时扫描 Git 历史 110 + - GitHub 头像直接由 profile URL 派生,不做额外抓取逻辑 111 + 112 + 每条贡献者记录只保留最小信息: 113 + 114 + - 名字 115 + - 链接 116 + - 一句话说明 117 + 118 + ## 5) 当前首版开源项目清单 119 + 120 + ### 5.1 当前 Go / Desktop 清单 121 + 122 + | 项目 | 链接 | 协议 | 当前用途 | 123 + | --- | --- | --- | --- | 124 + | Cobra | https://github.com/spf13/cobra | Apache-2.0 | 构建 CLI 命令树、参数解析和帮助输出。 | 125 + | Viper | https://github.com/spf13/viper | MIT | 读取配置文件、环境变量和运行时设置。 | 126 + | Gorilla WebSocket | https://github.com/gorilla/websocket | BSD-2-Clause | 处理 Slack 等通道里的 WebSocket 连接。 | 127 + | UniAI | https://github.com/quailyquaily/uniai | Apache-2.0 | 统一对接多个 LLM 提供方,并承接请求与计费模型。 | 128 + | Goldmark | https://github.com/yuin/goldmark | MIT | 把 Telegram 等场景里的 Markdown 转成受控 HTML。 | 129 + | Wails | https://github.com/wailsapp/wails | MIT | 支撑可选的桌面壳和桌面端应用入口。 | 130 + 131 + ### 5.2 当前 Console 前端清单 132 + 133 + #### `web/console` 134 + 135 + | 项目 | 链接 | 协议 | 当前用途 | 136 + | --- | --- | --- | --- | 137 + | Vue | https://github.com/vuejs/core/tree/main/packages/vue | MIT | 搭建 Console SPA 的组件、状态和渲染模型。 | 138 + | Quail UI | https://github.com/quailyquaily/quail-ui/ | Apache-2.0 | 提供 Console 当前使用的组件、样式和图标体系。 | 139 + | OverType | https://github.com/panphora/overtype | MIT | 提供 Markdown 编辑器能力。 | 140 + 141 + #### `web/markdown-renderer` 142 + 143 + | 项目 | 链接 | 协议 | 当前用途 | 144 + | --- | --- | --- | --- | 145 + | unified | https://github.com/unifiedjs/unified | MIT | 组织整个 Markdown 到 HTML 的处理流水线。 | 146 + | remark-parse | https://github.com/remarkjs/remark/tree/main/packages/remark-parse | MIT | 解析 Markdown 源文本。 | 147 + | remark-gfm | https://github.com/remarkjs/remark-gfm | MIT | 支持 GFM 表格、任务列表等扩展语法。 | 148 + | remark-math | https://github.com/remarkjs/remark-math/tree/main/packages/remark-math | MIT | 解析数学公式块和行内公式。 | 149 + | remark-rehype | https://github.com/remarkjs/remark-rehype | MIT | 把 remark AST 转成 rehype AST。 | 150 + | rehype-raw | https://github.com/rehypejs/rehype-raw | MIT | 把 Markdown 里的原始 HTML 纳入统一处理链。 | 151 + | rehype-stringify | https://github.com/rehypejs/rehype/tree/main/packages/rehype-stringify | MIT | 把处理后的 rehype AST 输出成 HTML。 | 152 + | rehype-katex | https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex | MIT | 把数学公式节点转成 KaTeX HTML。 | 153 + | DOMPurify | https://github.com/cure53/DOMPurify | MPL-2.0 OR Apache-2.0 | 对最终 HTML 做净化,降低注入风险。 | 154 + | Mermaid | https://github.com/mermaid-js/mermaid | MIT | 渲染 Mermaid 图表块。 | 155 + | KaTeX | https://github.com/KaTeX/KaTeX | MIT | 渲染数学公式。 | 156 + | Shiki | https://github.com/shikijs/shiki | MIT | 提供代码高亮。 | 157 + | Shiki Stream | https://github.com/antfu/shiki-stream | MIT | 处理流式代码高亮分词。 | 158 + | Viz.js | https://github.com/mdaines/viz-js | MIT | 渲染 Graphviz / DOT 图。 | 159 + | AntV Infographic | https://github.com/antvis/Infographic | MIT | 渲染 infographic 自定义块。 | 160 + 161 + ### 5.3 当前 contributors section 的要求 162 + 163 + 当前仓库已经不是单作者状态,所以 contributors section 不能留到以后。 164 + 165 + 但首版也不需要把名单问题搞复杂。实现上只需要做到: 166 + 167 + - 以当前仓库 Git 历史收集 contributors 候选名单 168 + - 对明显别名做最小合并 169 + - 排除只有很小文档修正或零碎提交的人 170 + - 给每个人补一个 GitHub profile URL 171 + - contributors section 首版不应为空 172 + - 记录名字、链接、说明 173 + - 和开源项目一起放进同一份 `credits` 数据里 174 + 175 + ## 6) 单一数据源 176 + 177 + 这类信息不能在 CLI、Console backend、Console 页面里各写一份。 178 + 179 + 首版应该只有一份结构化 credits 数据,建议放在: 180 + 181 + - `assets/credits/data.json` 182 + 183 + ### 6.1 数据结构 184 + 185 + 顶层结构保持最小,不做抽象过度: 186 + 187 + ```json 188 + { 189 + "open_source": [ 190 + { 191 + "id": "cobra", 192 + "name": "Cobra", 193 + "link": "https://github.com/spf13/cobra", 194 + "license": "Apache-2.0", 195 + "summary": "Builds the CLI command tree and help output." 196 + } 197 + ], 198 + "contributors": [ 199 + { 200 + "id": "contributor_1", 201 + "name": "Contributor Name", 202 + "link": "https://example.com/contributor", 203 + "summary": "Made a notable product contribution." 204 + } 205 + ] 206 + } 207 + ``` 208 + 209 + ### 6.2 字段规则 210 + 211 + `open_source` 条目字段: 212 + 213 + - `id`:稳定主键 214 + - `name` 215 + - `link` 216 + - `license` 217 + - `summary` 218 + 219 + `contributors` 条目字段: 220 + 221 + - `id`:稳定主键 222 + - `name` 223 + - `link` 224 + - `summary` 225 + 226 + 这里不要: 227 + 228 + - 给开源项目加 `surface` 229 + - 给贡献者加 `role` 230 + - 把两类条目硬塞成一维数组 231 + 232 + ### 6.3 读取原则 233 + 234 + 实现上只需要做到: 235 + 236 + - Go 端从这份 JSON 读取成 `CreditsData` 237 + - CLI 命令直接用它 238 + - Console backend 接口直接用它 239 + - Console 页面通过接口读出来展示 240 + 241 + 数组顺序就是最终展示顺序,不再额外引入排序规则。 242 + 243 + ## 7) CLI 命令 244 + 245 + ### 7.1 命令名 246 + 247 + 建议新增: 248 + 249 + - `mistermorph credits` 250 + 251 + 不要再叫 `mistermorph licenses`。 252 + 253 + 原因很直接: 254 + 255 + - 展示内容不只会有 license 256 + - 现在已经要纳入 contributors 257 + - 这个名字和上层概念一致 258 + 259 + ### 7.2 输出要求 260 + 261 + CLI 只需要按两个 section 输出: 262 + 263 + - `Open Source` 264 + - `Contributors` 265 + 266 + 开源项目记录展示: 267 + 268 + - 项目名 269 + - 协议名 270 + - 链接 271 + - 一句话用途 272 + 273 + 贡献者记录展示: 274 + 275 + - 名字 276 + - 链接 277 + - 一句话说明 278 + 279 + 这里不要求复杂表格,也不要求二级分组。核心要求只有一个:直接可读。 280 + 281 + ## 8) Console Settings 子页面 282 + 283 + ### 8.1 路由形态 284 + 285 + 这块不应该继续塞回现在的大型 `SettingsView` 里当一个 section。 286 + 287 + 建议新增一个明确的子路由: 288 + 289 + - `/settings/credits` 290 + 291 + 这样做有两个直接好处: 292 + 293 + - URL 语义清楚,能直接分享和刷新 294 + - 不需要以后再为 contributors 改路由 295 + 296 + ### 8.2 页面位置 297 + 298 + 页面挂在 Settings 之下,但不是顶层导航的新入口。 299 + 300 + 首版可以这样处理: 301 + 302 + - Settings 主页面保留现状 303 + - 在 Settings 内部增加一个二级入口,进入 `Credits` 子页 304 + 305 + ### 8.3 页面内容 306 + 307 + 页面展示和 CLI 保持同一份内容,但表达方式更适合 Web: 308 + 309 + - 顶部先说明:这是产品维护的一份精选 credits,不是全量依赖清单 310 + - 页面内分成 `Open Source` 和 `Contributors` 311 + - 每条记录展示名字、协议或链接、说明 312 + 313 + 首版不需要: 314 + 315 + - 搜索 316 + - 排序 317 + - 筛选 318 + - 下载 CSV 319 + - 展示完整 LICENSE 正文 320 + 321 + ### 8.4 现有路由约束 322 + 323 + 当前 router 只把 `/settings` 本身当作 setup-free 路径。 324 + 325 + 如果新增 `/settings/credits`,实现时要一起更新这条规则,否则在 setup 未完成时,子页行为会和主设置页不一致。 326 + 327 + ## 9) Console API 328 + 329 + 这份数据是 Console 自己的静态产品信息,不属于 runtime proxy 的动态数据面。 330 + 331 + 所以首版直接在 Console backend 增一个只读接口即可: 332 + 333 + - `GET /api/settings/credits` 334 + 335 + 返回体保持最小: 336 + 337 + ```json 338 + { 339 + "open_source": [ 340 + { 341 + "id": "cobra", 342 + "name": "Cobra", 343 + "link": "https://github.com/spf13/cobra", 344 + "license": "Apache-2.0", 345 + "summary": "Builds the CLI command tree and help output." 346 + } 347 + ], 348 + "contributors": [ 349 + { 350 + "id": "contributor_1", 351 + "name": "Contributor Name", 352 + "link": "https://example.com/contributor", 353 + "summary": "Made a notable product contribution." 354 + } 355 + ] 356 + } 357 + ``` 358 + 359 + 这里只读,不做增删改。 360 + 361 + 这样有两个直接收益: 362 + 363 + - Console 页面不需要自己维护第二份静态数据 364 + - CLI 和 Web 的事实来源保持一致 365 + 366 + ## 10) 建议落点 367 + 368 + 首版实现落点建议如下: 369 + 370 + - `assets/credits/data.json`:唯一数据源 371 + - `cmd/mistermorph/credits.go`:CLI 命令 372 + - `cmd/mistermorph/consolecmd/credits.go`:Console backend 只读接口 373 + - `web/console/src/views/SettingsCreditsView.js`:Settings 子页 374 + - `web/console/src/router/index.js`:新增 `/settings/credits` 路由,并修正 setup-free 规则 375 + 376 + 这里不建议: 377 + 378 + - 把清单复制到 `web/console/src` 再读一遍 379 + - 让前端直接读仓库文件 380 + - 为这点静态数据引入新的存储或后台任务 381 + 382 + ## 11) 验收标准 383 + 384 + 首版完成后,至少满足下面这些条件: 385 + 386 + - `mistermorph credits` 可直接输出首版清单 387 + - CLI 输出里每条开源项目记录都有项目名、链接、协议名、用途说明 388 + - CLI 输出里每条贡献者记录都有名字、链接、说明 389 + - Console 可从 Settings 进入 `Credits` 子页 390 + - `/settings/credits` 可直接访问和刷新 391 + - Console 页面展示的开源项目和贡献者集合、顺序、字段值与 CLI 一致 392 + - Console 页面数据来自 `GET /api/settings/credits` 393 + - 新增或修改 credit 时,只需要改一份 `assets/credits/data.json` 394 + 395 + ## 12) Checklist 396 + 397 + - [x] 把顶层概念从 `licenses` / `open source notices` 调整为 `credits` 398 + - [x] 明确首版同时包含 open source 和 contributors 399 + - [x] 扫描当前 Go 主要直接依赖 400 + - [x] 扫描当前 Console 直接依赖 401 + - [x] 扫描当前 shipped markdown renderer 依赖 402 + - [x] 明确 contributors section 由维护者手工维护,不做运行时自动生成 403 + - [x] 记录每个纳入项目的链接、协议、用途 404 + - [x] 明确首版不做全量 SBOM 405 + - [x] 明确 CLI 和 Console 共享同一份数据源 406 + - [x] 明确 Console 走 Settings 子页面,不新开顶层导航 407 + - [x] 明确 Console backend 只增加一个只读接口
-303
docs/feat/feat_20260421_open_source_notices.md
··· 1 - --- 2 - date: 2026-04-21 3 - title: Open Source Notices in CLI and Console 4 - status: in_progress 5 - --- 6 - 7 - # Open Source Notices in CLI and Console 8 - 9 - ## 1) 目标 10 - 11 - 这次需求只做一件简单的事: 12 - 13 - - 把当前产品里实际用到的一组主要开源项目,整理成一份可读、可查、可复用的清单 14 - 15 - 这份清单要同时服务两处入口: 16 - 17 - - 新增一个 CLI 命令,展示项目名、链接、协议名、以及一句话用途 18 - - 在 Console 的 Settings 下新增一个子页面,用合适的页面结构展示同一份内容 19 - 20 - 这里的重点不是“自动扫描一切依赖”,而是先把对用户和开发者最有信息量的一批项目说明白。 21 - 22 - ## 2) 非目标 23 - 24 - 这版不做下面这些事: 25 - 26 - - 不做全量 SBOM 27 - - 不做所有传递依赖的枚举 28 - - 不在 CLI 或 Web 里展示完整 LICENSE 正文 29 - - 不做运行时动态扫描 `go.mod`、`pnpm-lock.yaml` 或 `node_modules` 30 - - 不把文档站、构建链、部署链工具都塞进首版清单 31 - - 不在这期里解释各协议的法律义务;这里只展示事实信息 32 - - 不做多语言开源项目描述;首版只保留一套固定展示文案 33 - 34 - ## 3) 首版纳入范围 35 - 36 - ### 3.1 纳入原则 37 - 38 - 首版只纳入三类项目: 39 - 40 - - `go.mod` 里直接依赖,且确实参与 CLI、runtime、channel、desktop 等主路径 41 - - `web/console` 直接依赖 42 - - `web/markdown-renderer` 直接依赖,且产物会被复制进 Console 使用 43 - 44 - 首版明确不纳入: 45 - 46 - - `vite`、`vitepress`、`wrangler`、`esbuild` 这类构建、文档、发布工具 47 - - 只在测试里出现、但不进入产品能力面的库 48 - - 价值很低的通用小依赖 49 - 50 - ### 3.2 当前 Go / Desktop 清单 51 - 52 - | 项目 | 链接 | 协议 | 当前用途 | 53 - | --- | --- | --- | --- | 54 - | Cobra | https://github.com/spf13/cobra | Apache-2.0 | 构建 CLI 命令树、参数解析和帮助输出。 | 55 - | Viper | https://github.com/spf13/viper | MIT | 读取配置文件、环境变量和运行时设置。 | 56 - | Gorilla WebSocket | https://github.com/gorilla/websocket | BSD-2-Clause | 处理 Slack 等通道里的 WebSocket 连接。 | 57 - | Google UUID | https://github.com/google/uuid | BSD-3-Clause | 生成 task、session、message 等稳定 ID。 | 58 - | MCP Go SDK | https://github.com/modelcontextprotocol/go-sdk | Apache-2.0 | 提供 MCP host 接入和 tool adapter 能力。 | 59 - | UniAI | https://github.com/quailyquaily/uniai | Apache-2.0 | 统一对接多个 LLM 提供方,并承接请求与计费模型。 | 60 - | Goldmark | https://github.com/yuin/goldmark | MIT | 把 Telegram 等场景里的 Markdown 转成受控 HTML。 | 61 - | Wails | https://github.com/wailsapp/wails | MIT | 支撑可选的桌面壳和桌面端应用入口。 | 62 - 63 - ### 3.3 当前 Console 前端清单 64 - 65 - #### `web/console` 66 - 67 - | 项目 | 链接 | 协议 | 当前用途 | 68 - | --- | --- | --- | --- | 69 - | Vue | https://github.com/vuejs/core/tree/main/packages/vue | MIT | 搭建 Console SPA 的组件、状态和渲染模型。 | 70 - | Vue Router | https://github.com/vuejs/router | MIT | 负责 Console 页面路由和 Settings 子页面切换。 | 71 - | Quail UI | https://quailyquaily.github.io/quail-ui/ | AGPL-3.0 | 提供 Console 当前使用的组件、样式和图标体系。 | 72 - | OverType | https://github.com/panphora/overtype | MIT | 提供 Markdown 编辑器能力。 | 73 - 74 - #### `web/markdown-renderer` 75 - 76 - | 项目 | 链接 | 协议 | 当前用途 | 77 - | --- | --- | --- | --- | 78 - | unified | https://github.com/unifiedjs/unified | MIT | 组织整个 Markdown 到 HTML 的处理流水线。 | 79 - | remark-parse | https://github.com/remarkjs/remark/tree/main/packages/remark-parse | MIT | 解析 Markdown 源文本。 | 80 - | remark-gfm | https://github.com/remarkjs/remark-gfm | MIT | 支持 GFM 表格、任务列表等扩展语法。 | 81 - | remark-math | https://github.com/remarkjs/remark-math/tree/main/packages/remark-math | MIT | 解析数学公式块和行内公式。 | 82 - | remark-rehype | https://github.com/remarkjs/remark-rehype | MIT | 把 remark AST 转成 rehype AST。 | 83 - | rehype-raw | https://github.com/rehypejs/rehype-raw | MIT | 把 Markdown 里的原始 HTML 纳入统一处理链。 | 84 - | rehype-stringify | https://github.com/rehypejs/rehype/tree/main/packages/rehype-stringify | MIT | 把处理后的 rehype AST 输出成 HTML。 | 85 - | rehype-katex | https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex | MIT | 把数学公式节点转成 KaTeX HTML。 | 86 - | DOMPurify | https://github.com/cure53/DOMPurify | MPL-2.0 OR Apache-2.0 | 对最终 HTML 做净化,降低注入风险。 | 87 - | Mermaid | https://github.com/mermaid-js/mermaid | MIT | 渲染 Mermaid 图表块。 | 88 - | KaTeX | https://github.com/KaTeX/KaTeX | MIT | 渲染数学公式。 | 89 - | Shiki | https://github.com/shikijs/shiki | MIT | 提供代码高亮。 | 90 - | Shiki Stream | https://github.com/antfu/shiki-stream | MIT | 处理流式代码高亮分词。 | 91 - | Viz.js | https://github.com/mdaines/viz-js | MIT | 渲染 Graphviz / DOT 图。 | 92 - | AntV Infographic | https://github.com/antvis/Infographic | MIT | 渲染 infographic 自定义块。 | 93 - 94 - ### 3.4 说明事项 95 - 96 - 有三点需要在文档里提前说清楚: 97 - 98 - - `modelcontextprotocol/go-sdk` 的 LICENSE 里写了项目正从 MIT 迁移到 Apache-2.0;首版展示值按当前 LICENSE 事实写 `Apache-2.0`,不额外展开法律解释 99 - - `DOMPurify` 是双协议,首版按包元数据原样展示 `MPL-2.0 OR Apache-2.0` 100 - - `quail-ui` 的本地包元数据没有公开仓库字段;首版链接先使用 README 里的项目主页 101 - 102 - ## 4) 单一数据源 103 - 104 - 这类信息不能在 CLI、Console API、Console 页面里各写一份。 105 - 106 - 首版应该只有一份结构化清单,建议放在: 107 - 108 - - `assets/open_source/projects.json` 109 - 110 - 数据模型保持最小: 111 - 112 - ```json 113 - [ 114 - { 115 - "id": "cobra", 116 - "surface": "go", 117 - "name": "Cobra", 118 - "link": "https://github.com/spf13/cobra", 119 - "license": "Apache-2.0", 120 - "usage": "Builds the CLI command tree and help output." 121 - } 122 - ] 123 - ``` 124 - 125 - 字段只保留当前展示真正需要的内容: 126 - 127 - - `id`:稳定主键,用于测试和前端渲染 key 128 - - `surface`:分组字段,首版可取 `go`、`desktop`、`console`、`console_markdown` 129 - - `name` 130 - - `link` 131 - - `license` 132 - - `usage` 133 - 134 - 这里不要再包一层没有价值的 wrapper,也不要再做一份前端专用 JSON。 135 - 136 - 实现上只需要做到: 137 - 138 - - Go 端从这份 JSON 读取成 `[]OpenSourceProject` 139 - - CLI 命令直接用它 140 - - Console 后端接口直接用它 141 - - Console 页面通过接口读出来展示 142 - 143 - 数组顺序就是最终展示顺序,不再额外引入排序规则。 144 - 145 - ## 5) CLI 命令 146 - 147 - ### 5.1 命令名 148 - 149 - 建议新增: 150 - 151 - - `mistermorph licenses` 152 - 153 - 原因很直接: 154 - 155 - - 这是一个静态信息命令 156 - - 用户心智简单,和展示内容一致 157 - - 不依赖当前 runtime、endpoint、API key 或 topic 状态 158 - 159 - ### 5.2 输出要求 160 - 161 - 输出内容必须至少包含四列: 162 - 163 - - 项目名 164 - - 协议名 165 - - 链接 166 - - 一句话用途 167 - 168 - 展示要求如下: 169 - 170 - - 按 `surface` 分组输出,不要把 Go、Desktop、Console 全混在一起 171 - - 分组内按清单文件顺序输出 172 - - 宽度不够时允许换行,不做截断省略 173 - - 命令在未配置任何 provider 时也能正常运行 174 - 175 - 示意: 176 - 177 - ```text 178 - Go / Runtime 179 - NAME LICENSE LINK USED FOR 180 - Cobra Apache-2.0 https://github.com/spf13/cobra Builds the CLI command tree and help output. 181 - Viper MIT https://github.com/spf13/viper Loads config files and environment overrides. 182 - ... 183 - ``` 184 - 185 - 如果现有 `NameDetailTable` 不适合四列展示,就直接为这个命令写最小可读的表格输出,不要硬套两列表头。 186 - 187 - ## 6) Console Settings 子页面 188 - 189 - ### 6.1 路由形态 190 - 191 - 这块不应该继续塞回现在的大型 `SettingsView` 里当一个 section。 192 - 193 - 建议新增一个明确的子路由: 194 - 195 - - `/settings/open-source` 196 - 197 - 这样做有两个好处: 198 - 199 - - URL 语义清楚,能直接分享和刷新 200 - - 后续如果 Settings 再拆页,不会继续把一个大文件堆得更重 201 - 202 - ### 6.2 页面位置 203 - 204 - 页面挂在 Settings 之下,但不是顶层导航的新入口。 205 - 206 - 首版可以这样处理: 207 - 208 - - Settings 主页面保留现状 209 - - 在 Settings 内部增加一个二级入口,进入 `Open Source` 子页 210 - 211 - ### 6.3 页面内容 212 - 213 - 页面展示和 CLI 保持同一份内容,但表达方式更适合 Web: 214 - 215 - - 按 `surface` 分组 216 - - 每个项目一行,至少展示名字、协议、链接、用途 217 - - 链接可直接打开上游项目页 218 - - 页面顶部明确说明“这是首版纳入的主要开源项目,不是全量依赖清单” 219 - 220 - 首版不需要: 221 - 222 - - 搜索 223 - - 排序 224 - - 筛选 225 - - 下载 CSV 226 - - 展示完整 LICENSE 正文 227 - 228 - ### 6.4 现有路由约束 229 - 230 - 当前 router 只把 `/settings` 本身当作 setup-free 路径。 231 - 232 - 如果新增 `/settings/open-source`,实现时要一起更新这条规则,否则在 setup 未完成时,子页行为会和主设置页不一致。 233 - 234 - ## 7) Console API 235 - 236 - 这份数据是 Console 自己的静态产品信息,不属于 runtime proxy 的动态数据面。 237 - 238 - 所以首版直接在 Console backend 增一个只读接口即可: 239 - 240 - - `GET /api/settings/open-source` 241 - 242 - 返回体保持最小: 243 - 244 - ```json 245 - { 246 - "items": [ 247 - { 248 - "id": "cobra", 249 - "surface": "go", 250 - "name": "Cobra", 251 - "link": "https://github.com/spf13/cobra", 252 - "license": "Apache-2.0", 253 - "usage": "Builds the CLI command tree and help output." 254 - } 255 - ] 256 - } 257 - ``` 258 - 259 - 这里只读,不做增删改。 260 - 261 - 这样有两个直接收益: 262 - 263 - - Console 页面不需要自己维护第二份静态数据 264 - - CLI 和 Web 的事实来源保持一致 265 - 266 - ## 8) 建议落点 267 - 268 - 首版实现落点建议如下: 269 - 270 - - `assets/open_source/projects.json`:唯一数据源 271 - - `cmd/mistermorph/licenses.go`:CLI 命令 272 - - `cmd/mistermorph/consolecmd/open_source.go`:Console backend 只读接口 273 - - `web/console/src/views/SettingsOpenSourceView.js`:Settings 子页 274 - - `web/console/src/router/index.js`:新增 `/settings/open-source` 路由,并修正 setup-free 规则 275 - 276 - 这里不建议: 277 - 278 - - 把清单复制到 `web/console/src` 再读一遍 279 - - 让前端直接读仓库文件 280 - - 为这点静态数据引入新的存储或后台任务 281 - 282 - ## 9) 验收标准 283 - 284 - 首版完成后,至少满足下面这些条件: 285 - 286 - - `mistermorph licenses` 可直接输出首版清单 287 - - CLI 输出里每条记录都有项目名、链接、协议名、用途说明 288 - - Console 可从 Settings 进入 `Open Source` 子页 289 - - `/settings/open-source` 可直接访问和刷新 290 - - Console 页面展示的记录集合、顺序、字段值与 CLI 一致 291 - - Console 页面数据来自 `GET /api/settings/open-source` 292 - - 新增或修改项目时,只需要改一份 `assets/open_source/projects.json` 293 - 294 - ## 10) Checklist 295 - 296 - - [x] 扫描当前 Go 主要直接依赖 297 - - [x] 扫描当前 Console 直接依赖 298 - - [x] 扫描当前 shipped markdown renderer 依赖 299 - - [x] 记录每个纳入项目的链接、协议、用途 300 - - [x] 明确首版不做全量 SBOM 301 - - [x] 明确 CLI 和 Console 共享同一份数据源 302 - - [x] 明确 Console 走 Settings 子页面,不新开顶层导航 303 - - [x] 明确 Console backend 只增加一个只读接口
+130
internal/credits/credits.go
··· 1 + package credits 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io/fs" 8 + "strings" 9 + 10 + "github.com/quailyquaily/mistermorph/assets" 11 + ) 12 + 13 + const DataPath = "credits/data.json" 14 + 15 + type OpenSourceEntry struct { 16 + ID string `json:"id"` 17 + Name string `json:"name"` 18 + Link string `json:"link"` 19 + License string `json:"license"` 20 + Summary string `json:"summary"` 21 + } 22 + 23 + type ContributorEntry struct { 24 + ID string `json:"id"` 25 + Name string `json:"name"` 26 + Link string `json:"link"` 27 + Summary string `json:"summary"` 28 + } 29 + 30 + type Data struct { 31 + OpenSource []OpenSourceEntry `json:"open_source"` 32 + Contributors []ContributorEntry `json:"contributors"` 33 + } 34 + 35 + func Load() (Data, error) { 36 + return LoadFS(assets.CreditsFS, DataPath) 37 + } 38 + 39 + func LoadFS(fsys fs.FS, path string) (Data, error) { 40 + if fsys == nil { 41 + return Data{}, fmt.Errorf("credits fs is nil") 42 + } 43 + dataPath := strings.TrimSpace(path) 44 + if dataPath == "" { 45 + dataPath = DataPath 46 + } 47 + raw, err := fs.ReadFile(fsys, dataPath) 48 + if err != nil { 49 + return Data{}, fmt.Errorf("read credits data: %w", err) 50 + } 51 + var out Data 52 + decoder := json.NewDecoder(bytes.NewReader(raw)) 53 + decoder.DisallowUnknownFields() 54 + if err := decoder.Decode(&out); err != nil { 55 + return Data{}, fmt.Errorf("decode credits data: %w", err) 56 + } 57 + if err := validate(out); err != nil { 58 + return Data{}, err 59 + } 60 + if out.OpenSource == nil { 61 + out.OpenSource = []OpenSourceEntry{} 62 + } 63 + if out.Contributors == nil { 64 + out.Contributors = []ContributorEntry{} 65 + } 66 + return out, nil 67 + } 68 + 69 + func validate(data Data) error { 70 + openSourceIDs := make(map[string]struct{}, len(data.OpenSource)) 71 + for i, item := range data.OpenSource { 72 + if err := validateOpenSourceEntry(item, i, openSourceIDs); err != nil { 73 + return err 74 + } 75 + } 76 + contributorIDs := make(map[string]struct{}, len(data.Contributors)) 77 + for i, item := range data.Contributors { 78 + if err := validateContributorEntry(item, i, contributorIDs); err != nil { 79 + return err 80 + } 81 + } 82 + return nil 83 + } 84 + 85 + func validateOpenSourceEntry(item OpenSourceEntry, index int, seen map[string]struct{}) error { 86 + if err := validateID("open_source", item.ID, index, seen); err != nil { 87 + return err 88 + } 89 + if strings.TrimSpace(item.Name) == "" { 90 + return fmt.Errorf("credits open_source[%d]: name is required", index) 91 + } 92 + if strings.TrimSpace(item.Link) == "" { 93 + return fmt.Errorf("credits open_source[%d]: link is required", index) 94 + } 95 + if strings.TrimSpace(item.License) == "" { 96 + return fmt.Errorf("credits open_source[%d]: license is required", index) 97 + } 98 + if strings.TrimSpace(item.Summary) == "" { 99 + return fmt.Errorf("credits open_source[%d]: summary is required", index) 100 + } 101 + return nil 102 + } 103 + 104 + func validateContributorEntry(item ContributorEntry, index int, seen map[string]struct{}) error { 105 + if err := validateID("contributors", item.ID, index, seen); err != nil { 106 + return err 107 + } 108 + if strings.TrimSpace(item.Name) == "" { 109 + return fmt.Errorf("credits contributors[%d]: name is required", index) 110 + } 111 + if strings.TrimSpace(item.Link) == "" { 112 + return fmt.Errorf("credits contributors[%d]: link is required", index) 113 + } 114 + if strings.TrimSpace(item.Summary) == "" { 115 + return fmt.Errorf("credits contributors[%d]: summary is required", index) 116 + } 117 + return nil 118 + } 119 + 120 + func validateID(section, id string, index int, seen map[string]struct{}) error { 121 + key := strings.TrimSpace(id) 122 + if key == "" { 123 + return fmt.Errorf("credits %s[%d]: id is required", section, index) 124 + } 125 + if _, exists := seen[key]; exists { 126 + return fmt.Errorf("credits %s[%d]: duplicate id %q", section, index, key) 127 + } 128 + seen[key] = struct{}{} 129 + return nil 130 + }
+104
internal/credits/credits_test.go
··· 1 + package credits 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + "testing/fstest" 7 + ) 8 + 9 + func TestLoadFS(t *testing.T) { 10 + fsys := fstest.MapFS{ 11 + DataPath: { 12 + Data: []byte(`{ 13 + "open_source": [ 14 + { 15 + "id": "cobra", 16 + "name": "Cobra", 17 + "link": "https://github.com/spf13/cobra", 18 + "license": "Apache-2.0", 19 + "summary": "CLI command tree." 20 + } 21 + ], 22 + "contributors": [ 23 + { 24 + "id": "quaily", 25 + "name": "Quaily", 26 + "link": "https://github.com/quailyquaily", 27 + "summary": "Maintains the project." 28 + } 29 + ] 30 + }`), 31 + }, 32 + } 33 + 34 + data, err := LoadFS(fsys, DataPath) 35 + if err != nil { 36 + t.Fatalf("LoadFS() error = %v", err) 37 + } 38 + if len(data.OpenSource) != 1 || data.OpenSource[0].ID != "cobra" { 39 + t.Fatalf("OpenSource = %+v", data.OpenSource) 40 + } 41 + if len(data.Contributors) != 1 || data.Contributors[0].ID != "quaily" { 42 + t.Fatalf("Contributors = %+v", data.Contributors) 43 + } 44 + } 45 + 46 + func TestLoadFSRejectsDuplicateIDs(t *testing.T) { 47 + fsys := fstest.MapFS{ 48 + DataPath: { 49 + Data: []byte(`{ 50 + "open_source": [ 51 + { 52 + "id": "cobra", 53 + "name": "Cobra", 54 + "link": "https://github.com/spf13/cobra", 55 + "license": "Apache-2.0", 56 + "summary": "CLI command tree." 57 + }, 58 + { 59 + "id": "cobra", 60 + "name": "Viper", 61 + "link": "https://github.com/spf13/viper", 62 + "license": "MIT", 63 + "summary": "Configuration loader." 64 + } 65 + ], 66 + "contributors": [] 67 + }`), 68 + }, 69 + } 70 + 71 + _, err := LoadFS(fsys, DataPath) 72 + if err == nil { 73 + t.Fatalf("LoadFS() error = nil, want duplicate id error") 74 + } 75 + if !strings.Contains(err.Error(), `duplicate id "cobra"`) { 76 + t.Fatalf("LoadFS() error = %v, want duplicate id", err) 77 + } 78 + } 79 + 80 + func TestLoadFSRejectsMissingContributorSummary(t *testing.T) { 81 + fsys := fstest.MapFS{ 82 + DataPath: { 83 + Data: []byte(`{ 84 + "open_source": [], 85 + "contributors": [ 86 + { 87 + "id": "quaily", 88 + "name": "Quaily", 89 + "link": "https://github.com/quailyquaily", 90 + "summary": "" 91 + } 92 + ] 93 + }`), 94 + }, 95 + } 96 + 97 + _, err := LoadFS(fsys, DataPath) 98 + if err == nil { 99 + t.Fatalf("LoadFS() error = nil, want validation error") 100 + } 101 + if !strings.Contains(err.Error(), "contributors[0]: summary is required") { 102 + t.Fatalf("LoadFS() error = %v, want summary validation error", err) 103 + } 104 + }
+42
web/console/src/i18n/index.js
··· 416 416 files_editor_hint: "Markdown changes write back to the runtime state file on save.", 417 417 files_editor_hint_new: "This document does not exist yet. The first save will create it.", 418 418 settings_title: "Settings", 419 + settings_credits_title: "Credits", 420 + settings_credits_meta: "Open source and contributors.", 421 + settings_credits_heading: "Credits", 422 + settings_credits_intro: "Projects and people that helped create MisterMorph.", 423 + settings_credits_intro_note: "CLI and Console read the same source of truth.", 424 + settings_credits_open_source_title: "Open Source", 425 + settings_credits_open_source_meta: "Core libraries and packages used in the shipped product.", 426 + settings_credits_contributors_title: "Contributors", 427 + settings_credits_contributors_meta: "People with notable product and engineering contributions.", 428 + settings_credits_license_label: "License", 429 + settings_credits_open_link: "Open project", 430 + settings_credits_open_profile: "Open profile", 431 + settings_credits_empty_open_source: "No open source entries.", 432 + settings_credits_empty_contributors: "No contributor entries.", 419 433 settings_agent_title: "LLM", 420 434 settings_agent_block_title: "LLM Config", 421 435 settings_agent_llm_hint: "Writes common LLM settings into {path}.", ··· 1044 1058 files_editor_hint: "保存后会把 Markdown 内容写回运行时状态文件。", 1045 1059 files_editor_hint_new: "这份文档还不存在,首次保存会直接创建。", 1046 1060 settings_title: "设置", 1061 + settings_credits_title: "Credits", 1062 + settings_credits_meta: "开源项目和贡献者。", 1063 + settings_credits_heading: "Credits", 1064 + settings_credits_intro: "为创作 MisterMorph 提供帮助的项目和人", 1065 + settings_credits_intro_note: "CLI 和 Console 共用同一份事实来源。", 1066 + settings_credits_open_source_title: "Open Source", 1067 + settings_credits_open_source_meta: "实际进入产品能力面的核心库和包。", 1068 + settings_credits_contributors_title: "Contributors", 1069 + settings_credits_contributors_meta: "在产品和工程上有明确主要贡献的人。", 1070 + settings_credits_license_label: "License", 1071 + settings_credits_open_link: "打开项目", 1072 + settings_credits_open_profile: "打开主页", 1073 + settings_credits_empty_open_source: "还没有开源项目条目。", 1074 + settings_credits_empty_contributors: "还没有贡献者条目。", 1047 1075 settings_agent_title: "LLM", 1048 1076 settings_agent_block_title: "LLM 配置", 1049 1077 settings_agent_llm_hint: "把常用的 LLM 配置写入 {path}。", ··· 1662 1690 files_editor_hint: "保存すると Markdown の内容がランタイムの状態ファイルへ書き戻されます。", 1663 1691 files_editor_hint_new: "このドキュメントはまだ存在しません。最初の保存で作成されます。", 1664 1692 settings_title: "設定", 1693 + settings_credits_title: "Credits", 1694 + settings_credits_meta: "オープンソースと貢献者。", 1695 + settings_credits_heading: "Credits", 1696 + settings_credits_intro: "MisterMorph の制作を支えてくれたプロジェクトと人たちです。", 1697 + settings_credits_intro_note: "CLI と Console は同じソースオブトゥルースを使います。", 1698 + settings_credits_open_source_title: "Open Source", 1699 + settings_credits_open_source_meta: "出荷される製品で実際に使っている主要ライブラリです。", 1700 + settings_credits_contributors_title: "Contributors", 1701 + settings_credits_contributors_meta: "製品と実装に明確な主要貢献をした人たちです。", 1702 + settings_credits_license_label: "License", 1703 + settings_credits_open_link: "プロジェクトを開く", 1704 + settings_credits_open_profile: "プロフィールを開く", 1705 + settings_credits_empty_open_source: "オープンソース項目はまだありません。", 1706 + settings_credits_empty_contributors: "貢献者項目はまだありません。", 1665 1707 settings_agent_title: "LLM", 1666 1708 settings_agent_block_title: "LLM 設定", 1667 1709 settings_agent_llm_hint: "一般的な LLM 設定を {path} に書き込みます。",
+3
web/console/src/router/index.js
··· 32 32 RepairView, 33 33 RuntimeView, 34 34 SetupView, 35 + SettingsCreditsView, 35 36 SettingsView, 36 37 StatsView, 37 38 StateFilesView, ··· 60 61 "/setup/done", 61 62 "/setup/repair", 62 63 "/settings", 64 + "/settings/credits", 63 65 ]); 64 66 65 67 function selectedEndpointCanChat() { ··· 101 103 { path: "/memory", component: MemoryView }, 102 104 { path: "/files", component: StateFilesView }, 103 105 { path: "/contacts", component: ContactsView }, 106 + { path: "/settings/credits", component: SettingsCreditsView }, 104 107 { path: "/settings", component: SettingsView }, 105 108 { path: "/", component: RootRedirectView, meta: { shellless: true } }, 106 109 ];
+601
web/console/src/views/SettingsCreditsView.css
··· 1 + .settings-credits-page { 2 + --credits-paper: color-mix(in srgb, var(--bg-2) 92%, var(--accent-1) 2%); 3 + --credits-surface: color-mix(in srgb, var(--bg-2) 88%, var(--accent-1) 4%); 4 + --credits-surface-alt: color-mix(in srgb, var(--credits-paper) 82%, var(--bg-1) 18%); 5 + --credits-grid: color-mix(in srgb, var(--line) 74%, transparent); 6 + --credits-grid-strong: color-mix(in srgb, var(--line) 100%, var(--accent-1) 10%); 7 + --credits-primary: color-mix(in srgb, var(--accent-1) 62%, var(--text-0) 38%); 8 + --credits-primary-soft: color-mix(in srgb, var(--credits-primary) 22%, transparent); 9 + --credits-copy: color-mix(in srgb, var(--text-1) 88%, transparent); 10 + --credits-muted: color-mix(in srgb, var(--text-2) 86%, transparent); 11 + --credits-layout-max: 88rem; 12 + --credits-section-gap: clamp(1.1rem, 2vw, 1.65rem); 13 + --credits-rail-sidebar: clamp(13rem, 19vw, 17rem); 14 + --credits-rail-gap: clamp(1rem, 2vw, 1.8rem); 15 + --credits-top-offset: calc(var(--chrome-bar-height) + 18px); 16 + } 17 + 18 + .settings-credits-page .page-body { 19 + position: relative; 20 + gap: clamp(1rem, 2vw, 1.6rem); 21 + padding: clamp(18px, 2vw, 28px) var(--content-gutter-x) var(--content-gutter-y); 22 + background: 23 + linear-gradient(rgba(30, 89, 165, 0.04) 0.5px, transparent 0.5px), 24 + linear-gradient(90deg, rgba(30, 89, 165, 0.04) 0.5px, transparent 0.5px), 25 + linear-gradient(rgba(30, 89, 165, 0.018) 0.5px, transparent 0.5px), 26 + linear-gradient(90deg, rgba(30, 89, 165, 0.018) 0.5px, transparent 0.5px), 27 + var(--credits-paper); 28 + background-size: 92px 92px, 92px 92px, 18px 18px, 18px 18px, auto; 29 + } 30 + 31 + .settings-credits-bar { 32 + display: flex; 33 + align-items: center; 34 + gap: 0.625rem; 35 + } 36 + 37 + .settings-credits-back { 38 + flex: 0 0 auto; 39 + } 40 + 41 + .settings-credits-layout { 42 + display: grid; 43 + width: min(100%, var(--credits-layout-max)); 44 + gap: clamp(1.1rem, 2vw, 1.8rem); 45 + margin-inline: auto; 46 + } 47 + 48 + .settings-credits-kicker { 49 + margin: 0; 50 + color: var(--credits-primary); 51 + font-family: var(--font-mono); 52 + font-size: 0.68rem; 53 + font-weight: 600; 54 + letter-spacing: 0.12em; 55 + line-height: 1.35; 56 + text-transform: uppercase; 57 + } 58 + 59 + .settings-credits-kicker-sep { 60 + display: inline-block; 61 + margin: 0 0.2rem 0 0.1rem; 62 + font-size: 0.82rem; 63 + letter-spacing: -0.28rem; 64 + opacity: 0.34; 65 + } 66 + 67 + .settings-credits-kicker-bracket { 68 + display: inline-block; 69 + font-size: 0.82rem; 70 + opacity: 0.34; 71 + } 72 + 73 + .settings-credits-hero-shell { 74 + position: relative; 75 + display: grid; 76 + grid-template-columns: minmax(0, 1.15fr) minmax(13rem, 16rem); 77 + gap: clamp(1rem, 2vw, 1.8rem); 78 + align-items: start; 79 + padding: clamp(1rem, 2vw, 1.35rem); 80 + border: 1px solid var(--credits-grid-strong); 81 + background: var(--credits-surface); 82 + } 83 + 84 + .settings-credits-hero-shell::before, 85 + .settings-credits-hero-shell::after { 86 + content: ""; 87 + position: absolute; 88 + width: 12px; 89 + height: 12px; 90 + border: 2px solid var(--credits-primary); 91 + opacity: 0.9; 92 + pointer-events: none; 93 + } 94 + 95 + .settings-credits-hero-shell::before { 96 + top: -1px; 97 + left: -1px; 98 + border-right: none; 99 + border-bottom: none; 100 + } 101 + 102 + .settings-credits-hero-shell::after { 103 + right: -1px; 104 + bottom: -1px; 105 + border-left: none; 106 + border-top: none; 107 + } 108 + 109 + .settings-credits-hero-copy { 110 + display: grid; 111 + gap: 0.72rem; 112 + } 113 + 114 + .settings-credits-hero-title, 115 + .settings-credits-rail-title, 116 + .settings-credits-record-title { 117 + margin: 0; 118 + font-family: var(--q-card-title-font-family); 119 + color: var(--text-0); 120 + letter-spacing: -0.032em; 121 + } 122 + 123 + .settings-credits-hero-title { 124 + max-width: 10ch; 125 + font-size: clamp(1.9rem, 3vw, 2.7rem); 126 + line-height: 1.02; 127 + } 128 + 129 + .settings-credits-hero-lead { 130 + margin: 0; 131 + max-width: 46rem; 132 + color: var(--credits-copy); 133 + font-family: inherit; 134 + font-size: clamp(0.98rem, 1.2vw, 1.08rem); 135 + line-height: 1.66; 136 + } 137 + 138 + .settings-credits-rail-meta, 139 + .settings-credits-record-summary, 140 + .settings-credits-empty { 141 + margin: 0; 142 + color: var(--credits-copy); 143 + line-height: 1.72; 144 + } 145 + 146 + .settings-credits-ledger { 147 + display: grid; 148 + align-content: start; 149 + } 150 + 151 + .settings-credits-ledger-row { 152 + display: grid; 153 + grid-template-columns: minmax(0, 1fr) auto; 154 + gap: 0.75rem; 155 + align-items: end; 156 + padding: 0.85rem 0; 157 + border-bottom: 1px solid var(--credits-grid); 158 + } 159 + 160 + .settings-credits-ledger-row:first-child { 161 + padding-top: 0; 162 + } 163 + 164 + .settings-credits-ledger-label { 165 + color: var(--credits-primary); 166 + font-family: var(--font-mono); 167 + font-size: 0.74rem; 168 + font-weight: 600; 169 + letter-spacing: 0.1em; 170 + line-height: 1.35; 171 + text-transform: uppercase; 172 + } 173 + 174 + .settings-credits-ledger-value { 175 + color: var(--text-0); 176 + font-family: var(--q-card-title-font-family); 177 + font-size: clamp(1.32rem, 2.1vw, 1.8rem); 178 + font-weight: 600; 179 + letter-spacing: -0.035em; 180 + line-height: 1; 181 + } 182 + 183 + .settings-credits-rail { 184 + position: relative; 185 + display: grid; 186 + grid-template-columns: var(--credits-rail-sidebar) minmax(0, 1fr); 187 + column-gap: var(--credits-rail-gap); 188 + align-items: start; 189 + padding-top: clamp(0.75rem, 1.4vw, 1.05rem); 190 + } 191 + 192 + .settings-credits-rail::before { 193 + content: ""; 194 + position: absolute; 195 + inset: 0 0 auto; 196 + height: 1px; 197 + background: linear-gradient( 198 + 90deg, 199 + transparent 0%, 200 + color-mix(in srgb, var(--credits-primary) 16%, transparent) 12%, 201 + color-mix(in srgb, var(--credits-primary) 42%, transparent) 50%, 202 + color-mix(in srgb, var(--credits-primary) 16%, transparent) 88%, 203 + transparent 100% 204 + ); 205 + opacity: 0.52; 206 + } 207 + 208 + .settings-credits-rail::after { 209 + content: ""; 210 + position: absolute; 211 + top: 0; 212 + bottom: 0; 213 + left: calc(var(--credits-rail-sidebar) + (var(--credits-rail-gap) * 0.5)); 214 + width: 1px; 215 + background: repeating-linear-gradient( 216 + 180deg, 217 + color-mix(in srgb, var(--credits-primary) 24%, transparent) 0 3px, 218 + transparent 6px 9px 219 + ); 220 + mask-image: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.94) 8%, rgba(0, 0, 0, 0.94) 82%, transparent 100%); 221 + -webkit-mask-image: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.94) 8%, rgba(0, 0, 0, 0.94) 82%, transparent 100%); 222 + opacity: 0.82; 223 + pointer-events: none; 224 + } 225 + 226 + .settings-credits-rail--contributors { 227 + --credits-primary: color-mix(in srgb, var(--accent-0) 64%, var(--q-c-red) 36%); 228 + --credits-primary-soft: color-mix(in srgb, var(--credits-primary) 18%, transparent); 229 + padding-bottom: 1rem; 230 + } 231 + 232 + .settings-credits-rail-head, 233 + .settings-credits-rail-body { 234 + position: relative; 235 + z-index: 1; 236 + min-width: 0; 237 + } 238 + 239 + .settings-credits-rail-head { 240 + position: sticky; 241 + top: var(--credits-top-offset); 242 + display: grid; 243 + gap: 0.75rem; 244 + align-content: start; 245 + padding-top: 0.2rem; 246 + } 247 + 248 + .settings-credits-rail-title { 249 + font-size: clamp(1.44rem, 2vw, 1.95rem); 250 + line-height: 1.06; 251 + } 252 + 253 + .settings-credits-rail-meta { 254 + max-width: 29ch; 255 + font-size: 0.94rem; 256 + } 257 + 258 + .settings-credits-rail-body { 259 + display: grid; 260 + gap: var(--credits-section-gap); 261 + } 262 + 263 + .settings-credits-list { 264 + display: grid; 265 + grid-template-columns: repeat(2, minmax(0, 1fr)); 266 + gap: 0.85rem; 267 + align-items: stretch; 268 + } 269 + 270 + .settings-credits-list--contributors { 271 + grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); 272 + justify-content: stretch; 273 + } 274 + 275 + .settings-credits-record { 276 + position: relative; 277 + display: grid; 278 + grid-template-columns: minmax(0, 1fr); 279 + gap: 0.8rem; 280 + align-items: start; 281 + padding: clamp(0.92rem, 1.6vw, 1.1rem); 282 + border: 1px solid var(--credits-grid-strong); 283 + background: color-mix(in srgb, var(--credits-surface) 94%, transparent); 284 + transition: 285 + transform 180ms ease, 286 + border-color 180ms ease, 287 + background-color 180ms ease, 288 + box-shadow 180ms ease; 289 + min-height: 100%; 290 + } 291 + 292 + .settings-credits-record::before, 293 + .settings-credits-record::after { 294 + content: ""; 295 + position: absolute; 296 + width: 10px; 297 + height: 10px; 298 + border: 2px solid var(--credits-primary); 299 + opacity: 0.78; 300 + transition: 301 + width 180ms ease, 302 + height 180ms ease, 303 + opacity 180ms ease; 304 + } 305 + 306 + .settings-credits-record::before { 307 + top: -1px; 308 + left: -1px; 309 + border-right: none; 310 + border-bottom: none; 311 + } 312 + 313 + .settings-credits-record::after { 314 + right: -1px; 315 + bottom: -1px; 316 + border-left: none; 317 + border-top: none; 318 + } 319 + 320 + .settings-credits-record.is-alt { 321 + background: var(--credits-surface-alt); 322 + } 323 + 324 + .settings-credits-record:hover, 325 + .settings-credits-record:focus-within { 326 + transform: translateY(-2px); 327 + border-color: color-mix(in srgb, var(--credits-primary) 44%, var(--credits-grid-strong) 56%); 328 + background-color: color-mix(in srgb, var(--credits-surface) 98%, white 2%); 329 + box-shadow: 0 12px 28px color-mix(in srgb, var(--credits-primary) 10%, transparent); 330 + } 331 + 332 + .settings-credits-record:hover::before, 333 + .settings-credits-record:hover::after, 334 + .settings-credits-record:focus-within::before, 335 + .settings-credits-record:focus-within::after { 336 + width: 14px; 337 + height: 14px; 338 + } 339 + 340 + .settings-credits-record-main { 341 + min-width: 0; 342 + display: grid; 343 + gap: 0.42rem; 344 + } 345 + 346 + .settings-credits-record-head { 347 + display: grid; 348 + grid-template-columns: minmax(0, 1fr) auto; 349 + align-items: flex-start; 350 + column-gap: 0.75rem; 351 + row-gap: 0.35rem; 352 + } 353 + 354 + .settings-credits-record-title { 355 + font-size: clamp(1rem, 0.96rem + 0.14vw, 1.1rem); 356 + line-height: 1.08; 357 + min-width: 0; 358 + } 359 + 360 + .settings-credits-record-tag { 361 + display: inline-flex; 362 + align-items: center; 363 + justify-content: center; 364 + margin-left: auto; 365 + padding: 0.18rem 0.42rem; 366 + border: 1px solid color-mix(in srgb, var(--credits-primary) 24%, transparent); 367 + background: color-mix(in srgb, var(--credits-primary-soft) 48%, transparent); 368 + color: var(--credits-primary); 369 + font-family: var(--font-mono); 370 + font-size: 0.68rem; 371 + font-weight: 600; 372 + letter-spacing: 0.08em; 373 + line-height: 1.2; 374 + text-transform: uppercase; 375 + white-space: nowrap; 376 + } 377 + 378 + .settings-credits-record-actions { 379 + display: flex; 380 + justify-content: flex-end; 381 + align-items: center; 382 + min-width: 0; 383 + } 384 + 385 + .settings-credits-record-link { 386 + flex: 0 0 auto; 387 + } 388 + 389 + .settings-credits-link-icon { 390 + flex: 0 0 auto; 391 + width: 0.9rem; 392 + height: 0.9rem; 393 + } 394 + 395 + .settings-credits-contributor-card { 396 + position: relative; 397 + display: grid; 398 + justify-items: center; 399 + align-content: center; 400 + gap: 0.9rem; 401 + min-height: 13rem; 402 + padding: 1.1rem 0.9rem; 403 + border: 1px solid var(--credits-grid-strong); 404 + background: color-mix(in srgb, var(--credits-surface) 95%, transparent); 405 + color: inherit; 406 + text-align: center; 407 + text-decoration: none; 408 + transition: 409 + transform 180ms ease, 410 + border-color 180ms ease, 411 + background-color 180ms ease, 412 + box-shadow 180ms ease; 413 + } 414 + 415 + .settings-credits-contributor-card::before, 416 + .settings-credits-contributor-card::after { 417 + content: ""; 418 + position: absolute; 419 + width: 10px; 420 + height: 10px; 421 + border: 2px solid var(--credits-primary); 422 + opacity: 0.78; 423 + transition: 424 + width 180ms ease, 425 + height 180ms ease, 426 + opacity 180ms ease; 427 + } 428 + 429 + .settings-credits-contributor-card::before { 430 + top: -1px; 431 + left: -1px; 432 + border-right: none; 433 + border-bottom: none; 434 + } 435 + 436 + .settings-credits-contributor-card::after { 437 + right: -1px; 438 + bottom: -1px; 439 + border-left: none; 440 + border-top: none; 441 + } 442 + 443 + .settings-credits-contributor-card:hover, 444 + .settings-credits-contributor-card:focus-visible { 445 + transform: translateY(-2px); 446 + border-color: color-mix(in srgb, var(--credits-primary) 44%, var(--credits-grid-strong) 56%); 447 + background-color: color-mix(in srgb, var(--credits-surface) 98%, white 2%); 448 + box-shadow: 0 12px 28px color-mix(in srgb, var(--credits-primary) 10%, transparent); 449 + outline: none; 450 + } 451 + 452 + .settings-credits-contributor-card:hover::before, 453 + .settings-credits-contributor-card:hover::after, 454 + .settings-credits-contributor-card:focus-visible::before, 455 + .settings-credits-contributor-card:focus-visible::after { 456 + width: 14px; 457 + height: 14px; 458 + } 459 + 460 + .settings-credits-contributor-avatar-shell { 461 + display: flex; 462 + align-items: center; 463 + justify-content: center; 464 + width: 64px; 465 + height: 64px; 466 + border-radius: 999px; 467 + border: 1px solid color-mix(in srgb, var(--credits-primary) 20%, transparent); 468 + background: color-mix(in srgb, var(--credits-primary-soft) 48%, var(--credits-surface) 52%); 469 + overflow: hidden; 470 + } 471 + 472 + .settings-credits-contributor-avatar, 473 + .settings-credits-contributor-avatar-fallback { 474 + width: 100%; 475 + height: 100%; 476 + } 477 + 478 + .settings-credits-contributor-avatar { 479 + display: block; 480 + object-fit: cover; 481 + } 482 + 483 + .settings-credits-contributor-avatar-fallback { 484 + display: inline-flex; 485 + align-items: center; 486 + justify-content: center; 487 + color: var(--credits-primary); 488 + font-family: var(--font-mono); 489 + font-size: 1.18rem; 490 + font-weight: 700; 491 + letter-spacing: 0.08em; 492 + text-transform: uppercase; 493 + } 494 + 495 + .settings-credits-contributor-name { 496 + margin: 0; 497 + color: var(--text-0); 498 + font-family: var(--q-card-title-font-family); 499 + font-size: 1rem; 500 + font-weight: 600; 501 + line-height: 1.2; 502 + text-align: center; 503 + } 504 + 505 + .settings-credits-empty-block { 506 + position: relative; 507 + min-height: 96px; 508 + display: flex; 509 + align-items: center; 510 + padding: clamp(1rem, 2vw, 1.35rem); 511 + border: 1px solid var(--credits-grid-strong); 512 + background: 513 + linear-gradient(rgba(191, 208, 226, 0.42) 0.5px, transparent 0.5px), 514 + linear-gradient(90deg, rgba(191, 208, 226, 0.42) 0.5px, transparent 0.5px), 515 + color-mix(in srgb, var(--credits-surface) 94%, transparent); 516 + background-size: 20px 20px, 20px 20px, auto; 517 + } 518 + 519 + .settings-credits-empty-block::before, 520 + .settings-credits-empty-block::after { 521 + content: ""; 522 + position: absolute; 523 + width: 12px; 524 + height: 12px; 525 + border: 2px solid var(--credits-primary); 526 + opacity: 0.9; 527 + pointer-events: none; 528 + } 529 + 530 + .settings-credits-empty-block::before { 531 + top: -1px; 532 + left: -1px; 533 + border-right: none; 534 + border-bottom: none; 535 + } 536 + 537 + .settings-credits-empty-block::after { 538 + right: -1px; 539 + bottom: -1px; 540 + border-left: none; 541 + border-top: none; 542 + } 543 + 544 + .settings-credits-empty { 545 + color: var(--credits-muted); 546 + } 547 + 548 + @media (max-width: 1120px) { 549 + .settings-credits-hero-shell { 550 + grid-template-columns: 1fr; 551 + } 552 + 553 + .settings-credits-rail { 554 + grid-template-columns: 1fr; 555 + row-gap: 0.9rem; 556 + } 557 + 558 + .settings-credits-rail::after { 559 + display: none; 560 + } 561 + 562 + .settings-credits-rail-head { 563 + position: static; 564 + padding-top: 0; 565 + } 566 + 567 + .settings-credits-rail-meta { 568 + max-width: none; 569 + } 570 + } 571 + 572 + @media (max-width: 760px) { 573 + .settings-credits-page .page-body { 574 + padding-top: 14px; 575 + background-size: 78px 78px, 78px 78px, 16px 16px, 16px 16px, auto; 576 + } 577 + 578 + .settings-credits-list { 579 + grid-template-columns: 1fr; 580 + } 581 + 582 + .settings-credits-list--contributors { 583 + grid-template-columns: repeat(2, minmax(0, 1fr)); 584 + justify-content: stretch; 585 + } 586 + 587 + .settings-credits-record { 588 + grid-template-columns: 1fr; 589 + gap: 0.72rem; 590 + } 591 + } 592 + 593 + @media (max-width: 520px) { 594 + .settings-credits-hero-copy { 595 + gap: 0.6rem; 596 + } 597 + 598 + .settings-credits-hero-title { 599 + max-width: none; 600 + } 601 + }
+272
web/console/src/views/SettingsCreditsView.js
··· 1 + import { computed, onMounted, ref } from "vue"; 2 + import { useRouter } from "vue-router"; 3 + import "./SettingsCreditsView.css"; 4 + 5 + import AppPage from "../components/AppPage"; 6 + import { apiFetch, translate } from "../core/context"; 7 + 8 + function formatCount(value) { 9 + const n = Number.isFinite(Number(value)) ? Math.max(0, Math.trunc(Number(value))) : 0; 10 + return String(n).padStart(2, "0"); 11 + } 12 + 13 + function contributorAvatarURL(item, brokenContributorAvatars) { 14 + const link = typeof item?.link === "string" ? item.link.trim() : ""; 15 + if (!link || brokenContributorAvatars[item?.id]) { 16 + return ""; 17 + } 18 + try { 19 + const url = new URL(link); 20 + if (url.hostname !== "github.com" && url.hostname !== "www.github.com") { 21 + return ""; 22 + } 23 + const handle = url.pathname.split("/").filter(Boolean)[0] || ""; 24 + return handle ? `https://github.com/${handle}.png?size=160` : ""; 25 + } catch { 26 + return ""; 27 + } 28 + } 29 + 30 + function contributorInitials(name) { 31 + const parts = String(name || "").trim().split(/\s+/).filter(Boolean); 32 + if (!parts.length) { 33 + return "?"; 34 + } 35 + if (parts.length === 1) { 36 + return Array.from(parts[0]).slice(0, 2).join("").toUpperCase(); 37 + } 38 + return parts.slice(0, 2).map((part) => Array.from(part)[0] || "").join("").toUpperCase(); 39 + } 40 + 41 + const SettingsCreditsView = { 42 + components: { 43 + AppPage, 44 + }, 45 + setup() { 46 + const t = translate; 47 + const router = useRouter(); 48 + const loading = ref(false); 49 + const err = ref(""); 50 + const openSource = ref([]); 51 + const contributors = ref([]); 52 + const brokenContributorAvatars = ref({}); 53 + 54 + const introText = computed(() => t("settings_credits_intro")); 55 + const summaryRows = computed(() => [ 56 + { 57 + id: "open_source", 58 + label: t("settings_credits_open_source_title"), 59 + value: formatCount(openSource.value.length), 60 + }, 61 + { 62 + id: "contributors", 63 + label: t("settings_credits_contributors_title"), 64 + value: formatCount(contributors.value.length), 65 + }, 66 + ]); 67 + 68 + async function loadCredits() { 69 + loading.value = true; 70 + err.value = ""; 71 + try { 72 + const data = await apiFetch("/settings/credits"); 73 + openSource.value = Array.isArray(data?.open_source) ? data.open_source : []; 74 + contributors.value = Array.isArray(data?.contributors) ? data.contributors : []; 75 + } catch (e) { 76 + err.value = e.message || t("msg_load_failed"); 77 + } finally { 78 + loading.value = false; 79 + } 80 + } 81 + 82 + function goBack() { 83 + router.push("/settings"); 84 + } 85 + 86 + function openExternal(url) { 87 + if (typeof window === "undefined" || typeof url !== "string" || !url.trim()) { 88 + return; 89 + } 90 + window.open(url, "_blank", "noopener,noreferrer"); 91 + } 92 + 93 + function contributorAvatar(item) { 94 + return contributorAvatarURL(item, brokenContributorAvatars.value); 95 + } 96 + 97 + function markContributorAvatarBroken(id) { 98 + if (!id || brokenContributorAvatars.value[id]) { 99 + return; 100 + } 101 + brokenContributorAvatars.value = { 102 + ...brokenContributorAvatars.value, 103 + [id]: true, 104 + }; 105 + } 106 + 107 + function recordClass(kind, index) { 108 + const classes = ["settings-credits-record", `settings-credits-record--${kind}`]; 109 + if (index % 2 === 1) { 110 + classes.push("is-alt"); 111 + } 112 + return classes.join(" "); 113 + } 114 + 115 + onMounted(() => { 116 + void loadCredits(); 117 + }); 118 + 119 + return { 120 + t, 121 + loading, 122 + err, 123 + openSource, 124 + contributors, 125 + introText, 126 + summaryRows, 127 + goBack, 128 + openExternal, 129 + contributorAvatar, 130 + contributorInitials, 131 + markContributorAvatarBroken, 132 + recordClass, 133 + formatCount, 134 + }; 135 + }, 136 + template: ` 137 + <AppPage :title="t('settings_credits_title')" class="settings-credits-page"> 138 + <template #leading> 139 + <div class="settings-credits-bar"> 140 + <QButton 141 + class="outlined xs icon settings-credits-back" 142 + :title="t('settings_title')" 143 + :aria-label="t('settings_title')" 144 + @click="goBack" 145 + > 146 + <QIconArrowLeft class="icon" /> 147 + </QButton> 148 + <h2 class="page-title page-bar-title workspace-section-title">{{ t("settings_credits_title") }}</h2> 149 + </div> 150 + </template> 151 + 152 + <QProgress v-if="loading" :infinite="true" /> 153 + <QFence v-if="err" type="danger" icon="QIconCloseCircle" :text="err" /> 154 + 155 + <section class="settings-credits-layout"> 156 + <section class="settings-credits-hero"> 157 + <div class="settings-credits-hero-shell"> 158 + <div class="settings-credits-hero-copy"> 159 + <h3 class="settings-credits-hero-title">{{ t("settings_credits_heading") }}</h3> 160 + <p class="settings-credits-hero-lead">{{ introText }}</p> 161 + </div> 162 + 163 + <div class="settings-credits-ledger" aria-label="credits summary"> 164 + <div v-for="row in summaryRows" :key="row.id" class="settings-credits-ledger-row"> 165 + <span class="settings-credits-ledger-label">{{ row.label }}</span> 166 + <strong class="settings-credits-ledger-value">{{ row.value }}</strong> 167 + </div> 168 + </div> 169 + </div> 170 + </section> 171 + 172 + <section class="settings-credits-rail settings-credits-rail--open-source"> 173 + <header class="settings-credits-rail-head"> 174 + <p class="settings-credits-kicker"> 175 + <span class="settings-credits-kicker-bracket">[</span> 176 + Credits 177 + <span class="settings-credits-kicker-sep">//</span> 178 + Open Source 179 + <span class="settings-credits-kicker-bracket">]</span> 180 + </p> 181 + <h3 class="settings-credits-rail-title">{{ t("settings_credits_open_source_title") }}</h3> 182 + <p class="settings-credits-rail-meta">{{ t("settings_credits_open_source_meta") }}</p> 183 + </header> 184 + 185 + <div class="settings-credits-rail-body"> 186 + <div v-if="openSource.length" class="settings-credits-list"> 187 + <article 188 + v-for="(item, index) in openSource" 189 + :key="item.id" 190 + :class="recordClass('open-source', index)" 191 + > 192 + <div class="settings-credits-record-main"> 193 + <div class="settings-credits-record-head"> 194 + <h4 class="settings-credits-record-title">{{ item.name }}</h4> 195 + <span v-if="item.license" class="settings-credits-record-tag">{{ item.license }}</span> 196 + </div> 197 + <p class="settings-credits-record-summary">{{ item.summary }}</p> 198 + </div> 199 + 200 + <div class="settings-credits-record-actions"> 201 + <QButton 202 + class="plain xs icon settings-credits-record-link" 203 + :title="t('settings_credits_open_link')" 204 + :aria-label="t('settings_credits_open_link')" 205 + @click="openExternal(item.link)" 206 + > 207 + <QIconLinkExternal class="icon settings-credits-link-icon" /> 208 + </QButton> 209 + </div> 210 + </article> 211 + </div> 212 + 213 + <div v-else class="settings-credits-empty-block"> 214 + <p class="settings-credits-empty">{{ t("settings_credits_empty_open_source") }}</p> 215 + </div> 216 + </div> 217 + </section> 218 + 219 + <section class="settings-credits-rail settings-credits-rail--contributors"> 220 + <header class="settings-credits-rail-head"> 221 + <p class="settings-credits-kicker"> 222 + <span class="settings-credits-kicker-bracket">[</span> 223 + Credits 224 + <span class="settings-credits-kicker-sep">//</span> 225 + Contributors 226 + <span class="settings-credits-kicker-bracket">]</span> 227 + </p> 228 + <h3 class="settings-credits-rail-title">{{ t("settings_credits_contributors_title") }}</h3> 229 + <p class="settings-credits-rail-meta">{{ t("settings_credits_contributors_meta") }}</p> 230 + </header> 231 + 232 + <div class="settings-credits-rail-body"> 233 + <div v-if="contributors.length" class="settings-credits-list settings-credits-list--contributors"> 234 + <a 235 + v-for="item in contributors" 236 + :key="item.id" 237 + class="settings-credits-contributor-card" 238 + :href="item.link" 239 + target="_blank" 240 + rel="noreferrer" 241 + :title="item.name" 242 + :aria-label="item.name" 243 + > 244 + <div class="settings-credits-contributor-avatar-shell"> 245 + <img 246 + v-if="contributorAvatar(item)" 247 + class="settings-credits-contributor-avatar" 248 + :src="contributorAvatar(item)" 249 + :alt="item.name" 250 + loading="lazy" 251 + decoding="async" 252 + @error="markContributorAvatarBroken(item.id)" 253 + /> 254 + <span v-else class="settings-credits-contributor-avatar-fallback"> 255 + {{ contributorInitials(item.name) }} 256 + </span> 257 + </div> 258 + <h4 class="settings-credits-contributor-name">{{ item.name }}</h4> 259 + </a> 260 + </div> 261 + 262 + <div v-else class="settings-credits-empty-block"> 263 + <p class="settings-credits-empty">{{ t("settings_credits_empty_contributors") }}</p> 264 + </div> 265 + </div> 266 + </section> 267 + </section> 268 + </AppPage> 269 + `, 270 + }; 271 + 272 + export default SettingsCreditsView;
+8
web/console/src/views/SettingsView.css
··· 59 59 padding: clamp(14px, 1.4vw, 20px) 0 var(--content-gutter-y) calc(var(--content-gutter-x) - 14px); 60 60 } 61 61 62 + .settings-index-links.workspace-sidebar-list { 63 + padding: 0 0 var(--content-gutter-y) calc(var(--content-gutter-x) - 14px); 64 + } 65 + 66 + .settings-index-link { 67 + width: 100%; 68 + } 69 + 62 70 .settings-panel-card.q-card-default { 63 71 margin: clamp(14px, 1.4vw, 20px) var(--content-gutter-x) var(--content-gutter-y) 0; 64 72 }
+16
web/console/src/views/SettingsView.js
··· 1512 1512 mobilePanelVisible.value = false; 1513 1513 } 1514 1514 1515 + function openCreditsPage() { 1516 + router.push("/settings/credits"); 1517 + } 1518 + 1515 1519 function selectSection(id) { 1516 1520 selectedSectionID.value = String(id || "").trim(); 1517 1521 if (isMobile.value) { ··· 1662 1666 isSelectedSection, 1663 1667 sectionClass, 1664 1668 showIndexView, 1669 + openCreditsPage, 1665 1670 apiBasePickerOpen, 1666 1671 modelPickerOpen, 1667 1672 modelPickerLoading, ··· 1708 1713 </span> 1709 1714 <span class="workspace-sidebar-item-marker"> 1710 1715 <QBadge v-if="isSelectedSection(item)" dot type="primary" size="sm" /> 1716 + </span> 1717 + </button> 1718 + </div> 1719 + <div class="settings-index-links workspace-sidebar-list"> 1720 + <button type="button" class="settings-index-link workspace-sidebar-item" @click="openCreditsPage"> 1721 + <span class="workspace-sidebar-item-copy"> 1722 + <span class="workspace-sidebar-item-title">{{ t("settings_credits_title") }}</span> 1723 + <span class="workspace-sidebar-item-meta">{{ t("settings_credits_meta") }}</span> 1724 + </span> 1725 + <span class="workspace-sidebar-item-marker"> 1726 + <QIconLinkExternal class="icon" /> 1711 1727 </span> 1712 1728 </button> 1713 1729 </div>
+1
web/console/src/views/index.js
··· 14 14 export { default as StateFilesView } from "./StateFilesView"; 15 15 export { default as ContactsView } from "./ContactsView"; 16 16 export { default as SettingsView } from "./SettingsView"; 17 + export { default as SettingsCreditsView } from "./SettingsCreditsView";