feat(sdk): SettingsAPI.DataDir() 插件专属数据目录 + ai_image 本地交付

【SDK DataDir API】
- SettingsAPI 新增 DataDir() string:返回插件专属数据目录
  <data>/plugin_data/<name>(内核保证存在),解决此前插件只能
  靠 GetCore("daemon.data_dir") 手工解析的缺陷
- settingsImpl 新增 dataDir 字段 + SetDataDir;Registry buildSDK
  注入(<data>/plugin_data/<name> 并 MkdirAll);main.go 接线
- cabi 新增 CORE_SETTINGS_DATA_DIR (id 51);plugindev dispatchSettings
  模板补 DataDir() 实现

【ai_image 交付本地路径】
- 生成后下载临时 S3 URL 到插件数据目录,返回本地文件路径(永久不
  过期),而非 1 小时过期的 S3 URL。带 UA 规避图床对无 UA 客户端拦截
  (此前 agent 裸 curl 验证被拒导致误报失败)
- 返回 local_paths 字段 + 提示用 output_send(type=image) 展示

端到端:ai_image_generate → plugin_data/ai_image/*.png 有效 PNG(1024²),
经 llmsproxy→siliconflow 生成。
This commit is contained in:
JianFeeeee
2026-08-26 21:40:29 +08:00
parent d0fc7e03c1
commit f0cdbdb030
12 changed files with 1432 additions and 16 deletions

View File

@ -6,6 +6,8 @@ import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@ -27,6 +29,7 @@ type Plugin struct {
baseURL string
model string
size string
dataDir string
}
func New(name string) *Plugin {
@ -55,6 +58,14 @@ func getSettingString(s sdk.SettingsAPI, key, def string) string {
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
// 插件专属数据目录SDK DataDir API内核保证存在
if dd := s.Settings().DataDir(); dd != "" {
p.dataDir = dd
}
if p.dataDir != "" {
os.MkdirAll(p.dataDir, 0755)
}
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "base_url", Default: "http://127.0.0.1:8081/v1", Type: "string",
DisplayName: "Base URL", Description: "OpenAI 兼容生图网关地址(默认本机 llmsproxy",
@ -193,8 +204,7 @@ func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error
}
var urls []string
var saved []string
for i, d := range result.Data {
for _, d := range result.Data {
imgURL := d.URL
if imgURL == "" && d.B64JSON != "" {
imgURL = "data:image/png;base64," + d.B64JSON
@ -202,18 +212,87 @@ func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error
if imgURL != "" {
urls = append(urls, imgURL)
}
if i == 0 {
saved = append(saved, imgURL)
}
}
if len(urls) == 0 {
return map[string]interface{}{"isError": true, "content": "生图响应中没有可用图片"}, nil
}
// 下载到插件数据目录,返回本地文件路径(而非临时 S3 URL
// - S3 临时 URL 约 1 小时过期,且对无浏览器 UA 客户端拒绝访问agent 裸 curl 验证必败)
// - 本地路径经 webui /files/ 永久下发,支持 output_send type=image
var localPaths []string
var dlErrs []string
for i, u := range urls {
if strings.HasPrefix(u, "data:") {
continue // base64 内联图不落盘
}
path, err := p.downloadImage(u, fmt.Sprintf("ai_%d_%d", time.Now().UnixNano(), i))
if err != nil {
dlErrs = append(dlErrs, fmt.Sprintf("第%d张保存失败: %v", i+1, err))
continue
}
localPaths = append(localPaths, path)
}
content := fmt.Sprintf("Generated %d image(s) with model %s", len(urls), model)
if len(localPaths) > 0 {
content += "\n本地文件\n" + strings.Join(localPaths, "\n")
content += "\n\n图片已保存到本地不会过期。如需展示请用 output_send__webui(payload=本地路径, type=image)。"
}
if len(dlErrs) > 0 {
content += "\n\n" + strings.Join(dlErrs, "\n")
}
if len(localPaths) < len(urls) {
content += "\n原始 URL1小时内有效\n" + strings.Join(urls, "\n")
}
return map[string]interface{}{
"content": fmt.Sprintf("Generated %d image(s) with model %s:\n%s", len(urls), model, strings.Join(urls, "\n")),
"images": urls,
"prompt": prompt,
"model": model,
"content": content,
"images": urls,
"local_paths": localPaths,
"prompt": prompt,
"model": model,
}, nil
}
// downloadImage 把生图返回的临时 URL 下载为本地文件,返回路径。
// 带浏览器 UA 规避图床对无 UA 客户端的拦截。
func (p *Plugin) downloadImage(imgURL, baseName string) (string, error) {
if p.dataDir == "" {
return "", fmt.Errorf("data dir unavailable")
}
dl := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest(http.MethodGet, imgURL, 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)
if len(b) > 200 {
b = b[:200]
}
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
ext := ".png"
switch ct := resp.Header.Get("Content-Type"); {
case strings.Contains(ct, "jpeg"), strings.Contains(ct, "jpg"):
ext = ".jpg"
case 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
}