Compare commits

3 Commits

Author SHA1 Message Date
chick af297ab4de fix: remove antd deprecated prop warnings 2026-06-01 23:59:16 +08:00
chick 92c1ec696e chore: add local source install script 2026-06-01 23:43:53 +08:00
chick f66540a273 feat: add sub2api compatibility 2026-06-01 23:43:53 +08:00
11 changed files with 477 additions and 21 deletions
+92
View File
@@ -9,8 +9,10 @@ import (
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"strings"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/service"
)
@@ -84,6 +86,12 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
Fail(w, "AI 接口请求失败")
return
}
body, contentType, err = adaptAIRequestForChannel(body, contentType, path, channel)
if err != nil {
log.Printf("AI proxy adapt request failed: model=%s path=%s err=%v", modelName, path, err)
Fail(w, "AI 接口请求失败")
return
}
path = resolveAIProxyPath(channel.BaseURL, modelName, path)
request, err := http.NewRequest(http.MethodPost, service.BuildModelChannelURL(channel, path), bytes.NewReader(body))
if err != nil {
@@ -140,6 +148,90 @@ func copyAIResponse(w http.ResponseWriter, request *http.Request, onFailure func
_, _ = io.Copy(w, response.Body)
}
func adaptAIRequestForChannel(body []byte, contentType string, path string, channel model.ModelChannel) ([]byte, string, error) {
if path != "/images/generations" && path != "/images/edits" {
return body, contentType, nil
}
format := strings.ToLower(strings.TrimSpace(channel.RequestOptions.ImageResponseFormat))
if format == "" {
if strings.EqualFold(strings.TrimSpace(channel.Compatibility), "sub2api") {
format = "url"
} else {
format = "b64_json"
}
}
if format != "url" && format != "b64_json" {
return body, contentType, nil
}
if strings.HasPrefix(contentType, "multipart/form-data") {
return setMultipartField(body, contentType, "response_format", format)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, "", err
}
payload["response_format"] = format
nextBody, err := json.Marshal(payload)
if err != nil {
return nil, "", err
}
return nextBody, contentType, nil
}
func setMultipartField(body []byte, contentType string, fieldName string, value string) ([]byte, string, error) {
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, "", err
}
reader := multipart.NewReader(bytes.NewReader(body), params["boundary"])
var buffer bytes.Buffer
writer := multipart.NewWriter(&buffer)
written := false
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, "", err
}
if part.FormName() == fieldName && part.FileName() == "" {
if err := writer.WriteField(fieldName, value); err != nil {
_ = part.Close()
return nil, "", err
}
written = true
_ = part.Close()
continue
}
header := make(textproto.MIMEHeader, len(part.Header))
for key, values := range part.Header {
copied := make([]string, len(values))
copy(copied, values)
header[key] = copied
}
target, err := writer.CreatePart(header)
if err != nil {
_ = part.Close()
return nil, "", err
}
if _, err := io.Copy(target, part); err != nil {
_ = part.Close()
return nil, "", err
}
_ = part.Close()
}
if !written {
if err := writer.WriteField(fieldName, value); err != nil {
return nil, "", err
}
}
if err := writer.Close(); err != nil {
return nil, "", err
}
return buffer.Bytes(), writer.FormDataContentType(), nil
}
func readAIRequest(r *http.Request) ([]byte, string, string, error) {
contentType := r.Header.Get("Content-Type")
body, err := io.ReadAll(r.Body)
+53
View File
@@ -1,8 +1,13 @@
package handler
import (
"bytes"
"encoding/json"
"mime/multipart"
"strings"
"testing"
"github.com/basketikun/infinite-canvas/model"
)
func TestAIUpstreamErrorDetail(t *testing.T) {
@@ -25,3 +30,51 @@ func TestSafeUpstreamTextTruncates(t *testing.T) {
t.Fatalf("truncated rune length = %d", len([]rune(got)))
}
}
func TestAdaptAIRequestForSub2APIImagesUsesURLResponseFormat(t *testing.T) {
body, contentType, err := adaptAIRequestForChannel([]byte(`{"model":"gpt-image-1","prompt":"cat"}`), "application/json", "/images/generations", model.ModelChannel{Compatibility: "sub2api"})
if err != nil {
t.Fatalf("adaptAIRequestForChannel returned error: %v", err)
}
if contentType != "application/json" {
t.Fatalf("contentType = %q", contentType)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("invalid json body: %v", err)
}
if got := payload["response_format"]; got != "url" {
t.Fatalf("response_format = %#v, want url", got)
}
}
func TestAdaptAIRequestForSub2APIImageEditsUpdatesMultipartResponseFormat(t *testing.T) {
var input bytes.Buffer
writer := multipart.NewWriter(&input)
_ = writer.WriteField("model", "gpt-image-1")
_ = writer.WriteField("response_format", "b64_json")
part, err := writer.CreateFormFile("image", "input.png")
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
_, _ = part.Write([]byte("png"))
if err := writer.Close(); err != nil {
t.Fatalf("Close multipart writer: %v", err)
}
body, contentType, err := adaptAIRequestForChannel(input.Bytes(), writer.FormDataContentType(), "/images/edits", model.ModelChannel{Compatibility: "sub2api"})
if err != nil {
t.Fatalf("adaptAIRequestForChannel returned error: %v", err)
}
reader := multipart.NewReader(bytes.NewReader(body), strings.TrimPrefix(contentType, "multipart/form-data; boundary="))
form, err := reader.ReadForm(1024)
if err != nil {
t.Fatalf("ReadForm: %v", err)
}
if got := form.Value["response_format"]; len(got) != 1 || got[0] != "url" {
t.Fatalf("response_format = %#v, want [url]", got)
}
if files := form.File["image"]; len(files) != 1 || files[0].Filename != "input.png" {
t.Fatalf("image file = %#v", files)
}
}
+17 -8
View File
@@ -11,14 +11,23 @@ const (
// ModelChannel 模型渠道配置。
type ModelChannel struct {
Protocol string `json:"protocol"`
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Models []string `json:"models"`
Weight int `json:"weight"`
Enabled bool `json:"enabled"`
Remark string `json:"remark"`
Protocol string `json:"protocol"`
Compatibility string `json:"compatibility"`
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Models []string `json:"models"`
Weight int `json:"weight"`
Enabled bool `json:"enabled"`
Remark string `json:"remark"`
RequestOptions ModelRequestOptions `json:"requestOptions"`
}
// ModelRequestOptions 控制 OpenAI 兼容接口的可选请求参数。
type ModelRequestOptions struct {
// ImageResponseFormat 为图片生成/编辑请求设置 response_format。
// OpenAI 默认使用 b64_jsonSub2API 推荐使用 url,避免部分上游不返回 b64_json。
ImageResponseFormat string `json:"imageResponseFormat"`
}
// ModelCost 模型算力点配置。
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP_NAME="infinite-canvas"
REPO_URL="${REPO_URL:-https://gitea.chickliu.fun/Hermes/infinite-canvas.git}"
APP_DIR="${APP_DIR:-$HOME/.Hermes/workspace/git/infinite-canvas}"
HOST="${HOST:-0.0.0.0}"
WEB_PORT="${WEB_PORT:-3000}"
API_PORT="${API_PORT:-8080}"
DATA_DIR="${DATA_DIR:-$APP_DIR/data}"
DB_PATH="${DATABASE_DSN:-$DATA_DIR/infinite-canvas.db}"
PROMPT_DATA_DIR="${PROMPT_DATA_DIR:-$DATA_DIR/prompts}"
LOG_DIR="${LOG_DIR:-$APP_DIR/logs}"
PID_DIR="${PID_DIR:-$APP_DIR/.pids}"
MODE="${1:-start}"
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || fail "缺少命令:$1"; }
ensure_repo() {
if [ -d "$APP_DIR/.git" ]; then
info "使用现有代码目录:$APP_DIR"
return 0
fi
info "克隆代码到:$APP_DIR"
mkdir -p "$(dirname "$APP_DIR")"
git clone "$REPO_URL" "$APP_DIR"
}
ensure_deps() {
need_cmd git
need_cmd go
need_cmd npm
mkdir -p "$DATA_DIR" "$PROMPT_DATA_DIR" "$LOG_DIR" "$PID_DIR"
if [ ! -d "$APP_DIR/web/node_modules" ]; then
info "安装前端依赖"
(cd "$APP_DIR/web" && npm install --legacy-peer-deps)
fi
}
kill_pid_file() {
local file="$1"
if [ -f "$file" ]; then
local pid
pid="$(cat "$file" 2>/dev/null || true)"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
for _ in $(seq 1 20); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.2
done
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$file"
fi
}
stop_app() {
info "停止 $APP_NAME 源码服务"
kill_pid_file "$PID_DIR/backend.pid"
kill_pid_file "$PID_DIR/frontend.pid"
}
start_app() {
ensure_repo
ensure_deps
stop_app
info "启动后端:http://127.0.0.1:$API_PORT"
(
cd "$APP_DIR"
PORT="$API_PORT" \
DATABASE_DSN="$DB_PATH" \
PROMPT_DATA_DIR="$PROMPT_DATA_DIR" \
nohup go run . >>"$LOG_DIR/backend.log" 2>&1 &
echo $! >"$PID_DIR/backend.pid"
)
info "启动前端:http://127.0.0.1:$WEB_PORT"
(
cd "$APP_DIR/web"
API_BASE_URL="http://127.0.0.1:$API_PORT" \
nohup npm run dev -- -H "$HOST" -p "$WEB_PORT" >>"$LOG_DIR/frontend.log" 2>&1 &
echo $! >"$PID_DIR/frontend.pid"
)
info "等待服务就绪"
for _ in $(seq 1 120); do
if curl -fsS "http://127.0.0.1:$API_PORT/api/health" >/dev/null 2>&1 && \
curl -fsS "http://127.0.0.1:$WEB_PORT/api/health" >/dev/null 2>&1; then
info "启动完成"
printf '前端: http://127.0.0.1:%s\n' "$WEB_PORT"
printf '后端: http://127.0.0.1:%s\n' "$API_PORT"
printf '账号: admin\n'
printf '默认密码: infinite-canvas(如已改过,以数据库中现有用户为准)\n'
return 0
fi
sleep 1
done
warn "服务未在预期时间内完成健康检查,请查看日志:"
printf ' tail -f %q %q\n' "$LOG_DIR/backend.log" "$LOG_DIR/frontend.log"
return 1
}
status_app() {
for name in backend frontend; do
local file="$PID_DIR/$name.pid"
if [ -f "$file" ] && kill -0 "$(cat "$file")" 2>/dev/null; then
printf '%s: running pid=%s\n' "$name" "$(cat "$file")"
else
printf '%s: stopped\n' "$name"
fi
done
curl -fsS "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null && printf ' backend_http=ok\n' || true
curl -fsS "http://127.0.0.1:$WEB_PORT/api/health" 2>/dev/null && printf ' frontend_proxy=ok\n' || true
}
case "$MODE" in
start|install) start_app ;;
stop) stop_app ;;
restart) stop_app; start_app ;;
status) status_app ;;
*)
cat <<USAGE
Usage: $0 [start|install|stop|restart|status]
Environment overrides:
APP_DIR=$APP_DIR
REPO_URL=$REPO_URL
WEB_PORT=$WEB_PORT
API_PORT=$API_PORT
HOST=$HOST
USAGE
exit 2
;;
esac
+108 -1
View File
@@ -129,6 +129,8 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting
if setting.Channels[i].Protocol == "" {
setting.Channels[i].Protocol = "openai"
}
setting.Channels[i].Compatibility = normalizeChannelCompatibility(setting.Channels[i].Compatibility)
setting.Channels[i].RequestOptions = normalizeModelRequestOptions(setting.Channels[i].Compatibility, setting.Channels[i].RequestOptions)
if setting.Channels[i].Models == nil {
setting.Channels[i].Models = []string{}
}
@@ -202,12 +204,54 @@ func SelectModelChannel(modelName string) (model.ModelChannel, error) {
func BuildModelChannelURL(channel model.ModelChannel, path string) string {
baseURL := normalizeModelChannelBaseURL(channel.BaseURL)
lowerBaseURL := strings.ToLower(baseURL)
if !strings.HasSuffix(lowerBaseURL, "/v1") && !strings.HasSuffix(lowerBaseURL, "/api/v3") && !strings.HasSuffix(lowerBaseURL, "/api/plan/v3") {
if !hasModelChannelVersionSuffix(lowerBaseURL) && !strings.HasSuffix(lowerBaseURL, "/api/v3") && !strings.HasSuffix(lowerBaseURL, "/api/plan/v3") {
baseURL += "/v1"
}
return baseURL + path
}
func hasModelChannelVersionSuffix(baseURL string) bool {
pathValue := baseURL
if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" {
pathValue = parsed.Path
}
pathValue = strings.TrimRight(pathValue, "/")
if pathValue == "" {
return false
}
segment := pathValue
if slash := strings.LastIndex(pathValue, "/"); slash >= 0 {
segment = pathValue[slash+1:]
}
segment = strings.ToLower(strings.TrimSpace(segment))
if len(segment) < 2 || segment[0] != 'v' || !isASCIIDigit(segment[1]) {
return false
}
i := 1
for i < len(segment) && isASCIIDigit(segment[i]) {
i++
}
if i == len(segment) {
return true
}
if segment[i] == '.' {
i++
if i == len(segment) || !isASCIIDigit(segment[i]) {
return false
}
for i < len(segment) && isASCIIDigit(segment[i]) {
i++
}
return i == len(segment)
}
suffix := segment[i:]
return strings.HasPrefix(suffix, "alpha") || strings.HasPrefix(suffix, "beta") || strings.HasPrefix(suffix, "preview")
}
func isASCIIDigit(ch byte) bool {
return ch >= '0' && ch <= '9'
}
func normalizeModelChannelBaseURL(baseURL string) string {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
parsed, err := url.Parse(baseURL)
@@ -299,6 +343,8 @@ func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel {
if channel.Protocol == "" {
channel.Protocol = "openai"
}
channel.Compatibility = normalizeChannelCompatibility(channel.Compatibility)
channel.RequestOptions = normalizeModelRequestOptions(channel.Compatibility, channel.RequestOptions)
if channel.Models == nil {
channel.Models = []string{}
}
@@ -308,6 +354,30 @@ func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel {
return channel
}
func normalizeChannelCompatibility(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "sub2api":
return "sub2api"
default:
return "openai"
}
}
func normalizeModelRequestOptions(compatibility string, options model.ModelRequestOptions) model.ModelRequestOptions {
format := strings.ToLower(strings.TrimSpace(options.ImageResponseFormat))
switch format {
case "url", "b64_json":
options.ImageResponseFormat = format
default:
if normalizeChannelCompatibility(compatibility) == "sub2api" {
options.ImageResponseFormat = "url"
} else {
options.ImageResponseFormat = "b64_json"
}
}
return options
}
func resolveAdminChannel(index *int, channel model.ModelChannel) (model.ModelChannel, error) {
resolved := normalizeModelChannel(channel)
if strings.TrimSpace(resolved.APIKey) == "" {
@@ -380,6 +450,9 @@ func testAdminChannelModel(channel model.ModelChannel, modelName string) (string
if strings.TrimSpace(modelName) == "" {
return "", errors.New("缺少模型名称")
}
if isImageModelName(modelName) {
return testAdminImageChannelModel(channel, modelName)
}
body, _ := json.Marshal(map[string]any{
"model": modelName,
"messages": []map[string]string{{
@@ -416,6 +489,40 @@ func testAdminChannelModel(channel model.ModelChannel, modelName string) (string
return "ok", nil
}
func testAdminImageChannelModel(channel model.ModelChannel, modelName string) (string, error) {
format := strings.ToLower(strings.TrimSpace(channel.RequestOptions.ImageResponseFormat))
if format == "" {
if strings.EqualFold(strings.TrimSpace(channel.Compatibility), "sub2api") {
format = "url"
} else {
format = "b64_json"
}
}
body, _ := json.Marshal(map[string]any{
"model": modelName,
"prompt": "test",
"n": 1,
"size": "1024x1024",
"response_format": format,
})
request, err := http.NewRequest(http.MethodPost, BuildModelChannelURL(channel, "/images/generations"), strings.NewReader(string(body)))
if err != nil {
return "", err
}
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
request.Header.Set("Content-Type", "application/json")
response, err := adminModelHTTPClient.Do(request)
if err != nil {
return "", safeMessageError{message: "测试失败:上游接口无响应或网络不可达"}
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
if response.StatusCode >= http.StatusBadRequest {
return "", readAdminChannelError(responseBody, response.StatusCode, "测试失败")
}
return "ok", nil
}
func testArkSeedanceChannelModel(channel model.ModelChannel, modelName string) (string, error) {
if strings.TrimSpace(modelName) == "" {
return "", errors.New("缺少模型名称")
+51
View File
@@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
@@ -61,6 +62,56 @@ func TestBuildModelChannelURLNormalizesArkPlanTaskPath(t *testing.T) {
}
}
func TestBuildModelChannelURLAcceptsVersionedSub2APIBaseURL(t *testing.T) {
got := BuildModelChannelURL(model.ModelChannel{BaseURL: "https://sub2api.example.com/v1"}, "/images/generations")
want := "https://sub2api.example.com/v1/images/generations"
if got != want {
t.Fatalf("BuildModelChannelURL = %q, want %q", got, want)
}
}
func TestNormalizePrivateSettingDefaultsSub2APIImageResponseFormatToURL(t *testing.T) {
setting := normalizePrivateSetting(model.PrivateSetting{Channels: []model.ModelChannel{{Compatibility: "sub2api"}}})
if got := setting.Channels[0].RequestOptions.ImageResponseFormat; got != "url" {
t.Fatalf("image response format = %q, want url", got)
}
}
func TestAdminTestChannelModelUsesImageGenerationProbeForImageModels(t *testing.T) {
var gotPath string
var gotPayload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
t.Fatalf("Decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/image.png"}]}`))
}))
defer server.Close()
result, err := testAdminChannelModel(model.ModelChannel{
Compatibility: "sub2api",
BaseURL: server.URL + "/v1",
APIKey: "test-key",
}, "gpt-image-2")
if err != nil {
t.Fatalf("testAdminChannelModel returned error: %v", err)
}
if result != "ok" {
t.Fatalf("result = %q, want ok", result)
}
if gotPath != "/v1/images/generations" {
t.Fatalf("path = %q, want /v1/images/generations", gotPath)
}
if gotPayload["model"] != "gpt-image-2" {
t.Fatalf("model = %#v", gotPayload["model"])
}
if gotPayload["response_format"] != "url" {
t.Fatalf("response_format = %#v, want url", gotPayload["response_format"])
}
}
func TestNormalizeSettingsPublishesEnabledChannelModelsAndRepairsDefaults(t *testing.T) {
settings := normalizeSettings(model.Settings{
Public: model.PublicSetting{
@@ -92,7 +92,7 @@ export default function AdminCreditLogsPage() {
return (
<main style={{ padding: 24 }}>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Space orientation="vertical" size={16} style={{ width: "100%" }}>
<Card variant="borderless">
<Form layout="vertical">
<Row gutter={16} align="bottom">
+11 -9
View File
@@ -467,15 +467,17 @@ export default function AdminSettingsPage() {
dataIndex: "credits",
width: 220,
render: (_, item) => (
<InputNumber
min={0}
step={1}
precision={0}
className="!w-full"
value={item.credits}
addonAfter="点"
onChange={(value) => setModelCost(form, setModelCosts, item.model, Number(value) || 0)}
/>
<Space.Compact className="!w-full">
<InputNumber
min={0}
step={1}
precision={0}
className="!w-full"
value={item.credits}
onChange={(value) => setModelCost(form, setModelCosts, item.model, Number(value) || 0)}
/>
<Button disabled></Button>
</Space.Compact>
),
},
]}
+1 -1
View File
@@ -464,7 +464,7 @@ export default function ImagePage() {
onPreviewLog={(log) => void previewGenerationLog(log)}
/>
</Drawer>
<Drawer title="参数" placement="bottom" height="82vh" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
<Drawer title="参数" placement="bottom" size="large" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
<div className="grid grid-cols-2 gap-3 pb-4">
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
</div>
+1 -1
View File
@@ -463,7 +463,7 @@ export default function VideoPage() {
<Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}>
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={previewGenerationLog} />
</Drawer>
<Drawer title="参数" placement="bottom" height="82vh" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
<Drawer title="参数" placement="bottom" size="large" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
<div className="grid grid-cols-2 gap-3 pb-4">
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
</div>
+4
View File
@@ -158,6 +158,7 @@ export async function deleteAdminAsset(token: string, id: string) {
export type AdminModelChannel = {
protocol: "openai";
compatibility: "openai" | "sub2api";
name: string;
baseUrl: string;
apiKey: string;
@@ -165,6 +166,9 @@ export type AdminModelChannel = {
weight: number;
enabled: boolean;
remark: string;
requestOptions: {
imageResponseFormat: "b64_json" | "url";
};
};
export type AdminPublicModelChannelSettings = {