mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
* feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode)
## 是什么
agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。
38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。
## sidecar 后端抽象(本轮核心)
传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:
driver.json 描述一个后端:script / cliPath / env / args / llmConfig
三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}
★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。
默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
· 单线:commondev_branch_open(cli="zcode")
· 按机器:commondev_endpoint_set(host=..., cli="zcode")
## zcode 接入(三条真机判据,全部要求真改工程文件)
· TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
· TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
· TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
cli_path,走端点表而不是全局 settings
★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。
## 其他
· rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
· 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
· secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
· 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)
判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。
* fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染
## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道
子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:
[proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'
**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。
修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
+ 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法
判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。
## hmapdev
- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
**导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)
## 判据
- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
(第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据
## 其他
- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)
* fix(examples): 恢复 go.mod 相对路径 + 移除 plg.json 的 sdk 固定(修 CI 红)
CI 上 `Examples (linux/amd64)` 与 `(darwin/arm64)` 双红,只有两个失败:
✗ commondev 构建失败
✗ editdoc 构建失败
error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本
其余 20 个示例全部成功 —— 说明不是通用问题,而是这两个被改坏了。
## 根因(两个叠加)
**① go.mod 被本机绝对路径污染**
我在上一个提交里用 `git add -A`,把本机 `go build` **自动改写**过的
go.mod 一并提交了:
require github.com/JianFeeeee/homeagentsdk v1.4.0
replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0
基线(origin/main)是:
require github.com/JianFeeeee/homeagentsdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../
本机那个目录真的存在 ⇒ 本地全绿;CI 没有 ⇒ 必红。
**② plg.json 声明了 `"sdk": "1.4.0"`**
一声明版本,hmapdev 就去**本地 SDK store** 找它,找不到直接拒绝构建。
全仓 20+ 示例**都不声明**,只有我改过的这两个声明了。
示例的正确依赖表达是 go.mod 的 `v0.0.0` + `replace ../../`。
## 修法
两个文件都恢复到与其余示例一致的写法。
## 判据(3 条,都是新加的)
· `TestExampleGoModHasNoAbsoluteReplace`:任何示例 go.mod 不得含
`/root/`、`/home/`、`/Users/`、`C:\`
· `TestExampleGoModUsesRelativeSDKReplace`:replace 必须是相对路径 `../../`
· `TestExamplePlgJsonDoesNotPinSDKVersion`:plg.json 不得声明 sdk
变异 2/2 咬住(重新引入绝对路径 / 重新声明 sdk,都准确报出文件路径)。
## 本地验证(模拟 CI 干净环境)
用干净 HOME 跑 `package/build-examples.sh`:
· 修复前:成功 18 / 失败 4(commondev, editdoc, browser, rss)
· 修复后:成功 20 / 失败 2(browser, rss —— **本机代理不通**,
`proxy.golang.org` 超时;用本地 modcache 验证这两个可正常构建)
即 22/22 源码都能构建,CI 有网络不受影响。
## 教训
`git add -A` 会把「构建工具自动改写的文件」一起提交。
提交前必须 `git status` 逐个看,尤其 go.mod / go.sum。
---------
Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
435 lines
13 KiB
Go
435 lines
13 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||
)
|
||
|
||
type Plugin struct {
|
||
name string
|
||
sdk *sdk.PluginSDK
|
||
client *http.Client
|
||
apiKey string
|
||
provider string
|
||
model string
|
||
size string
|
||
baseURL string
|
||
dataDir string // <data>/ai_images:生成本地图片存放目录
|
||
}
|
||
|
||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||
return &Plugin{name: name}, nil
|
||
}
|
||
|
||
func (p *Plugin) Name() string { return p.name }
|
||
|
||
func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, def T) T {
|
||
v, err := s.Get(key)
|
||
if err != nil || v == nil {
|
||
return def
|
||
}
|
||
switch any(def).(type) {
|
||
case string:
|
||
if sv, ok := v.(string); ok {
|
||
return any(sv).(T)
|
||
}
|
||
case int64:
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return any(int64(n)).(T)
|
||
case int64:
|
||
return any(n).(T)
|
||
case string:
|
||
if i, err := strconv.ParseInt(n, 10, 64); err == nil {
|
||
return any(i).(T)
|
||
}
|
||
}
|
||
case float64:
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return any(n).(T)
|
||
case int64:
|
||
return any(float64(n)).(T)
|
||
case string:
|
||
if f, err := strconv.ParseFloat(n, 64); err == nil {
|
||
return any(f).(T)
|
||
}
|
||
}
|
||
}
|
||
return def
|
||
}
|
||
|
||
func getArg[T string | int64 | float64](args map[string]interface{}, key string, def T) T {
|
||
v, ok := args[key]
|
||
if !ok || v == nil {
|
||
return def
|
||
}
|
||
switch any(def).(type) {
|
||
case string:
|
||
if s, ok := v.(string); ok {
|
||
return any(s).(T)
|
||
}
|
||
case int64:
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return any(int64(n)).(T)
|
||
case int64:
|
||
return any(n).(T)
|
||
case string:
|
||
if i, err := strconv.ParseInt(n, 10, 64); err == nil {
|
||
return any(i).(T)
|
||
}
|
||
}
|
||
case float64:
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return any(n).(T)
|
||
case int64:
|
||
return any(float64(n)).(T)
|
||
case string:
|
||
if f, err := strconv.ParseFloat(n, 64); err == nil {
|
||
return any(f).(T)
|
||
}
|
||
}
|
||
}
|
||
return def
|
||
}
|
||
|
||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||
s.SetAutoRestart(true)
|
||
p.sdk = s
|
||
p.client = &http.Client{Timeout: 120 * time.Second}
|
||
|
||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||
Key: "api_key", Default: "", Type: "string",
|
||
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
|
||
Category: "ai_image", Secret: true,
|
||
})
|
||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||
Key: "base_url", Default: "", Type: "string",
|
||
DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com",
|
||
Category: "ai_image",
|
||
})
|
||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||
Key: "provider", Default: "openai", Type: "string",
|
||
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
||
Category: "ai_image",
|
||
})
|
||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||
Key: "model", Default: "dall-e-3", Type: "string",
|
||
DisplayName: "Model", Description: "Model name (dall-e-3, sd-xl, etc.)",
|
||
Category: "ai_image",
|
||
})
|
||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||
Key: "size", Default: "1024x1024", Type: "string",
|
||
DisplayName: "Size", Description: "Default image size (1024x1024, 1024x1792, 1792x1024)",
|
||
Category: "ai_image",
|
||
})
|
||
|
||
p.apiKey = getSetting(s.Settings(), "api_key", "")
|
||
p.provider = getSetting(s.Settings(), "provider", "openai")
|
||
p.model = getSetting(s.Settings(), "model", "dall-e-3")
|
||
p.size = getSetting(s.Settings(), "size", "1024x1024")
|
||
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
|
||
|
||
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
|
||
if p.sdk != nil {
|
||
if dd := s.Settings().DataDir(); dd != "" {
|
||
p.dataDir = dd
|
||
}
|
||
}
|
||
if p.dataDir == "" {
|
||
// 旧版内核无 DataDir API 时退到 /tmp
|
||
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
|
||
}
|
||
os.MkdirAll(p.dataDir, 0755)
|
||
|
||
tp := p.name + "_"
|
||
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
||
Name: tp + "generate", Description: "Generate image from text prompt using AI. Downloads the result locally and returns a local file path (permanent, no expiry). To show the user, send it via output_send with type=image and payload=the returned path.",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"prompt": map[string]interface{}{"type": "string", "description": "Text description of the image to generate"},
|
||
"size": map[string]interface{}{"type": "string", "description": "Image size (1024x1024, 1024x1792, 1792x1024), default from config"},
|
||
"model": map[string]interface{}{"type": "string", "description": "Model override (dall-e-3, dall-e-2)"},
|
||
"n": map[string]interface{}{"type": "integer", "description": "Number of images to generate (1-10), default 1"},
|
||
},
|
||
"required": []string{"prompt"},
|
||
},
|
||
}, p.handleGenerate)
|
||
|
||
log.Printf("[%s] started (provider=%s, model=%s)", p.name, p.provider, p.model)
|
||
return nil
|
||
}
|
||
|
||
func (p *Plugin) Stop() error {
|
||
log.Printf("[%s] stopped", p.name)
|
||
return nil
|
||
}
|
||
|
||
type openAIReq struct {
|
||
Model string `json:"model"`
|
||
Prompt string `json:"prompt"`
|
||
N int `json:"n"`
|
||
Size string `json:"size"`
|
||
ResponseFormat string `json:"response_format"`
|
||
}
|
||
|
||
type openAIResp struct {
|
||
Created int64 `json:"created"`
|
||
Data []struct {
|
||
RevisedPrompt string `json:"revised_prompt"`
|
||
URL string `json:"url"`
|
||
} `json:"data"`
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
Type string `json:"type"`
|
||
} `json:"error"`
|
||
}
|
||
|
||
func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error) {
|
||
prompt := getArg(args, "prompt", "")
|
||
if prompt == "" {
|
||
return map[string]interface{}{"isError": true, "content": "prompt is required"}, nil
|
||
}
|
||
|
||
key := getSetting(p.sdk.Settings(), "api_key", p.apiKey)
|
||
if key == "" {
|
||
return map[string]interface{}{"isError": true, "content": "API key not configured. Set plugin.ai_image.api_key via CLI."}, nil
|
||
}
|
||
|
||
provider := getSetting(p.sdk.Settings(), "provider", p.provider)
|
||
model := getArg(args, "model", getSetting(p.sdk.Settings(), "model", p.model))
|
||
size := getArg(args, "size", getSetting(p.sdk.Settings(), "size", p.size))
|
||
n := getArg(args, "n", int64(1))
|
||
if n < 1 {
|
||
n = 1
|
||
}
|
||
if n > 10 {
|
||
n = 10
|
||
}
|
||
|
||
switch provider {
|
||
case "openai":
|
||
return p.generateOpenAI(prompt, model, size, int(n), key)
|
||
case "stability":
|
||
return p.generateStability(prompt, model, size, int(n), key)
|
||
default:
|
||
return map[string]interface{}{"isError": true, "content": "Unknown provider: " + provider + ". Supported: openai, stability"}, nil
|
||
}
|
||
}
|
||
|
||
func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) {
|
||
// 上游地址:base_url 非空时走自定义网关(如本机 llmsproxy),约定不带 /v1 尾缀;
|
||
// 为空保持官方直连。兼容误配了 /v1 尾缀的情况(去重)。
|
||
endpoint := "https://api.openai.com/v1/images/generations"
|
||
if p.baseURL != "" {
|
||
base := strings.TrimSuffix(p.baseURL, "/v1")
|
||
endpoint = base + "/v1/images/generations"
|
||
}
|
||
|
||
body := openAIReq{
|
||
Model: model,
|
||
Prompt: prompt,
|
||
N: n,
|
||
Size: size,
|
||
ResponseFormat: "url",
|
||
}
|
||
|
||
log.Printf("[ai_image] endpoint=%s baseURL=%q model=%q", endpoint, p.baseURL, model)
|
||
b, _ := json.Marshal(body)
|
||
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(b))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||
|
||
resp, err := p.client.Do(req)
|
||
if err != nil {
|
||
return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
var result openAIResp
|
||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||
return map[string]interface{}{"isError": true, "content": "Failed to parse response: " + err.Error()}, nil
|
||
}
|
||
|
||
if result.Error != nil {
|
||
return map[string]interface{}{"isError": true, "content": "API error: " + result.Error.Message}, nil
|
||
}
|
||
|
||
if len(result.Data) == 0 {
|
||
return map[string]interface{}{"isError": true, "content": "No images returned"}, nil
|
||
}
|
||
|
||
urls := make([]string, len(result.Data))
|
||
for i, d := range result.Data {
|
||
urls[i] = d.URL
|
||
}
|
||
|
||
// 下载到本地 data 目录,返回本地文件路径(而非临时 S3 URL):
|
||
// - S3 临时 URL 约 1 小时过期,且对无浏览器 UA 的客户端拒绝访问
|
||
// - 本地路径可经 webui /files/ 永久下发给所有客户端(含 API key 客户端)
|
||
localPaths := make([]string, len(urls))
|
||
var errs []string
|
||
for i, u := range urls {
|
||
path, err := p.downloadImage(u, fmt.Sprintf("ai_%s_%d", model, time.Now().UnixNano()))
|
||
if err != nil {
|
||
errs = append(errs, fmt.Sprintf("第%d张下载失败: %v", i+1, err))
|
||
continue
|
||
}
|
||
localPaths[i] = path
|
||
}
|
||
|
||
content := fmt.Sprintf("Generated %d image(s) with model %s:", len(urls), model)
|
||
for _, pth := range localPaths {
|
||
if pth != "" {
|
||
content += "\n" + pth
|
||
}
|
||
}
|
||
if len(errs) > 0 {
|
||
content += "\n\n" + strings.Join(errs, "\n")
|
||
}
|
||
content += "\n\n已将图片保存到本地(不会过期)。如需展示请用 output_send__webui(payload=本地路径, type=image)。"
|
||
return map[string]interface{}{
|
||
"content": content,
|
||
"images": localPaths,
|
||
"prompt": prompt,
|
||
"model": model,
|
||
"local_paths": localPaths,
|
||
}, nil
|
||
}
|
||
|
||
// downloadImage 把生图返回的临时 URL 下载为本地文件,返回本地路径。
|
||
// 带浏览器 UA 以规避图床对无 UA 客户端的拦截。
|
||
func (p *Plugin) downloadImage(url, baseName string) (string, error) {
|
||
dl := &http.Client{Timeout: 60 * time.Second}
|
||
req, err := http.NewRequest("GET", url, nil)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; HomeAgent/1.0)")
|
||
resp, err := dl.Do(req)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
b, _ := io.ReadAll(resp.Body)
|
||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))[:200])
|
||
}
|
||
data, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
ext := ".png"
|
||
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
|
||
ext = ".jpg"
|
||
} else if strings.Contains(ct, "webp") {
|
||
ext = ".webp"
|
||
}
|
||
path := filepath.Join(p.dataDir, baseName+ext)
|
||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||
return "", err
|
||
}
|
||
return path, nil
|
||
}
|
||
|
||
type stabilityReq struct {
|
||
TextPrompts []stabilityPrompt `json:"text_prompts"`
|
||
Width int `json:"width"`
|
||
Height int `json:"height"`
|
||
Samples int `json:"samples"`
|
||
}
|
||
|
||
type stabilityPrompt struct {
|
||
Text string `json:"text"`
|
||
Weight float64 `json:"weight,omitempty"`
|
||
}
|
||
|
||
type stabilityArtifact struct {
|
||
Base64 string `json:"base64"`
|
||
Seed int `json:"seed"`
|
||
}
|
||
|
||
type stabilityResp struct {
|
||
Artifacts []stabilityArtifact `json:"artifacts"`
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
func (p *Plugin) generateStability(prompt, model, size string, n int, apiKey string) (interface{}, error) {
|
||
width, height := 1024, 1024
|
||
if parts := strings.Split(size, "x"); len(parts) == 2 {
|
||
if w, err := strconv.Atoi(parts[0]); err == nil {
|
||
width = w
|
||
}
|
||
if h, err := strconv.Atoi(parts[1]); err == nil {
|
||
height = h
|
||
}
|
||
}
|
||
|
||
body := stabilityReq{
|
||
TextPrompts: []stabilityPrompt{{Text: prompt, Weight: 1.0}},
|
||
Width: width,
|
||
Height: height,
|
||
Samples: n,
|
||
}
|
||
|
||
apiURL := "https://api.stability.ai/v1/generation/stable-diffusion-xl-1024-v1-0/text-to-image"
|
||
|
||
b, _ := json.Marshal(body)
|
||
req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(b))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||
req.Header.Set("Accept", "application/json")
|
||
|
||
resp, err := p.client.Do(req)
|
||
if err != nil {
|
||
return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != 200 {
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}, nil
|
||
}
|
||
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
var result stabilityResp
|
||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||
return map[string]interface{}{"isError": true, "content": "Failed to parse response: " + err.Error()}, nil
|
||
}
|
||
|
||
if len(result.Artifacts) == 0 {
|
||
msg := result.Message
|
||
if msg == "" {
|
||
msg = "No images returned"
|
||
}
|
||
return map[string]interface{}{"isError": true, "content": msg}, nil
|
||
}
|
||
|
||
urls := make([]string, len(result.Artifacts))
|
||
for i, a := range result.Artifacts {
|
||
urls[i] = "data:image/png;base64," + a.Base64
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s\n\n图片已保存到本地,如需展示请用 output_send(type=image)。", len(urls), strings.Join(urls, "\n")),
|
||
"images": urls,
|
||
"prompt": prompt,
|
||
"model": model,
|
||
}, nil
|
||
}
|