feat(merge): 以官方 main 为基底融合 PR#530 多维表格与本地定制
- 多维表格(canvas-batch-table-node/use-canvas-batch-table/canvas-batch-table 等)以 PR#530 实现为主 - 保留官方 main 全部新功能:banner/画布版本历史/提示词编辑/方舟 Agent Plan/支付导出 - 移植本地定制:ddcat 多参考图校验、万有引力 lxmone 连接错误提示、Description v20(v24)、自建插件(a6api/metaso-h3/antigravity-proxy/lxmone)、seccomp、Windows 脚本 - 迁移版本升至 v24(官方 v20-23 banner/版本历史 + 我们的 Description 列) - 修复官方缺失的 markdownPlainText import
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -68,5 +68,3 @@ camera_language_guide.md
|
||||
|
||||
# Local backend executable; never commit build output.
|
||||
backend/cmd/server/server
|
||||
# One-off local probe scripts and their compiled output.
|
||||
backend/tmp_*
|
||||
|
||||
@@ -11,6 +11,15 @@
|
||||
- 工作台侧栏增加账号容量进度与签到加油站;创作页加宽对话区、工具条进顶栏、默认图片生成,并修正参数弹窗表面色。
|
||||
- 画布新增发送到 Agent 快捷入口,修复重生成媒体绑定死锁、素材删除误拦已结束任务,以及图片档位并集与视频提示词上限。
|
||||
|
||||
## v1.4.0
|
||||
|
||||
- 发布正式版本 `v1.4.0`,提供 `ghcr.io/ddcat-ai/open-ai-canvas-web:1.4.0` 与 `ghcr.io/ddcat-ai/open-ai-canvas-backend:1.4.0` 的 Linux amd64/arm64 镜像,并更新 `latest` 镜像。
|
||||
- 管理后台改为独立设计系统,与用户端皮肤隔离。
|
||||
- 完善云端 Agent:计划插话与记忆、统一文本模型选择、媒体失败后对话可继续、漏传快照由服务端补齐;生成审批支持改模型参数并取消 8 轮硬限制。
|
||||
- 新增虎皮椒聚合支付官方插件与跨平台 provider;积分中心改为全局弹窗,去掉独立钱包页。
|
||||
- 工作台侧栏增加账号容量进度与签到加油站;创作页加宽对话区、工具条进顶栏、默认图片生成,并修正参数弹窗表面色。
|
||||
- 画布新增发送到 Agent 快捷入口,修复重生成媒体绑定死锁、素材删除误拦已结束任务,以及图片档位并集与视频提示词上限。
|
||||
|
||||
## v1.3.0
|
||||
|
||||
- 发布正式版本 `v1.3.0`,提供 `ghcr.io/ddcat-ai/open-ai-canvas-web:1.3.0` 与 `ghcr.io/ddcat-ai/open-ai-canvas-backend:1.3.0` 的 Linux amd64/arm64 镜像,并更新 `latest` 镜像。
|
||||
|
||||
@@ -412,11 +412,5 @@ Issue 反馈、技术讨论和产品升级建议可以在微信交流群中沟
|
||||
<em>最难不过坚持</em>
|
||||
<br clear="left">
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="assets/user-chunqiu.jpg" alt="春秋" width="56" align="left">
|
||||
<strong>春秋<br><sub>Agent记忆核心贡献者 · VV:A13068788886</sub></strong><br>
|
||||
<a href="mailto:558328@qq.com">558328@qq.com</a><br>
|
||||
<br clear="left">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -22,6 +22,7 @@ type ChannelModelRequest struct {
|
||||
ProviderModelKey string `json:"providerModelKey"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
Capability string `json:"capability"`
|
||||
Protocol string `json:"protocol"`
|
||||
BillingMode string `json:"billingMode"`
|
||||
@@ -351,6 +352,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
|
||||
item.DisplayName = modelKey
|
||||
}
|
||||
item.Icon = strings.TrimSpace(req.Icon)
|
||||
item.Description = truncateRunes(strings.TrimSpace(req.Description), 600)
|
||||
item.Capability = capability
|
||||
item.Protocol = protocol
|
||||
s.applyChannelModelPriceTierSummary(item, tiers)
|
||||
|
||||
60
backend/internal/app/ddcat_live_test.go
Normal file
60
backend/internal/app/ddcat_live_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Opt-in only: this test makes two paid requests and never logs credentials or payloads.
|
||||
func TestDDCatLiveImageRoundTrip(t *testing.T) {
|
||||
key := os.Getenv("DDCAT_SMOKE_API_KEY")
|
||||
if key == "" {
|
||||
t.Skip("set DDCAT_SMOKE_API_KEY to explicitly enable paid live verification")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
|
||||
defer cancel()
|
||||
ctx = withProtocolRegistry(ctx, loadOfficialFallbackRegistry())
|
||||
input := canvasGenerationInput{Mode: "image", Prompt: "A single blue ceramic cup on a plain white background, no text.", Config: providerConfig{BaseURL: "https://api.ddcat.pronhubcn.com/v1", APIKey: key, Model: "gpt-image-2.5", InterfaceType: "openai-image", Size: "1:1"}}
|
||||
for _, mode := range []string{"generate", "edit"} {
|
||||
result, err := runImageTask(ctx, input)
|
||||
if err != nil {
|
||||
t.Fatalf("%s failed: %v", mode, err)
|
||||
}
|
||||
images, ok := result["images"].([]interface{})
|
||||
if !ok || len(images) == 0 {
|
||||
t.Fatalf("%s returned no images", mode)
|
||||
}
|
||||
item, ok := images[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("invalid image result")
|
||||
}
|
||||
dataURL := stringField(item, "dataUrl")
|
||||
_, encoded, ok := strings.Cut(dataURL, ",")
|
||||
if !ok {
|
||||
t.Fatal("missing inline image")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatal("invalid image base64")
|
||||
}
|
||||
info, format, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("invalid image bytes: %v", err)
|
||||
}
|
||||
t.Logf("%s: %s %dx%d, %d bytes", mode, format, info.Width, info.Height, len(raw))
|
||||
if dir := os.Getenv("DDCAT_SMOKE_OUTPUT"); dir != "" {
|
||||
if err := os.WriteFile(dir+"/"+mode+".png", raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
input.ReferenceImages = []providerMedia{{Name: "reference.png", Type: "image/png", DataURL: dataURL}}
|
||||
input.Prompt = "Change the blue cup to red. Keep the white background and composition."
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ type PublicChannelModel struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Capability string `json:"capability"`
|
||||
Protocol model.ChannelInterfaceType `json:"protocol"`
|
||||
CapabilityConfig map[string]any `json:"capabilityConfig,omitempty"`
|
||||
@@ -204,6 +205,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod
|
||||
DisplayName: cm.DisplayName,
|
||||
SortOrder: cm.SortOrder,
|
||||
Icon: cm.Icon,
|
||||
Description: cm.Description,
|
||||
Capability: cm.Capability,
|
||||
Protocol: cm.Protocol,
|
||||
CapabilityConfig: capabilityConfig,
|
||||
|
||||
@@ -271,6 +271,7 @@ func doBinaryWithConsumer(req *http.Request, onChunk func(string, []byte)) ([]by
|
||||
client := OutboundHTTPClient(requestTimeout)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
err = providerConnectionError(err)
|
||||
if runtimeService != nil {
|
||||
_ = runtimeService.RecordChannelResult(req.Context(), channelID, !errors.Is(err, context.Canceled))
|
||||
}
|
||||
@@ -304,6 +305,7 @@ func doBinaryWithConsumer(req *http.Request, onChunk func(string, []byte)) ([]by
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
readErr = providerConnectionError(readErr)
|
||||
recordProviderRequest(req, startedAt, resp.StatusCode, buffered.Bytes(), readErr)
|
||||
return nil, "", readErr
|
||||
}
|
||||
@@ -329,6 +331,13 @@ func doBinaryWithConsumer(req *http.Request, onChunk func(string, []byte)) ([]by
|
||||
return data, mimeType, nil
|
||||
}
|
||||
|
||||
func providerConnectionError(err error) error {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return fmt.Errorf("模型服务连接提前关闭,未收到完整结果;请先核对中转站任务和扣费记录,再决定是否重试:%w", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func parseRetryAfter(value string, now time.Time) time.Duration {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
|
||||
@@ -19,6 +19,11 @@ import (
|
||||
)
|
||||
|
||||
func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
|
||||
if input.Config.InterfaceType == string(model.ChannelInterfaceOpenAIImage) {
|
||||
if err := validateOpenAIImageInput(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, ok := declarativeProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok {
|
||||
return runDeclarativeProtocolTask(ctx, input)
|
||||
}
|
||||
@@ -113,6 +118,30 @@ func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string]
|
||||
return map[string]interface{}{"mode": "image", "images": images}, nil
|
||||
}
|
||||
|
||||
// Validate before selecting the plugin so declarative and handwritten paths share
|
||||
// the same write boundary. Invalid explicit sizes must not silently become auto.
|
||||
func validateOpenAIImageInput(input canvasGenerationInput) error {
|
||||
if input.Mask != nil && len(input.ReferenceImages) == 0 {
|
||||
return errors.New("蒙版编辑必须提供与蒙版同尺寸的源图片")
|
||||
}
|
||||
raw := strings.TrimSpace(input.Config.Size)
|
||||
if raw == "" || raw == "auto" {
|
||||
return nil
|
||||
}
|
||||
size := normalizePixelSize(raw)
|
||||
widthText, heightText, ok := strings.Cut(size, "x")
|
||||
width, widthErr := strconv.Atoi(widthText)
|
||||
height, heightErr := strconv.Atoi(heightText)
|
||||
if !ok || widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
|
||||
return errors.New("图片尺寸无效:请选择支持的宽高比或填写正整数像素尺寸,如 1024x1024")
|
||||
}
|
||||
base, err := url.Parse(input.Config.BaseURL)
|
||||
if err == nil && strings.EqualFold(base.Hostname(), "api.ddcat.pronhubcn.com") && (width > 4096 || height > 4096) {
|
||||
return errors.New("ddcat 图片尺寸超过上游限制:宽和高均不得超过 4096 像素")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGeminiImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
|
||||
if input.Mask != nil {
|
||||
return nil, errors.New("Gemini Images 不支持蒙版编辑,请移除蒙版后重试")
|
||||
|
||||
65
backend/internal/app/provider_image_boundary_test.go
Normal file
65
backend/internal/app/provider_image_boundary_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAIImageRejectsInvalidSizeBeforeSubmission(t *testing.T) {
|
||||
for _, size := range []string{"banana", "7:0", "0x1024", "-1x1024", "4097x1024", "1024x5000"} {
|
||||
t.Run(size, func(t *testing.T) {
|
||||
_, err := runImageTask(context.Background(), canvasGenerationInput{Mode: "image", Config: providerConfig{InterfaceType: "openai-image", BaseURL: "https://api.ddcat.pronhubcn.com/v1", Size: size}})
|
||||
if err == nil || !strings.Contains(err.Error(), "尺寸") {
|
||||
t.Fatalf("expected local size rejection: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, size := range []string{"", "auto", "1:1", "16:9", "1024x1024", "4096x4096"} {
|
||||
if err := validateOpenAIImageInput(canvasGenerationInput{Config: providerConfig{Size: size}}); err != nil {
|
||||
t.Fatalf("valid size %q: %v", size, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIImageRejectsMaskWithoutSourceBeforePlugin(t *testing.T) {
|
||||
_, err := runImageTask(context.Background(), canvasGenerationInput{Mode: "image", Config: providerConfig{InterfaceType: "openai-image"}, Mask: &providerMedia{ID: "mask"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "源图片") {
|
||||
t.Fatalf("expected missing source rejection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderConnectionErrorPreservesCause(t *testing.T) {
|
||||
for _, cause := range []error{io.EOF, io.ErrUnexpectedEOF} {
|
||||
err := providerConnectionError(cause)
|
||||
if !errors.Is(err, cause) || !strings.Contains(err.Error(), "扣费记录") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if providerConnectionError(context.Canceled) != context.Canceled {
|
||||
t.Fatal("cancellation must remain unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageSubmissionEOFIsNotAutomaticallyReplayed(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}))
|
||||
defer server.Close()
|
||||
err := postJSON(context.Background(), providerConfig{BaseURL: server.URL}, "/v1/images/generations", map[string]string{"prompt": "cup"}, &map[string]interface{}{})
|
||||
if err == nil || !strings.Contains(err.Error(), "连接提前关闭") || calls != 1 {
|
||||
t.Fatalf("calls=%d error=%v", calls, err)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/protocol"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -186,6 +187,10 @@ func protocolRequestFromInput(input canvasGenerationInput) protocol.GenerationRe
|
||||
resolution = declared
|
||||
}
|
||||
}
|
||||
aspectRatio := input.Config.Size
|
||||
if input.Mode == "image" && strings.TrimSpace(input.Config.InterfaceType) == string(model.ChannelInterfaceOpenAIImage) {
|
||||
aspectRatio = normalizePixelSize(aspectRatio)
|
||||
}
|
||||
request := protocol.GenerationRequest{
|
||||
Capability: protocol.Capability(input.Mode),
|
||||
Model: input.Config.Model,
|
||||
@@ -194,7 +199,7 @@ func protocolRequestFromInput(input canvasGenerationInput) protocol.GenerationRe
|
||||
Images: protocolImageReferences(input),
|
||||
Videos: protocolMediaReferences(input.ReferenceVideos, "video"),
|
||||
Audios: protocolMediaReferences(input.ReferenceAudios, "audio"),
|
||||
AspectRatio: input.Config.Size,
|
||||
AspectRatio: aspectRatio,
|
||||
Resolution: resolution,
|
||||
Quality: input.Config.Quality,
|
||||
GenerateAudio: parseBool(input.Config.VideoGenerateAudio, false),
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const CurrentSchemaVersion int64 = 23
|
||||
const CurrentSchemaVersion int64 = 24
|
||||
|
||||
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
|
||||
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
|
||||
@@ -89,6 +89,16 @@ var schemaMigrations = []migration{
|
||||
{version: 23, name: "canvas_revision_history", checksum: "sha256:canvas-revision-history-v23-20260918", apply: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.CanvasProject{}, &model.CanvasSnapshot{}, &model.CanvasSnapshotResource{})
|
||||
}},
|
||||
{version: 24, name: "channel_model_description", checksum: "sha256:channel-model-description-v24-20260918", apply: migrateSchemaV24},
|
||||
}
|
||||
|
||||
// migrateSchemaV24 为系统渠道模型补充面向用户的说明字段。
|
||||
// 说明只用于前台选型展示,存量模型保持空说明,不影响价格、路由与结算。
|
||||
func migrateSchemaV24(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasColumn(&model.ChannelModel{}, "Description") {
|
||||
return tx.Migrator().AddColumn(&model.ChannelModel{}, "Description")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateSchemaV14(tx *gorm.DB) error {
|
||||
|
||||
@@ -44,6 +44,7 @@ type ChannelModel struct {
|
||||
DisplayName string `json:"displayName" gorm:"size:160"`
|
||||
SortOrder int `json:"sortOrder" gorm:"not null;default:0"`
|
||||
Icon string `json:"icon" gorm:"size:80"`
|
||||
Description string `json:"description" gorm:"size:600"`
|
||||
Capability string `json:"capability" gorm:"size:32;index"`
|
||||
Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"`
|
||||
BillingMode string `json:"billingMode" gorm:"size:32"`
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
security_opt:
|
||||
- seccomp:unconfined
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-open_ai_canvas}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-open_ai_canvas}
|
||||
@@ -27,7 +29,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
migrate:
|
||||
image: ghcr.io/ddcat-ai/open-ai-canvas-backend:${CANVAS_IMAGE_TAG:-latest}
|
||||
image: ghcr.io/15657306288/open-ai-canvas-backend:${CANVAS_IMAGE_TAG:-latest}
|
||||
command: ["migrate-schema", "up"]
|
||||
environment:
|
||||
CANVAS_BACKEND_DATA_DIR: /data
|
||||
@@ -38,7 +40,7 @@ services:
|
||||
condition: service_healthy
|
||||
|
||||
backend:
|
||||
image: ghcr.io/ddcat-ai/open-ai-canvas-backend:${CANVAS_IMAGE_TAG:-latest}
|
||||
image: ghcr.io/15657306288/open-ai-canvas-backend:${CANVAS_IMAGE_TAG:-latest}
|
||||
environment:
|
||||
CANVAS_BACKEND_ADDR: ":8080"
|
||||
CANVAS_BACKEND_DATA_DIR: /data
|
||||
@@ -70,7 +72,7 @@ services:
|
||||
stop_grace_period: ${CANVAS_SHUTDOWN_TIMEOUT:-10m}
|
||||
|
||||
web:
|
||||
image: ghcr.io/ddcat-ai/open-ai-canvas-web:${CANVAS_IMAGE_TAG:-latest}
|
||||
image: ghcr.io/15657306288/open-ai-canvas-web:${CANVAS_IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- "${CANVAS_HTTP_PORT:-3000}:3000"
|
||||
depends_on:
|
||||
|
||||
BIN
plugin-packages/a6api.yingce-plugin
Normal file
BIN
plugin-packages/a6api.yingce-plugin
Normal file
Binary file not shown.
20
plugin-packages/a6api/README.md
Normal file
20
plugin-packages/a6api/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# A6api
|
||||
|
||||
A6api 模型网关协议插件。统一入口 `https://api.a6api.com`,OpenAI 兼容(`Authorization: Bearer <api_key>`)。
|
||||
|
||||
## Providers
|
||||
|
||||
| Provider | 能力 | 端点 |
|
||||
|---|---|---|
|
||||
| a6api-chat | text | POST /chat/completions |
|
||||
| a6api-image | image | POST /images/generations、POST /images/edits |
|
||||
|
||||
## 配置
|
||||
|
||||
在渠道中填写 API Key(A6api 控制台创建,形如 `sk-xxxxxxxx`),baseUrl 使用 `https://api.a6api.com`。
|
||||
|
||||
## 说明
|
||||
|
||||
- 模型名以 A6api 模型市场当前列表为准。
|
||||
- 图像编辑(带参考图)走 `/images/edits` multipart 请求。
|
||||
- 错误体为 OpenAI 风格,携带 `request_id`。
|
||||
522
plugin-packages/a6api/docs/interface.md
Normal file
522
plugin-packages/a6api/docs/interface.md
Normal file
@@ -0,0 +1,522 @@
|
||||
# A6api 接口说明
|
||||
|
||||
## 鉴权
|
||||
|
||||
`Authorization: Bearer sk-xxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
## 文本对话
|
||||
|
||||
POST `https://api.a6api.com/chat/completions`
|
||||
|
||||
请求体 OpenAI Chat Completions 格式:`model`、`messages`、`temperature`、`max_tokens`、`tools`、`tool_choice`、`response_format`、`stream` 等。
|
||||
|
||||
响应 `choices[0].message.content` 为正文,`choices[0].message.reasoning_content` 为推理内容,`usage` 为用量。
|
||||
|
||||
## 图像生成
|
||||
|
||||
POST `https://api.a6api.com/images/generations`
|
||||
|
||||
请求体:`model`、`prompt`、`n`、`size`、`quality`、`response_format`。
|
||||
|
||||
响应 `data[].url` 或 `data[].b64_json`。
|
||||
|
||||
## 图像编辑
|
||||
|
||||
POST `https://api.a6api.com/images/edits`(multipart,带参考图时使用)
|
||||
|
||||
## 模型列表
|
||||
|
||||
GET `https://api.a6api.com/models`
|
||||
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_START -->
|
||||
## Manifest 完整接口定义
|
||||
|
||||
以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": "yingce.plugin/v2",
|
||||
"id": "a6api",
|
||||
"name": "A6api",
|
||||
"version": "1.0.0",
|
||||
"author": "A6api",
|
||||
"description": "A6api 模型网关协议插件:OpenAI 兼容对话与图像生成。",
|
||||
"permissions": [
|
||||
"generation.run",
|
||||
"media.read"
|
||||
],
|
||||
"configuration": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "apiKey",
|
||||
"type": "secret",
|
||||
"label": "API Key",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "a6api-chat",
|
||||
"label": "A6api Chat",
|
||||
"capabilities": [
|
||||
"text"
|
||||
],
|
||||
"scopes": [
|
||||
"admin.system-channel",
|
||||
"user.custom-channel",
|
||||
"canvas",
|
||||
"creation",
|
||||
"agent"
|
||||
],
|
||||
"baseUrl": "https://api.a6api.com",
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"field": "apiKey"
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"mapping": "model",
|
||||
"description": "A6api 模型市场中的模型 ID。"
|
||||
},
|
||||
{
|
||||
"name": "messages",
|
||||
"type": "message[]",
|
||||
"required": true,
|
||||
"mapping": "provider message container",
|
||||
"description": "包含历史消息和当前用户输入。"
|
||||
},
|
||||
{
|
||||
"name": "instructions",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"mapping": "system/instructions",
|
||||
"description": "系统指令。"
|
||||
},
|
||||
{
|
||||
"name": "temperature",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"mapping": "temperature",
|
||||
"description": "采样温度。"
|
||||
},
|
||||
{
|
||||
"name": "top_p",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"mapping": "top_p",
|
||||
"description": "核采样参数。"
|
||||
},
|
||||
{
|
||||
"name": "max_tokens",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"mapping": "max_tokens/max_output_tokens",
|
||||
"description": "最大输出 token。"
|
||||
},
|
||||
{
|
||||
"name": "tools",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"mapping": "tools/toolConfig",
|
||||
"description": "工具定义。"
|
||||
},
|
||||
{
|
||||
"name": "tool_choice",
|
||||
"type": "object|string",
|
||||
"required": false,
|
||||
"mapping": "tool_choice",
|
||||
"description": "工具选择策略。"
|
||||
},
|
||||
{
|
||||
"name": "response_format",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"mapping": "response_format/text",
|
||||
"description": "结构化输出配置。"
|
||||
},
|
||||
{
|
||||
"name": "stream",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"mapping": "stream",
|
||||
"description": "流式开关;后台任务当前以最终响应归一。"
|
||||
},
|
||||
{
|
||||
"name": "providerOptions",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"mapping": "provider-specific fields",
|
||||
"description": "插件命名空间内的厂商扩展字段。"
|
||||
}
|
||||
],
|
||||
"create": {
|
||||
"method": "POST",
|
||||
"path": "/chat/completions",
|
||||
"contentType": "application/json",
|
||||
"body": {
|
||||
"model": {
|
||||
"$ref": "request.model"
|
||||
},
|
||||
"messages": {
|
||||
"$ref": "request.messages"
|
||||
},
|
||||
"temperature": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.a6api-chat.temperature"
|
||||
}
|
||||
},
|
||||
"top_p": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.a6api-chat.top_p"
|
||||
}
|
||||
},
|
||||
"max_tokens": {
|
||||
"$omitEmpty": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "request.extra.max_tokens"
|
||||
},
|
||||
{
|
||||
"$ref": "request.providerOptions.a6api-chat.max_tokens"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.a6api-chat.tools"
|
||||
}
|
||||
},
|
||||
"tool_choice": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.a6api-chat.tool_choice"
|
||||
}
|
||||
},
|
||||
"response_format": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.a6api-chat.response_format"
|
||||
}
|
||||
},
|
||||
"stream": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.a6api-chat.stream"
|
||||
}
|
||||
},
|
||||
"stop": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.a6api-chat.stop"
|
||||
}
|
||||
},
|
||||
"seed": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.a6api-chat.seed"
|
||||
}
|
||||
},
|
||||
"frequency_penalty": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.a6api-chat.frequency_penalty"
|
||||
}
|
||||
},
|
||||
"presence_penalty": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.a6api-chat.presence_penalty"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.a6api-chat.user"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": "succeeded",
|
||||
"textPaths": [
|
||||
"choices.0.message.content",
|
||||
"choices.0.text"
|
||||
],
|
||||
"reasoningPaths": [
|
||||
"choices.0.message.reasoning_content"
|
||||
],
|
||||
"usage": {
|
||||
"$ref": "response.usage"
|
||||
},
|
||||
"errorPaths": [
|
||||
"error.code"
|
||||
],
|
||||
"messagePaths": [
|
||||
"error.message"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a6api-image",
|
||||
"label": "A6api Image",
|
||||
"capabilities": [
|
||||
"image"
|
||||
],
|
||||
"scopes": [
|
||||
"admin.system-channel",
|
||||
"user.custom-channel",
|
||||
"canvas",
|
||||
"creation",
|
||||
"agent"
|
||||
],
|
||||
"baseUrl": "https://api.a6api.com",
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"field": "apiKey"
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"mapping": "model",
|
||||
"description": "A6api 模型市场中的图像模型 ID。"
|
||||
},
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"mapping": "prompt",
|
||||
"description": "图片提示词。"
|
||||
},
|
||||
{
|
||||
"name": "images",
|
||||
"type": "media[]",
|
||||
"required": false,
|
||||
"mapping": "provider image/reference fields",
|
||||
"description": "参考图或编辑源图,role 由业务层确定。"
|
||||
},
|
||||
{
|
||||
"name": "imageCount",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"mapping": "n/sample_count",
|
||||
"description": "输出数量。"
|
||||
},
|
||||
{
|
||||
"name": "aspectRatio",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"mapping": "size/aspect_ratio",
|
||||
"description": "比例或尺寸,语义按协议说明。"
|
||||
},
|
||||
{
|
||||
"name": "resolution",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"mapping": "resolution/imageSize",
|
||||
"description": "分辨率档位。"
|
||||
},
|
||||
{
|
||||
"name": "quality",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"mapping": "quality",
|
||||
"description": "质量档位。"
|
||||
},
|
||||
{
|
||||
"name": "providerOptions",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"mapping": "provider-specific fields",
|
||||
"description": "插件命名空间内的厂商扩展字段。"
|
||||
}
|
||||
],
|
||||
"create": {
|
||||
"method": "POST",
|
||||
"path": "/images/generations",
|
||||
"pathTemplate": {
|
||||
"$if": {
|
||||
"condition": {
|
||||
"$gt": [
|
||||
{
|
||||
"$len": {
|
||||
"$ref": "request.images"
|
||||
}
|
||||
},
|
||||
0
|
||||
]
|
||||
},
|
||||
"then": "/images/edits",
|
||||
"else": "/images/generations"
|
||||
}
|
||||
},
|
||||
"contentType": "application/json",
|
||||
"contentTypeTemplate": {
|
||||
"$if": {
|
||||
"condition": {
|
||||
"$gt": [
|
||||
{
|
||||
"$len": {
|
||||
"$ref": "request.images"
|
||||
}
|
||||
},
|
||||
0
|
||||
]
|
||||
},
|
||||
"then": "multipart/form-data",
|
||||
"else": "application/json"
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"model": {
|
||||
"$ref": "request.model"
|
||||
},
|
||||
"prompt": {
|
||||
"$ref": "request.prompt"
|
||||
},
|
||||
"n": {
|
||||
"$omitEmpty": {
|
||||
"$if": {
|
||||
"condition": {
|
||||
"$gt": [
|
||||
{
|
||||
"$ref": "request.imageCount"
|
||||
},
|
||||
0
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"$ref": "request.imageCount"
|
||||
},
|
||||
"else": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.aspectRatio"
|
||||
}
|
||||
},
|
||||
"quality": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.quality"
|
||||
}
|
||||
},
|
||||
"output_format": {
|
||||
"$omitEmpty": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "request.providerOptions.a6api-image.output_format"
|
||||
},
|
||||
"png"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response_format": {
|
||||
"$omitEmpty": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "request.providerOptions.a6api-image.response_format"
|
||||
},
|
||||
"b64_json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.a6api-image.user"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"name": "image",
|
||||
"source": {
|
||||
"$filter": {
|
||||
"from": {
|
||||
"$ref": "request.images"
|
||||
},
|
||||
"as": "media",
|
||||
"where": {
|
||||
"$ne": [
|
||||
{
|
||||
"$ref": "media.role"
|
||||
},
|
||||
"mask"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"filename": "source.png"
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"source": {
|
||||
"$filter": {
|
||||
"from": {
|
||||
"$ref": "request.images"
|
||||
},
|
||||
"as": "media",
|
||||
"where": {
|
||||
"$eq": [
|
||||
{
|
||||
"$ref": "media.role"
|
||||
},
|
||||
"mask"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"filename": "mask.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"status": "succeeded",
|
||||
"images": {
|
||||
"$map": {
|
||||
"from": {
|
||||
"$ref": "response.data"
|
||||
},
|
||||
"as": "item",
|
||||
"in": {
|
||||
"url": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "item.url"
|
||||
}
|
||||
},
|
||||
"dataUrl": {
|
||||
"$if": {
|
||||
"condition": {
|
||||
"$ref": "item.b64_json"
|
||||
},
|
||||
"then": {
|
||||
"$concat": [
|
||||
"data:image/png;base64,",
|
||||
{
|
||||
"$ref": "item.b64_json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"else": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"$ref": "response.usage"
|
||||
},
|
||||
"errorPaths": [
|
||||
"error.code"
|
||||
],
|
||||
"messagePaths": [
|
||||
"error.message"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>"
|
||||
}
|
||||
```
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_END -->
|
||||
484
plugin-packages/a6api/manifest.json
Normal file
484
plugin-packages/a6api/manifest.json
Normal file
File diff suppressed because one or more lines are too long
BIN
plugin-packages/antigravity-proxy.yingce-plugin
Normal file
BIN
plugin-packages/antigravity-proxy.yingce-plugin
Normal file
Binary file not shown.
41
plugin-packages/antigravity-proxy/README.md
Normal file
41
plugin-packages/antigravity-proxy/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Antigravity Proxy
|
||||
|
||||
Antigravity 中转渠道插件:OpenAI Chat Completions 兼容对话协议。
|
||||
|
||||
## 协议身份
|
||||
|
||||
- 插件 ID:`antigravity-proxy`
|
||||
- Provider ID:`antigravity-chat`
|
||||
- 能力:`text`
|
||||
- 默认 Base URL:`http://103.242.14.110:8317`
|
||||
- 鉴权驱动:`bearer`
|
||||
- 创建:`POST /chat/completions`
|
||||
- 生命周期:同步响应
|
||||
|
||||
## 配置字段
|
||||
|
||||
| 字段 | 类型 | 必填 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `apiKey` | secret | 是 | 中转渠道 API Key |
|
||||
|
||||
## 模型
|
||||
|
||||
- `gemini-3.8-flash-high`
|
||||
- `gemini-pro-agent`
|
||||
|
||||
## 统一字段映射
|
||||
|
||||
| 统一字段 | 类型 | 必填 | 上游映射 |
|
||||
| --- | --- | --- | --- |
|
||||
| `model` | string | 是 | `model` |
|
||||
| `messages` | message[] | 是 | `messages` |
|
||||
| `instructions` | string | 否 | `system/instructions` |
|
||||
| `temperature` | number | 否 | `temperature` |
|
||||
| `top_p` | number | 否 | `top_p` |
|
||||
| `max_tokens` | integer | 否 | `max_tokens` |
|
||||
| `tools` | array | 否 | `tools` |
|
||||
| `tool_choice` | object\|string | 否 | `tool_choice` |
|
||||
| `response_format` | object | 否 | `response_format` |
|
||||
| `stream` | boolean | 否 | `stream` |
|
||||
|
||||
完整接口见 [docs/interface.md](docs/interface.md)。
|
||||
303
plugin-packages/antigravity-proxy/docs/interface.md
Normal file
303
plugin-packages/antigravity-proxy/docs/interface.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# Antigravity Proxy 接口字段
|
||||
|
||||
## 协议身份
|
||||
|
||||
- 插件 ID:`antigravity-proxy`
|
||||
- Provider ID:`antigravity-chat`
|
||||
- 能力:`text`
|
||||
- 默认 Base URL:`http://103.242.14.110:8317`
|
||||
- 鉴权驱动:`bearer`
|
||||
- 创建:`POST /chat/completions`
|
||||
- 生命周期:同步响应
|
||||
|
||||
## 配置字段
|
||||
|
||||
| 字段 | 类型 | 必填 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `apiKey` | secret | 是 | 中转渠道 API Key |
|
||||
|
||||
## 统一字段映射
|
||||
|
||||
| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `model` | string | 是 | `model` | 上游模型 ID。 |
|
||||
| `messages` | message[] | 是 | `messages` | 包含历史消息和当前用户输入。 |
|
||||
| `instructions` | string | 否 | `system/instructions` | 系统指令。 |
|
||||
| `temperature` | number | 否 | `temperature` | 采样温度。 |
|
||||
| `top_p` | number | 否 | `top_p` | 核采样参数。 |
|
||||
| `max_tokens` | integer | 否 | `max_tokens/max_output_tokens` | 最大输出 token。 |
|
||||
| `tools` | array | 否 | `tools/toolConfig` | 工具定义。 |
|
||||
| `tool_choice` | object\|string | 否 | `tool_choice` | 工具选择策略。 |
|
||||
| `response_format` | object | 否 | `response_format/text` | 结构化输出配置。 |
|
||||
| `stream` | boolean | 否 | `stream` | 流式开关。 |
|
||||
|
||||
## 上游请求模板
|
||||
|
||||
| 上游位置 | 值或转换表达式 |
|
||||
| --- | --- |
|
||||
| `create.method` | `"POST"` |
|
||||
| `create.path` | `"/chat/completions"` |
|
||||
| `create.contentType` | `"application/json"` |
|
||||
| `create.body.model` | `{"$ref":"request.model"}` |
|
||||
| `create.body.messages` | `{"$ref":"request.messages"}` |
|
||||
| `create.body.temperature` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.temperature"}}` |
|
||||
| `create.body.top_p` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.top_p"}}` |
|
||||
| `create.body.max_tokens` | `{"$omitEmpty":{"$coalesce":[{"$ref":"request.extra.max_tokens"},{"$ref":"request.providerOptions.antigravity-chat.max_tokens"}]}}` |
|
||||
| `create.body.tools` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.tools"}}` |
|
||||
| `create.body.tool_choice` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.tool_choice"}}` |
|
||||
| `create.body.response_format` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.response_format"}}` |
|
||||
| `create.body.stream` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.stream"}}` |
|
||||
| `create.body.stop` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.stop"}}` |
|
||||
| `create.body.seed` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.seed"}}` |
|
||||
| `create.body.frequency_penalty` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.frequency_penalty"}}` |
|
||||
| `create.body.presence_penalty` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.presence_penalty"}}` |
|
||||
| `create.body.user` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.user"}}` |
|
||||
|
||||
## 响应映射
|
||||
|
||||
| 映射位置 | 上游路径或转换表达式 |
|
||||
| --- | --- |
|
||||
| `response.status` | `"succeeded"` |
|
||||
| `response.textPaths[0]` | `"choices.0.message.content"` |
|
||||
| `response.textPaths[1]` | `"choices.0.text"` |
|
||||
| `response.reasoningPaths[0]` | `"choices.0.message.reasoning_content"` |
|
||||
| `response.usage` | `{"$ref":"response.usage"}` |
|
||||
| `response.errorPaths[0]` | `"error.code"` |
|
||||
| `response.messagePaths[0]` | `"error.message"` |
|
||||
|
||||
## 兼容边界
|
||||
|
||||
该包只代表 Antigravity 中转渠道的 OpenAI Chat Completions 线协议;其他端点或网关包装必须使用独立插件。
|
||||
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_START -->
|
||||
## Manifest 完整接口定义
|
||||
|
||||
以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": "yingce.plugin/v2",
|
||||
"id": "antigravity-proxy",
|
||||
"name": "Antigravity Proxy",
|
||||
"version": "1.0.0",
|
||||
"author": "Yingce",
|
||||
"description": "Antigravity 中转渠道插件:OpenAI 兼容对话(gemini-3.8-flash-high / gemini-pro-agent)。",
|
||||
"permissions": [
|
||||
"generation.run",
|
||||
"media.read"
|
||||
],
|
||||
"configuration": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "apiKey",
|
||||
"type": "secret",
|
||||
"label": "API Key",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "antigravity-chat",
|
||||
"label": "Antigravity Chat",
|
||||
"capabilities": [
|
||||
"text"
|
||||
],
|
||||
"scopes": [
|
||||
"admin.system-channel",
|
||||
"user.custom-channel",
|
||||
"canvas",
|
||||
"creation",
|
||||
"agent"
|
||||
],
|
||||
"baseUrl": "http://103.242.14.110:8317",
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"field": "apiKey"
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"mapping": "model",
|
||||
"description": "模型 ID(gemini-3.8-flash-high / gemini-pro-agent)。"
|
||||
},
|
||||
{
|
||||
"name": "messages",
|
||||
"type": "message[]",
|
||||
"required": true,
|
||||
"mapping": "provider message container",
|
||||
"description": "包含历史消息和当前用户输入。"
|
||||
},
|
||||
{
|
||||
"name": "instructions",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"mapping": "system/instructions",
|
||||
"description": "系统指令。"
|
||||
},
|
||||
{
|
||||
"name": "temperature",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"mapping": "temperature",
|
||||
"description": "采样温度。"
|
||||
},
|
||||
{
|
||||
"name": "top_p",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"mapping": "top_p",
|
||||
"description": "核采样参数。"
|
||||
},
|
||||
{
|
||||
"name": "max_tokens",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"mapping": "max_tokens/max_output_tokens",
|
||||
"description": "最大输出 token。"
|
||||
},
|
||||
{
|
||||
"name": "tools",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"mapping": "tools/toolConfig",
|
||||
"description": "工具定义。"
|
||||
},
|
||||
{
|
||||
"name": "tool_choice",
|
||||
"type": "object|string",
|
||||
"required": false,
|
||||
"mapping": "tool_choice",
|
||||
"description": "工具选择策略。"
|
||||
},
|
||||
{
|
||||
"name": "response_format",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"mapping": "response_format/text",
|
||||
"description": "结构化输出配置。"
|
||||
},
|
||||
{
|
||||
"name": "stream",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"mapping": "stream",
|
||||
"description": "流式开关;后台任务当前以最终响应归一。"
|
||||
},
|
||||
{
|
||||
"name": "providerOptions",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"mapping": "provider-specific fields",
|
||||
"description": "插件命名空间内的厂商扩展字段。"
|
||||
}
|
||||
],
|
||||
"create": {
|
||||
"method": "POST",
|
||||
"path": "/chat/completions",
|
||||
"contentType": "application/json",
|
||||
"body": {
|
||||
"model": {
|
||||
"$ref": "request.model"
|
||||
},
|
||||
"messages": {
|
||||
"$ref": "request.messages"
|
||||
},
|
||||
"temperature": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.antigravity-chat.temperature"
|
||||
}
|
||||
},
|
||||
"top_p": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.antigravity-chat.top_p"
|
||||
}
|
||||
},
|
||||
"max_tokens": {
|
||||
"$omitEmpty": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "request.extra.max_tokens"
|
||||
},
|
||||
{
|
||||
"$ref": "request.providerOptions.antigravity-chat.max_tokens"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.antigravity-chat.tools"
|
||||
}
|
||||
},
|
||||
"tool_choice": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.antigravity-chat.tool_choice"
|
||||
}
|
||||
},
|
||||
"response_format": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.antigravity-chat.response_format"
|
||||
}
|
||||
},
|
||||
"stream": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.antigravity-chat.stream"
|
||||
}
|
||||
},
|
||||
"stop": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.antigravity-chat.stop"
|
||||
}
|
||||
},
|
||||
"seed": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.antigravity-chat.seed"
|
||||
}
|
||||
},
|
||||
"frequency_penalty": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.antigravity-chat.frequency_penalty"
|
||||
}
|
||||
},
|
||||
"presence_penalty": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.antigravity-chat.presence_penalty"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.antigravity-chat.user"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": "succeeded",
|
||||
"textPaths": [
|
||||
"choices.0.message.content",
|
||||
"choices.0.text"
|
||||
],
|
||||
"reasoningPaths": [
|
||||
"choices.0.message.reasoning_content"
|
||||
],
|
||||
"usage": {
|
||||
"$ref": "response.usage"
|
||||
},
|
||||
"errorPaths": [
|
||||
"error.code"
|
||||
],
|
||||
"messagePaths": [
|
||||
"error.message"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>"
|
||||
}
|
||||
```
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_END -->
|
||||
224
plugin-packages/antigravity-proxy/manifest.json
Normal file
224
plugin-packages/antigravity-proxy/manifest.json
Normal file
File diff suppressed because one or more lines are too long
BIN
plugin-packages/lxmone-video-suite.yingce-plugin
Normal file
BIN
plugin-packages/lxmone-video-suite.yingce-plugin
Normal file
Binary file not shown.
27
plugin-packages/lxmone-video-suite/README.md
Normal file
27
plugin-packages/lxmone-video-suite/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 万有引力视频套件(lxmone.xyz)
|
||||
|
||||
该目录是万有引力中转站(`https://lxmone.xyz/v1`)视频协议的官方声明式插件源码。
|
||||
后端从生成的 `lxmone-video-suite.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。
|
||||
|
||||
协议按中转站公开接口文档 `https://lxmone.xyz/docs/` 的 `/v1/videos` 与 `/v1/videos/generations`
|
||||
两套请求体形状编写,拆成 8 个 provider,覆盖该站点当前开放的全部视频模型:
|
||||
|
||||
| Provider ID | 覆盖模型 | 创建接口 |
|
||||
| --- | --- | --- |
|
||||
| `lxmone-wan-videos` | `wan3.0-video`、`wan3.0-video-prime` | `POST /v1/videos` |
|
||||
| `lxmone-wan-channel-s` | `wan3.0-video-s`、`wan3.0-video-prime-s` | `POST /v1/videos` |
|
||||
| `lxmone-seedance-videos` | `seedance-2-pro/fast/mini`、`seedance-2.5-pro`、`seedance2.0-a`~`seedance2.0-f`、`seedance2.5-a` | `POST /v1/videos` |
|
||||
| `lxmone-h3-max-videos` | `minimax-h3-max` | `POST /v1/videos` |
|
||||
| `lxmone-sd-videos` | `sd-2.0-a`、`sd-2.0-b`、`sd-2.0-c`、`sd-2.5-a`、`sd-2.5` | `POST /v1/videos` |
|
||||
| `lxmone-sd-mini-videos` | `sd-mini` | `POST /v1/videos` |
|
||||
| `lxmone-grok-videos` | `grok-imagine-video`、`grok-imagine-video-1.5`、`grok-imagine-video-1.5S` | `POST /v1/videos/generations` |
|
||||
| `lxmone-h3-workflow` | `minimax-h3-a`~`minimax-h3-e` | `POST /v1/videos/generations` |
|
||||
|
||||
完整字段、请求模板与响应映射见 [docs/interface.md](docs/interface.md)。
|
||||
|
||||
## 构建
|
||||
|
||||
```sh
|
||||
node plugin-packages/embed-documentation.mjs lxmone-video-suite
|
||||
sh plugin-packages/build-packages.sh
|
||||
```
|
||||
2391
plugin-packages/lxmone-video-suite/docs/interface.md
Normal file
2391
plugin-packages/lxmone-video-suite/docs/interface.md
Normal file
File diff suppressed because it is too large
Load Diff
2289
plugin-packages/lxmone-video-suite/manifest.json
Normal file
2289
plugin-packages/lxmone-video-suite/manifest.json
Normal file
File diff suppressed because one or more lines are too long
BIN
plugin-packages/metaso-h3.yingce-plugin
Normal file
BIN
plugin-packages/metaso-h3.yingce-plugin
Normal file
Binary file not shown.
22
plugin-packages/metaso-h3/README.md
Normal file
22
plugin-packages/metaso-h3/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# METASO MiniMax H3
|
||||
|
||||
秘塔 METASO MiniMax H3 文生视频 / 图生视频协议插件(异步任务模式)。
|
||||
|
||||
## Provider
|
||||
|
||||
| Provider | 能力 | 端点 |
|
||||
|---|---|---|
|
||||
| metaso-h3 | video | POST /api/minimax/v2/video_generation(创建)<br>GET /api/minimax/v2/query/video_generation/{taskId}(轮询) |
|
||||
|
||||
## 配置
|
||||
|
||||
在渠道中填写 API Key(METASO 控制台创建,形如 `mk-xxxxxxxx`),baseUrl 使用 `https://metaso.cn`。
|
||||
|
||||
## 说明
|
||||
|
||||
- 模型:`MiniMax-H3`(支持 768P / 2K,4-15 秒,文生图/图生视频/多模态参考),`MiniMax-H3-Max`(480P / 768P,5-15 秒)。
|
||||
- 文生视频:仅文本提示词,ratio 必填(21:9 / 16:9 / 4:3 / 1:1 / 3:4 / 9:16)。
|
||||
- 图生视频:图片 role 为 first_frame / last_frame(首尾帧)。
|
||||
- 多模态参考:图片 reference_image / 视频 reference_video / 音频 reference_audio。
|
||||
- 异步任务:创建后返回 task_id,轮询查询接口直到 status=succeeded,成片 URL 在 task.content.url。
|
||||
- 鉴权:`Authorization: Bearer mk-xxx`。
|
||||
437
plugin-packages/metaso-h3/docs/interface.md
Normal file
437
plugin-packages/metaso-h3/docs/interface.md
Normal file
@@ -0,0 +1,437 @@
|
||||
# METASO MiniMax H3 接口说明
|
||||
|
||||
## 鉴权
|
||||
|
||||
`Authorization: Bearer mk-xxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
## 创建视频生成任务
|
||||
|
||||
POST `https://metaso.cn/api/minimax/v2/video_generation`
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "MiniMax-H3",
|
||||
"content": [
|
||||
{ "type": "text", "text": "视频描述" },
|
||||
{ "type": "image_url", "image_url": { "url": "https://..." }, "role": "first_frame" },
|
||||
{ "type": "video_url", "video_url": { "url": "https://..." }, "role": "reference_video" },
|
||||
{ "type": "audio_url", "audio_url": { "url": "https://..." }, "role": "reference_audio" }
|
||||
],
|
||||
"resolution": "768P",
|
||||
"duration": 5,
|
||||
"ratio": "16:9"
|
||||
}
|
||||
```
|
||||
|
||||
- content 必须包含一个非空 text 项。
|
||||
- 首尾帧(first_frame/last_frame)与多模态参考(reference_*)互斥。
|
||||
- 图片 ≤ 9 张、视频 ≤ 3 段、音频 ≤ 3 段;请求体总大小 ≤ 64 MB。
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{ "task_id": "424010985738629" }
|
||||
```
|
||||
|
||||
## 查询任务
|
||||
|
||||
GET `https://metaso.cn/api/minimax/v2/query/video_generation/{task_id}`
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"task": {
|
||||
"id": "424010985738629",
|
||||
"status": "succeeded",
|
||||
"content": { "url": "https://.../output.mp4" },
|
||||
"resolution": "2K",
|
||||
"duration": 5,
|
||||
"usage": { "total_seconds": 5, "output_seconds": 5 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
status 取值:queued / running / succeeded / failed / cancelled。
|
||||
|
||||
## 错误
|
||||
|
||||
创建与查询均返回 OpenAI 风格错误体,含 `error.type` / `error.message` / `request_id`;余额不足为 402,速率限制为 429。
|
||||
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_START -->
|
||||
## Manifest 完整接口定义
|
||||
|
||||
以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": "yingce.plugin/v2",
|
||||
"id": "metaso-h3",
|
||||
"name": "METASO MiniMax H3",
|
||||
"version": "1.0.0",
|
||||
"author": "METASO",
|
||||
"description": "秘塔 METASO MiniMax H3 文生视频/图生视频协议插件(异步任务)。",
|
||||
"permissions": [
|
||||
"generation.run",
|
||||
"media.read"
|
||||
],
|
||||
"configuration": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "apiKey",
|
||||
"type": "secret",
|
||||
"label": "API Key",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "metaso-h3",
|
||||
"label": "METASO MiniMax H3",
|
||||
"capabilities": [
|
||||
"video"
|
||||
],
|
||||
"scopes": [
|
||||
"admin.system-channel",
|
||||
"user.custom-channel",
|
||||
"canvas",
|
||||
"creation",
|
||||
"agent"
|
||||
],
|
||||
"baseUrl": "https://metaso.cn",
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"field": "apiKey"
|
||||
},
|
||||
"requiresPublicMediaUrls": true,
|
||||
"parameters": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"mapping": "model",
|
||||
"description": "视频模型 ID(MiniMax-H3 / MiniMax-H3-Max)。"
|
||||
},
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"mapping": "prompt/content/input",
|
||||
"description": "视频提示词。"
|
||||
},
|
||||
{
|
||||
"name": "images",
|
||||
"type": "media[]",
|
||||
"required": false,
|
||||
"mapping": "first/last/reference image",
|
||||
"description": "显式 role 图片输入(首帧/尾帧/参考图)。"
|
||||
},
|
||||
{
|
||||
"name": "videos",
|
||||
"type": "media[]",
|
||||
"required": false,
|
||||
"mapping": "reference video",
|
||||
"description": "参考视频。"
|
||||
},
|
||||
{
|
||||
"name": "audios",
|
||||
"type": "media[]",
|
||||
"required": false,
|
||||
"mapping": "reference audio/voice",
|
||||
"description": "参考音频或音色。"
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"mapping": "duration/seconds",
|
||||
"description": "时长秒数(4-15)。"
|
||||
},
|
||||
{
|
||||
"name": "aspectRatio",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"mapping": "ratio/aspect_ratio/size",
|
||||
"description": "画幅比例(16:9 等)。"
|
||||
},
|
||||
{
|
||||
"name": "resolution",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"mapping": "resolution",
|
||||
"description": "分辨率档位(768P / 2K)。"
|
||||
},
|
||||
{
|
||||
"name": "watermark",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"mapping": "watermark",
|
||||
"description": "AIGC 水印开关。"
|
||||
},
|
||||
{
|
||||
"name": "providerOptions",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"mapping": "provider-specific fields",
|
||||
"description": "插件命名空间内的厂商扩展字段。"
|
||||
}
|
||||
],
|
||||
"create": {
|
||||
"method": "POST",
|
||||
"path": "/api/minimax/v2/video_generation",
|
||||
"contentType": "application/json",
|
||||
"body": {
|
||||
"model": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "request.model"
|
||||
},
|
||||
"MiniMax-H3"
|
||||
]
|
||||
},
|
||||
"content": {
|
||||
"$concatArrays": [
|
||||
{
|
||||
"$if": {
|
||||
"condition": {
|
||||
"$gt": [
|
||||
{
|
||||
"$len": {
|
||||
"$ref": "request.prompt"
|
||||
}
|
||||
},
|
||||
0
|
||||
]
|
||||
},
|
||||
"then": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": {
|
||||
"$ref": "request.prompt"
|
||||
}
|
||||
}
|
||||
],
|
||||
"else": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"$map": {
|
||||
"from": {
|
||||
"$ref": "request.images"
|
||||
},
|
||||
"as": "media",
|
||||
"in": {
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "media.url"
|
||||
},
|
||||
{
|
||||
"$ref": "media.dataUrl"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "media.role"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$map": {
|
||||
"from": {
|
||||
"$ref": "request.videos"
|
||||
},
|
||||
"as": "media",
|
||||
"in": {
|
||||
"type": "video_url",
|
||||
"video_url": {
|
||||
"url": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "media.url"
|
||||
},
|
||||
{
|
||||
"$ref": "media.dataUrl"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "media.role"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$map": {
|
||||
"from": {
|
||||
"$ref": "request.audios"
|
||||
},
|
||||
"as": "media",
|
||||
"in": {
|
||||
"type": "audio_url",
|
||||
"audio_url": {
|
||||
"url": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "media.url"
|
||||
},
|
||||
{
|
||||
"$ref": "media.dataUrl"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "media.role"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"resolution": {
|
||||
"$omitEmpty": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "request.resolution"
|
||||
},
|
||||
{
|
||||
"$ref": "request.providerOptions.metaso-h3.resolution"
|
||||
},
|
||||
"768P"
|
||||
]
|
||||
}
|
||||
},
|
||||
"duration": {
|
||||
"$omitEmpty": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "request.duration"
|
||||
},
|
||||
{
|
||||
"$ref": "request.providerOptions.metaso-h3.duration"
|
||||
},
|
||||
5
|
||||
]
|
||||
}
|
||||
},
|
||||
"ratio": {
|
||||
"$omitEmpty": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "request.aspectRatio"
|
||||
},
|
||||
{
|
||||
"$ref": "request.providerOptions.metaso-h3.ratio"
|
||||
},
|
||||
"16:9"
|
||||
]
|
||||
}
|
||||
},
|
||||
"aigc_watermark": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.metaso-h3.aigc_watermark"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"poll": {
|
||||
"method": "GET",
|
||||
"path": "/api/minimax/v2/query/video_generation/{{taskId}}"
|
||||
},
|
||||
"response": {
|
||||
"taskId": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "response.task_id"
|
||||
},
|
||||
{
|
||||
"$ref": "response.taskId"
|
||||
},
|
||||
{
|
||||
"$ref": "response.data.task_id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "response.task.status"
|
||||
},
|
||||
{
|
||||
"$ref": "response.status"
|
||||
},
|
||||
{
|
||||
"$ref": "response.data.status"
|
||||
},
|
||||
"pending"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "response.task.error.message"
|
||||
},
|
||||
{
|
||||
"$ref": "response.task.fail_reason"
|
||||
},
|
||||
{
|
||||
"$ref": "response.error.message"
|
||||
},
|
||||
{
|
||||
"$ref": "response.message"
|
||||
}
|
||||
]
|
||||
},
|
||||
"videos": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "response.task.content.url"
|
||||
},
|
||||
{
|
||||
"$ref": "response.task.content.video_url"
|
||||
},
|
||||
{
|
||||
"$ref": "response.content.url"
|
||||
},
|
||||
{
|
||||
"$ref": "response.data.url"
|
||||
}
|
||||
]
|
||||
},
|
||||
"usage": {
|
||||
"$ref": "response.task.usage"
|
||||
},
|
||||
"errorPaths": [
|
||||
"task.error.type",
|
||||
"error.type",
|
||||
"error.code"
|
||||
],
|
||||
"resultEphemeral": true,
|
||||
"messagePaths": [
|
||||
"task.error.message",
|
||||
"error.message"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>"
|
||||
}
|
||||
```
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_END -->
|
||||
367
plugin-packages/metaso-h3/manifest.json
Normal file
367
plugin-packages/metaso-h3/manifest.json
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -40,8 +40,6 @@
|
||||
| `create.contentType` | `"application/json"` |
|
||||
| `create.body.model` | `{"$ref":"request.model"}` |
|
||||
| `create.body.prompt` | `{"$ref":"request.prompt"}` |
|
||||
| `create.body.images` | `{"$omitEmpty":{"$if":{"condition":{"$gt":[{"$len":{"$ref":"request.images"}},0]},"then":{"$map":{"from":{"$filter":{"from":{"$sortByOrder":{"$ref":"request.images"}},"as":"media","where":{"$ne":[{"$ref":"media.role"},"mask"]}}},"as":"media","in":{"image_url":{"$ref":"media.value"}}}},"else":null}}}` |
|
||||
| `create.body.mask` | `{"$omitEmpty":{"$if":{"condition":{"$gt":[{"$len":{"$filter":{"from":{"$ref":"request.images"},"as":"media","where":{"$eq":[{"$ref":"media.role"},"mask"]}}}},0]},"then":{"image_url":{"$first":{"$map":{"from":{"$filter":{"from":{"$sortByOrder":{"$ref":"request.images"}},"as":"media","where":{"$eq":[{"$ref":"media.role"},"mask"]}}},"as":"media","in":{"$ref":"media.value"}}}}},"else":null}}}` |
|
||||
| `create.body.n` | `{"$omitEmpty":{"$if":{"condition":{"$gt":[{"$ref":"request.imageCount"},0]},"then":{"$ref":"request.imageCount"},"else":1}}}` |
|
||||
| `create.body.size` | `{"$omitEmpty":{"$if":{"condition":{"$in":[{"$lower":{"$trim":{"$ref":"request.aspectRatio"}}},["","auto"]]},"then":null,"else":{"$ref":"request.aspectRatio"}}}}` |
|
||||
| `create.body.quality` | `{"$omitEmpty":{"$switch":{"cases":[{"when":{"$in":[{"$lower":{"$trim":{"$ref":"request.quality"}}},["1k"]]},"then":"low"},{"when":{"$in":[{"$lower":{"$trim":{"$ref":"request.quality"}}},["2k"]]},"then":"medium"},{"when":{"$in":[{"$lower":{"$trim":{"$ref":"request.quality"}}},["4k"]]},"then":"high"},{"when":{"$in":[{"$lower":{"$trim":{"$ref":"request.quality"}}},["","auto"]]},"then":null}],"default":{"$ref":"request.quality"}}}}` |
|
||||
@@ -49,9 +47,15 @@
|
||||
| `create.body.output_format` | `{"$omitEmpty":{"$coalesce":[{"$ref":"request.providerOptions.openai-image.output_format"},"png"]}}` |
|
||||
| `create.body.output_compression` | `{"$omitEmpty":{"$ref":"request.providerOptions.openai-image.output_compression"}}` |
|
||||
| `create.body.moderation` | `{"$omitEmpty":{"$ref":"request.providerOptions.openai-image.moderation"}}` |
|
||||
| `create.body.response_format` | `{"$omitEmpty":{"$ref":"request.providerOptions.openai-image.response_format"}}` |
|
||||
| `create.body.response_format` | `{"$omitEmpty":{"$coalesce":[{"$ref":"request.providerOptions.openai-image.response_format"},"b64_json"]}}` |
|
||||
| `create.body.style` | `{"$omitEmpty":{"$ref":"request.providerOptions.openai-image.style"}}` |
|
||||
| `create.body.user` | `{"$omitEmpty":{"$ref":"request.providerOptions.openai-image.user"}}` |
|
||||
| `create.files[0].name` | `"image"` |
|
||||
| `create.files[0].source` | `{"$filter":{"from":{"$ref":"request.images"},"as":"media","where":{"$ne":[{"$ref":"media.role"},"mask"]}}}` |
|
||||
| `create.files[0].filename` | `"source.png"` |
|
||||
| `create.files[1].name` | `"mask"` |
|
||||
| `create.files[1].source` | `{"$filter":{"from":{"$ref":"request.images"},"as":"media","where":{"$eq":[{"$ref":"media.role"},"mask"]}}}` |
|
||||
| `create.files[1].filename` | `"mask.png"` |
|
||||
|
||||
## Provider 扩展键
|
||||
|
||||
@@ -81,7 +85,7 @@
|
||||
|
||||
## 兼容边界
|
||||
|
||||
无参考图走 JSON generations;有参考图或蒙版走 JSON edits,并按官方 images 数组传入多张 image_url。quality 的 1k/2k/4k 映射为 OpenAI low/medium/high。
|
||||
无参考图走 JSON generations;有参考图或蒙版走 multipart edits。quality 的 1k/2k/4k 映射为 OpenAI low/medium/high。
|
||||
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_START -->
|
||||
## Manifest 完整接口定义
|
||||
@@ -127,7 +131,7 @@
|
||||
"agent"
|
||||
],
|
||||
"baseUrl": "https://api.openai.com",
|
||||
"requiresPublicMediaUrls": true,
|
||||
"requiresPublicMediaUrls": false,
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"field": "apiKey"
|
||||
@@ -210,6 +214,22 @@
|
||||
}
|
||||
},
|
||||
"contentType": "application/json",
|
||||
"contentTypeTemplate": {
|
||||
"$if": {
|
||||
"condition": {
|
||||
"$gt": [
|
||||
{
|
||||
"$len": {
|
||||
"$ref": "request.images"
|
||||
}
|
||||
},
|
||||
0
|
||||
]
|
||||
},
|
||||
"then": "multipart/form-data",
|
||||
"else": "application/json"
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"model": {
|
||||
"$ref": "request.model"
|
||||
@@ -217,111 +237,6 @@
|
||||
"prompt": {
|
||||
"$ref": "request.prompt"
|
||||
},
|
||||
"images": {
|
||||
"$omitEmpty": {
|
||||
"$if": {
|
||||
"condition": {
|
||||
"$gt": [
|
||||
{
|
||||
"$len": {
|
||||
"$ref": "request.images"
|
||||
}
|
||||
},
|
||||
0
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"$map": {
|
||||
"from": {
|
||||
"$filter": {
|
||||
"from": {
|
||||
"$sortByOrder": {
|
||||
"$ref": "request.images"
|
||||
}
|
||||
},
|
||||
"as": "media",
|
||||
"where": {
|
||||
"$ne": [
|
||||
{
|
||||
"$ref": "media.role"
|
||||
},
|
||||
"mask"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"as": "media",
|
||||
"in": {
|
||||
"image_url": {
|
||||
"$ref": "media.value"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"else": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"mask": {
|
||||
"$omitEmpty": {
|
||||
"$if": {
|
||||
"condition": {
|
||||
"$gt": [
|
||||
{
|
||||
"$len": {
|
||||
"$filter": {
|
||||
"from": {
|
||||
"$ref": "request.images"
|
||||
},
|
||||
"as": "media",
|
||||
"where": {
|
||||
"$eq": [
|
||||
{
|
||||
"$ref": "media.role"
|
||||
},
|
||||
"mask"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
0
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"image_url": {
|
||||
"$first": {
|
||||
"$map": {
|
||||
"from": {
|
||||
"$filter": {
|
||||
"from": {
|
||||
"$sortByOrder": {
|
||||
"$ref": "request.images"
|
||||
}
|
||||
},
|
||||
"as": "media",
|
||||
"where": {
|
||||
"$eq": [
|
||||
{
|
||||
"$ref": "media.role"
|
||||
},
|
||||
"mask"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"as": "media",
|
||||
"in": {
|
||||
"$ref": "media.value"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"else": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"n": {
|
||||
"$omitEmpty": {
|
||||
"$if": {
|
||||
@@ -490,7 +405,12 @@
|
||||
},
|
||||
"response_format": {
|
||||
"$omitEmpty": {
|
||||
"$ref": "request.providerOptions.openai-image.response_format"
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "request.providerOptions.openai-image.response_format"
|
||||
},
|
||||
"b64_json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"style": {
|
||||
@@ -503,7 +423,49 @@
|
||||
"$ref": "request.providerOptions.openai-image.user"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"name": "image",
|
||||
"source": {
|
||||
"$filter": {
|
||||
"from": {
|
||||
"$ref": "request.images"
|
||||
},
|
||||
"as": "media",
|
||||
"where": {
|
||||
"$ne": [
|
||||
{
|
||||
"$ref": "media.role"
|
||||
},
|
||||
"mask"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"filename": "source.png"
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"source": {
|
||||
"$filter": {
|
||||
"from": {
|
||||
"$ref": "request.images"
|
||||
},
|
||||
"as": "media",
|
||||
"where": {
|
||||
"$eq": [
|
||||
{
|
||||
"$ref": "media.role"
|
||||
},
|
||||
"mask"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"filename": "mask.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"status": "succeeded",
|
||||
|
||||
File diff suppressed because one or more lines are too long
20
plugin-packages/src-a6api/README.md
Normal file
20
plugin-packages/src-a6api/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# A6api
|
||||
|
||||
A6api 模型网关协议插件。统一入口 `https://api.a6api.com`,OpenAI 兼容(`Authorization: Bearer <api_key>`)。
|
||||
|
||||
## Providers
|
||||
|
||||
| Provider | 能力 | 端点 |
|
||||
|---|---|---|
|
||||
| a6api-chat | text | POST /chat/completions |
|
||||
| a6api-image | image | POST /images/generations、POST /images/edits |
|
||||
|
||||
## 配置
|
||||
|
||||
在渠道中填写 API Key(A6api 控制台创建,形如 `sk-xxxxxxxx`),baseUrl 使用 `https://api.a6api.com`。
|
||||
|
||||
## 说明
|
||||
|
||||
- 模型名以 A6api 模型市场当前列表为准。
|
||||
- 图像编辑(带参考图)走 `/images/edits` multipart 请求。
|
||||
- 错误体为 OpenAI 风格,携带 `request_id`。
|
||||
29
plugin-packages/src-a6api/docs/interface.md
Normal file
29
plugin-packages/src-a6api/docs/interface.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# A6api 接口说明
|
||||
|
||||
## 鉴权
|
||||
|
||||
`Authorization: Bearer sk-xxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
## 文本对话
|
||||
|
||||
POST `https://api.a6api.com/chat/completions`
|
||||
|
||||
请求体 OpenAI Chat Completions 格式:`model`、`messages`、`temperature`、`max_tokens`、`tools`、`tool_choice`、`response_format`、`stream` 等。
|
||||
|
||||
响应 `choices[0].message.content` 为正文,`choices[0].message.reasoning_content` 为推理内容,`usage` 为用量。
|
||||
|
||||
## 图像生成
|
||||
|
||||
POST `https://api.a6api.com/images/generations`
|
||||
|
||||
请求体:`model`、`prompt`、`n`、`size`、`quality`、`response_format`。
|
||||
|
||||
响应 `data[].url` 或 `data[].b64_json`。
|
||||
|
||||
## 图像编辑
|
||||
|
||||
POST `https://api.a6api.com/images/edits`(multipart,带参考图时使用)
|
||||
|
||||
## 模型列表
|
||||
|
||||
GET `https://api.a6api.com/models`
|
||||
143
plugin-packages/src-a6api/manifest.json
Normal file
143
plugin-packages/src-a6api/manifest.json
Normal file
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"apiVersion": "yingce.plugin/v2",
|
||||
"id": "a6api",
|
||||
"name": "A6api",
|
||||
"version": "1.0.0",
|
||||
"author": "A6api",
|
||||
"description": "A6api 模型网关协议插件:OpenAI 兼容对话与图像生成。",
|
||||
"permissions": ["generation.run", "media.read"],
|
||||
"configuration": {
|
||||
"fields": [
|
||||
{ "name": "apiKey", "type": "secret", "label": "API Key", "required": true }
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "a6api-chat",
|
||||
"label": "A6api Chat",
|
||||
"capabilities": ["text"],
|
||||
"scopes": ["admin.system-channel", "user.custom-channel", "canvas", "creation", "agent"],
|
||||
"baseUrl": "https://api.a6api.com",
|
||||
"auth": { "type": "bearer", "field": "apiKey" },
|
||||
"parameters": [
|
||||
{ "name": "model", "type": "string", "required": true, "mapping": "model", "description": "A6api 模型市场中的模型 ID。" },
|
||||
{ "name": "messages", "type": "message[]", "required": true, "mapping": "provider message container", "description": "包含历史消息和当前用户输入。" },
|
||||
{ "name": "instructions", "type": "string", "required": false, "mapping": "system/instructions", "description": "系统指令。" },
|
||||
{ "name": "temperature", "type": "number", "required": false, "mapping": "temperature", "description": "采样温度。" },
|
||||
{ "name": "top_p", "type": "number", "required": false, "mapping": "top_p", "description": "核采样参数。" },
|
||||
{ "name": "max_tokens", "type": "integer", "required": false, "mapping": "max_tokens/max_output_tokens", "description": "最大输出 token。" },
|
||||
{ "name": "tools", "type": "array", "required": false, "mapping": "tools/toolConfig", "description": "工具定义。" },
|
||||
{ "name": "tool_choice", "type": "object|string", "required": false, "mapping": "tool_choice", "description": "工具选择策略。" },
|
||||
{ "name": "response_format", "type": "object", "required": false, "mapping": "response_format/text", "description": "结构化输出配置。" },
|
||||
{ "name": "stream", "type": "boolean", "required": false, "mapping": "stream", "description": "流式开关;后台任务当前以最终响应归一。" },
|
||||
{ "name": "providerOptions", "type": "object", "required": false, "mapping": "provider-specific fields", "description": "插件命名空间内的厂商扩展字段。" }
|
||||
],
|
||||
"create": {
|
||||
"method": "POST",
|
||||
"path": "/chat/completions",
|
||||
"contentType": "application/json",
|
||||
"body": {
|
||||
"model": { "$ref": "request.model" },
|
||||
"messages": { "$ref": "request.messages" },
|
||||
"temperature": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.temperature" } },
|
||||
"top_p": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.top_p" } },
|
||||
"max_tokens": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.extra.max_tokens" }, { "$ref": "request.providerOptions.a6api-chat.max_tokens" } ] } },
|
||||
"tools": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.tools" } },
|
||||
"tool_choice": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.tool_choice" } },
|
||||
"response_format": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.response_format" } },
|
||||
"stream": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.stream" } },
|
||||
"stop": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.stop" } },
|
||||
"seed": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.seed" } },
|
||||
"frequency_penalty": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.frequency_penalty" } },
|
||||
"presence_penalty": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.presence_penalty" } },
|
||||
"user": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.user" } }
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": "succeeded",
|
||||
"textPaths": ["choices.0.message.content", "choices.0.text"],
|
||||
"reasoningPaths": ["choices.0.message.reasoning_content"],
|
||||
"usage": { "$ref": "response.usage" },
|
||||
"errorPaths": ["error.code"],
|
||||
"messagePaths": ["error.message"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a6api-image",
|
||||
"label": "A6api Image",
|
||||
"capabilities": ["image"],
|
||||
"scopes": ["admin.system-channel", "user.custom-channel", "canvas", "creation", "agent"],
|
||||
"baseUrl": "https://api.a6api.com",
|
||||
"auth": { "type": "bearer", "field": "apiKey" },
|
||||
"parameters": [
|
||||
{ "name": "model", "type": "string", "required": true, "mapping": "model", "description": "A6api 模型市场中的图像模型 ID。" },
|
||||
{ "name": "prompt", "type": "string", "required": true, "mapping": "prompt", "description": "图片提示词。" },
|
||||
{ "name": "images", "type": "media[]", "required": false, "mapping": "provider image/reference fields", "description": "参考图或编辑源图,role 由业务层确定。" },
|
||||
{ "name": "imageCount", "type": "integer", "required": false, "mapping": "n/sample_count", "description": "输出数量。" },
|
||||
{ "name": "aspectRatio", "type": "string", "required": false, "mapping": "size/aspect_ratio", "description": "比例或尺寸,语义按协议说明。" },
|
||||
{ "name": "resolution", "type": "string", "required": false, "mapping": "resolution/imageSize", "description": "分辨率档位。" },
|
||||
{ "name": "quality", "type": "string", "required": false, "mapping": "quality", "description": "质量档位。" },
|
||||
{ "name": "providerOptions", "type": "object", "required": false, "mapping": "provider-specific fields", "description": "插件命名空间内的厂商扩展字段。" }
|
||||
],
|
||||
"create": {
|
||||
"method": "POST",
|
||||
"path": "/images/generations",
|
||||
"pathTemplate": {
|
||||
"$if": {
|
||||
"condition": { "$gt": [ { "$len": { "$ref": "request.images" } }, 0 ] },
|
||||
"then": "/images/edits",
|
||||
"else": "/images/generations"
|
||||
}
|
||||
},
|
||||
"contentType": "application/json",
|
||||
"contentTypeTemplate": {
|
||||
"$if": {
|
||||
"condition": { "$gt": [ { "$len": { "$ref": "request.images" } }, 0 ] },
|
||||
"then": "multipart/form-data",
|
||||
"else": "application/json"
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"model": { "$ref": "request.model" },
|
||||
"prompt": { "$ref": "request.prompt" },
|
||||
"n": { "$omitEmpty": { "$if": { "condition": { "$gt": [ { "$ref": "request.imageCount" }, 0 ] }, "then": { "$ref": "request.imageCount" }, "else": 1 } } },
|
||||
"size": { "$omitEmpty": { "$ref": "request.aspectRatio" } },
|
||||
"quality": { "$omitEmpty": { "$ref": "request.quality" } },
|
||||
"output_format": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.providerOptions.a6api-image.output_format" }, "png" ] } },
|
||||
"response_format": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.providerOptions.a6api-image.response_format" }, "b64_json" ] } },
|
||||
"user": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-image.user" } }
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"name": "image",
|
||||
"source": { "$filter": { "from": { "$ref": "request.images" }, "as": "media", "where": { "$ne": [ { "$ref": "media.role" }, "mask" ] } } },
|
||||
"filename": "source.png"
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"source": { "$filter": { "from": { "$ref": "request.images" }, "as": "media", "where": { "$eq": [ { "$ref": "media.role" }, "mask" ] } } },
|
||||
"filename": "mask.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"status": "succeeded",
|
||||
"images": {
|
||||
"$map": {
|
||||
"from": { "$ref": "response.data" },
|
||||
"as": "item",
|
||||
"in": {
|
||||
"url": { "$omitEmpty": { "$ref": "item.url" } },
|
||||
"dataUrl": { "$if": { "condition": { "$ref": "item.b64_json" }, "then": { "$concat": [ "data:image/png;base64,", { "$ref": "item.b64_json" } ] }, "else": null } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"usage": { "$ref": "response.usage" },
|
||||
"errorPaths": ["error.code"],
|
||||
"messagePaths": ["error.message"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
41
plugin-packages/src-antigravity-proxy/README.md
Normal file
41
plugin-packages/src-antigravity-proxy/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Antigravity Proxy
|
||||
|
||||
Antigravity 中转渠道插件:OpenAI Chat Completions 兼容对话协议。
|
||||
|
||||
## 协议身份
|
||||
|
||||
- 插件 ID:`antigravity-proxy`
|
||||
- Provider ID:`antigravity-chat`
|
||||
- 能力:`text`
|
||||
- 默认 Base URL:`http://103.242.14.110:8317`
|
||||
- 鉴权驱动:`bearer`
|
||||
- 创建:`POST /chat/completions`
|
||||
- 生命周期:同步响应
|
||||
|
||||
## 配置字段
|
||||
|
||||
| 字段 | 类型 | 必填 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `apiKey` | secret | 是 | 中转渠道 API Key |
|
||||
|
||||
## 模型
|
||||
|
||||
- `gemini-3.8-flash-high`
|
||||
- `gemini-pro-agent`
|
||||
|
||||
## 统一字段映射
|
||||
|
||||
| 统一字段 | 类型 | 必填 | 上游映射 |
|
||||
| --- | --- | --- | --- |
|
||||
| `model` | string | 是 | `model` |
|
||||
| `messages` | message[] | 是 | `messages` |
|
||||
| `instructions` | string | 否 | `system/instructions` |
|
||||
| `temperature` | number | 否 | `temperature` |
|
||||
| `top_p` | number | 否 | `top_p` |
|
||||
| `max_tokens` | integer | 否 | `max_tokens` |
|
||||
| `tools` | array | 否 | `tools` |
|
||||
| `tool_choice` | object\|string | 否 | `tool_choice` |
|
||||
| `response_format` | object | 否 | `response_format` |
|
||||
| `stream` | boolean | 否 | `stream` |
|
||||
|
||||
完整接口见 [docs/interface.md](docs/interface.md)。
|
||||
70
plugin-packages/src-antigravity-proxy/docs/interface.md
Normal file
70
plugin-packages/src-antigravity-proxy/docs/interface.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Antigravity Proxy 接口字段
|
||||
|
||||
## 协议身份
|
||||
|
||||
- 插件 ID:`antigravity-proxy`
|
||||
- Provider ID:`antigravity-chat`
|
||||
- 能力:`text`
|
||||
- 默认 Base URL:`http://103.242.14.110:8317`
|
||||
- 鉴权驱动:`bearer`
|
||||
- 创建:`POST /chat/completions`
|
||||
- 生命周期:同步响应
|
||||
|
||||
## 配置字段
|
||||
|
||||
| 字段 | 类型 | 必填 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `apiKey` | secret | 是 | 中转渠道 API Key |
|
||||
|
||||
## 统一字段映射
|
||||
|
||||
| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `model` | string | 是 | `model` | 上游模型 ID。 |
|
||||
| `messages` | message[] | 是 | `messages` | 包含历史消息和当前用户输入。 |
|
||||
| `instructions` | string | 否 | `system/instructions` | 系统指令。 |
|
||||
| `temperature` | number | 否 | `temperature` | 采样温度。 |
|
||||
| `top_p` | number | 否 | `top_p` | 核采样参数。 |
|
||||
| `max_tokens` | integer | 否 | `max_tokens/max_output_tokens` | 最大输出 token。 |
|
||||
| `tools` | array | 否 | `tools/toolConfig` | 工具定义。 |
|
||||
| `tool_choice` | object\|string | 否 | `tool_choice` | 工具选择策略。 |
|
||||
| `response_format` | object | 否 | `response_format/text` | 结构化输出配置。 |
|
||||
| `stream` | boolean | 否 | `stream` | 流式开关。 |
|
||||
|
||||
## 上游请求模板
|
||||
|
||||
| 上游位置 | 值或转换表达式 |
|
||||
| --- | --- |
|
||||
| `create.method` | `"POST"` |
|
||||
| `create.path` | `"/chat/completions"` |
|
||||
| `create.contentType` | `"application/json"` |
|
||||
| `create.body.model` | `{"$ref":"request.model"}` |
|
||||
| `create.body.messages` | `{"$ref":"request.messages"}` |
|
||||
| `create.body.temperature` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.temperature"}}` |
|
||||
| `create.body.top_p` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.top_p"}}` |
|
||||
| `create.body.max_tokens` | `{"$omitEmpty":{"$coalesce":[{"$ref":"request.extra.max_tokens"},{"$ref":"request.providerOptions.antigravity-chat.max_tokens"}]}}` |
|
||||
| `create.body.tools` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.tools"}}` |
|
||||
| `create.body.tool_choice` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.tool_choice"}}` |
|
||||
| `create.body.response_format` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.response_format"}}` |
|
||||
| `create.body.stream` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.stream"}}` |
|
||||
| `create.body.stop` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.stop"}}` |
|
||||
| `create.body.seed` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.seed"}}` |
|
||||
| `create.body.frequency_penalty` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.frequency_penalty"}}` |
|
||||
| `create.body.presence_penalty` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.presence_penalty"}}` |
|
||||
| `create.body.user` | `{"$omitEmpty":{"$ref":"request.providerOptions.antigravity-chat.user"}}` |
|
||||
|
||||
## 响应映射
|
||||
|
||||
| 映射位置 | 上游路径或转换表达式 |
|
||||
| --- | --- |
|
||||
| `response.status` | `"succeeded"` |
|
||||
| `response.textPaths[0]` | `"choices.0.message.content"` |
|
||||
| `response.textPaths[1]` | `"choices.0.text"` |
|
||||
| `response.reasoningPaths[0]` | `"choices.0.message.reasoning_content"` |
|
||||
| `response.usage` | `{"$ref":"response.usage"}` |
|
||||
| `response.errorPaths[0]` | `"error.code"` |
|
||||
| `response.messagePaths[0]` | `"error.message"` |
|
||||
|
||||
## 兼容边界
|
||||
|
||||
该包只代表 Antigravity 中转渠道的 OpenAI Chat Completions 线协议;其他端点或网关包装必须使用独立插件。
|
||||
68
plugin-packages/src-antigravity-proxy/manifest.json
Normal file
68
plugin-packages/src-antigravity-proxy/manifest.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"apiVersion": "yingce.plugin/v2",
|
||||
"id": "antigravity-proxy",
|
||||
"name": "Antigravity Proxy",
|
||||
"version": "1.0.0",
|
||||
"author": "Yingce",
|
||||
"description": "Antigravity 中转渠道插件:OpenAI 兼容对话(gemini-3.8-flash-high / gemini-pro-agent)。",
|
||||
"permissions": ["generation.run", "media.read"],
|
||||
"configuration": {
|
||||
"fields": [
|
||||
{ "name": "apiKey", "type": "secret", "label": "API Key", "required": true }
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "antigravity-chat",
|
||||
"label": "Antigravity Chat",
|
||||
"capabilities": ["text"],
|
||||
"scopes": ["admin.system-channel", "user.custom-channel", "canvas", "creation", "agent"],
|
||||
"baseUrl": "http://103.242.14.110:8317",
|
||||
"auth": { "type": "bearer", "field": "apiKey" },
|
||||
"parameters": [
|
||||
{ "name": "model", "type": "string", "required": true, "mapping": "model", "description": "模型 ID(gemini-3.8-flash-high / gemini-pro-agent)。" },
|
||||
{ "name": "messages", "type": "message[]", "required": true, "mapping": "provider message container", "description": "包含历史消息和当前用户输入。" },
|
||||
{ "name": "instructions", "type": "string", "required": false, "mapping": "system/instructions", "description": "系统指令。" },
|
||||
{ "name": "temperature", "type": "number", "required": false, "mapping": "temperature", "description": "采样温度。" },
|
||||
{ "name": "top_p", "type": "number", "required": false, "mapping": "top_p", "description": "核采样参数。" },
|
||||
{ "name": "max_tokens", "type": "integer", "required": false, "mapping": "max_tokens/max_output_tokens", "description": "最大输出 token。" },
|
||||
{ "name": "tools", "type": "array", "required": false, "mapping": "tools/toolConfig", "description": "工具定义。" },
|
||||
{ "name": "tool_choice", "type": "object|string", "required": false, "mapping": "tool_choice", "description": "工具选择策略。" },
|
||||
{ "name": "response_format", "type": "object", "required": false, "mapping": "response_format/text", "description": "结构化输出配置。" },
|
||||
{ "name": "stream", "type": "boolean", "required": false, "mapping": "stream", "description": "流式开关;后台任务当前以最终响应归一。" },
|
||||
{ "name": "providerOptions", "type": "object", "required": false, "mapping": "provider-specific fields", "description": "插件命名空间内的厂商扩展字段。" }
|
||||
],
|
||||
"create": {
|
||||
"method": "POST",
|
||||
"path": "/chat/completions",
|
||||
"contentType": "application/json",
|
||||
"body": {
|
||||
"model": { "$ref": "request.model" },
|
||||
"messages": { "$ref": "request.messages" },
|
||||
"temperature": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.temperature" } },
|
||||
"top_p": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.top_p" } },
|
||||
"max_tokens": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.extra.max_tokens" }, { "$ref": "request.providerOptions.antigravity-chat.max_tokens" } ] } },
|
||||
"tools": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.tools" } },
|
||||
"tool_choice": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.tool_choice" } },
|
||||
"response_format": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.response_format" } },
|
||||
"stream": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.stream" } },
|
||||
"stop": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.stop" } },
|
||||
"seed": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.seed" } },
|
||||
"frequency_penalty": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.frequency_penalty" } },
|
||||
"presence_penalty": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.presence_penalty" } },
|
||||
"user": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.user" } }
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": "succeeded",
|
||||
"textPaths": ["choices.0.message.content", "choices.0.text"],
|
||||
"reasoningPaths": ["choices.0.message.reasoning_content"],
|
||||
"usage": { "$ref": "response.usage" },
|
||||
"errorPaths": ["error.code"],
|
||||
"messagePaths": ["error.message"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
22
plugin-packages/src-metaso-h3/README.md
Normal file
22
plugin-packages/src-metaso-h3/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# METASO MiniMax H3
|
||||
|
||||
秘塔 METASO MiniMax H3 文生视频 / 图生视频协议插件(异步任务模式)。
|
||||
|
||||
## Provider
|
||||
|
||||
| Provider | 能力 | 端点 |
|
||||
|---|---|---|
|
||||
| metaso-h3 | video | POST /api/minimax/v2/video_generation(创建)<br>GET /api/minimax/v2/query/video_generation/{taskId}(轮询) |
|
||||
|
||||
## 配置
|
||||
|
||||
在渠道中填写 API Key(METASO 控制台创建,形如 `mk-xxxxxxxx`),baseUrl 使用 `https://metaso.cn`。
|
||||
|
||||
## 说明
|
||||
|
||||
- 模型:`MiniMax-H3`(支持 768P / 2K,4-15 秒,文生图/图生视频/多模态参考),`MiniMax-H3-Max`(480P / 768P,5-15 秒)。
|
||||
- 文生视频:仅文本提示词,ratio 必填(21:9 / 16:9 / 4:3 / 1:1 / 3:4 / 9:16)。
|
||||
- 图生视频:图片 role 为 first_frame / last_frame(首尾帧)。
|
||||
- 多模态参考:图片 reference_image / 视频 reference_video / 音频 reference_audio。
|
||||
- 异步任务:创建后返回 task_id,轮询查询接口直到 status=succeeded,成片 URL 在 task.content.url。
|
||||
- 鉴权:`Authorization: Bearer mk-xxx`。
|
||||
61
plugin-packages/src-metaso-h3/docs/interface.md
Normal file
61
plugin-packages/src-metaso-h3/docs/interface.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# METASO MiniMax H3 接口说明
|
||||
|
||||
## 鉴权
|
||||
|
||||
`Authorization: Bearer mk-xxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
## 创建视频生成任务
|
||||
|
||||
POST `https://metaso.cn/api/minimax/v2/video_generation`
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "MiniMax-H3",
|
||||
"content": [
|
||||
{ "type": "text", "text": "视频描述" },
|
||||
{ "type": "image_url", "image_url": { "url": "https://..." }, "role": "first_frame" },
|
||||
{ "type": "video_url", "video_url": { "url": "https://..." }, "role": "reference_video" },
|
||||
{ "type": "audio_url", "audio_url": { "url": "https://..." }, "role": "reference_audio" }
|
||||
],
|
||||
"resolution": "768P",
|
||||
"duration": 5,
|
||||
"ratio": "16:9"
|
||||
}
|
||||
```
|
||||
|
||||
- content 必须包含一个非空 text 项。
|
||||
- 首尾帧(first_frame/last_frame)与多模态参考(reference_*)互斥。
|
||||
- 图片 ≤ 9 张、视频 ≤ 3 段、音频 ≤ 3 段;请求体总大小 ≤ 64 MB。
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{ "task_id": "424010985738629" }
|
||||
```
|
||||
|
||||
## 查询任务
|
||||
|
||||
GET `https://metaso.cn/api/minimax/v2/query/video_generation/{task_id}`
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"task": {
|
||||
"id": "424010985738629",
|
||||
"status": "succeeded",
|
||||
"content": { "url": "https://.../output.mp4" },
|
||||
"resolution": "2K",
|
||||
"duration": 5,
|
||||
"usage": { "total_seconds": 5, "output_seconds": 5 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
status 取值:queued / running / succeeded / failed / cancelled。
|
||||
|
||||
## 错误
|
||||
|
||||
创建与查询均返回 OpenAI 风格错误体,含 `error.type` / `error.message` / `request_id`;余额不足为 402,速率限制为 429。
|
||||
100
plugin-packages/src-metaso-h3/manifest.json
Normal file
100
plugin-packages/src-metaso-h3/manifest.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"apiVersion": "yingce.plugin/v2",
|
||||
"id": "metaso-h3",
|
||||
"name": "METASO MiniMax H3",
|
||||
"version": "1.0.0",
|
||||
"author": "METASO",
|
||||
"description": "秘塔 METASO MiniMax H3 文生视频/图生视频协议插件(异步任务)。",
|
||||
"permissions": ["generation.run", "media.read"],
|
||||
"configuration": {
|
||||
"fields": [
|
||||
{ "name": "apiKey", "type": "secret", "label": "API Key", "required": true }
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "metaso-h3",
|
||||
"label": "METASO MiniMax H3",
|
||||
"capabilities": ["video"],
|
||||
"scopes": ["admin.system-channel", "user.custom-channel", "canvas", "creation", "agent"],
|
||||
"baseUrl": "https://metaso.cn",
|
||||
"auth": { "type": "bearer", "field": "apiKey" },
|
||||
"requiresPublicMediaUrls": true,
|
||||
"parameters": [
|
||||
{ "name": "model", "type": "string", "required": true, "mapping": "model", "description": "视频模型 ID(MiniMax-H3 / MiniMax-H3-Max)。" },
|
||||
{ "name": "prompt", "type": "string", "required": true, "mapping": "prompt/content/input", "description": "视频提示词。" },
|
||||
{ "name": "images", "type": "media[]", "required": false, "mapping": "first/last/reference image", "description": "显式 role 图片输入(首帧/尾帧/参考图)。" },
|
||||
{ "name": "videos", "type": "media[]", "required": false, "mapping": "reference video", "description": "参考视频。" },
|
||||
{ "name": "audios", "type": "media[]", "required": false, "mapping": "reference audio/voice", "description": "参考音频或音色。" },
|
||||
{ "name": "duration", "type": "integer", "required": false, "mapping": "duration/seconds", "description": "时长秒数(4-15)。" },
|
||||
{ "name": "aspectRatio", "type": "string", "required": false, "mapping": "ratio/aspect_ratio/size", "description": "画幅比例(16:9 等)。" },
|
||||
{ "name": "resolution", "type": "string", "required": false, "mapping": "resolution", "description": "分辨率档位(768P / 2K)。" },
|
||||
{ "name": "watermark", "type": "boolean", "required": false, "mapping": "watermark", "description": "AIGC 水印开关。" },
|
||||
{ "name": "providerOptions", "type": "object", "required": false, "mapping": "provider-specific fields", "description": "插件命名空间内的厂商扩展字段。" }
|
||||
],
|
||||
"create": {
|
||||
"method": "POST",
|
||||
"path": "/api/minimax/v2/video_generation",
|
||||
"contentType": "application/json",
|
||||
"body": {
|
||||
"model": { "$coalesce": [ { "$ref": "request.model" }, "MiniMax-H3" ] },
|
||||
"content": {
|
||||
"$concatArrays": [
|
||||
{ "$if": { "condition": { "$gt": [ { "$len": { "$ref": "request.prompt" } }, 0 ] }, "then": [ { "type": "text", "text": { "$ref": "request.prompt" } } ], "else": [] } },
|
||||
{ "$map": { "from": { "$ref": "request.images" }, "as": "media", "in": { "type": "image_url", "image_url": { "url": { "$coalesce": [ { "$ref": "media.url" }, { "$ref": "media.dataUrl" } ] } }, "role": { "$omitEmpty": { "$ref": "media.role" } } } } },
|
||||
{ "$map": { "from": { "$ref": "request.videos" }, "as": "media", "in": { "type": "video_url", "video_url": { "url": { "$coalesce": [ { "$ref": "media.url" }, { "$ref": "media.dataUrl" } ] } }, "role": { "$omitEmpty": { "$ref": "media.role" } } } } },
|
||||
{ "$map": { "from": { "$ref": "request.audios" }, "as": "media", "in": { "type": "audio_url", "audio_url": { "url": { "$coalesce": [ { "$ref": "media.url" }, { "$ref": "media.dataUrl" } ] } }, "role": { "$omitEmpty": { "$ref": "media.role" } } } } }
|
||||
]
|
||||
},
|
||||
"resolution": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.resolution" }, { "$ref": "request.providerOptions.metaso-h3.resolution" }, "768P" ] } },
|
||||
"duration": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.duration" }, { "$ref": "request.providerOptions.metaso-h3.duration" }, 5 ] } },
|
||||
"ratio": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.aspectRatio" }, { "$ref": "request.providerOptions.metaso-h3.ratio" }, "16:9" ] } },
|
||||
"aigc_watermark": { "$omitEmpty": { "$ref": "request.providerOptions.metaso-h3.aigc_watermark" } }
|
||||
}
|
||||
},
|
||||
"poll": {
|
||||
"method": "GET",
|
||||
"path": "/api/minimax/v2/query/video_generation/{{taskId}}"
|
||||
},
|
||||
"response": {
|
||||
"taskId": {
|
||||
"$coalesce": [
|
||||
{ "$ref": "response.task_id" },
|
||||
{ "$ref": "response.taskId" },
|
||||
{ "$ref": "response.data.task_id" }
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$coalesce": [
|
||||
{ "$ref": "response.task.status" },
|
||||
{ "$ref": "response.status" },
|
||||
{ "$ref": "response.data.status" },
|
||||
"pending"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"$coalesce": [
|
||||
{ "$ref": "response.task.error.message" },
|
||||
{ "$ref": "response.task.fail_reason" },
|
||||
{ "$ref": "response.error.message" },
|
||||
{ "$ref": "response.message" }
|
||||
]
|
||||
},
|
||||
"videos": {
|
||||
"$coalesce": [
|
||||
{ "$ref": "response.task.content.url" },
|
||||
{ "$ref": "response.task.content.video_url" },
|
||||
{ "$ref": "response.content.url" },
|
||||
{ "$ref": "response.data.url" }
|
||||
]
|
||||
},
|
||||
"usage": { "$ref": "response.task.usage" },
|
||||
"errorPaths": ["task.error.type", "error.type", "error.code"],
|
||||
"resultEphemeral": true,
|
||||
"messagePaths": ["task.error.message", "error.message"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
105
scripts/launch-canvas.ps1
Normal file
105
scripts/launch-canvas.ps1
Normal file
@@ -0,0 +1,105 @@
|
||||
# 影策画布一键启动器
|
||||
# 启动后端(8080) + 前端(3000) + cloudflared 隧道(公网 canvas.yingce.cc.cd),打开浏览器
|
||||
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = "D:\yingce\open-ai-canvas-main"
|
||||
$backendDir = Join-Path $repoRoot "backend"
|
||||
$webDir = Join-Path $repoRoot "web"
|
||||
$dataDir = Join-Path $repoRoot ".local\project-workbench-debug"
|
||||
$goBuildCache = Join-Path $repoRoot ".local\cache\go-build"
|
||||
$goModuleCache = Join-Path $repoRoot ".local\cache\go-mod"
|
||||
$cloudflaredExe = "C:\Program Files (x86)\cloudflared\cloudflared.exe"
|
||||
$bunExe = "C:\Users\Administrator\AppData\Roaming\npm\node_modules\bun\bin\bun.exe"
|
||||
if (-not (Test-Path -LiteralPath $bunExe)) { $bunExe = "bun" }
|
||||
$localUrl = "http://localhost:3000"
|
||||
$healthUrl = "http://127.0.0.1:3000/api/health"
|
||||
|
||||
function Test-PortListen([int]$Port) {
|
||||
try {
|
||||
return $null -ne (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop | Select-Object -First 1)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-Http([string]$Url) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 3
|
||||
return $r.StatusCode -ge 200 -and $r.StatusCode -lt 400
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# 0) 已全部就绪则直接打开
|
||||
if ((Test-PortListen 8080) -and (Test-PortListen 3000)) {
|
||||
Write-Host "影策画布已在运行,正在打开..." -ForegroundColor Green
|
||||
Start-Process $localUrl
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 1) 后端:8080
|
||||
if (-not (Test-PortListen 8080)) {
|
||||
Write-Host "启动后端 (8080)..." -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $dataDir, $goBuildCache, $goModuleCache | Out-Null
|
||||
$env:CANVAS_BACKEND_ADDR = "127.0.0.1:8080"
|
||||
$env:CANVAS_BACKEND_DATA_DIR = $dataDir
|
||||
$env:CANVAS_PUBLIC_BASE_URL = "https://canvas.yingce.cc.cd"
|
||||
$env:GOPROXY = "https://goproxy.cn,direct"
|
||||
$env:GOCACHE = $goBuildCache
|
||||
$env:GOMODCACHE = $goModuleCache
|
||||
Start-Process -FilePath "go" -ArgumentList "run", "./cmd/server" `
|
||||
-WorkingDirectory $backendDir `
|
||||
-RedirectStandardOutput (Join-Path $repoRoot ".local\backend.stdout.log") `
|
||||
-RedirectStandardError (Join-Path $repoRoot ".local\backend.stderr.log") `
|
||||
-WindowStyle Hidden | Out-Null
|
||||
}
|
||||
|
||||
# 2) 前端:3000
|
||||
if (-not (Test-PortListen 3000)) {
|
||||
Write-Host "启动前端 (3000)..." -ForegroundColor Cyan
|
||||
$viteBinary = Join-Path $webDir "node_modules\.bin\vite"
|
||||
if (-not (Test-Path -LiteralPath $viteBinary)) {
|
||||
Write-Host "安装前端依赖..." -ForegroundColor Yellow
|
||||
Push-Location $webDir
|
||||
try { & bun install --frozen-lockfile } finally { Pop-Location }
|
||||
}
|
||||
$env:VITE_API_PROXY_TARGET = "http://127.0.0.1:8080"
|
||||
Start-Process -FilePath $bunExe -ArgumentList "run", "dev" `
|
||||
-WorkingDirectory $webDir `
|
||||
-RedirectStandardOutput (Join-Path $repoRoot ".local\web.stdout.log") `
|
||||
-RedirectStandardError (Join-Path $repoRoot ".local\web.stderr.log") `
|
||||
-WindowStyle Hidden | Out-Null
|
||||
}
|
||||
|
||||
# 3) cloudflared 隧道(公网 canvas.yingce.cc.cd)
|
||||
if (-not (Get-Process -Name cloudflared -ErrorAction SilentlyContinue)) {
|
||||
if (Test-Path -LiteralPath $cloudflaredExe) {
|
||||
Write-Host "启动公网隧道 cloudflared..." -ForegroundColor Cyan
|
||||
Start-Process -FilePath $cloudflaredExe -ArgumentList "--config", "C:\Users\Administrator\.cloudflared\config.yml", "tunnel", "run" -WindowStyle Hidden | Out-Null
|
||||
} else {
|
||||
Write-Host "未找到 cloudflared,跳过公网隧道(本地仍可访问)" -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
Write-Host "公网隧道 cloudflared 已在运行" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# 4) 等待健康检查并打开浏览器
|
||||
$deadline = (Get-Date).AddMinutes(3)
|
||||
do {
|
||||
Start-Sleep -Seconds 2
|
||||
if (Test-Http $healthUrl) {
|
||||
Write-Host "影策画布已启动,正在打开浏览器..." -ForegroundColor Green
|
||||
Start-Sleep -Seconds 1
|
||||
Start-Process $localUrl
|
||||
Write-Host "本地: http://localhost:3000" -ForegroundColor Green
|
||||
Write-Host "公网: https://canvas.yingce.cc.cd" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
Write-Host "启动超时,请查看日志:.local\backend.stderr.log / .local\web.stderr.log" -ForegroundColor Red
|
||||
56
scripts/start-canvas.ps1
Normal file
56
scripts/start-canvas.ps1
Normal file
@@ -0,0 +1,56 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$localStarter = Join-Path $PSScriptRoot "start-local.ps1"
|
||||
$healthUrl = "http://127.0.0.1:3000/api/health"
|
||||
$canvasUrl = "http://localhost:3000/canvas"
|
||||
|
||||
function Test-CanvasHttp([string]$Url) {
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 3
|
||||
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 400
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ListeningPort([int]$Port) {
|
||||
try {
|
||||
return $null -ne (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop | Select-Object -First 1)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-CanvasHttp $healthUrl) {
|
||||
Write-Host "影策画布已经在运行,正在打开..." -ForegroundColor Green
|
||||
Start-Process $canvasUrl
|
||||
exit 0
|
||||
}
|
||||
|
||||
$occupiedPorts = @(3000, 8080) | Where-Object { Test-ListeningPort $_ }
|
||||
if ($occupiedPorts.Count -gt 0) {
|
||||
throw "端口 $($occupiedPorts -join ', ') 已被其他程序占用,无法安全启动影策。"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $localStarter)) {
|
||||
throw "未找到项目启动脚本:$localStarter"
|
||||
}
|
||||
|
||||
Write-Host "正在启动影策前端和后端,请稍候..." -ForegroundColor Cyan
|
||||
& $localStarter
|
||||
|
||||
$deadline = (Get-Date).AddMinutes(3)
|
||||
do {
|
||||
Start-Sleep -Seconds 2
|
||||
if (Test-CanvasHttp $healthUrl) {
|
||||
Write-Host "影策画布已启动,正在打开浏览器..." -ForegroundColor Green
|
||||
Start-Process $canvasUrl
|
||||
exit 0
|
||||
}
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
throw "影策服务已拉起,但 3 分钟内未通过健康检查。请查看打开的前端、后端窗口。"
|
||||
@@ -3,6 +3,9 @@ param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
. (Join-Path $PSScriptRoot "windows-proxy.ps1")
|
||||
Import-CanvasWindowsProxy
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$backendDir = Join-Path $repoRoot "backend"
|
||||
$webDir = Join-Path $repoRoot "web"
|
||||
|
||||
35
scripts/windows-proxy.ps1
Normal file
35
scripts/windows-proxy.ps1
Normal file
@@ -0,0 +1,35 @@
|
||||
# Go uses proxy environment variables, not Windows Internet Settings.
|
||||
# Inherit the user's enabled static proxy only when no explicit proxy was set.
|
||||
function Import-CanvasWindowsProxy {
|
||||
if ($env:HTTPS_PROXY -or $env:HTTP_PROXY -or $env:ALL_PROXY) { return }
|
||||
$settings = Get-ItemProperty -LiteralPath 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
|
||||
if (-not $settings -or $settings.ProxyEnable -ne 1 -or -not $settings.ProxyServer) { return }
|
||||
$proxies = @{}
|
||||
foreach ($entry in ($settings.ProxyServer -split ';')) {
|
||||
if ($entry -match '^\s*(https?)=(.+)$') {
|
||||
$proxies[$Matches[1]] = $Matches[2].Trim()
|
||||
} elseif ($entry -notmatch '=') {
|
||||
$proxies['http'] = $entry.Trim()
|
||||
$proxies['https'] = $entry.Trim()
|
||||
}
|
||||
}
|
||||
foreach ($scheme in @('http', 'https')) {
|
||||
$address = $proxies[$scheme]
|
||||
if (-not $address) { continue }
|
||||
if ($address -notmatch '^[a-z]+://') { $address = "http://$address" }
|
||||
$parsed = $null
|
||||
if (-not [Uri]::TryCreate($address, [UriKind]::Absolute, [ref]$parsed) -or $parsed.Scheme -notin @('http', 'https')) {
|
||||
throw 'Windows proxy address is invalid. Check system proxy settings.'
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable("$($scheme.ToUpper())_PROXY", $address, 'Process')
|
||||
}
|
||||
if ($env:HTTP_PROXY -or $env:HTTPS_PROXY) {
|
||||
$bypass = @($env:NO_PROXY, 'localhost', '127.0.0.1', '::1')
|
||||
foreach ($entry in ($settings.ProxyOverride -split ';')) {
|
||||
$entry = $entry.Trim()
|
||||
if ($entry -and $entry -ne '<local>') { $bypass += $entry }
|
||||
}
|
||||
$env:NO_PROXY = ($bypass | Where-Object { $_ } | Select-Object -Unique) -join ','
|
||||
Write-Host 'Backend network: using the enabled Windows proxy.' -ForegroundColor Cyan
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||
import { Button, Switch, Tooltip } from "antd";
|
||||
import { Image as ImageIcon, LoaderCircle, Minus, Play, Plus, Rows3, Trash2, Upload } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||
import { Button, Input, Select, Switch, Tooltip } from "antd";
|
||||
import { GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload } from "lucide-react";
|
||||
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||||
import {
|
||||
BATCH_REFERENCE_HANDLE_GAP,
|
||||
BATCH_REFERENCE_HANDLE_TOP,
|
||||
MAX_BATCH_REFERENCE_COLUMNS,
|
||||
MIN_BATCH_REFERENCE_COLUMNS,
|
||||
batchPromptForRow,
|
||||
batchReferenceColumns,
|
||||
batchReferenceHandleId,
|
||||
batchReferenceMentionToken,
|
||||
} from "@/lib/canvas/canvas-batch-table";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { BATCH_REFERENCE_HANDLE_GAP, BATCH_REFERENCE_HANDLE_TOP, MAX_BATCH_REFERENCE_COLUMNS, MAX_BATCH_TEXT_COLUMNS, batchGridTemplateColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextColumns, batchTextHandleId, batchTextHandleTop } from "@/lib/canvas/canvas-batch-table";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasBatchOperation, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type ReferenceCell = { rowId: string; columnIndex: number };
|
||||
|
||||
type Props = {
|
||||
node: CanvasNodeData;
|
||||
nodes: CanvasNodeData[];
|
||||
@@ -34,11 +21,11 @@ type Props = {
|
||||
onGenerate: (rowIds?: string[]) => void;
|
||||
onRetryItem: (batchId: string, itemId: string) => void;
|
||||
onAddReferenceColumn: () => void;
|
||||
onRemoveReferenceColumn?: () => void;
|
||||
onFocusOutput?: (nodeId: string) => void;
|
||||
onReorderReferenceColumns?: (fromColumnId: string, toColumnId: string) => void;
|
||||
onMoveReferenceCell?: (sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => void;
|
||||
onUploadReference?: (rowId: string, columnIndex: number, file: File) => void;
|
||||
onAddTextColumn: () => void;
|
||||
onReorderReferenceColumns: (fromColumnId: string, toColumnId: string) => void;
|
||||
onMoveReferenceCell: (sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => void;
|
||||
onReplaceReference: (node: CanvasNodeData) => void;
|
||||
onUploadReference: (rowId: string, columnIndex: number, file: File) => void;
|
||||
onConnectStart: (event: ReactPointerEvent, handleId: string) => void;
|
||||
onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void;
|
||||
readOnly?: boolean;
|
||||
@@ -49,415 +36,545 @@ const OPERATION_OPTIONS = [
|
||||
{ value: "creative", label: "创意生图" },
|
||||
] satisfies Array<{ value: CanvasBatchOperation; label: string }>;
|
||||
|
||||
const CONCURRENCY_OPTIONS = [1, 5, 10] as const;
|
||||
type ReferenceCell = { rowId: string; columnIndex: number };
|
||||
|
||||
export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onRemoveReferenceColumn, onReorderReferenceColumns, onMoveReferenceCell, onUploadReference, onFocusOutput, onConnectStart, onConnectDrop, readOnly = false }: Props) {
|
||||
export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onAddTextColumn, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference, onUploadReference, onConnectStart, onConnectDrop, readOnly = false }: Props) {
|
||||
const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] };
|
||||
const referenceColumns = batchReferenceColumns(table);
|
||||
const textColumns = batchTextColumns(table);
|
||||
const globalPrompt = table.globalPrompt || "";
|
||||
const hasGlobalPrompt = Boolean(globalPrompt.trim());
|
||||
const nodeById = useMemo(() => new Map(nodes.map((item) => [item.id, item])), [nodes]);
|
||||
const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]);
|
||||
const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id && connection.relation !== "batch-output").map((connection) => connection.fromNodeId)).size, [connections, node.id]);
|
||||
const completed = table.rows.filter((row) => hasNodeMedia(row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined)).length;
|
||||
const unfinishedReadyCount = table.rows.filter((row) => rowReady(row, table, nodeById) && !hasNodeMedia(row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined)).length;
|
||||
const gridTemplateColumns = `64px repeat(${referenceColumns.length}, 88px) minmax(280px, 1fr) 88px 80px`;
|
||||
const subtleSurface = `color-mix(in srgb, ${theme.node.text} 4%, transparent)`;
|
||||
const inputSurface = theme.node.panel;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadTargetRef = useRef<ReferenceCell | null>(null);
|
||||
const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id).map((connection) => connection.fromNodeId)).size, [connections, node.id]);
|
||||
const completed = table.rows.filter((row) => Boolean(row.outputNodeId && nodeById.get(row.outputNodeId)?.metadata?.content)).length;
|
||||
const gridTemplateColumns = batchGridTemplateColumns(referenceColumns.length, textColumns.length);
|
||||
const [draggingColumnId, setDraggingColumnId] = useState<string | null>(null);
|
||||
const [draggingCell, setDraggingCell] = useState<ReferenceCell | null>(null);
|
||||
const [dropCell, setDropCell] = useState<ReferenceCell | null>(null);
|
||||
const draggingCellRef = useRef<ReferenceCell | null>(null);
|
||||
const dropCellRef = useRef<ReferenceCell | null>(null);
|
||||
const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null);
|
||||
const lastPointerRef = useRef({ x: 0, y: 0 });
|
||||
const suppressClickRef = useRef(false);
|
||||
const uploadTargetRef = useRef<{ rowId: string; columnIndex: number } | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const clearReferenceDrag = useCallback(() => {
|
||||
dragStartRef.current = null;
|
||||
draggingCellRef.current = null;
|
||||
dropCellRef.current = null;
|
||||
setDraggingCell(null);
|
||||
setDropCell(null);
|
||||
}, []);
|
||||
|
||||
const findReferenceCellAtPoint = useCallback((clientX: number, clientY: number) => {
|
||||
const target = document.elementFromPoint(clientX, clientY)?.closest<HTMLElement>("[data-batch-reference-cell]");
|
||||
const rowId = target?.dataset.rowId;
|
||||
const columnIndex = Number(target?.dataset.columnIndex);
|
||||
const hit = document.elementsFromPoint(clientX, clientY)
|
||||
.map((element) => element.closest<HTMLElement>("[data-batch-reference-cell]"))
|
||||
.find((element): element is HTMLElement => Boolean(element));
|
||||
if (!hit) return null;
|
||||
const rowId = hit.dataset.batchRowId;
|
||||
const columnIndex = Number(hit.dataset.batchColumnIndex);
|
||||
return rowId && Number.isInteger(columnIndex) ? { rowId, columnIndex } : null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly) return;
|
||||
const handleMove = (event: PointerEvent) => {
|
||||
const start = dragStartRef.current;
|
||||
if (!start || start.pointerId !== event.pointerId) return;
|
||||
const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y);
|
||||
if (!draggingCellRef.current && distance < 5) return;
|
||||
if (!draggingCellRef.current) {
|
||||
draggingCellRef.current = start.cell;
|
||||
suppressClickRef.current = true;
|
||||
setDraggingCell(start.cell);
|
||||
}
|
||||
};
|
||||
const handleUp = (event: PointerEvent) => {
|
||||
const start = dragStartRef.current;
|
||||
if (!start || start.pointerId !== event.pointerId) return;
|
||||
const sourceCell = draggingCellRef.current;
|
||||
const targetCell = sourceCell ? findReferenceCellAtPoint(event.clientX, event.clientY) : null;
|
||||
if (sourceCell && targetCell) onMoveReferenceCell?.(sourceCell.rowId, sourceCell.columnIndex, targetCell.rowId, targetCell.columnIndex);
|
||||
const didDrag = Boolean(sourceCell);
|
||||
clearReferenceDrag();
|
||||
if (didDrag) {
|
||||
suppressClickRef.current = true;
|
||||
window.setTimeout(() => { suppressClickRef.current = false; }, 0);
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
window.addEventListener("pointerup", handleUp);
|
||||
window.addEventListener("pointercancel", handleUp);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
window.removeEventListener("pointercancel", handleUp);
|
||||
};
|
||||
}, [clearReferenceDrag, findReferenceCellAtPoint, onMoveReferenceCell, readOnly]);
|
||||
|
||||
const startCellDrag = (event: ReactPointerEvent, rowId: string, columnIndex: number) => {
|
||||
if (readOnly || event.button !== 0) return;
|
||||
event.stopPropagation();
|
||||
dragStartRef.current = { x: event.clientX, y: event.clientY, pointerId: event.pointerId, cell: { rowId, columnIndex } };
|
||||
};
|
||||
|
||||
const pickReferenceFile = (rowId: string, columnIndex: number) => {
|
||||
if (readOnly) return;
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
return;
|
||||
const updateReferenceDrag = useCallback((clientX: number, clientY: number) => {
|
||||
const start = dragStartRef.current;
|
||||
if (!start || readOnly) return;
|
||||
const distance = Math.hypot(clientX - start.x, clientY - start.y);
|
||||
if (!draggingCellRef.current && distance < 5) return;
|
||||
if (!draggingCellRef.current) {
|
||||
draggingCellRef.current = start.cell;
|
||||
setDraggingCell(start.cell);
|
||||
suppressClickRef.current = true;
|
||||
}
|
||||
uploadTargetRef.current = { rowId, columnIndex };
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
const nextDropCell = findReferenceCellAtPoint(clientX, clientY);
|
||||
dropCellRef.current = nextDropCell;
|
||||
setDropCell(nextDropCell);
|
||||
}, [findReferenceCellAtPoint, readOnly]);
|
||||
|
||||
const finishReferenceDrag = useCallback((clientX: number, clientY: number, cancel = false) => {
|
||||
const start = dragStartRef.current;
|
||||
if (!start) return;
|
||||
const sourceCell = draggingCellRef.current;
|
||||
const targetCell = cancel ? null : dropCellRef.current || findReferenceCellAtPoint(clientX, clientY);
|
||||
if (sourceCell && targetCell) {
|
||||
onMoveReferenceCell(sourceCell.rowId, sourceCell.columnIndex, targetCell.rowId, targetCell.columnIndex);
|
||||
}
|
||||
clearReferenceDrag();
|
||||
window.setTimeout(() => { suppressClickRef.current = false; }, 0);
|
||||
}, [clearReferenceDrag, findReferenceCellAtPoint, onMoveReferenceCell]);
|
||||
|
||||
const cancelReferenceDrag = useCallback(() => {
|
||||
if (!dragStartRef.current) return;
|
||||
clearReferenceDrag();
|
||||
window.setTimeout(() => { suppressClickRef.current = false; }, 0);
|
||||
}, [clearReferenceDrag]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowPointerMove = (event: PointerEvent) => {
|
||||
const start = dragStartRef.current;
|
||||
if (!start || event.pointerId !== start.pointerId) return;
|
||||
const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y);
|
||||
if (distance >= 5) event.preventDefault();
|
||||
updateReferenceDrag(event.clientX, event.clientY);
|
||||
};
|
||||
const handleWindowPointerUp = (event: PointerEvent) => {
|
||||
const start = dragStartRef.current;
|
||||
if (!start || event.pointerId !== start.pointerId) return;
|
||||
finishReferenceDrag(event.clientX, event.clientY);
|
||||
};
|
||||
const handleWindowPointerCancel = (event: PointerEvent) => {
|
||||
const start = dragStartRef.current;
|
||||
if (!start || event.pointerId !== start.pointerId) return;
|
||||
cancelReferenceDrag();
|
||||
};
|
||||
const handleWindowMouseUp = () => {
|
||||
if (!dragStartRef.current) return;
|
||||
finishReferenceDrag(lastPointerRef.current.x, lastPointerRef.current.y);
|
||||
};
|
||||
const handleWindowBlur = () => cancelReferenceDrag();
|
||||
window.addEventListener("pointermove", handleWindowPointerMove, { passive: false });
|
||||
window.addEventListener("pointerup", handleWindowPointerUp, true);
|
||||
window.addEventListener("pointercancel", handleWindowPointerCancel, true);
|
||||
window.addEventListener("mouseup", handleWindowMouseUp, true);
|
||||
window.addEventListener("blur", handleWindowBlur);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handleWindowPointerMove);
|
||||
window.removeEventListener("pointerup", handleWindowPointerUp, true);
|
||||
window.removeEventListener("pointercancel", handleWindowPointerCancel, true);
|
||||
window.removeEventListener("mouseup", handleWindowMouseUp, true);
|
||||
window.removeEventListener("blur", handleWindowBlur);
|
||||
};
|
||||
}, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]);
|
||||
|
||||
const handleReferencePointerDown = useCallback((event: ReactPointerEvent<HTMLButtonElement>, rowId: string, columnIndex: number, hasMaterial: boolean) => {
|
||||
if (readOnly || !hasMaterial) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
lastPointerRef.current = { x: event.clientX, y: event.clientY };
|
||||
dragStartRef.current = { x: event.clientX, y: event.clientY, pointerId: event.pointerId, cell: { rowId, columnIndex } };
|
||||
suppressClickRef.current = false;
|
||||
}, [readOnly]);
|
||||
|
||||
const handleReferencePointerMove = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
lastPointerRef.current = { x: event.clientX, y: event.clientY };
|
||||
updateReferenceDrag(event.clientX, event.clientY);
|
||||
}, [updateReferenceDrag]);
|
||||
|
||||
const handleReferencePointerUp = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
finishReferenceDrag(event.clientX, event.clientY);
|
||||
}, [finishReferenceDrag]);
|
||||
|
||||
const handleReferencePointerCancel = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
cancelReferenceDrag();
|
||||
}, [cancelReferenceDrag]);
|
||||
|
||||
return (
|
||||
<div data-canvas-batch-table data-canvas-no-zoom data-canvas-wheel-scroll className="relative flex h-full w-full flex-col overflow-visible text-xs" style={{ color: theme.node.text }}>
|
||||
{!readOnly ? <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
const target = uploadTargetRef.current;
|
||||
event.currentTarget.value = "";
|
||||
uploadTargetRef.current = null;
|
||||
if (file && target) onUploadReference?.(target.rowId, target.columnIndex, file);
|
||||
}} /> : null}
|
||||
{!readOnly ? <BatchReferenceHandles columns={referenceColumns} theme={theme} onConnectStart={onConnectStart} onConnectDrop={onConnectDrop} /> : null}
|
||||
|
||||
<div className="shrink-0 overflow-hidden rounded-t-[inherit] border-b" style={{ borderColor: theme.node.stroke, background: subtleSurface }}>
|
||||
<div data-canvas-batch-drag className="flex h-11 cursor-grab items-center gap-2 px-3 active:cursor-grabbing">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<BatchChoiceGroup ariaLabel="批量任务类型" theme={theme} disabled={readOnly} options={OPERATION_OPTIONS} value={table.operation} onChange={(operation) => onPatchTable({ operation: operation as CanvasBatchOperation })} />
|
||||
<div className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-2" style={{ background: theme.node.panel }}>
|
||||
<span className="font-medium" style={{ color: theme.node.muted }}>并发</span>
|
||||
<BatchChoiceGroup ariaLabel="并发数" theme={theme} disabled={readOnly} compact options={CONCURRENCY_OPTIONS.map((value) => ({ value, label: String(value) }))} value={table.concurrency} onChange={(concurrency) => onPatchTable({ concurrency: Number(concurrency) })} />
|
||||
</div>
|
||||
<div className="flex h-8 shrink-0 items-center gap-0.5 rounded-lg px-2" style={{ background: theme.node.panel, color: theme.node.muted }}>
|
||||
<span className="pr-1">{referenceColumns.length}/{MAX_BATCH_REFERENCE_COLUMNS} 组参考</span>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Tooltip title={referenceColumns.length <= MIN_BATCH_REFERENCE_COLUMNS ? "至少保留 1 组参考图" : "减少一组参考图"}>
|
||||
<button type="button" aria-label="减少一组参考图" className="grid size-5 place-items-center rounded-md transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-1 dark:hover:bg-white/10" style={{ color: theme.node.text }} disabled={referenceColumns.length <= MIN_BATCH_REFERENCE_COLUMNS} onClick={onRemoveReferenceColumn}>
|
||||
<Minus className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title={referenceColumns.length >= MAX_BATCH_REFERENCE_COLUMNS ? "最多支持 6 组参考图" : `新增参考图 ${referenceColumns.length + 1}`}>
|
||||
<button type="button" aria-label={`新增参考图 ${referenceColumns.length + 1}`} className="grid size-5 place-items-center rounded-md transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-1 dark:hover:bg-white/10" style={{ color: theme.node.text }} disabled={referenceColumns.length >= MAX_BATCH_REFERENCE_COLUMNS} onClick={onAddReferenceColumn}>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 tabular-nums" style={{ color: theme.node.muted }}>已连 {connectedImageCount} · 完成 {completed}/{table.rows.length}</span>
|
||||
<div
|
||||
data-canvas-no-zoom
|
||||
data-canvas-wheel-scroll
|
||||
data-canvas-batch-table
|
||||
className="relative flex h-full w-full flex-col overflow-visible pt-9 text-xs"
|
||||
style={{ color: theme.node.text }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => {
|
||||
// Only the top padding is a node drag surface; keep child controls isolated.
|
||||
if (event.target !== event.currentTarget) event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={uploadInputRef}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
const target = uploadTargetRef.current;
|
||||
uploadTargetRef.current = null;
|
||||
event.currentTarget.value = "";
|
||||
if (file && target) onUploadReference(target.rowId, target.columnIndex, file);
|
||||
}}
|
||||
/>
|
||||
{!readOnly ? <BatchReferenceHandles columns={referenceColumns} textColumns={textColumns} theme={theme} onAdd={onAddReferenceColumn} onAddText={onAddTextColumn} onConnectStart={onConnectStart} onConnectDrop={onConnectDrop} /> : null}
|
||||
<div
|
||||
className="flex min-h-12 flex-wrap items-center gap-2 border-b px-3 py-2"
|
||||
style={{ borderColor: theme.node.stroke, background: theme.node.panel }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Select size="small" value={table.operation} options={OPERATION_OPTIONS} disabled={readOnly} onChange={(operation) => onPatchTable({ operation })} className="w-28" aria-label="批量任务类型" />
|
||||
<span className="opacity-55">并发</span>
|
||||
<Select
|
||||
size="small"
|
||||
value={table.concurrency}
|
||||
options={[10, 50, 100].map((value) => ({ value, label: String(value) }))}
|
||||
disabled={readOnly}
|
||||
onChange={(concurrency) => onPatchTable({ concurrency })}
|
||||
className="w-20"
|
||||
aria-label="并发数"
|
||||
/>
|
||||
<span className="opacity-55">
|
||||
{referenceColumns.length} 组参考 · 已连 {connectedImageCount} 张图 · 完成 {completed}/{table.rows.length}
|
||||
</span>
|
||||
{!readOnly ? (
|
||||
<div className="flex min-w-[280px] max-w-[min(42vw,520px)] flex-1 items-center gap-2">
|
||||
<span className="shrink-0 font-medium" style={{ color: theme.node.muted }}>全局提示词</span>
|
||||
<Input.TextArea
|
||||
size="small"
|
||||
value={globalPrompt}
|
||||
autoSize={{ minRows: 1, maxRows: 2 }}
|
||||
placeholder="输入一次,覆盖所有任务提示词"
|
||||
aria-label="全局提示词"
|
||||
onChange={(event) => onPatchTable({ globalPrompt: event.target.value })}
|
||||
/>
|
||||
{hasGlobalPrompt ? <span className="shrink-0 text-[11px]" style={{ color: theme.accent.primary }}>已覆盖全部</span> : null}
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<Tooltip title="增量同步画布连线,不会删除已有任务行">
|
||||
<Button size="small" type="text" icon={<Rows3 className="size-3.5" />} onClick={onFillRows}>同步连线</Button>
|
||||
</Tooltip>
|
||||
<Button size="small" type="text" icon={<Plus className="size-3.5" />} onClick={onAddRow}>添加任务</Button>
|
||||
<Button size="small" type="primary" icon={<Play className="size-3.5" />} disabled={!unfinishedReadyCount} onClick={() => onGenerate()}>
|
||||
生成未完成项{unfinishedReadyCount ? ` · ${unfinishedReadyCount}` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex h-9 items-center gap-2 border-t px-3" style={{ borderColor: theme.node.stroke }} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<span className="shrink-0 font-medium" style={{ color: theme.node.muted }}>全局提示词</span>
|
||||
<input
|
||||
value={globalPrompt}
|
||||
readOnly={readOnly}
|
||||
placeholder="填写后覆盖各任务提示词,留空则使用每行自己的提示词"
|
||||
aria-label="全局提示词"
|
||||
className="h-7 min-w-0 flex-1 rounded-md border px-2.5 text-xs outline-none"
|
||||
style={{ background: inputSurface, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onChange={(event) => onPatchTable({ globalPrompt: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{!readOnly ? (
|
||||
<div className="ml-auto flex gap-1.5">
|
||||
<Button size="small" icon={<Rows3 className="size-3.5" />} onClick={onFillRows}>
|
||||
同步连线
|
||||
</Button>
|
||||
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onAddRow}>
|
||||
加一行
|
||||
</Button>
|
||||
<Button size="small" type="primary" icon={<Play className="size-3.5" />} disabled={!table.rows.length} onClick={() => onGenerate()}>
|
||||
生成未完成项
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto rounded-b-[inherit]" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<div className="sticky top-0 z-10 grid h-9 items-center border-b px-3 text-center text-[11px] font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.panel, color: theme.node.muted, gridTemplateColumns }}>
|
||||
<span className="min-w-0 truncate px-1">任务</span>
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-b-[inherit]" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="sticky top-0 z-10 grid min-w-[820px] items-center gap-2 border-b px-3 py-2 font-medium" style={{ borderColor: theme.node.stroke, gridTemplateColumns, background: theme.node.panel, color: theme.node.muted }}>
|
||||
<span>#</span>
|
||||
<span>任务</span>
|
||||
{referenceColumns.map((column) => (
|
||||
<span
|
||||
key={column.id}
|
||||
draggable={!readOnly}
|
||||
title={column.label}
|
||||
className="min-w-0 cursor-grab truncate px-1 active:cursor-grabbing"
|
||||
style={{ opacity: draggingColumnId === column.id ? 0.45 : 1 }}
|
||||
onDragStart={() => setDraggingColumnId(column.id)}
|
||||
onDragEnd={() => setDraggingColumnId(null)}
|
||||
onDragOver={(event) => { event.preventDefault(); }}
|
||||
onDrop={() => { if (draggingColumnId) onReorderReferenceColumns?.(draggingColumnId, column.id); setDraggingColumnId(null); }}
|
||||
>
|
||||
{column.label}
|
||||
</span>
|
||||
<ReferenceColumnHeader key={column.id} column={column} theme={theme} readOnly={readOnly} dragging={draggingColumnId === column.id} onDragStart={() => setDraggingColumnId(column.id)} onDragEnd={() => setDraggingColumnId(null)} onDrop={(targetColumnId) => { if (draggingColumnId) onReorderReferenceColumns(draggingColumnId, targetColumnId); setDraggingColumnId(null); }} />
|
||||
))}
|
||||
<span className="min-w-0 truncate px-1">任务提示词</span>
|
||||
<span className="min-w-0 truncate px-1">生成结果</span>
|
||||
<span className="min-w-0 truncate px-1">操作</span>
|
||||
{textColumns.map((column, index) => (
|
||||
<span key={column.id} className={index === 0 ? "border-l pl-3" : undefined} style={index === 0 ? { borderColor: theme.node.stroke } : undefined}>{column.label}</span>
|
||||
))}
|
||||
<span>任务提示词</span>
|
||||
<span>生成结果</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{table.rows.length ? (
|
||||
table.rows.map((row, index) => {
|
||||
const output = row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined;
|
||||
const item = batchItemByRowId.get(row.id);
|
||||
const status = row.enabled ? rowStatus(item, output) : { label: "已停用", tone: "idle" as const, loading: false, retryable: false };
|
||||
const ready = rowReady(row, table, nodeById);
|
||||
const completedRow = hasNodeMedia(output);
|
||||
const references = batchRowMentionReferences(row, referenceColumns, nodeById);
|
||||
const effectivePrompt = batchPromptForRow(table, row);
|
||||
const disabledReason = status.loading ? "当前任务正在生成" : !row.enabled ? "请先启用这一行" : !effectivePrompt.trim() ? "请填写任务提示词" : table.operation === "try_on" && row.inputNodeIds.filter(Boolean).length < 2 ? "批量换装至少需要两张参考图" : !ready ? "请补齐有效参考图" : "";
|
||||
const status = rowStatus(item, output);
|
||||
return (
|
||||
<div key={row.id} className="group grid items-center border-b px-3 py-3 transition-colors hover:bg-black/[.025] dark:hover:bg-white/[.025]" style={{ borderColor: theme.node.stroke, gridTemplateColumns, opacity: row.enabled ? 1 : 0.58 }}>
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{!readOnly ? <Switch size="small" checked={row.enabled} aria-label={`启用任务 ${index + 1}`} onChange={(enabled) => onUpdateRow(row.id, { enabled })} /> : null}
|
||||
<span className="tabular-nums" style={{ color: theme.node.muted }}>{index + 1}</span>
|
||||
<div key={row.id} className="grid min-w-[820px] items-center gap-2 border-b px-3 py-3" style={{ borderColor: theme.node.stroke, gridTemplateColumns }}>
|
||||
<span className="tabular-nums opacity-45">{index + 1}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch size="small" checked={row.enabled !== false} disabled={readOnly} onChange={(enabled) => onUpdateRow(row.id, { enabled })} aria-label={`任务 ${index + 1}`} />
|
||||
<span className="text-[10px]" style={{ color: row.enabled === false ? theme.node.placeholder : theme.accent.primary }}>{row.enabled === false ? "关" : "开"}</span>
|
||||
</div>
|
||||
{referenceColumns.map((column, columnIndex) => (
|
||||
<div key={column.id} className="flex justify-center">
|
||||
<ReferenceThumbnail
|
||||
node={nodeById.get(row.inputNodeIds[columnIndex])}
|
||||
label={batchReferenceMentionToken(columnIndex)}
|
||||
theme={theme}
|
||||
readOnly={readOnly}
|
||||
rowId={row.id}
|
||||
columnIndex={columnIndex}
|
||||
isDraggingCell={draggingCell?.rowId === row.id && draggingCell.columnIndex === columnIndex}
|
||||
onPickFile={() => pickReferenceFile(row.id, columnIndex)}
|
||||
onUploadFile={(file) => onUploadReference?.(row.id, columnIndex, file)}
|
||||
onPointerDown={(event) => startCellDrag(event, row.id, columnIndex)}
|
||||
/>
|
||||
<ReferenceThumbnail
|
||||
key={column.id}
|
||||
node={nodeById.get(row.inputNodeIds[columnIndex])}
|
||||
theme={theme}
|
||||
readOnly={readOnly}
|
||||
rowId={row.id}
|
||||
columnIndex={columnIndex}
|
||||
columnId={column.id}
|
||||
draggingColumnId={draggingColumnId}
|
||||
isDraggingCell={draggingCell?.rowId === row.id && draggingCell.columnIndex === columnIndex}
|
||||
isDropTarget={dropCell?.rowId === row.id && dropCell.columnIndex === columnIndex}
|
||||
suppressClickRef={suppressClickRef}
|
||||
onReplace={onReplaceReference}
|
||||
onUpload={() => { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }}
|
||||
onPointerDown={handleReferencePointerDown}
|
||||
onPointerMove={handleReferencePointerMove}
|
||||
onPointerUp={handleReferencePointerUp}
|
||||
onPointerCancel={handleReferencePointerCancel}
|
||||
onLostPointerCapture={handleReferencePointerCancel}
|
||||
/>
|
||||
))}
|
||||
{textColumns.map((column, columnIndex) => (
|
||||
<div key={column.id} className={columnIndex === 0 ? "border-l pl-3" : undefined} style={columnIndex === 0 ? { borderColor: theme.node.stroke } : undefined}>
|
||||
<TextNodeCell node={row.textNodeIds?.[columnIndex] ? nodeById.get(row.textNodeIds[columnIndex]) : undefined} theme={theme} />
|
||||
</div>
|
||||
))}
|
||||
<div className="min-w-0 pr-3">
|
||||
<CanvasResourceMentionTextarea
|
||||
value={row.prompt}
|
||||
references={references}
|
||||
readOnly={readOnly}
|
||||
sendOnEnter={false}
|
||||
mentionMenuWidth={300}
|
||||
aria-label={`任务 ${index + 1} 提示词`}
|
||||
placeholder={hasGlobalPrompt ? "已使用全局提示词,可在此填写行级覆盖" : "描述生成目标,输入 @ 引用本行参考图"}
|
||||
containerClassName="h-[108px]"
|
||||
className="thin-scrollbar h-full w-full overflow-y-auto rounded-lg border px-3 py-2 text-xs leading-5 outline-none transition-shadow focus-visible:ring-2"
|
||||
style={{ background: inputSurface, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onChange={(prompt) => onUpdateRow(row.id, { prompt })}
|
||||
onSubmit={!readOnly && ready && !status.loading ? () => onGenerate([row.id]) : undefined}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<div className="mt-1 truncate px-0.5 text-[10px]" style={{ color: theme.node.faint }}>
|
||||
{readOnly ? "输入 @ 插入参考图" : "输入 @ 插入参考图 · ⌘/Ctrl + Enter 生成此行"}
|
||||
<Input.TextArea value={batchPromptForRow(table, row)} readOnly={readOnly || hasGlobalPrompt} autoSize={{ minRows: 2, maxRows: 4 }} placeholder={hasGlobalPrompt ? "已由全局提示词覆盖" : "描述这一行要生成的画面"} onChange={(event) => onUpdateRow(row.id, { prompt: event.target.value })} />
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{output?.metadata?.content || output?.metadata?.storageKey ? (
|
||||
<CachedResourceImage
|
||||
eager
|
||||
src={output.metadata.previewContent || output.metadata.content}
|
||||
storageKey={output.metadata.storageKey}
|
||||
alt="生成结果"
|
||||
className="size-12 rounded object-cover"
|
||||
fallback={<EmptyThumbnail theme={theme} />}
|
||||
/>
|
||||
) : (
|
||||
<EmptyThumbnail theme={theme} />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{status.label}</div>
|
||||
{!readOnly && status.retryable && batch && item ? (
|
||||
<Button type="link" size="small" className="h-auto p-0 text-[11px]" icon={<RefreshCw className="size-3" />} onClick={() => onRetryItem(batch.id, item.id)}>
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<ResultThumbnail
|
||||
output={output}
|
||||
status={status}
|
||||
theme={theme}
|
||||
onFocus={() => { if (row.outputNodeId) onFocusOutput?.(row.outputNodeId); }}
|
||||
/>
|
||||
{status.loading ? <LoaderCircle className="ml-auto size-4 animate-spin opacity-55" /> : null}
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip title={disabledReason || (completedRow ? "重新生成这一行" : "只生成这一行")}>
|
||||
<Button type={completedRow ? "text" : "primary"} size="small" className="w-8 px-0" disabled={Boolean(disabledReason)} icon={status.loading ? <LoaderCircle className="size-3.5 animate-spin" /> : <Play className="size-3.5" />} onClick={() => onGenerate([row.id])} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除这一行"><Button type="text" size="small" className="w-7 px-0 opacity-60 transition-opacity group-hover:opacity-100" danger icon={<Trash2 className="size-3.5" />} onClick={() => onRemoveRow(row.id)} /></Tooltip>
|
||||
</div>
|
||||
) : <span />}
|
||||
<Tooltip title="移除这一行">
|
||||
<Button type="text" size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => onRemoveRow(row.id)} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="grid min-h-44 place-items-center px-5 text-center">
|
||||
<div className="flex max-w-sm flex-col items-center gap-2">
|
||||
<div className="grid size-10 place-items-center rounded-xl" style={{ background: theme.accent.primarySoft, color: theme.node.text }}><Rows3 className="size-5" /></div>
|
||||
<div className="font-medium">还没有批量任务</div>
|
||||
<p className="m-0 leading-5" style={{ color: theme.node.muted }}>把图片连接到左侧参考图端口后同步连线,或先添加一行手工配置。</p>
|
||||
{!readOnly ? <Button size="small" icon={<Plus className="size-3.5" />} onClick={onAddRow}>添加第一条任务</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid h-40 place-items-center px-5 text-center" style={{ color: theme.node.muted }}>连接图片后会自动生成任务;点击缩略图可替换素材,拖动参考图列可调整顺序。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchChoiceGroup({ ariaLabel, options, value, onChange, theme, compact = false, disabled = false }: { ariaLabel: string; options: Array<{ value: string | number; label: string }>; value: string | number; onChange: (value: string | number) => void; theme: CanvasTheme; compact?: boolean; disabled?: boolean }) {
|
||||
function ReferenceColumnHeader({ column, theme, readOnly, dragging, onDragStart, onDragEnd, onDrop }: { column: { id: string; label: string }; theme: CanvasTheme; readOnly: boolean; dragging: boolean; onDragStart: () => void; onDragEnd: () => void; onDrop: (columnId: string) => void }) {
|
||||
return (
|
||||
<div role="group" aria-label={ariaLabel} className="flex shrink-0 items-center rounded-lg p-0.5" style={{ background: theme.node.panel }}>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value;
|
||||
return <button key={option.value} type="button" aria-pressed={selected} disabled={disabled} className={`rounded-md font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-50 ${compact ? "min-w-7 px-1.5 py-1 text-[10px]" : "px-2.5 py-1.5 text-[11px]"}`} style={{ background: selected ? theme.accent.primary : "transparent", color: selected ? theme.accent.onPrimary : theme.node.muted }} onClick={() => onChange(option.value)}>{option.label}</button>;
|
||||
})}
|
||||
</div>
|
||||
<span
|
||||
draggable={!readOnly}
|
||||
className="flex min-w-0 items-center gap-1 rounded px-1 py-1 transition-opacity"
|
||||
style={{ opacity: dragging ? 0.45 : 1, cursor: readOnly ? "default" : "grab" }}
|
||||
title={readOnly ? column.label : `${column.label}:拖动调整顺序`}
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", column.id);
|
||||
onDragStart();
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(event) => { if (!readOnly) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; } }}
|
||||
onDrop={(event) => { event.preventDefault(); onDrop(column.id); }}
|
||||
>
|
||||
{!readOnly ? <GripVertical className="size-3 shrink-0 opacity-45" /> : null}
|
||||
<span className="truncate">{column.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchReferenceHandles({ columns, theme, onConnectStart, onConnectDrop }: { columns: ReturnType<typeof batchReferenceColumns>; theme: CanvasTheme; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void }) {
|
||||
const commonStyle = { left: 0, width: 36, height: 36, transform: "translate(-50%, -50%)", transformOrigin: "center" };
|
||||
return <>{columns.map((column, index) => <BatchReferenceHandle key={column.id} column={column} index={index} theme={theme} commonStyle={commonStyle} onConnectStart={onConnectStart} onConnectDrop={onConnectDrop} />)}</>;
|
||||
function BatchTextHandle(props: Omit<React.ComponentProps<typeof BatchReferenceHandle>, "column"> & { column: { id: string; label: string } }) {
|
||||
return <BatchReferenceHandle {...props} column={props.column} handlePrefix="text" />;
|
||||
}
|
||||
|
||||
function BatchReferenceHandle({ column, index, theme, commonStyle, onConnectStart, onConnectDrop }: { column: { id: string; label: string }; index: number; theme: CanvasTheme; commonStyle: { left: number; width: number; height: number; transform: string; transformOrigin: string }; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void }) {
|
||||
function BatchReferenceHandles({
|
||||
columns,
|
||||
textColumns,
|
||||
theme,
|
||||
onAdd,
|
||||
onAddText,
|
||||
onConnectStart,
|
||||
onConnectDrop,
|
||||
}: {
|
||||
columns: ReturnType<typeof batchReferenceColumns>;
|
||||
textColumns: ReturnType<typeof batchTextColumns>;
|
||||
theme: CanvasTheme;
|
||||
onAdd: () => void;
|
||||
onAddText: () => void;
|
||||
onConnectStart: (event: ReactPointerEvent, handleId: string) => void;
|
||||
onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void;
|
||||
}) {
|
||||
const commonStyle = { left: 0, width: 44, height: 44, transform: "translate(-50%, -50%)", transformOrigin: "center" };
|
||||
const referenceAddTop = BATCH_REFERENCE_HANDLE_TOP + columns.length * BATCH_REFERENCE_HANDLE_GAP;
|
||||
const textTop = batchTextHandleTop(columns.length);
|
||||
return (
|
||||
<>
|
||||
{columns.map((column, index) => (
|
||||
<BatchReferenceHandle key={column.id} column={column} top={BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP} badge={String(index + 1)} theme={theme} commonStyle={commonStyle} onConnectStart={onConnectStart} onConnectDrop={onConnectDrop} />
|
||||
))}
|
||||
{textColumns.map((column, index) => (
|
||||
<BatchTextHandle key={column.id} column={column} top={textTop + index * BATCH_REFERENCE_HANDLE_GAP} badge={`T${index + 1}`} theme={theme} commonStyle={commonStyle} onConnectStart={onConnectStart} onConnectDrop={onConnectDrop} />
|
||||
))}
|
||||
{columns.length < MAX_BATCH_REFERENCE_COLUMNS ? (
|
||||
<Tooltip title={`新增参考图 ${columns.length + 1}`} placement="left">
|
||||
<button type="button" aria-label={`新增参考图 ${columns.length + 1}`} className="group absolute z-[var(--node-z-handle)] grid place-items-center rounded-full outline-none" style={{ ...commonStyle, top: referenceAddTop, color: theme.accent.primary }} onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onAdd(); }}>
|
||||
<span className="grid size-[22px] place-items-center rounded-full border shadow-sm transition-transform group-hover:scale-110 group-focus-visible:scale-110" style={{ background: theme.node.panel, borderColor: theme.accent.primary }}><Plus className="size-3" /></span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{textColumns.length < MAX_BATCH_TEXT_COLUMNS ? (
|
||||
<Tooltip title={`新增文字 ${textColumns.length + 1}`} placement="left">
|
||||
<button type="button" aria-label={`新增文字 ${textColumns.length + 1}`} className="group absolute z-[var(--node-z-handle)] grid place-items-center rounded-full outline-none" style={{ ...commonStyle, top: textTop + textColumns.length * BATCH_REFERENCE_HANDLE_GAP, color: theme.node.text }} onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onAddText(); }}>
|
||||
<span className="grid size-[22px] place-items-center rounded-full border shadow-sm transition-transform group-hover:scale-110 group-focus-visible:scale-110" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}><Plus className="size-3" /></span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchReferenceHandle({
|
||||
column,
|
||||
top,
|
||||
badge,
|
||||
theme,
|
||||
commonStyle,
|
||||
onConnectStart,
|
||||
onConnectDrop,
|
||||
handlePrefix = "reference",
|
||||
}: {
|
||||
column: { id: string; label: string };
|
||||
top: number;
|
||||
badge: string;
|
||||
theme: CanvasTheme;
|
||||
commonStyle: { left: number; width: number; height: number; transform: string; transformOrigin: string };
|
||||
onConnectStart: (event: ReactPointerEvent, handleId: string) => void;
|
||||
onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void;
|
||||
handlePrefix?: "reference" | "text";
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const handleId = batchReferenceHandleId(column.id);
|
||||
const reset = useCallback(() => { setHovered(false); setOffset({ x: 0, y: 0 }); }, []);
|
||||
const handleId = handlePrefix === "text" ? batchTextHandleId(column.id) : batchReferenceHandleId(column.id);
|
||||
const reset = useCallback(() => {
|
||||
setHovered(false);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
}, []);
|
||||
const update = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const dx = event.clientX - (bounds.left + bounds.width / 2);
|
||||
const dy = event.clientY - (bounds.top + bounds.height / 2);
|
||||
const limit = 10;
|
||||
const limit = 16;
|
||||
setOffset({ x: Math.max(-limit, Math.min(limit, dx)), y: Math.max(-limit, Math.min(limit, dy)) });
|
||||
}, []);
|
||||
return (
|
||||
<Tooltip title={`连接到${column.label}`} placement="left">
|
||||
<button type="button" aria-label={`${column.label}连线点`} className="group absolute z-[var(--node-z-handle)] grid place-items-center rounded-full outline-none" style={{ ...commonStyle, top: BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP, cursor: "crosshair" }} onPointerEnter={(event) => { setHovered(true); update(event); }} onPointerMove={update} onPointerLeave={reset} onPointerDown={(event) => { event.stopPropagation(); onConnectStart(event, handleId); }} onPointerUp={(event) => { event.stopPropagation(); onConnectDrop?.(event, handleId); }}>
|
||||
<span className="grid size-[18px] place-items-center rounded-full border text-[8px] font-semibold shadow-sm transition-transform duration-100 group-hover:scale-125 group-focus-visible:scale-125" style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${hovered ? 1.06 : 1})`, background: theme.node.panel, borderColor: theme.accent.primary, color: theme.accent.primary }}>{index + 1}</span>
|
||||
<Tooltip title={`磁吸到${column.label}`} placement="left">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${column.label}连线点`}
|
||||
className="group absolute z-[var(--node-z-handle)] grid place-items-center rounded-full outline-none"
|
||||
style={{ ...commonStyle, top, cursor: "crosshair" }}
|
||||
onPointerEnter={(event) => {
|
||||
setHovered(true);
|
||||
update(event);
|
||||
}}
|
||||
onPointerMove={update}
|
||||
onPointerLeave={reset}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onConnectStart(event, handleId);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.stopPropagation();
|
||||
onConnectDrop?.(event, handleId);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="grid size-[22px] place-items-center rounded-full border text-[9px] font-semibold shadow-sm transition-transform duration-100 group-hover:scale-125 group-focus-visible:scale-125"
|
||||
style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${hovered ? 1.08 : 1})`, background: theme.node.panel, borderColor: handlePrefix === "text" ? theme.node.stroke : theme.accent.primary, color: handlePrefix === "text" ? theme.node.text : theme.accent.primary }}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function batchRowMentionReferences(row: CanvasBatchRow, columns: ReturnType<typeof batchReferenceColumns>, nodeById: Map<string, CanvasNodeData>): CanvasResourceReference[] {
|
||||
return columns.flatMap((column, index) => {
|
||||
const source = nodeById.get(row.inputNodeIds[index]);
|
||||
if (!source) return [];
|
||||
return [{ id: `${row.id}:${column.id}:${source.id}`, nodeId: source.id, kind: "image" as const, label: `参考图${index + 1}`, title: `${column.label} · ${source.title || "图片"}`, previewUrl: source.metadata?.previewContent || source.metadata?.content, storageKey: source.metadata?.storageKey, active: true, sourceType: source.type, mentionToken: batchReferenceMentionToken(index) }];
|
||||
});
|
||||
function TextNodeCell({ node, theme }: { node?: CanvasNodeData; theme: CanvasTheme }) {
|
||||
const content = node?.metadata?.content || node?.metadata?.prompt;
|
||||
return <div className="min-h-12 max-w-full overflow-hidden rounded border px-2 py-1.5 text-[11px] leading-4" style={{ borderColor: theme.node.stroke, color: content ? theme.node.text : theme.node.placeholder }} title={content || "未连接文字节点"}>{content || "未连接文字"}</div>;
|
||||
}
|
||||
|
||||
function ReferenceThumbnail({ node, label, theme, readOnly, rowId, columnIndex, isDraggingCell, onPickFile, onUploadFile, onPointerDown }: { node?: CanvasNodeData; label: string; theme: CanvasTheme; readOnly: boolean; rowId: string; columnIndex: number; isDraggingCell: boolean; onPickFile: () => void; onUploadFile: (file: File) => void; onPointerDown: (event: ReactPointerEvent) => void }) {
|
||||
const filled = node && hasNodeMedia(node);
|
||||
function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, onReplace, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: {
|
||||
node?: CanvasNodeData;
|
||||
theme: CanvasTheme;
|
||||
readOnly: boolean;
|
||||
rowId: string;
|
||||
columnIndex: number;
|
||||
columnId: string;
|
||||
draggingColumnId: string | null;
|
||||
isDraggingCell: boolean;
|
||||
isDropTarget: boolean;
|
||||
suppressClickRef: MutableRefObject<boolean>;
|
||||
onReplace: (node: CanvasNodeData) => void;
|
||||
onUpload: () => void;
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLButtonElement>, rowId: string, columnIndex: number, hasMaterial: boolean) => void;
|
||||
onPointerMove: (event: ReactPointerEvent<HTMLButtonElement>) => void;
|
||||
onPointerUp: (event: ReactPointerEvent<HTMLButtonElement>) => void;
|
||||
onPointerCancel: (event: ReactPointerEvent<HTMLButtonElement>) => void;
|
||||
onLostPointerCapture: (event: ReactPointerEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const cellData = { "data-batch-reference-cell": true, "data-batch-row-id": rowId, "data-batch-column-index": String(columnIndex) };
|
||||
const dropStyle = isDropTarget ? { boxShadow: `0 0 0 2px ${theme.accent.primary}, 0 0 0 5px ${theme.accent.primary}33` } : undefined;
|
||||
if (!node || (!node.metadata?.content && !node.metadata?.storageKey)) {
|
||||
return (
|
||||
<Tooltip title={readOnly ? "未连接参考图" : "点击上传参考图"}>
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
className="group relative grid size-16 min-w-0 place-items-center rounded-md border border-dashed outline-none transition-all focus-visible:ring-2"
|
||||
style={{ borderColor: isDropTarget ? theme.accent.primary : theme.node.stroke, background: `${theme.node.panel}88`, ...dropStyle }}
|
||||
disabled={readOnly}
|
||||
onClick={(event) => { event.stopPropagation(); if (!suppressClickRef.current) onUpload(); }}
|
||||
aria-label={`${columnId},点击上传图片`}
|
||||
{...cellData}
|
||||
onPointerDown={(event) => onPointerDown(event, rowId, columnIndex, false)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onLostPointerCapture={onLostPointerCapture}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
>
|
||||
<EmptyThumbnail theme={theme} sizeClass="size-16 border-0" />
|
||||
{!readOnly ? <span className="pointer-events-none absolute inset-x-1 bottom-1 rounded bg-black/60 px-1 py-1 text-center text-[9px] text-white opacity-0 transition-opacity group-hover:opacity-100">点击上传</span> : null}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const fallback = <EmptyThumbnail theme={theme} />;
|
||||
return (
|
||||
<Tooltip title={filled ? `${label} · ${node.title || "图片"}` : `${label} · 点击上传或拖入图片`}>
|
||||
<Tooltip title={readOnly ? (node.title || "图片") : `${node.title || "图片"} · 点击替换,拖动排序`}>
|
||||
<button
|
||||
type="button"
|
||||
data-batch-reference-cell
|
||||
data-row-id={rowId}
|
||||
data-column-index={columnIndex}
|
||||
className="relative size-16 overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: filled ? theme.node.stroke : "transparent", opacity: isDraggingCell ? 0.55 : 1 }}
|
||||
disabled={readOnly}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!readOnly) onPickFile();
|
||||
}}
|
||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/"));
|
||||
if (file && !readOnly) onUploadFile(file);
|
||||
}}
|
||||
draggable={false}
|
||||
className="group relative size-16 min-w-0 touch-none overflow-hidden rounded-md border outline-none transition-all hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2"
|
||||
style={{ borderColor: isDropTarget || draggingColumnId === columnId ? theme.accent.primary : theme.node.stroke, opacity: isDraggingCell || draggingColumnId === columnId ? 0.55 : 1, ...dropStyle }}
|
||||
aria-label={readOnly ? node.title || "参考图" : `${node.title || "参考图"},点击替换素材`}
|
||||
onClick={(event) => { event.stopPropagation(); if (!readOnly && !draggingColumnId && !suppressClickRef.current) onReplace(node); }}
|
||||
{...cellData}
|
||||
onPointerDown={(event) => onPointerDown(event, rowId, columnIndex, true)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onLostPointerCapture={onLostPointerCapture}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
>
|
||||
{filled ? <CachedResourceImage eager src={node.metadata?.previewContent || node.metadata?.content} storageKey={node.metadata?.storageKey} alt={node.title || "参考图"} className="size-16 object-cover" fallback={fallback} /> : fallback}
|
||||
<span className="absolute bottom-1 left-1 rounded px-1 py-0.5 text-[8px] font-medium text-white" style={{ background: "rgba(0,0,0,.58)" }}>{label}</span>
|
||||
<CachedResourceImage draggable={false} eager src={node.metadata.previewContent || node.metadata.content} storageKey={node.metadata.storageKey} alt={node.title || "参考图"} className="size-16 select-none object-cover" fallback={fallback} />
|
||||
{!readOnly ? <span className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-center gap-1 bg-black/55 px-1 py-1 text-[9px] text-white opacity-0 transition-opacity group-hover:opacity-100"><Upload className="size-3" />替换</span> : null}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultThumbnail({ output, status, theme, onFocus }: { output?: CanvasNodeData; status: ReturnType<typeof rowStatus>; theme: CanvasTheme; onFocus: () => void }) {
|
||||
const filled = hasNodeMedia(output);
|
||||
const tone = statusColor(status.tone, theme.node.stroke);
|
||||
const title = filled ? `${status.label} · 点击定位到画布节点` : status.label;
|
||||
function EmptyThumbnail({ theme, sizeClass = "size-12" }: { theme: CanvasTheme; sizeClass?: string }): ReactNode {
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={title}
|
||||
disabled={!output}
|
||||
className="relative size-16 overflow-hidden rounded-lg border-2"
|
||||
style={{ borderColor: tone, cursor: output ? "pointer" : "default" }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (output) onFocus();
|
||||
}}
|
||||
>
|
||||
{filled && output ? <CachedResourceImage eager src={output.metadata?.previewContent || output.metadata?.content} storageKey={output.metadata?.storageKey} alt="生成结果" className="size-16 object-cover" fallback={<EmptyThumbnail theme={theme} compact />} /> : <EmptyThumbnail theme={theme} compact />}
|
||||
{status.loading ? <span className="absolute inset-0 grid place-items-center bg-black/35"><LoaderCircle className="size-4 animate-spin" style={{ color: tone }} /></span> : null}
|
||||
<span className="absolute right-1 top-1 size-2 rounded-full" style={{ background: tone }} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(tone: RowStatusTone, fallback: string) {
|
||||
if (tone === "success") return "var(--status-success)";
|
||||
if (tone === "error") return "var(--status-error)";
|
||||
if (tone === "loading") return "var(--status-loading)";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function EmptyThumbnail({ theme, compact = false }: { theme: CanvasTheme; compact?: boolean }): ReactNode {
|
||||
const sizeClass = compact ? "size-12" : "size-16";
|
||||
return (
|
||||
<div
|
||||
className={`grid shrink-0 place-items-center rounded-lg border border-dashed ${sizeClass}`}
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.placeholder, background: `color-mix(in srgb, ${theme.node.text} 3%, transparent)` }}
|
||||
>
|
||||
{compact ? <ImageIcon className="size-4" /> : (
|
||||
<span className="flex flex-col items-center gap-0.5">
|
||||
<Upload className="size-4" />
|
||||
<span className="text-[9px] leading-none">上传</span>
|
||||
</span>
|
||||
)}
|
||||
<div className={`grid ${sizeClass} shrink-0 place-items-center rounded border`} style={{ borderColor: theme.node.stroke, color: theme.node.placeholder }}>
|
||||
<ImageIcon className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function rowReady(row: CanvasBatchRow, table: CanvasBatchTableData, nodeById: Map<string, CanvasNodeData>) {
|
||||
if (!row.enabled || !batchPromptForRow(table, row).trim()) return false;
|
||||
const inputNodeIds = row.inputNodeIds.filter(Boolean);
|
||||
if (table.operation === "try_on" && inputNodeIds.length < 2) return false;
|
||||
if (!inputNodeIds.length) return false;
|
||||
return inputNodeIds.every((id) => hasNodeMedia(nodeById.get(id)));
|
||||
}
|
||||
|
||||
function hasNodeMedia(node?: CanvasNodeData) {
|
||||
return Boolean(node?.metadata?.content || node?.metadata?.storageKey);
|
||||
}
|
||||
|
||||
type RowStatusTone = "success" | "error" | "loading" | "idle";
|
||||
type RowStatus = { label: string; tone: RowStatusTone; loading: boolean; retryable: boolean };
|
||||
|
||||
function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined): RowStatus {
|
||||
if (hasNodeMedia(output)) return { label: "生成完成", tone: "success", loading: false, retryable: false };
|
||||
if (item?.status === "failed") return { label: item.errorDetails || "生成失败", tone: "error", loading: false, retryable: true };
|
||||
if (item?.status === "cancelled") return { label: "已停止", tone: "error", loading: false, retryable: false };
|
||||
if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", tone: "loading", loading: true, retryable: false };
|
||||
if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", tone: "error", loading: false, retryable: false };
|
||||
return { label: "待生成", tone: "idle", loading: false, retryable: false };
|
||||
function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined) {
|
||||
if (output?.metadata?.content) return { label: "生成完成", loading: false, retryable: false };
|
||||
if (item?.status === "failed") return { label: item.errorDetails || "生成失败", loading: false, retryable: true };
|
||||
if (item?.status === "cancelled") return { label: "已停止", loading: false, retryable: false };
|
||||
if (item && ["waiting", "submitting", "queued", "running"].includes(item.status))
|
||||
return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", loading: true, retryable: false };
|
||||
if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", loading: false, retryable: false };
|
||||
return { label: "待生成", loading: false, retryable: false };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type SetStateAction } from "react";
|
||||
import { markdownPlainText } from "@/lib/markdown-plain-text";
|
||||
import { Button, Dropdown, Input } from "antd";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { ArrowLeft, Bot, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react";
|
||||
|
||||
@@ -30,6 +30,7 @@ type InfiniteCanvasProps = {
|
||||
|
||||
const CANVAS_WHEEL_IGNORE_SELECTOR = "[data-canvas-no-zoom],[data-canvas-wheel-scroll],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown";
|
||||
const CANVAS_POINTER_IGNORE_SELECTOR = "[data-canvas-no-zoom],[data-connection-create-menu],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown";
|
||||
const CANVAS_INTERNAL_DRAG_SELECTOR = "[data-canvas-batch-table]";
|
||||
const WHEEL_ZOOM_DELTA = 72;
|
||||
const TRACKPAD_PINCH_ZOOM_DELTA = 24;
|
||||
|
||||
@@ -441,13 +442,38 @@ export function InfiniteCanvas({ interactive = true, containerRef, viewport, app
|
||||
if (!target?.closest("[data-node-id],[data-connection-id],[data-canvas-no-zoom]")) onCanvasDoubleClick?.(event);
|
||||
}}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragEnter={onFileDragEnter}
|
||||
onDragLeave={onFileDragLeave}
|
||||
onDragEnter={(event) => {
|
||||
if (isCanvasInternalDragEvent(event)) {
|
||||
event.preventDefault();
|
||||
onFileDragEnter?.(event);
|
||||
return;
|
||||
}
|
||||
onFileDragEnter?.(event);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (isCanvasInternalDragEvent(event)) {
|
||||
event.preventDefault();
|
||||
onFileDragLeave?.(event);
|
||||
return;
|
||||
}
|
||||
onFileDragLeave?.(event);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (isCanvasInternalDragEvent(event)) {
|
||||
event.preventDefault();
|
||||
onFileDragOver?.(event);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onFileDragOver?.(event);
|
||||
}}
|
||||
onDrop={onDrop}
|
||||
onDrop={(event) => {
|
||||
if (isCanvasInternalDragEvent(event)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onDrop?.(event);
|
||||
}}
|
||||
>
|
||||
<CanvasGrid appearance={appearance} mode={backgroundMode} />
|
||||
{graphicsLayer}
|
||||
@@ -463,6 +489,11 @@ export function InfiniteCanvas({ interactive = true, containerRef, viewport, app
|
||||
);
|
||||
}
|
||||
|
||||
function isCanvasInternalDragEvent(event: React.DragEvent<HTMLDivElement>) {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
return Boolean(target?.closest(CANVAS_INTERNAL_DRAG_SELECTOR));
|
||||
}
|
||||
|
||||
function CanvasGrid({ appearance, mode }: { appearance?: CanvasAppearance; mode: CanvasBackgroundMode }) {
|
||||
const colorTheme = useActiveTheme();
|
||||
const gridColor = resolveCanvasGridColor(appearance, colorTheme, mode);
|
||||
|
||||
@@ -27,7 +27,7 @@ export const NODE_DEFAULT_SIZE = {
|
||||
[CanvasNodeType.Chart]: { width: 480, height: 320, title: "图表" },
|
||||
[CanvasNodeType.ColorGrade]: { width: 420, height: 360, title: "调色" },
|
||||
[CanvasNodeType.MediaConversion]: { width: 480, height: 460, title: "转换" },
|
||||
[CanvasNodeType.BatchTable]: { width: 1280, height: 560, title: "批量创作表" },
|
||||
[CanvasNodeType.BatchTable]: { width: 900, height: 520, title: "批量创作表" },
|
||||
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
|
||||
|
||||
export const NODE_SPECS = {
|
||||
|
||||
@@ -5,17 +5,82 @@ import type { CanvasBatchOperation, CanvasBatchReferenceColumn, CanvasBatchRow,
|
||||
export const TRY_ON_BATCH_PROMPT = "参考图1是人物原图,参考图2是目标服装。保持人物身份、五官、姿态和背景不变,将人物服装替换为参考图2中的款式。准确还原服装版型、颜色、材质、纹理和装饰细节,穿着关系自然,光影与原图一致。";
|
||||
export const CREATIVE_BATCH_PROMPT = "基于参考图创作一张新的商业图片,保留主体身份和关键产品细节,画面构图完整,光影自然。";
|
||||
export const BATCH_REFERENCE_HANDLE_PREFIX = "batch-reference:";
|
||||
/** 两行工具栏(操作栏 44 + 全局提示词 36 + 分隔 1)之后,对准表头中线。 */
|
||||
export const BATCH_REFERENCE_HANDLE_TOP = 102;
|
||||
export const BATCH_REFERENCE_HANDLE_GAP = 40;
|
||||
export const MIN_BATCH_REFERENCE_COLUMNS = 1;
|
||||
export const BATCH_REFERENCE_HANDLE_TOP = 112;
|
||||
export const BATCH_REFERENCE_HANDLE_GAP = 38;
|
||||
export const MAX_BATCH_REFERENCE_COLUMNS = 6;
|
||||
const LEGACY_BATCH_TABLE_WIDTH = 900;
|
||||
export const MAX_BATCH_TEXT_COLUMNS = 4;
|
||||
export const BATCH_TEXT_HANDLE_PREFIX = "batch-text:";
|
||||
|
||||
/** 旧默认 900 宽的批量创作表升级到当前默认尺寸,已经手动改过宽度的节点保持原样。 */
|
||||
export function promoteLegacyBatchTableSize(node: CanvasNodeData): CanvasNodeData {
|
||||
if (node.type !== "batch-table" || node.width !== LEGACY_BATCH_TABLE_WIDTH) return node;
|
||||
return { ...node, width: 1280, height: Math.max(node.height, 560) };
|
||||
export function batchGridTemplateColumns(referenceCount: number, textCount: number) {
|
||||
return [
|
||||
"36px",
|
||||
"68px",
|
||||
`repeat(${referenceCount}, 88px)`,
|
||||
textCount > 0 ? `repeat(${textCount}, minmax(168px, 0.75fr))` : undefined,
|
||||
"minmax(280px, 1fr)",
|
||||
"148px",
|
||||
"36px",
|
||||
].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData {
|
||||
const columns = batchReferenceColumns(table);
|
||||
const fromIndex = columns.findIndex((column) => column.id === fromColumnId);
|
||||
const toIndex = columns.findIndex((column) => column.id === toColumnId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table;
|
||||
|
||||
const nextColumns = [...columns];
|
||||
const [moved] = nextColumns.splice(fromIndex, 1);
|
||||
nextColumns.splice(toIndex, 0, moved);
|
||||
const nextRows = table.rows.map((row) => ({
|
||||
...row,
|
||||
inputNodeIds: nextColumns.map((column) => {
|
||||
const oldIndex = columns.findIndex((item) => item.id === column.id);
|
||||
return row.inputNodeIds[oldIndex] || "";
|
||||
}),
|
||||
}));
|
||||
|
||||
return {
|
||||
...table,
|
||||
referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })),
|
||||
rows: nextRows,
|
||||
};
|
||||
}
|
||||
|
||||
export function moveBatchReferenceCell(
|
||||
table: CanvasBatchTableData,
|
||||
sourceRowId: string,
|
||||
sourceColumnIndex: number,
|
||||
targetRowId: string,
|
||||
targetColumnIndex: number,
|
||||
): CanvasBatchTableData {
|
||||
if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table;
|
||||
if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table;
|
||||
|
||||
const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1);
|
||||
const sourceRow = table.rows.find((row) => row.id === sourceRowId);
|
||||
const targetRow = table.rows.find((row) => row.id === targetRowId);
|
||||
if (!sourceRow || !targetRow) return table;
|
||||
|
||||
const nextRows = table.rows.map((row) => ({
|
||||
...row,
|
||||
inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || ""),
|
||||
}));
|
||||
const nextSourceRow = nextRows.find((row) => row.id === sourceRowId)!;
|
||||
const nextTargetRow = nextRows.find((row) => row.id === targetRowId)!;
|
||||
const sourceNodeId = nextSourceRow.inputNodeIds[sourceColumnIndex];
|
||||
if (!sourceNodeId) return table;
|
||||
|
||||
const targetNodeId = nextTargetRow.inputNodeIds[targetColumnIndex];
|
||||
nextTargetRow.inputNodeIds[targetColumnIndex] = sourceNodeId;
|
||||
nextSourceRow.inputNodeIds[sourceColumnIndex] = targetNodeId || "";
|
||||
|
||||
return { ...table, rows: nextRows };
|
||||
}
|
||||
|
||||
// Reserve one slot for adding a reference and a separate gap before text inputs.
|
||||
export function batchTextHandleTop(referenceCount: number) {
|
||||
return BATCH_REFERENCE_HANDLE_TOP + (referenceCount + 1) * BATCH_REFERENCE_HANDLE_GAP + 24;
|
||||
}
|
||||
|
||||
export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] {
|
||||
@@ -29,6 +94,10 @@ export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] {
|
||||
export function batchReferenceColumns(table?: CanvasBatchTableData) {
|
||||
const columns = table?.referenceColumns;
|
||||
if (!columns?.length) return defaultBatchReferenceColumns();
|
||||
// Older tables were created before the second default slot existed. They
|
||||
// never had a remove-column action, so extending this exact legacy shape is
|
||||
// a safe migration and makes reference slot 2 immediately selectable.
|
||||
if (columns.length === 1 && columns[0].id === "reference-1") return defaultBatchReferenceColumns();
|
||||
return columns;
|
||||
}
|
||||
|
||||
@@ -36,16 +105,47 @@ export function batchReferenceHandleId(columnId: string) {
|
||||
return `${BATCH_REFERENCE_HANDLE_PREFIX}${columnId}`;
|
||||
}
|
||||
|
||||
export function batchReferenceMentionToken(index: number) {
|
||||
return `@参考图${index + 1}`;
|
||||
}
|
||||
|
||||
export function batchReferenceColumnId(handleId?: string) {
|
||||
return handleId?.startsWith(BATCH_REFERENCE_HANDLE_PREFIX) ? handleId.slice(BATCH_REFERENCE_HANDLE_PREFIX.length) : undefined;
|
||||
}
|
||||
|
||||
export function batchTextColumns(table?: CanvasBatchTableData) {
|
||||
return table?.textColumns || [];
|
||||
}
|
||||
|
||||
export function batchTextHandleId(columnId: string) {
|
||||
return `${BATCH_TEXT_HANDLE_PREFIX}${columnId}`;
|
||||
}
|
||||
|
||||
export function batchTextColumnId(handleId?: string) {
|
||||
return handleId?.startsWith(BATCH_TEXT_HANDLE_PREFIX) ? handleId.slice(BATCH_TEXT_HANDLE_PREFIX.length) : undefined;
|
||||
}
|
||||
|
||||
export function batchTextHandleY(node: CanvasNodeData, handleId?: string) {
|
||||
if (node.type !== "batch-table") return undefined;
|
||||
const columnId = batchTextColumnId(handleId);
|
||||
const columns = batchTextColumns(node.metadata?.batchTable);
|
||||
const index = columnId ? columns.findIndex((column) => column.id === columnId) : -1;
|
||||
if (index < 0) return undefined;
|
||||
return node.position.y + batchTextHandleTop(batchReferenceColumns(node.metadata?.batchTable).length) + index * BATCH_REFERENCE_HANDLE_GAP;
|
||||
}
|
||||
|
||||
export function batchTextHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) {
|
||||
if (node.type !== "batch-table") return undefined;
|
||||
const columns = batchTextColumns(node.metadata?.batchTable);
|
||||
const referenceCount = batchReferenceColumns(node.metadata?.batchTable).length;
|
||||
let nearestIndex = -1;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
columns.forEach((_, index) => {
|
||||
const distance = Math.abs(worldY - (node.position.y + batchTextHandleTop(referenceCount) + index * BATCH_REFERENCE_HANDLE_GAP));
|
||||
if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; }
|
||||
});
|
||||
return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchTextHandleId(columns[nearestIndex].id) : undefined;
|
||||
}
|
||||
export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) {
|
||||
if (node.type !== "batch-table") return undefined;
|
||||
const textY = batchTextHandleY(node, handleId);
|
||||
if (textY !== undefined) return textY;
|
||||
const columnId = batchReferenceColumnId(handleId);
|
||||
const columns = batchReferenceColumns(node.metadata?.batchTable);
|
||||
const index = columnId ? columns.findIndex((column) => column.id === columnId) : 0;
|
||||
@@ -55,71 +155,17 @@ export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) {
|
||||
|
||||
export function batchReferenceHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) {
|
||||
if (node.type !== "batch-table") return undefined;
|
||||
const columns = batchReferenceColumns(node.metadata?.batchTable);
|
||||
let nearestIndex = -1;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
columns.forEach((_, index) => {
|
||||
const distance = Math.abs(worldY - (node.position.y + BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP));
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
});
|
||||
return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchReferenceHandleId(columns[nearestIndex].id) : undefined;
|
||||
}
|
||||
|
||||
export function removeLastBatchReferenceColumn(table: CanvasBatchTableData): CanvasBatchTableData | null {
|
||||
const columns = batchReferenceColumns(table);
|
||||
if (columns.length <= MIN_BATCH_REFERENCE_COLUMNS) return null;
|
||||
const nextColumns = columns.slice(0, -1).map((column, index) => ({ ...column, label: `参考图 ${index + 1}` }));
|
||||
return {
|
||||
...table,
|
||||
referenceColumns: nextColumns,
|
||||
rows: table.rows.map((row) => ({ ...row, inputNodeIds: nextColumns.map((_, index) => row.inputNodeIds[index] || "") })),
|
||||
};
|
||||
}
|
||||
|
||||
export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData {
|
||||
const columns = batchReferenceColumns(table);
|
||||
const fromIndex = columns.findIndex((column) => column.id === fromColumnId);
|
||||
const toIndex = columns.findIndex((column) => column.id === toColumnId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table;
|
||||
const nextColumns = [...columns];
|
||||
const [moved] = nextColumns.splice(fromIndex, 1);
|
||||
nextColumns.splice(toIndex, 0, moved);
|
||||
return {
|
||||
...table,
|
||||
referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })),
|
||||
rows: table.rows.map((row) => ({
|
||||
...row,
|
||||
inputNodeIds: nextColumns.map((column) => {
|
||||
const oldIndex = columns.findIndex((item) => item.id === column.id);
|
||||
return row.inputNodeIds[oldIndex] || "";
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function moveBatchReferenceCell(table: CanvasBatchTableData, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number): CanvasBatchTableData {
|
||||
if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table;
|
||||
if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table;
|
||||
const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1);
|
||||
const sourceRow = table.rows.find((row) => row.id === sourceRowId);
|
||||
const targetRow = table.rows.find((row) => row.id === targetRowId);
|
||||
if (!sourceRow || !targetRow) return table;
|
||||
const nextRows = table.rows.map((row) => ({ ...row, inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || "") }));
|
||||
const nextSource = nextRows.find((row) => row.id === sourceRowId)!;
|
||||
const nextTarget = nextRows.find((row) => row.id === targetRowId)!;
|
||||
const sourceNodeId = nextSource.inputNodeIds[sourceColumnIndex];
|
||||
if (!sourceNodeId) return table;
|
||||
const targetNodeId = nextTarget.inputNodeIds[targetColumnIndex];
|
||||
nextTarget.inputNodeIds[targetColumnIndex] = sourceNodeId;
|
||||
nextSource.inputNodeIds[sourceColumnIndex] = targetNodeId || "";
|
||||
return { ...table, rows: nextRows };
|
||||
}
|
||||
|
||||
export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) {
|
||||
return table.globalPrompt?.trim() || row.prompt;
|
||||
const referenceColumns = batchReferenceColumns(node.metadata?.batchTable);
|
||||
const textColumns = batchTextColumns(node.metadata?.batchTable);
|
||||
const handles = [
|
||||
...referenceColumns.map((column, index) => ({ id: batchReferenceHandleId(column.id), y: BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP })),
|
||||
...textColumns.map((column, index) => ({ id: batchTextHandleId(column.id), y: batchTextHandleTop(referenceColumns.length) + index * BATCH_REFERENCE_HANDLE_GAP })),
|
||||
];
|
||||
const nearest = handles.reduce<{ id: string; distance: number } | null>((current, handle) => {
|
||||
const distance = Math.abs(worldY - (node.position.y + handle.y));
|
||||
return !current || distance < current.distance ? { id: handle.id, distance } : current;
|
||||
}, null);
|
||||
return nearest && nearest.distance <= hitRadius ? nearest.id : undefined;
|
||||
}
|
||||
|
||||
export function batchInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
|
||||
@@ -135,20 +181,34 @@ export function batchInputColumns(node: CanvasNodeData, connections: CanvasConne
|
||||
return result;
|
||||
}
|
||||
|
||||
export function batchTextInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
|
||||
const columns = batchTextColumns(node.metadata?.batchTable);
|
||||
const indexById = new Map(columns.map((column, index) => [column.id, index]));
|
||||
const result = columns.map(() => [] as string[]);
|
||||
connections.filter((connection) => connection.toNodeId === node.id && connection.relation !== "batch-output").forEach((connection) => {
|
||||
const columnId = batchTextColumnId(connection.toHandleId);
|
||||
const index = columnId ? indexById.get(columnId) : undefined;
|
||||
if (index === undefined || result[index].includes(connection.fromNodeId)) return;
|
||||
result[index].push(connection.fromNodeId);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A non-empty global prompt takes precedence without destroying row-specific prompts. */
|
||||
export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) {
|
||||
return table.globalPrompt?.trim() || row.prompt;
|
||||
}
|
||||
|
||||
export function createBatchRow(operation: CanvasBatchOperation, inputNodeIds: string[] = []): CanvasBatchRow {
|
||||
return {
|
||||
id: `batch-row-${nanoid()}`,
|
||||
enabled: true,
|
||||
inputNodeIds,
|
||||
textNodeIds: [],
|
||||
prompt: operation === "try_on" ? TRY_ON_BATCH_PROMPT : CREATIVE_BATCH_PROMPT,
|
||||
};
|
||||
}
|
||||
|
||||
/** 手工追加任务时沿用上一行的参考图,但重新使用当前任务类型的默认提示词。 */
|
||||
export function createInheritedBatchRow(operation: CanvasBatchOperation, rows: CanvasBatchRow[]) {
|
||||
return createBatchRow(operation, [...(rows.at(-1)?.inputNodeIds || [])]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected try-on inputs follow the reference layout: all model/person images first,
|
||||
* followed by one shared garment image. Users can still edit any row afterwards.
|
||||
@@ -160,34 +220,32 @@ export function createBatchRowsFromInputs(operation: CanvasBatchOperation, input
|
||||
return inputNodeIds.slice(0, -1).map((personId) => createBatchRow(operation, [personId, garmentId]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zip equally sized columns and broadcast singleton columns across every row.
|
||||
*
|
||||
* 同步连线是增量操作:匹配到的行更新参考图,未被本次连线覆盖的手工行继续保留。
|
||||
* 这样用户在表格内追加或编辑的任务不会因为再次点击“同步连线”而被删除。
|
||||
*/
|
||||
/** Zip equally sized columns and broadcast singleton columns across every row. */
|
||||
export function createBatchRowsFromColumns(operation: CanvasBatchOperation, columns: string[][], previousRows: CanvasBatchRow[] = []) {
|
||||
const rowCount = Math.max(0, ...columns.map((column) => column.length));
|
||||
const unmatchedRows = [...previousRows];
|
||||
const synchronizedRows = Array.from({ length: rowCount }, (_, rowIndex) => {
|
||||
const previousByPrimary = new Map(previousRows.flatMap((row) => row.inputNodeIds[0] ? [[row.inputNodeIds[0], row] as const] : []));
|
||||
return Array.from({ length: rowCount }, (_, rowIndex) => {
|
||||
const inputNodeIds = columns.flatMap((column) => {
|
||||
const input = column.length === 1 ? column[0] : column[rowIndex];
|
||||
return input ? [input] : [];
|
||||
});
|
||||
let previousIndex = unmatchedRows.findIndex((row) => sameBatchInputs(row.inputNodeIds, inputNodeIds));
|
||||
if (previousIndex < 0 && inputNodeIds[0]) previousIndex = unmatchedRows.findIndex((row) => row.inputNodeIds[0] === inputNodeIds[0]);
|
||||
const previous = previousIndex >= 0 ? unmatchedRows.splice(previousIndex, 1)[0] : undefined;
|
||||
const previous = inputNodeIds[0] ? previousByPrimary.get(inputNodeIds[0]) : undefined;
|
||||
const inputsUnchanged = previous && previous.inputNodeIds.length === inputNodeIds.length && previous.inputNodeIds.every((id, index) => id === inputNodeIds[index]);
|
||||
return {
|
||||
...createBatchRow(operation, inputNodeIds),
|
||||
...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt } : {}),
|
||||
...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt, textNodeIds: previous.textNodeIds } : {}),
|
||||
...(inputsUnchanged && previous?.outputNodeId ? { outputNodeId: previous.outputNodeId } : {}),
|
||||
inputNodeIds,
|
||||
};
|
||||
});
|
||||
return [...synchronizedRows, ...unmatchedRows];
|
||||
}
|
||||
|
||||
function sameBatchInputs(left: string[], right: string[]) {
|
||||
return left.length === right.length && left.every((id, index) => id === right[index]);
|
||||
export function assignBatchTextRows(rows: CanvasBatchRow[], columns: string[][]) {
|
||||
return rows.map((row, rowIndex) => ({
|
||||
...row,
|
||||
textNodeIds: columns.flatMap((column) => {
|
||||
const input = column.length === 1 ? column[0] : column[rowIndex];
|
||||
return input ? [input] : [];
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -154,7 +154,14 @@ const BUILTIN_NODE_TRAITS = {
|
||||
showInCreateMenu: true,
|
||||
inputKind: "text",
|
||||
},
|
||||
[CanvasNodeType.BatchTable]: { label: "批量创作表", icon: <Table2 />, minSize: { width: 1080, height: 420 }, showInCreateMenu: true, acceptsInputKind: "image", inputKind: "text", resourceKind: () => null },
|
||||
[CanvasNodeType.BatchTable]: {
|
||||
label: "批量创作表",
|
||||
icon: <Table2 />,
|
||||
minSize: { width: 720, height: 360 },
|
||||
showInCreateMenu: true,
|
||||
acceptsInputKind: ["image", "text"],
|
||||
inputKind: "text",
|
||||
},
|
||||
[CanvasNodeType.MediaConversion]: { label: "转换", icon: <WandSparkles />, minSize: { width: 400, height: 360 }, showInCreateMenu: true, resourceKind: (node: CanvasNodeData) => { const c = node.metadata?.mediaConversion; return c?.status === "completed" && c.resultStorageKey ? (c.outputKind === "video" ? "video" : "image") : null; }, acceptsInputKind: ["image", "video"], maxInputCount: 1, inputKind: "image" },
|
||||
[CanvasNodeType.ColorGrade]: {
|
||||
label: "调色",
|
||||
|
||||
@@ -149,7 +149,7 @@ export function systemChannelModelChannels(channels: PublicChannelCatalog[]): Mo
|
||||
sortOrder: channel.sortOrder,
|
||||
// 系统渠道必须走带渠道 ID 的站内代理;/api 只是业务 API 根路径,
|
||||
// 不能作为模型请求的运行时 Base URL 传给 channelRequest。
|
||||
baseUrl: `/api/${channel.id}`,
|
||||
baseUrl: `/api/ai/system/${channel.id}`,
|
||||
apiKey: "system",
|
||||
apiFormat: "openai",
|
||||
scope: "system" as const,
|
||||
@@ -178,7 +178,7 @@ export function systemChannelModelChannels(channels: PublicChannelCatalog[]): Mo
|
||||
return {
|
||||
model: model.modelKey,
|
||||
displayName: model.displayName,
|
||||
description: "",
|
||||
description: model.description || "",
|
||||
icon: model.icon || "",
|
||||
capability: model.capability as ModelCapability,
|
||||
protocol: model.protocol as any,
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ChannelModelFormValues = {
|
||||
providerModelKey?: string;
|
||||
displayName?: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
capability: ModelCapabilityChoice;
|
||||
protocol?: string;
|
||||
priceTiers: PriceTierFormValues[];
|
||||
@@ -31,6 +32,7 @@ export function initialChannelModelValues(item: ChannelModel | null, protocols:
|
||||
providerModelKey: upstreamModel,
|
||||
displayName: item?.displayName || "",
|
||||
icon: item?.icon || "",
|
||||
description: item?.description || "",
|
||||
capability,
|
||||
protocol,
|
||||
priceTiers: item ? (item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)]) : [defaultPriceTier()],
|
||||
|
||||
@@ -107,6 +107,7 @@ export function ChannelModelEditor({
|
||||
providerModelKey: upstreamModel,
|
||||
displayName: values.displayName?.trim() || values.modelKey.trim(),
|
||||
icon: values.icon?.trim() || "",
|
||||
description: values.description?.trim() || "",
|
||||
capability: values.capability,
|
||||
protocol: values.protocol,
|
||||
priceTiers: values.priceTiers.map((tier) => ({
|
||||
@@ -263,6 +264,9 @@ export function ChannelModelEditor({
|
||||
<Form.Item name="icon" label="模型 Logo">
|
||||
<ModelIconPicker />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="模型说明" tooltip="展示在前台模型选择器里,用来说明该模型的能力边界与计费口径。">
|
||||
<Input.TextArea rows={2} maxLength={600} showCount placeholder="例如:固定 720P,支持 5/10/15 秒,最多 9 张参考图,按次计费。" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{tiersWithOwnUpstream.length ? (
|
||||
<Alert
|
||||
|
||||
@@ -68,7 +68,6 @@ import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
||||
import { CanvasShareModal } from "@/components/canvas/canvas-share-modal";
|
||||
import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node";
|
||||
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
|
||||
import { promoteLegacyBatchTableSize } from "@/lib/canvas/canvas-batch-table";
|
||||
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
|
||||
import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel";
|
||||
import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
|
||||
@@ -97,6 +96,7 @@ import { createCanvasNode, getInputSummary, isHiddenBatchChild } from "@/lib/can
|
||||
import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
|
||||
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { batchReferenceColumns } from "@/lib/canvas/canvas-batch-table";
|
||||
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
|
||||
import { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
|
||||
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
|
||||
@@ -1888,7 +1888,7 @@ function InfiniteCanvasPage() {
|
||||
handleGenerateNode,
|
||||
});
|
||||
|
||||
const { addReferenceColumn: addBatchReferenceColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeReferenceColumn: removeBatchReferenceColumn, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow } = useCanvasBatchTable({
|
||||
const { addReferenceColumn: addBatchReferenceColumn, addTextColumn: addBatchTextColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow } = useCanvasBatchTable({
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
setNodes,
|
||||
@@ -1899,19 +1899,27 @@ function InfiniteCanvasPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectLoaded) return;
|
||||
setNodes((current) => {
|
||||
let changed = false;
|
||||
const next = current.map((node) => {
|
||||
const promoted = promoteLegacyBatchTableSize(node);
|
||||
if (promoted !== node) changed = true;
|
||||
return promoted;
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
nodesRef.current.filter((node) => node.type === CanvasNodeType.BatchTable).forEach((node) => {
|
||||
syncRowsFromConnections(node.id, true);
|
||||
});
|
||||
}, [connections, projectLoaded, setNodes, syncRowsFromConnections]);
|
||||
}, [connections, projectLoaded, syncRowsFromConnections]);
|
||||
|
||||
const handleUploadBatchReference = useCallback(async (tableNodeId: string, rowId: string, columnIndex: number, file: File) => {
|
||||
const tableNode = nodesRef.current.find((item) => item.id === tableNodeId);
|
||||
const table = tableNode?.metadata?.batchTable;
|
||||
const row = table?.rows.find((item) => item.id === rowId);
|
||||
if (!tableNode || !table || !row) return;
|
||||
const rowIndex = table.rows.findIndex((item) => item.id === rowId);
|
||||
const uploadedNodeId = await createFileNode(file, {
|
||||
x: tableNode.position.x - 240,
|
||||
y: tableNode.position.y + 170 + Math.max(0, rowIndex) * 220,
|
||||
});
|
||||
if (!uploadedNodeId) return;
|
||||
const inputNodeIds = Array.from({ length: Math.max(batchReferenceColumns(table).length, columnIndex + 1) }, (_, index) => row.inputNodeIds[index] || "");
|
||||
inputNodeIds[columnIndex] = uploadedNodeId;
|
||||
updateBatchRow(tableNodeId, rowId, { inputNodeIds });
|
||||
message.success(`已上传并填入参考图 ${columnIndex + 1}`);
|
||||
}, [createFileNode, message, nodesRef, updateBatchRow]);
|
||||
|
||||
const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } =
|
||||
useCanvasStoryboard({
|
||||
@@ -2123,26 +2131,11 @@ function InfiniteCanvasPage() {
|
||||
onGenerate={(rowIds) => void generateBatchRows(contentNode.id, rowIds)}
|
||||
onRetryItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
|
||||
onAddReferenceColumn={() => addBatchReferenceColumn(contentNode.id)}
|
||||
onRemoveReferenceColumn={() => removeBatchReferenceColumn(contentNode.id)}
|
||||
onFocusOutput={(nodeId) => focusCanvasImageNode(nodeId)}
|
||||
onAddTextColumn={() => addBatchTextColumn(contentNode.id)}
|
||||
onReorderReferenceColumns={(fromColumnId, toColumnId) => reorderBatchReferenceColumns(contentNode.id, fromColumnId, toColumnId)}
|
||||
onMoveReferenceCell={(sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex) => moveBatchReferenceCell(contentNode.id, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex)}
|
||||
onUploadReference={(rowId, columnIndex, file) => {
|
||||
const row = contentNode.metadata?.batchTable?.rows.find((item) => item.id === rowId);
|
||||
const existingId = row?.inputNodeIds[columnIndex];
|
||||
if (existingId) {
|
||||
void replaceNodeMedia(existingId, file);
|
||||
return;
|
||||
}
|
||||
void createFileNode(file, { x: contentNode.position.x - 180, y: contentNode.position.y + columnIndex * 90 }).then((insertedId) => {
|
||||
if (!insertedId) return;
|
||||
const currentRow = nodesRef.current.find((item) => item.id === contentNode.id)?.metadata?.batchTable?.rows.find((item) => item.id === rowId);
|
||||
const ids = [...(currentRow?.inputNodeIds || [])];
|
||||
while (ids.length <= columnIndex) ids.push("");
|
||||
ids[columnIndex] = insertedId;
|
||||
updateBatchRow(contentNode.id, rowId, { inputNodeIds: ids });
|
||||
});
|
||||
}}
|
||||
onReplaceReference={replaceCanvasNodeMedia}
|
||||
onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }}
|
||||
onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)}
|
||||
onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)}
|
||||
/>
|
||||
@@ -2217,6 +2210,7 @@ function InfiniteCanvasPage() {
|
||||
},
|
||||
[
|
||||
addBatchReferenceColumn,
|
||||
addBatchTextColumn,
|
||||
addBatchRow,
|
||||
addScriptRow,
|
||||
configInputsById,
|
||||
@@ -2227,11 +2221,12 @@ function InfiniteCanvasPage() {
|
||||
createScriptVideoNodes,
|
||||
currentProject?.directorScenes,
|
||||
fillRowsFromConnections,
|
||||
focusCanvasImageNode,
|
||||
generateBatchRows,
|
||||
generateScriptImages,
|
||||
generateScriptRows,
|
||||
generateScriptVideos,
|
||||
handleUploadBatchReference,
|
||||
moveBatchReferenceCell,
|
||||
handleConfigNodeChange,
|
||||
handleConnectDrop,
|
||||
handleConnectStart,
|
||||
@@ -2242,8 +2237,9 @@ function InfiniteCanvasPage() {
|
||||
openDirectorWorkbench,
|
||||
openStoryInput,
|
||||
patchBatchTable,
|
||||
removeBatchReferenceColumn,
|
||||
removeBatchRow,
|
||||
reorderBatchReferenceColumns,
|
||||
replaceCanvasNodeMedia,
|
||||
removeScriptRow,
|
||||
retryFailedBatchItems,
|
||||
runningNodeId,
|
||||
|
||||
@@ -238,7 +238,7 @@ export default function SharedCanvasPage() {
|
||||
}));
|
||||
const renderSharedNode = useCallback((node: CanvasNodeData): ReactNode => {
|
||||
if (node.type === CanvasNodeType.Script) return <SharedScriptNode node={node} onUnauthorized={unauthorized} />;
|
||||
if (node.type === CanvasNodeType.BatchTable) return <CanvasBatchTableNodeContent node={node} nodes={nodes} connections={connections} batch={node.metadata?.generationBatches?.at(-1)} theme={theme} readOnly onPatchTable={() => {}} onAddRow={() => {}} onRemoveRow={() => {}} onUpdateRow={() => {}} onFillRows={() => {}} onGenerate={() => {}} onRetryItem={() => {}} onAddReferenceColumn={() => {}} onConnectStart={() => {}} />;
|
||||
if (node.type === CanvasNodeType.BatchTable) return <CanvasBatchTableNodeContent node={node} nodes={nodes} connections={connections} batch={node.metadata?.generationBatches?.at(-1)} theme={theme} readOnly onPatchTable={() => {}} onAddRow={() => {}} onRemoveRow={() => {}} onUpdateRow={() => {}} onFillRows={() => {}} onGenerate={() => {}} onRetryItem={() => {}} onAddReferenceColumn={() => {}} onAddTextColumn={() => {}} onReorderReferenceColumns={() => {}} onMoveReferenceCell={() => {}} onReplaceReference={() => {}} onUploadReference={() => {}} onConnectStart={() => {}} />;
|
||||
return <SharedConfigNode node={node} onUnauthorized={unauthorized} />;
|
||||
}, [connections, nodes, theme, unauthorized]);
|
||||
const toolbarNodeKey = selectedNodeId;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { App } from "antd";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { batchInputColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, createBatchRowsFromColumns, createInheritedBatchRow, moveBatchReferenceCell, removeLastBatchReferenceColumn, reorderBatchReferenceColumns } from "@/lib/canvas/canvas-batch-table";
|
||||
import { batchInputColumns, batchPromptForRow, batchReferenceColumns, batchTextInputColumns, createBatchRow, createBatchRowsFromColumns, moveBatchReferenceCell, reorderBatchReferenceColumns } from "@/lib/canvas/canvas-batch-table";
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { buildGenerationConfig, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
@@ -25,7 +25,7 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
|
||||
const patchTable = useCallback((nodeId: string, patch: Partial<CanvasBatchTableData>) => {
|
||||
setNodes((current) => current.map((node) => node.id !== nodeId ? node : { ...node, metadata: { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, rows: [], ...node.metadata?.batchTable, ...patch } } }));
|
||||
setNodes((current) => current.map((node) => node.id !== nodeId ? node : { ...node, metadata: { ...node.metadata, batchTable: { operation: "try_on", concurrency: 3, rows: [], ...node.metadata?.batchTable, ...patch } } }));
|
||||
}, [setNodes]);
|
||||
|
||||
const updateRow = useCallback((nodeId: string, rowId: string, patch: Partial<CanvasBatchRow>) => {
|
||||
@@ -37,7 +37,7 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
const addRow = useCallback((nodeId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
patchTable(nodeId, { rows: [...table.rows, createInheritedBatchRow(table.operation, table.rows)] });
|
||||
patchTable(nodeId, { rows: [...table.rows, createBatchRow(table.operation)] });
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const removeRow = useCallback((nodeId: string, rowId: string) => {
|
||||
@@ -55,19 +55,13 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] });
|
||||
}, [message, nodesRef, patchTable]);
|
||||
|
||||
const removeReferenceColumn = useCallback((nodeId: string) => {
|
||||
const addTextColumn = useCallback((nodeId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
const columns = batchReferenceColumns(table);
|
||||
const nextTable = removeLastBatchReferenceColumn(table);
|
||||
if (!nextTable) return message.info("至少保留 1 组参考图");
|
||||
const removed = columns.at(-1);
|
||||
patchTable(nodeId, nextTable);
|
||||
if (removed) {
|
||||
const handleId = batchReferenceHandleId(removed.id);
|
||||
setConnections((current) => current.filter((connection) => !(connection.toNodeId === nodeId && connection.toHandleId === handleId)));
|
||||
}
|
||||
}, [message, nodesRef, patchTable, setConnections]);
|
||||
const columns = table.textColumns || [];
|
||||
if (columns.length >= 4) return message.info("最多支持 4 组文字");
|
||||
patchTable(nodeId, { textColumns: [...columns, { id: `text-${nanoid()}`, label: `文字 ${columns.length + 1}` }] });
|
||||
}, [message, nodesRef, patchTable]);
|
||||
|
||||
const syncRowsFromConnections = useCallback((nodeId: string, silent = false) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
@@ -83,16 +77,25 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
return false;
|
||||
}
|
||||
const rows = createBatchRowsFromColumns(table.operation, columns, table.rows);
|
||||
const textColumns = batchTextInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => {
|
||||
const input = nodeById.get(inputNodeId);
|
||||
return input?.type === CanvasNodeType.Text && Boolean(input.metadata?.content || input.metadata?.prompt);
|
||||
}));
|
||||
const rowsWithText = rows.map((row, index) => ({
|
||||
...row,
|
||||
textNodeIds: textColumns.flatMap((column) => {
|
||||
const input = column.length === 1 ? column[0] : column[index];
|
||||
return input ? [input] : [];
|
||||
}),
|
||||
}));
|
||||
if (!rows.length) {
|
||||
if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图");
|
||||
return false;
|
||||
}
|
||||
if (JSON.stringify(rows) === JSON.stringify(table.rows)) return true;
|
||||
patchTable(nodeId, { rows });
|
||||
if (!silent) {
|
||||
const added = Math.max(0, rows.length - table.rows.length);
|
||||
message.success(added ? `已同步连线并新增 ${added} 行,原有任务均已保留` : "已同步最新连线,原有任务均已保留");
|
||||
}
|
||||
const rowsChanged = JSON.stringify(table.rows) !== JSON.stringify(rowsWithText);
|
||||
if (!rowsChanged) return false;
|
||||
patchTable(nodeId, { rows: rowsWithText });
|
||||
if (!silent) message.success(`已按连线创建 ${rows.length} 行任务`);
|
||||
return true;
|
||||
}, [connectionsRef, message, nodesRef, patchTable]);
|
||||
|
||||
@@ -126,15 +129,11 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId)));
|
||||
const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null;
|
||||
const rows = table.rows.filter((row) => {
|
||||
const inputNodeIds = row.inputNodeIds.filter(Boolean);
|
||||
if (!row.enabled || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).trim()) return false;
|
||||
if (table.operation === "try_on" && inputNodeIds.length < 2) return false;
|
||||
if (!inputNodeIds.length || inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false;
|
||||
if (row.enabled === false || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).trim()) return false;
|
||||
if (table.operation === "try_on" && row.inputNodeIds.length < 2) return false;
|
||||
if (!row.inputNodeIds.length || row.inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false;
|
||||
const output = row.outputNodeId ? nodesRef.current.find((node) => node.id === row.outputNodeId) : undefined;
|
||||
if (output && activeNodeIds.has(output.id)) return false;
|
||||
// 操作列的单行生成允许对已完成结果重新生成;顶部批量按钮仍只提交未完成项。
|
||||
if (requested) return true;
|
||||
return !output?.metadata?.content;
|
||||
return !output?.metadata?.content && (!output || !activeNodeIds.has(output.id));
|
||||
});
|
||||
if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词");
|
||||
const confirmed = await new Promise<boolean>((resolve) => modal.confirm({
|
||||
@@ -159,7 +158,10 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
const metadata = {
|
||||
...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}),
|
||||
prompt,
|
||||
composerContent: prompt,
|
||||
composerContent: [prompt, ...(row.textNodeIds || []).map((textNodeId) => {
|
||||
const textNode = nodesRef.current.find((node) => node.id === textNodeId);
|
||||
return textNode?.metadata?.content || textNode?.metadata?.prompt || "";
|
||||
})].filter(Boolean).join("\n\n"),
|
||||
model: buildGenerationConfig(effectiveConfig, undefined, "image").model,
|
||||
generationMode: "image" as const,
|
||||
generationType: "edit" as const,
|
||||
@@ -178,7 +180,7 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
if (existingIndex >= 0) nextNodes[existingIndex] = output;
|
||||
else nextNodes.push(output);
|
||||
nextConnections = nextConnections.filter((connection) => connection.toNodeId !== output.id);
|
||||
row.inputNodeIds.filter(Boolean).forEach((inputNodeId) => nextConnections.push({ id: nanoid(), fromNodeId: inputNodeId, toNodeId: output.id }));
|
||||
row.inputNodeIds.forEach((inputNodeId) => nextConnections.push({ id: nanoid(), fromNodeId: inputNodeId, toNodeId: output.id }));
|
||||
nextConnections.push({ id: nanoid(), fromNodeId: sourceNode.id, toNodeId: output.id, relation: "batch-output", storyboardRowId: row.id });
|
||||
outputByRowId.set(row.id, output.id);
|
||||
targets.push({ rowId: row.id, nodeId: output.id });
|
||||
@@ -193,5 +195,5 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency: table.concurrency })) message.success(`${targets.length} 个任务已加入并发队列`);
|
||||
}, [connectionsRef, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, modal, nodesRef, setConnections, setNodes, setSelectedNodeIds]);
|
||||
|
||||
return { addReferenceColumn, addRow, fillRowsFromConnections, generateRows, moveReferenceCell, patchTable, removeReferenceColumn, removeRow, reorderReferenceColumns, syncRowsFromConnections, updateRow };
|
||||
return { addReferenceColumn, addTextColumn, addRow, fillRowsFromConnections, generateRows, moveReferenceCell, patchTable, removeRow, reorderReferenceColumns, syncRowsFromConnections, updateRow };
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ const NODE_STATUS_SUCCESS = "success" as const;
|
||||
const BATCH_UPLOAD_COLUMNS = 3;
|
||||
const BATCH_UPLOAD_COLUMN_GAP = 380;
|
||||
const BATCH_UPLOAD_ROW_GAP = 300;
|
||||
|
||||
const CANVAS_BATCH_TABLE_SELECTOR = "[data-canvas-batch-table]";
|
||||
|
||||
function isBatchTableDragEvent(event: DragEvent<HTMLElement>) {
|
||||
@@ -544,11 +543,13 @@ export function useCanvasUpload({
|
||||
const handleDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
if (isBatchTableDragEvent(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
fileDragDepthRef.current = 0;
|
||||
setFileDropActive(false);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
fileDragDepthRef.current = 0;
|
||||
setFileDropActive(false);
|
||||
const chapterPayload = parseProjectChapterPayload(event.dataTransfer.getData(CANVAS_PROJECT_CHAPTER_DND_TYPE));
|
||||
@@ -590,12 +591,14 @@ export function useCanvasUpload({
|
||||
const handleFileDragEnter = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
if (isBatchTableDragEvent(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
fileDragDepthRef.current = 0;
|
||||
setFileDropActive(false);
|
||||
return;
|
||||
}
|
||||
if (!hasDraggedFiles(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
fileDragDepthRef.current += 1;
|
||||
setFileDropActive(true);
|
||||
}, []);
|
||||
@@ -603,18 +606,21 @@ export function useCanvasUpload({
|
||||
const handleFileDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
if (isBatchTableDragEvent(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
fileDragDepthRef.current = 0;
|
||||
setFileDropActive(false);
|
||||
return;
|
||||
}
|
||||
if (!hasDraggedFiles(event) && !Array.from(event.dataTransfer.types).includes(CANVAS_PROJECT_CHAPTER_DND_TYPE)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}, []);
|
||||
|
||||
const handleFileDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
if (isBatchTableDragEvent(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
fileDragDepthRef.current = 0;
|
||||
setFileDropActive(false);
|
||||
return;
|
||||
@@ -732,6 +738,7 @@ export function useCanvasUpload({
|
||||
return {
|
||||
assetPickerOpen,
|
||||
closeAssetPicker,
|
||||
createFileNode,
|
||||
createVideoNodeFromBlob,
|
||||
createAssetPayloadNode,
|
||||
createImageAssetNode,
|
||||
@@ -752,7 +759,6 @@ export function useCanvasUpload({
|
||||
pasteAssistantImage,
|
||||
pasteSystemClipboard,
|
||||
replaceNodeMedia,
|
||||
createFileNode,
|
||||
startUploadStatus,
|
||||
uploadModalOpen,
|
||||
uploadStatus,
|
||||
|
||||
@@ -150,6 +150,7 @@ export type PublicChannelModel = {
|
||||
displayName: string;
|
||||
sortOrder?: number;
|
||||
icon: string;
|
||||
description?: string;
|
||||
capability: string;
|
||||
protocol?: string;
|
||||
capabilityConfig?: Record<string, any>;
|
||||
|
||||
@@ -54,6 +54,7 @@ export type ChannelModel = {
|
||||
displayName: string;
|
||||
sortOrder?: number;
|
||||
icon: string;
|
||||
description?: string;
|
||||
capability: "text" | "image" | "video" | "audio" | "";
|
||||
protocol?: import("@/lib/model-protocols").ModelProtocol;
|
||||
billingMode: "fixed_request" | "per_second" | "token";
|
||||
@@ -97,6 +98,7 @@ export type ChannelModelMutation = {
|
||||
providerModelKey?: string;
|
||||
displayName?: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
capability: ChannelModel["capability"];
|
||||
protocol?: ChannelModel["protocol"];
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -164,9 +164,28 @@ export type CanvasGenerationBatch = {
|
||||
};
|
||||
|
||||
export type CanvasBatchOperation = "try_on" | "creative";
|
||||
export type CanvasBatchRow = { id: string; enabled: boolean; inputNodeIds: string[]; prompt: string; outputNodeId?: string };
|
||||
export type CanvasBatchReferenceColumn = { id: string; label: string };
|
||||
export type CanvasBatchTableData = { operation: CanvasBatchOperation; concurrency: number; globalPrompt?: string; referenceColumns?: CanvasBatchReferenceColumn[]; rows: CanvasBatchRow[] };
|
||||
export type CanvasBatchRow = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
inputNodeIds: string[];
|
||||
/** Text nodes selected for this row; their contents are appended to prompt. */
|
||||
textNodeIds?: string[];
|
||||
prompt: string;
|
||||
outputNodeId?: string;
|
||||
};
|
||||
export type CanvasBatchReferenceColumn = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
export type CanvasBatchTableData = {
|
||||
operation: CanvasBatchOperation;
|
||||
concurrency: number;
|
||||
/** Optional prompt override applied to every batch row while non-empty. */
|
||||
globalPrompt?: string;
|
||||
referenceColumns?: CanvasBatchReferenceColumn[];
|
||||
textColumns?: CanvasBatchReferenceColumn[];
|
||||
rows: CanvasBatchRow[];
|
||||
};
|
||||
|
||||
export type CanvasSkillSnapshot = {
|
||||
id: string;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
|
||||
import { canvasConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
import { defaultConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
@@ -20,11 +19,6 @@ const nodes: CanvasNodeData[] = [
|
||||
const baseConfig = { ...defaultConfig };
|
||||
|
||||
describe("planBatchConnections", () => {
|
||||
it("only accepts image inputs for the batch creation table", () => {
|
||||
expect(canvasConnectionError(baseConfig, nodes, [], { fromNodeId: "image-a", toNodeId: "batch-table" })).toBe("");
|
||||
expect(canvasConnectionError(baseConfig, nodes, [], { fromNodeId: "text-a", toNodeId: "batch-table" })).toContain("批量创作表节点只接受图片输入");
|
||||
});
|
||||
|
||||
it("plans all legal source nodes and preserves the target handle", () => {
|
||||
const result = planBatchConnections({
|
||||
sourceNodeIds: ["text-a", "text-b"],
|
||||
|
||||
@@ -1,101 +1,95 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
batchPromptForRow,
|
||||
batchReferenceColumns,
|
||||
batchReferenceHandleAtY,
|
||||
batchReferenceHandleId,
|
||||
batchReferenceMentionToken,
|
||||
createBatchRowsFromColumns,
|
||||
createBatchRowsFromInputs,
|
||||
createInheritedBatchRow,
|
||||
moveBatchReferenceCell,
|
||||
promoteLegacyBatchTableSize,
|
||||
removeLastBatchReferenceColumn,
|
||||
reorderBatchReferenceColumns,
|
||||
BATCH_REFERENCE_HANDLE_GAP,
|
||||
BATCH_REFERENCE_HANDLE_TOP,
|
||||
TRY_ON_BATCH_PROMPT,
|
||||
} from "@/lib/canvas/canvas-batch-table";
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
describe("batch creation table", () => {
|
||||
test("creates one try-on row per person with the final image as the shared garment", () => {
|
||||
const rows = createBatchRowsFromInputs("try_on", ["person-1", "person-2", "person-3", "garment"]);
|
||||
|
||||
expect(rows.map((row) => row.inputNodeIds)).toEqual([
|
||||
["person-1", "garment"],
|
||||
["person-2", "garment"],
|
||||
["person-3", "garment"],
|
||||
]);
|
||||
expect(rows.every((row) => row.prompt === TRY_ON_BATCH_PROMPT)).toBe(true);
|
||||
});
|
||||
|
||||
test("creates one creative row per connected image", () => {
|
||||
expect(createBatchRowsFromInputs("creative", ["a", "b"]).map((row) => row.inputNodeIds)).toEqual([["a"], ["b"]]);
|
||||
});
|
||||
|
||||
test("broadcasts a singleton reference column across rows", () => {
|
||||
expect(createBatchRowsFromColumns("try_on", [["person-1", "person-2"], ["garment"]]).map((row) => row.inputNodeIds)).toEqual([
|
||||
["person-1", "garment"],
|
||||
["person-2", "garment"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("inherits the previous row references when adding a manual task", () => {
|
||||
const row = createInheritedBatchRow("try_on", [{ id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "自定义提示词" }]);
|
||||
|
||||
expect(row.inputNodeIds).toEqual(["person-1", "garment"]);
|
||||
expect(row.inputNodeIds).not.toBe(createInheritedBatchRow("try_on", [{ id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "自定义提示词" }]).inputNodeIds);
|
||||
expect(row.prompt).toBe(TRY_ON_BATCH_PROMPT);
|
||||
});
|
||||
|
||||
test("syncing connections preserves unmatched manual rows", () => {
|
||||
const manual = { id: "manual-row", enabled: false, inputNodeIds: ["manual-image"], prompt: "保留手工任务" };
|
||||
const rows = createBatchRowsFromColumns("try_on", [["person-1"], ["garment"]], [manual]);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[1]).toEqual(manual);
|
||||
});
|
||||
|
||||
test("syncing unchanged references keeps the row identity and linked output", () => {
|
||||
const existing = { id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "保留提示词", outputNodeId: "output-1" };
|
||||
const [row] = createBatchRowsFromColumns("try_on", [["person-1"], ["garment"]], [existing]);
|
||||
|
||||
expect(row).toEqual(existing);
|
||||
});
|
||||
|
||||
test("uses stable prompt mention tokens for positional references", () => {
|
||||
expect(batchReferenceMentionToken(0)).toBe("@参考图1");
|
||||
expect(batchReferenceMentionToken(1)).toBe("@参考图2");
|
||||
});
|
||||
|
||||
test("new canvas node has durable batch defaults", () => {
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { batchGridTemplateColumns, batchPromptForRow, batchReferenceHandleAtY, batchReferenceHandleId, batchTextHandleId, batchTextHandleTop, createBatchRowsFromColumns, createBatchRowsFromInputs, CREATIVE_BATCH_PROMPT, moveBatchReferenceCell, reorderBatchReferenceColumns, TRY_ON_BATCH_PROMPT } from "@/lib/canvas/canvas-batch-table";
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
describe("batch creation table", () => {
|
||||
test("creates one try-on row per person with the final image as the shared garment", () => {
|
||||
const rows = createBatchRowsFromInputs("try_on", ["person-1", "person-2", "person-3", "garment"]);
|
||||
|
||||
expect(rows.map((row) => row.inputNodeIds)).toEqual([
|
||||
["person-1", "garment"],
|
||||
["person-2", "garment"],
|
||||
["person-3", "garment"],
|
||||
]);
|
||||
expect(rows.every((row) => row.prompt === TRY_ON_BATCH_PROMPT)).toBe(true);
|
||||
});
|
||||
|
||||
test("creates one creative row per connected image", () => {
|
||||
expect(createBatchRowsFromInputs("creative", ["a", "b"]).map((row) => row.inputNodeIds)).toEqual([["a"], ["b"]]);
|
||||
});
|
||||
|
||||
test("broadcasts a singleton reference column across rows", () => {
|
||||
expect(createBatchRowsFromColumns("try_on", [["person-1", "person-2"], ["garment"]]).map((row) => row.inputNodeIds)).toEqual([
|
||||
["person-1", "garment"],
|
||||
["person-2", "garment"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("new canvas node has durable batch defaults", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
|
||||
expect(node.title).toBe("批量创作表");
|
||||
expect(node.metadata?.batchTable).toEqual({ operation: "try_on", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }], rows: [] });
|
||||
});
|
||||
test("keeps the third reference handle addressable", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + 112 + 2 * 38)).toBe(batchReferenceHandleId("reference-3"));
|
||||
});
|
||||
test("keeps the second reference handle addressable", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
const referenceColumns = [
|
||||
{ id: "reference-1", label: "参考图 1" },
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
];
|
||||
node.metadata = { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, referenceColumns, rows: [] } };
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38)).toBe(batchReferenceHandleId("reference-2"));
|
||||
});
|
||||
|
||||
test("chooses the nearest reference handle when magnetic hit areas overlap", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
|
||||
expect(node.title).toBe("批量创作表");
|
||||
expect(node.width).toBe(1280);
|
||||
expect(node.height).toBe(560);
|
||||
expect(node.metadata?.batchTable).toEqual({ operation: "try_on", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }], rows: [] });
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38, 90)).toBe(batchReferenceHandleId("reference-2"));
|
||||
});
|
||||
|
||||
test("promotes the previous 900-wide default without touching resized tables", () => {
|
||||
test("keeps text handles separated from reference controls", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
const legacy = { ...node, width: 900, height: 520 };
|
||||
const resized = { ...node, width: 1100, height: 480 };
|
||||
node.metadata = { ...node.metadata, batchTable: { ...node.metadata?.batchTable!, textColumns: [{ id: "text-1", label: "文字 1" }] } };
|
||||
|
||||
expect(promoteLegacyBatchTableSize(legacy)).toEqual({ ...legacy, width: 1280, height: 560 });
|
||||
expect(promoteLegacyBatchTableSize(resized)).toBe(resized);
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + batchTextHandleTop(3))).toBe(batchTextHandleId("text-1"));
|
||||
expect(batchTextHandleTop(3)).toBeGreaterThan(112 + 3 * 38);
|
||||
});
|
||||
test("keeps a single explicitly stored reference column", () => {
|
||||
|
||||
test("global prompt overrides a row prompt without changing the row data", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
node.metadata = { ...node.metadata, batchTable: { operation: "creative", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }], rows: [] } };
|
||||
const table = node.metadata?.batchTable!;
|
||||
const row = createBatchRowsFromInputs("creative", ["image-1"])[0];
|
||||
|
||||
expect(batchReferenceColumns(node.metadata.batchTable).map((column) => column.id)).toEqual(["reference-1"]);
|
||||
expect(batchPromptForRow(table, row)).toBe(row.prompt);
|
||||
expect(batchPromptForRow({ ...table, globalPrompt: "统一风格:高级商业摄影" }, row)).toBe("统一风格:高级商业摄影");
|
||||
expect(row.prompt).toBe(CREATIVE_BATCH_PROMPT);
|
||||
});
|
||||
|
||||
test("removes the last reference column down to one and trims row inputs", () => {
|
||||
test("legacy rows without enabled stay enabled by default", () => {
|
||||
const row = createBatchRowsFromInputs("creative", ["image-1"])[0];
|
||||
expect(row.enabled).toBe(true);
|
||||
expect({ ...row, enabled: undefined }.enabled === false).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps the table grid valid when no text columns exist", () => {
|
||||
const withoutText = batchGridTemplateColumns(3, 0);
|
||||
const withText = batchGridTemplateColumns(3, 1);
|
||||
|
||||
expect(withoutText).not.toContain("repeat(0,");
|
||||
expect(withoutText).toContain("68px");
|
||||
expect(withoutText).toContain("repeat(3, 88px)");
|
||||
expect(withText).toContain("repeat(1, minmax(168px, 0.75fr))");
|
||||
});
|
||||
|
||||
test("reorders reference columns and keeps row materials aligned", () => {
|
||||
const table = {
|
||||
operation: "creative" as const,
|
||||
concurrency: 10,
|
||||
@@ -104,49 +98,16 @@ describe("batch creation table", () => {
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
{ id: "reference-3", label: "参考图 3" },
|
||||
],
|
||||
rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "x" }],
|
||||
rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "" }],
|
||||
};
|
||||
const reordered = reorderBatchReferenceColumns(table, "reference-1", "reference-3");
|
||||
|
||||
const next = removeLastBatchReferenceColumn(table);
|
||||
expect(next?.referenceColumns?.map((column) => column.id)).toEqual(["reference-1", "reference-2"]);
|
||||
expect(next?.rows[0].inputNodeIds).toEqual(["a", "b"]);
|
||||
expect(removeLastBatchReferenceColumn({ ...table, referenceColumns: [{ id: "reference-1", label: "参考图 1" }] })).toBeNull();
|
||||
expect(reordered.referenceColumns?.map((column) => column.label)).toEqual(["参考图 1", "参考图 2", "参考图 3"]);
|
||||
expect(reordered.referenceColumns?.map((column) => column.id)).toEqual(["reference-2", "reference-3", "reference-1"]);
|
||||
expect(reordered.rows[0].inputNodeIds).toEqual(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
test("keeps the second reference handle addressable", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
const referenceColumns = [
|
||||
{ id: "reference-1", label: "参考图 1" },
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
];
|
||||
node.metadata = { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, referenceColumns, rows: [] } };
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + BATCH_REFERENCE_HANDLE_TOP + BATCH_REFERENCE_HANDLE_GAP)).toBe(batchReferenceHandleId("reference-2"));
|
||||
});
|
||||
|
||||
test("chooses the nearest reference handle when magnetic hit areas overlap", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + BATCH_REFERENCE_HANDLE_TOP + BATCH_REFERENCE_HANDLE_GAP, 90)).toBe(batchReferenceHandleId("reference-2"));
|
||||
});
|
||||
|
||||
test("reorders reference columns and keeps row inputs aligned", () => {
|
||||
const table = {
|
||||
operation: "try_on" as const,
|
||||
concurrency: 10,
|
||||
referenceColumns: [
|
||||
{ id: "reference-1", label: "参考图 1" },
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
{ id: "reference-3", label: "参考图 3" },
|
||||
],
|
||||
rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "x" }],
|
||||
};
|
||||
const next = reorderBatchReferenceColumns(table, "reference-1", "reference-3");
|
||||
expect(next.referenceColumns?.map((column) => column.id)).toEqual(["reference-2", "reference-3", "reference-1"]);
|
||||
expect(next.rows[0].inputNodeIds).toEqual(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
test("swaps reference cells across rows and columns", () => {
|
||||
test("moves a reference image into an empty slot across rows", () => {
|
||||
const table = {
|
||||
operation: "creative" as const,
|
||||
concurrency: 10,
|
||||
@@ -155,18 +116,26 @@ describe("batch creation table", () => {
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
],
|
||||
rows: [
|
||||
{ id: "row-1", enabled: true, inputNodeIds: ["a", "b"], prompt: "one" },
|
||||
{ id: "row-2", enabled: true, inputNodeIds: ["c", "d"], prompt: "two" },
|
||||
{ id: "row-1", enabled: true, inputNodeIds: ["a", ""], prompt: "" },
|
||||
{ id: "row-2", enabled: true, inputNodeIds: ["b", ""], prompt: "" },
|
||||
],
|
||||
};
|
||||
const next = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1);
|
||||
expect(next.rows[0].inputNodeIds).toEqual(["d", "b"]);
|
||||
expect(next.rows[1].inputNodeIds).toEqual(["c", "a"]);
|
||||
|
||||
const moved = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1);
|
||||
|
||||
expect(moved.rows.map((row) => row.inputNodeIds)).toEqual([["", ""], ["b", "a"]]);
|
||||
});
|
||||
|
||||
test("uses a non-empty global prompt instead of the row prompt", () => {
|
||||
const table = { operation: "creative" as const, concurrency: 10, globalPrompt: " 全局覆盖 ", rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a"], prompt: "行提示词" }] };
|
||||
expect(batchPromptForRow(table, table.rows[0])).toBe("全局覆盖");
|
||||
expect(batchPromptForRow({ ...table, globalPrompt: " " }, table.rows[0])).toBe("行提示词");
|
||||
test("swaps reference images when the target slot is occupied", () => {
|
||||
const table = {
|
||||
operation: "creative" as const,
|
||||
concurrency: 10,
|
||||
referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }],
|
||||
rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b"], prompt: "" }, { id: "row-2", enabled: true, inputNodeIds: ["c", "d"], prompt: "" }],
|
||||
};
|
||||
|
||||
const swapped = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1);
|
||||
|
||||
expect(swapped.rows.map((row) => row.inputNodeIds)).toEqual([["d", "b"], ["c", "a"]]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user