Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 2 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,7 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推

当前没有独立部署的在线 Web 版。仓库里的浏览器预览主要用于前端调试和 target platform 预览,不等同于可直接对外提供服务的 SaaS Web 端。

桌面端支持从配套的提示词聚合站 [**Image-Prompts** · prompts.sorry.ink](https://prompts.sorry.ink) 一键导入提示词。Image-Prompts 是 Image-Studio 生态里专门做「Prompt 这一侧」的中英双语提示词聚合站,提示词详情页都带「Send to Image-Studio」按钮 —— 不登录也能用,签发一次性 token 后通过自定义协议跳转到桌面端。

- macOS: 由 Wails 桌面版接收 `image-studio://import?...`
- Windows / Linux: 由 Gio 客户端接收 `image-studio://import?...`

桌面端拿到 token 后向站点回取 prompt payload(24h 过期、一次消费),弹出独立确认组件后写入当前 prompt 编辑器。仓库:[RoseKhlifa/Image-Prompts](https://github.com/RoseKhlifa/Image-Prompts)。
**配套项目 [Image-Prompts](https://prompts.sorry.ink/) 提供提示词浏览与一键导入能力,支持把网页上的提示词直接送入 Image Studio 桌面端。相关说明见 [docs/prompt-import.md](./docs/prompt-import.md)。**

## 快速上手

Expand Down Expand Up @@ -67,6 +62,7 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推
| 源码构建、验证脚本、CI 产物链路 | [docs/build.md](./docs/build.md) |
| 真机 / 真实上游手工验证矩阵 | [docs/manual-verification.md](./docs/manual-verification.md) |
| 首次配置、API 形态选择、参数策略 | [docs/usage.md](./docs/usage.md) |
| 配套项目 Image-Prompts 与提示词导入 | [docs/prompt-import.md](./docs/prompt-import.md) |
| 提 Issue 前自查、数据存储位置、524/504、模型权限、字段兼容问题 | [docs/troubleshooting.md](./docs/troubleshooting.md) |
| 仓库结构、前端分层、内核 / Worker / Android 关系 | [docs/project-structure.md](./docs/project-structure.md) |
| 原始提示词传递策略 | [docs/no-prompt-revision/README.md](./docs/no-prompt-revision/README.md) |
Expand All @@ -81,12 +77,6 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推

这意味着基于本项目进行修改后再分发,或将修改版作为网络服务提供给他人使用时,都需要按同一许可证公开对应源码。

## 配套项目

| 项目 | 角色 |
|---|---|
| [**Image-Prompts**](https://github.com/RoseKhlifa/Image-Prompts) · [prompts.sorry.ink](https://prompts.sorry.ink) | 中英双语 AI 提示词聚合站,Image-Studio 生态的「灵感源」一侧。社区投稿 + 公开档案融合,可检索 / 收藏 / 一键导入到 Image-Studio。详情页「Send to Image-Studio」会通过 `image-studio://` 深链把 prompt 直接送进本应用编辑器,**游客也可用** |

## 致谢

- <a href="https://linux.do/"><img src="./docs/picture/linuxdo.png" alt="linux.do" height="20" align="absmiddle" /></a> [**linux.do**](https://linux.do/) —— 感谢 L 站及其社区为项目开发与交流提供的支持与启发。
Expand Down
25 changes: 25 additions & 0 deletions docs/prompt-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Image-Prompts 配套项目与导入说明

[Image-Prompts](https://prompts.sorry.ink/) 是 Image Studio 的配套提示词站,支持从网页端一键导入提示词到桌面端。

## 使用方式

1. 在 Image-Prompts 的提示词详情页点击 `Send to Image-Studio`。
2. 站点会通过 `image-studio://import?...` 拉起桌面端。
3. 客户端拉取提示词内容后,会先弹出确认界面,再写入当前 prompt 编辑器。

## 平台分流

- macOS: 由 `image-studio/` 下的 Wails 桌面端接收导入请求。
- Windows / Linux: 由 `gio-client/` 接收导入请求。

## 导入约束

- 当前导入能力仅面向桌面端,不包含 Android。
- 站点签发的是一次性 token,默认 24 小时内有效,消费后失效。
- Windows / Linux 下的协议注册、CLI 和实现细节见 [gio-client.md](./gio-client.md)。

## 相关链接

- 站点: [prompts.sorry.ink](https://prompts.sorry.ink/)
- 仓库: [RoseKhlifa/Image-Prompts](https://github.com/RoseKhlifa/Image-Prompts)
1 change: 1 addition & 0 deletions gio-client/cmd/image-studio-gio/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func main() {
os.Exit(exitCode)
}
appUI := ui.New()
appUI.StartBackgroundAppUpdateCheck()
server, alreadyRunning, err := promptipc.TryStart(func(msg promptipc.Message) {
switch msg.Type {
case promptipc.MessageTypeRaise:
Expand Down
21 changes: 21 additions & 0 deletions gio-client/internal/compat/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"crypto/rand"
"encoding/hex"
"errors"
"path/filepath"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -151,6 +152,25 @@ func SetSavePromptSuppressed(value bool) error {
return SaveState(state)
}

func RememberTrustedOutputRoot(state shared.State, root string) shared.State {
state = shared.Normalize(state)
root = strings.TrimSpace(root)
if root == "" {
return state
}
if abs, err := filepath.Abs(root); err == nil {
root = abs
}
root = filepath.Clean(root)
for _, existing := range state.Settings.TrustedOutputRoots {
if filepath.Clean(strings.TrimSpace(existing)) == root {
return state
}
}
state.Settings.TrustedOutputRoots = append(state.Settings.TrustedOutputRoots, root)
return state
}

func UpsertConfig(state shared.State, cfg kernel.Config) shared.State {
state = shared.Normalize(state)
now := time.Now().UnixMilli()
Expand Down Expand Up @@ -208,6 +228,7 @@ func UpsertConfig(state shared.State, cfg kernel.Config) shared.State {
state.Settings.CompletionSound = &completionSound
state.Settings.OutputFormat = strings.TrimSpace(cfg.OutputFormat)
state.Settings.OutputDir = strings.TrimSpace(cfg.OutputDir)
state = RememberTrustedOutputRoot(state, state.Settings.OutputDir)
state.Settings.Background = strings.TrimSpace(cfg.Background)
state.Settings.InputFidelity = strings.TrimSpace(cfg.InputFidelity)
state.Settings.ImageStyle = strings.TrimSpace(cfg.ImageStyle)
Expand Down
16 changes: 16 additions & 0 deletions gio-client/internal/compat/state_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package compat

import (
"path/filepath"
"testing"

"image-studio/gio-client/internal/kernel"
Expand Down Expand Up @@ -103,6 +104,9 @@ func TestUpsertConfigPreservesActiveProfileIdentity(t *testing.T) {
if next.Settings.OutputFormat != "jpeg" || next.Settings.OutputDir != "/tmp/images" || next.Settings.ProxyMode != client.ProxyModeNone {
t.Fatalf("settings not updated: %#v", next.Settings)
}
if len(next.Settings.TrustedOutputRoots) != 1 || next.Settings.TrustedOutputRoots[0] != filepath.Clean("/tmp/images") {
t.Fatalf("trusted output roots not updated: %#v", next.Settings.TrustedOutputRoots)
}
if next.Settings.CompletionSound == nil || !next.Settings.CompletionSound.Enabled || next.Settings.CompletionSound.Mode != "custom" || next.Settings.CompletionSound.CustomName != "done.wav" || next.Settings.CompletionSound.CustomData != "data:audio/wav;base64,BB==" {
t.Fatalf("completion sound not updated: %#v", next.Settings.CompletionSound)
}
Expand Down Expand Up @@ -141,3 +145,15 @@ func TestHistoryItemFromRunUsesWebViewCompatibleFields(t *testing.T) {
t.Fatalf("source paths not mapped: %#v", item.SourcePaths)
}
}

func TestRememberTrustedOutputRootDeduplicatesAndNormalizes(t *testing.T) {
state := RememberTrustedOutputRoot(shared.State{}, "./tmp/out")
if len(state.Settings.TrustedOutputRoots) != 1 {
t.Fatalf("trusted output roots len=%d want 1", len(state.Settings.TrustedOutputRoots))
}
first := state.Settings.TrustedOutputRoots[0]
state = RememberTrustedOutputRoot(state, first)
if len(state.Settings.TrustedOutputRoots) != 1 {
t.Fatalf("trusted output roots duplicated: %#v", state.Settings.TrustedOutputRoots)
}
}
54 changes: 51 additions & 3 deletions gio-client/internal/kernel/probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,18 @@ const probeUpstreamTimeout = 20 * time.Second
const probeUpstreamMaxBody = 1 << 20

type ProbeResult struct {
ModelCount int
ModelCount int
Models []UpstreamModelDescriptor
ResponsesTransport string
ResponsesTransportOK bool
ResponsesTransportError string
}

type UpstreamModelDescriptor struct {
ID string
Object string
OwnedBy string
DisplayName string
}

func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) {
Expand Down Expand Up @@ -70,15 +81,52 @@ func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) {
}

var parsed struct {
Data []json.RawMessage `json:"data"`
Data []struct {
ID string `json:"id"`
Object string `json:"object"`
OwnedBy string `json:"owned_by"`
DisplayName string `json:"name"`
} `json:"data"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return ProbeResult{}, fmt.Errorf("上游 /v1/models 返回的 JSON 无效: %w", err)
}
if parsed.Data == nil {
return ProbeResult{}, fmt.Errorf("上游 /v1/models 响应缺少 data 数组")
}
return ProbeResult{ModelCount: len(parsed.Data)}, nil
models := make([]UpstreamModelDescriptor, 0, len(parsed.Data))
for _, item := range parsed.Data {
id := strings.TrimSpace(item.ID)
if id == "" {
continue
}
models = append(models, UpstreamModelDescriptor{
ID: id,
Object: strings.TrimSpace(item.Object),
OwnedBy: strings.TrimSpace(item.OwnedBy),
DisplayName: strings.TrimSpace(item.DisplayName),
})
}
result := ProbeResult{
ModelCount: len(parsed.Data),
Models: models,
}
if cfg.APIMode == client.APIModeResponses &&
client.NormalizeProxyTransportValue(strings.TrimSpace(string(cfg.ResponsesTransport))) == string(client.ResponsesTransportWebSocket) {
result.ResponsesTransport = string(client.ResponsesTransportWebSocket)
if wsErr := client.ProbeResponsesWebSocket(ctx, client.ProbeResponsesWebSocketOptions{
BaseURL: baseURL,
APIKey: apiKey,
Proxy: proxyConfig,
Model: client.NormalizeTextModel(cfg.TextModelID),
}); wsErr != nil {
result.ResponsesTransportOK = false
result.ResponsesTransportError = wsErr.Error()
return result, nil
}
result.ResponsesTransportOK = true
}
return result, nil
}

func summarizeProbeBody(body []byte) string {
Expand Down
7 changes: 5 additions & 2 deletions gio-client/internal/kernel/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ func (Runner) Run(ctx context.Context, cfg Config, cb Callbacks) (Result, error)
if idx > 0 {
nonNilLog(cb.Log)("主上游自动重试失败,切换到备用上游再试一次...")
}
result, err := runSingleConfig(ctx, variant, cb, imagesDir, logDir)
result, err := runSingleConfig(ctx, variant, cb, imagesDir, logDir, idx)
if err == nil {
return result, nil
}
Expand Down Expand Up @@ -351,7 +351,7 @@ func normalizeRequestPolicy(policy client.RequestPolicy) client.RequestPolicy {
return client.RequestPolicyOpenAI
}

func runSingleConfig(ctx context.Context, cfg Config, cb Callbacks, imagesDir string, logDir string) (Result, error) {
func runSingleConfig(ctx context.Context, cfg Config, cb Callbacks, imagesDir string, logDir string, variantIndex int) (Result, error) {
proxy, err := client.NormalizeProxyConfig(cfg.ProxyMode, cfg.ProxyURL)
if err != nil {
return Result{}, err
Expand Down Expand Up @@ -397,6 +397,9 @@ func runSingleConfig(ctx context.Context, cfg Config, cb Callbacks, imagesDir st
}

timestamp := time.Now().Format("20060102-150405")
if variantIndex > 0 {
timestamp = fmt.Sprintf("%s-fallback%d", timestamp, variantIndex)
}
result, rawPath, err := client.RequestAndExtractWithRetriesAndPartial(
ctx,
transport,
Expand Down
Loading
Loading