diff --git a/portal/handler/mysql_integration_test.go b/portal/handler/mysql_integration_test.go index 5b16d6c..0c34663 100644 --- a/portal/handler/mysql_integration_test.go +++ b/portal/handler/mysql_integration_test.go @@ -176,7 +176,7 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) { t.Fatalf("login=%d %s", response.Code, response.Body.String()) } emptyWorkspace := clientA.do(http.MethodGet, "/", nil, "") - if emptyWorkspace.Code != http.StatusOK || !strings.Contains(emptyWorkspace.Body.String(), "还没有生成记录") || !strings.Contains(emptyWorkspace.Body.String(), "开始第一次生成") { + if emptyWorkspace.Code != http.StatusOK || !strings.Contains(emptyWorkspace.Body.String(), "还没有生成记录") || !strings.Contains(emptyWorkspace.Body.String(), "文生图") || !strings.Contains(emptyWorkspace.Body.String(), "图片编辑") { t.Fatalf("empty workspace=%d %s", emptyWorkspace.Code, emptyWorkspace.Body.String()) } oldRequest := httptest.NewRequest(http.MethodGet, "/api/generations", nil) diff --git a/portal/handler/router.go b/portal/handler/router.go index ea51f51..56c6f37 100644 --- a/portal/handler/router.go +++ b/portal/handler/router.go @@ -479,7 +479,8 @@ func (h *Handler) appPage(c *gin.Context) { c.Status(http.StatusUnauthorized) return } - history, err := h.service.RecentHistory(c.Request.Context(), state.UserID) + historyLimit := h.service.DefaultHistoryLimit() + history, err := h.service.History(c.Request.Context(), state.UserID, historyLimit, "") if err != nil { c.Status(http.StatusInternalServerError) return @@ -499,7 +500,7 @@ func (h *Handler) appPage(c *gin.Context) { current = &detail } maxPromptBytes, maxImages := h.service.Limits() - page := web.Page{Title: "生成工作台", DisplayName: user.DisplayName, CSRFToken: state.CSRFToken, History: history, Current: current, MaxPromptBytes: maxPromptBytes, MaxImages: maxImages} + page := web.Page{Title: "生成工作台", DisplayName: user.DisplayName, CSRFToken: state.CSRFToken, History: history.Items, HistoryCursor: history.NextCursor, HistoryHasMore: history.HasMore, HistoryLimit: historyLimit, Current: current, MaxPromptBytes: maxPromptBytes, MaxImages: maxImages} c.Header("Cache-Control", "no-store") c.Header("Content-Type", "text/html; charset=utf-8") if err := h.renderer.Render(c.Writer, "app", page); err != nil { diff --git a/portal/web/e2e/fixture/main.go b/portal/web/e2e/fixture/main.go index f4af0a4..a56347f 100644 --- a/portal/web/e2e/fixture/main.go +++ b/portal/web/e2e/fixture/main.go @@ -9,9 +9,11 @@ import ( "image/png" "os" "strconv" + "strings" "time" "git.ilapage.cn/OPC/chorus/internal/core/model" + passwordpkg "git.ilapage.cn/OPC/chorus/internal/platform/password" platformstorage "git.ilapage.cn/OPC/chorus/internal/platform/storage" "gorm.io/driver/mysql" "gorm.io/gorm" @@ -28,7 +30,7 @@ func main() { func run() error { if len(os.Args) != 4 { - return fmt.Errorf("usage: fixture ") + return fmt.Errorf("usage: fixture ") } dsn := os.Getenv("CHORUS_E2E_DSN") if dsn == "" { @@ -49,6 +51,10 @@ func run() error { defer sqlDB.Close() switch os.Args[1] { + case "setup": + return setup(db, os.Args[3], os.Getenv("CHORUS_E2E_PASSWORD")) + case "cleanup": + return cleanup(db, os.Args[3]) case "running": return setRunning(db, generationID, os.Args[3]) case "text-success": @@ -62,6 +68,99 @@ func run() error { } } +func setup(db *gorm.DB, email, password string) error { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" || password == "" { + return fmt.Errorf("e2e credentials are required") + } + passwordHash, err := passwordpkg.Encode(password) + if err != nil { + return fmt.Errorf("encode e2e password") + } + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Exec(`INSERT INTO users (email,password_hash,display_name,status) VALUES (?,?,?,'active') ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash),display_name=VALUES(display_name),status='active'`, email, passwordHash, "Portal E2E User").Error; err != nil { + return err + } + if err := tx.Exec(`INSERT INTO providers (slug,name,base_url,auth_type,enabled) VALUES ('portal-e2e','Portal E2E','https://example.invalid','none',TRUE) ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id),name=VALUES(name),base_url=VALUES(base_url),auth_type='none',enabled=TRUE`).Error; err != nil { + return err + } + var providerID uint64 + if err := tx.Raw("SELECT id FROM providers WHERE slug='portal-e2e'").Scan(&providerID).Error; err != nil || providerID == 0 { + return fmt.Errorf("load e2e provider") + } + for _, route := range []struct { + capability model.Capability + apiType model.APIType + }{ + {model.CapabilityImageGenerate, model.APIImages}, + {model.CapabilityImageEdit, model.APIImagesEdits}, + } { + key := "portal-e2e-" + string(route.capability) + if err := tx.Exec(`INSERT INTO prompt_templates (template_key,kind,api_type,capability,name,version,template_text,default_role_rule,enabled) VALUES (?,'image',?,?,?,1,'{{.UserPrompt}}','Use the primary image as the subject and reference images only as supporting context.',TRUE) ON DUPLICATE KEY UPDATE kind='image',api_type=VALUES(api_type),capability=VALUES(capability),name=VALUES(name),version=1,template_text=VALUES(template_text),default_role_rule=VALUES(default_role_rule),enabled=TRUE`, key, route.apiType, route.capability, "Portal E2E "+string(route.capability)).Error; err != nil { + return err + } + var templateID uint64 + if err := tx.Raw("SELECT id FROM prompt_templates WHERE template_key=?", key).Scan(&templateID).Error; err != nil || templateID == 0 { + return fmt.Errorf("load e2e prompt template") + } + modelID := "portal-e2e-" + string(route.capability) + if err := tx.Exec(`INSERT INTO provider_models (provider_id,name,model_id,api_type,kind,extra_body,timeout_ms,weight,enabled) VALUES (?,?,?,?, 'image',JSON_OBJECT(),1000,100,TRUE) ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id),name=VALUES(name),kind='image',extra_body=JSON_OBJECT(),timeout_ms=1000,weight=100,enabled=TRUE`, providerID, "Portal E2E Model", modelID, route.apiType).Error; err != nil { + return err + } + var providerModelID uint64 + if err := tx.Raw("SELECT id FROM provider_models WHERE provider_id=? AND model_id=? AND api_type=?", providerID, modelID, route.apiType).Scan(&providerModelID).Error; err != nil || providerModelID == 0 { + return fmt.Errorf("load e2e provider model") + } + if err := tx.Exec("INSERT IGNORE INTO provider_model_capabilities (provider_model_id,capability) VALUES (?,?)", providerModelID, route.capability).Error; err != nil { + return err + } + slug := "portal-e2e-" + string(route.capability) + if err := tx.Exec(`INSERT INTO route_pools (slug,name,capability,prompt_template_id,max_failover,version,enabled) VALUES (?,?,?,?,0,1,TRUE) ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id),name=VALUES(name),capability=VALUES(capability),prompt_template_id=VALUES(prompt_template_id),max_failover=0,version=1,enabled=TRUE`, slug, "Portal E2E Route", route.capability, templateID).Error; err != nil { + return err + } + var routeID uint64 + if err := tx.Raw("SELECT id FROM route_pools WHERE slug=?", slug).Scan(&routeID).Error; err != nil || routeID == 0 { + return fmt.Errorf("load e2e route") + } + if err := tx.Exec(`INSERT INTO route_pool_members (route_pool_id,provider_model_id,weight,failure_threshold,open_seconds,half_open_max,enabled,position) VALUES (?,?,100,3,60,1,TRUE,0) ON DUPLICATE KEY UPDATE weight=100,failure_threshold=3,open_seconds=60,half_open_max=1,enabled=TRUE,position=0`, routeID, providerModelID).Error; err != nil { + return err + } + if err := tx.Exec(`INSERT IGNORE INTO active_routes (capability,route_pool_id) VALUES (?,?)`, route.capability, routeID).Error; err != nil { + return err + } + } + return nil + }) +} + +func cleanup(db *gorm.DB, email string) error { + return db.Transaction(func(tx *gorm.DB) error { + var userID uint64 + _ = tx.Raw("SELECT id FROM users WHERE email=?", strings.ToLower(strings.TrimSpace(email))).Scan(&userID).Error + if userID != 0 { + if err := tx.Exec("DELETE FROM generations WHERE user_id=?", userID).Error; err != nil { + return err + } + if err := tx.Exec("DELETE FROM users WHERE id=?", userID).Error; err != nil { + return err + } + } + if err := tx.Exec("DELETE ar FROM active_routes ar JOIN route_pools rp ON rp.id=ar.route_pool_id WHERE rp.slug LIKE 'portal-e2e-%'").Error; err != nil { + return err + } + if err := tx.Exec("DELETE FROM route_pools WHERE slug LIKE 'portal-e2e-%'").Error; err != nil { + return err + } + if err := tx.Exec("DELETE FROM provider_models WHERE provider_id IN (SELECT id FROM providers WHERE slug='portal-e2e')").Error; err != nil { + return err + } + if err := tx.Exec("DELETE FROM providers WHERE slug='portal-e2e'").Error; err != nil { + return err + } + return tx.Exec("DELETE FROM prompt_templates WHERE template_key LIKE 'portal-e2e-%'").Error + }) +} + func ownedGeneration(db *gorm.DB, generationID uint64, email string) (model.Generation, error) { var generation model.Generation err := db.Table("generations g").Select("g.*").Joins("JOIN users u ON u.id=g.user_id").Where("g.id=? AND u.email=?", generationID, email).Take(&generation).Error diff --git a/portal/web/e2e/portal.spec.js b/portal/web/e2e/portal.spec.js index f39128a..887fd30 100644 --- a/portal/web/e2e/portal.spec.js +++ b/portal/web/e2e/portal.spec.js @@ -27,6 +27,7 @@ test.describe("portal responsive workflow", () => { Date.now = () => 1_787_280_000_000; Math.random = () => 0.125; }); + await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto("/login"); await expect(page.getByRole("heading", { name: "登录 Chorus", level: 2 })).toBeVisible(); await expectNoHorizontalOverflow(page); @@ -44,25 +45,27 @@ test.describe("portal responsive workflow", () => { await expect(page).toHaveURL(/\/$/); await expect(page.locator("#composer-form")).toBeVisible(); - await expect(page.locator("#result-section")).toBeVisible(); - await expect(page.locator(".history-end, .history-empty")).toBeVisible(); + await expect(page.locator("#result-section")).toHaveCount(0); + await expect(page.locator(".history-feedback:not(.hidden), .history-empty")).toBeVisible(); await expectNoHorizontalOverflow(page); - await expectMinimumTargetSize(page, ".button, .icon-button, .mode-button, .history-item"); + await expectMinimumTargetSize(page, ".button, .icon-button, .mode-button, .history-item, .role-button, .order-button"); const width = testInfo.project.use.viewport.width; - const result = await page.locator("#result-section").boundingBox(); const composer = await page.locator("#composer-form").boundingBox(); + const promptFlow = await page.locator(".prompt-panel").boundingBox(); const history = await page.locator(".history-panel").boundingBox(); if (width < 768) { - expect(result.y).toBeLessThan(composer.y); - expect(composer.y).toBeLessThan(history.y); + expect(composer.y).toBeLessThan(promptFlow.y); + expect(promptFlow.y).toBeLessThan(history.y); } else { - expect(history.x).toBeLessThan(result.x); + expect(history.x).toBeLessThan(composer.x); } - if (width >= 1100) expect(result.x).toBeLessThan(composer.x); + if (width >= 1024) expect(composer.x).toBeLessThan(promptFlow.x); + const reducedTransition = await page.locator(".mode-button").first().evaluate(element => Number.parseFloat(getComputedStyle(element).transitionDuration)); + expect(reducedTransition).toBeLessThanOrEqual(0.01); - await page.locator('[data-mode="text"]').click(); + await page.locator('[data-mode="generate"]').click(); await expect(page.locator("#image-fields")).toBeHidden(); - await page.locator('[data-mode="image"]').click(); + await page.locator('[data-mode="edit"]').click(); await expect(page.locator("#image-fields")).toBeVisible(); await page.locator("#image-upload").setInputFiles({ name: "not-an-image.txt", @@ -71,7 +74,7 @@ test.describe("portal responsive workflow", () => { }); await expect(page.locator("#upload-error")).toBeVisible(); await expect(page.locator("#image-upload")).toBeFocused(); - await page.locator('[data-mode="text"]').click(); + await page.locator('[data-mode="generate"]').click(); await page.locator("#prompt").fill("responsive workflow"); await page.locator("#generate-button").click(); await expect(page).toHaveURL(/\/generations\/\d+$/); @@ -80,6 +83,28 @@ test.describe("portal responsive workflow", () => { expect(externalRequests).toEqual([]); expect(browserErrors).toEqual([]); + const historyCount = await page.locator("#history-list [data-history-search]").count(); + await page.locator("#history-controls").evaluate(element => { + element.dataset.hasMore = "true"; + element.dataset.cursor = "test-cursor"; + }); + await page.locator("#load-more-history").evaluate(element => element.classList.remove("hidden")); + await page.route("**/api/generations?*", route => route.abort("failed")); + await page.locator("#load-more-history").click(); + await expect(page.locator("#history-error")).toBeVisible(); + await expect(page.locator("#history-list [data-history-search]")).toHaveCount(historyCount); + await page.unroute("**/api/generations?*"); + await page.route("**/api/generations?*", route => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: [{ id: 999999, kind: "image", status: "failed", user_prompt: "历史恢复记录", created_at: new Date().toISOString() }], next_cursor: "", has_more: false }) + })); + await page.locator("#load-more-history").click(); + await expect(page.getByText("历史恢复记录")).toBeVisible(); + await expect(page.locator("#history-end")).toBeVisible(); + await expect(page.locator("#history-list [data-history-search]")).toHaveCount(historyCount + 1); + await page.unroute("**/api/generations?*"); + await page.route("**/ui/generations/*/result", route => route.abort("failed")); await expect(page.getByText("状态更新失败。当前内容仍然保留,正在等待网络恢复。")).toBeVisible({ timeout: 6_000 }); await page.unroute("**/ui/generations/*/result"); @@ -90,48 +115,68 @@ test.describe("portal responsive workflow", () => { }); }); -test("browser observes text, image, and failed terminal states", async ({ page }, testInfo) => { +test("browser submits image generation and ordered image editing", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "desktop-small", "terminal state flow runs once"); test.skip(!email || !password || !process.env.CHORUS_E2E_DSN || !process.env.CHORUS_E2E_STORAGE_ROOT, "E2E fixture environment is required"); test.setTimeout(60_000); await login(page); await expect(page.locator("#composer-form")).toBeVisible(); - await page.locator('[data-mode="text"]').click(); - await expect(page.locator('[data-mode="text"]')).toHaveAttribute("aria-pressed", "true"); - await page.locator("#prompt").fill("browser text success"); + await page.locator('[data-mode="generate"]').click(); + await expect(page.locator('[data-mode="generate"]')).toHaveAttribute("aria-pressed", "true"); + await page.locator("#prompt").fill("browser image generation success"); await page.locator("#generate-button").click(); await expect(page).toHaveURL(/\/generations\/\d+$/); - const textID = generationID(page.url()); - fixture("running", textID); + const generatedID = generationID(page.url()); + fixture("running", generatedID); await page.reload(); await expect(page.getByText("正在生成内容")).toBeVisible(); - fixture("text-success", textID); - await expect(page.getByText("MVP-0 browser text result")).toBeVisible({ timeout: 5_000 }); - await expect(page.locator("#result-section")).not.toHaveAttribute("hx-get"); - - await page.goto("/"); - await page.locator('[data-mode="image"]').click(); - await expect(page.locator('[data-mode="image"]')).toHaveAttribute("aria-pressed", "true"); - await page.locator("#image-upload").setInputFiles({ - name: "source.png", - mimeType: "image/png", - buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAFElEQVR4nGPkWfSfAQkwMaABFQAAZQ4BAagN7JwAAAAASUVORK5CYII=", "base64") - }); - await page.locator("#prompt").fill("browser image success"); - await page.locator("#generate-button").click(); - await expect(page).toHaveURL(/\/generations\/\d+$/); - const imageID = generationID(page.url()); - fixture("running", imageID); - await page.reload(); - await expect(page.getByText("正在生成内容")).toBeVisible(); - fixture("image-success", imageID); + fixture("image-success", generatedID); await expect(page.locator(".result-gallery img")).toBeVisible({ timeout: 5_000 }); await expect(page.locator("#result-section")).not.toHaveAttribute("hx-get"); await page.goto("/"); - await page.locator('[data-mode="text"]').click(); - await expect(page.locator('[data-mode="text"]')).toHaveAttribute("aria-pressed", "true"); + await page.locator('[data-mode="edit"]').click(); + await expect(page.locator('[data-mode="edit"]')).toHaveAttribute("aria-pressed", "true"); + await page.locator("#image-upload").setInputFiles({ + name: "primary.png", + mimeType: "image/png", + buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAFElEQVR4nGPkWfSfAQkwMaABFQAAZQ4BAagN7JwAAAAASUVORK5CYII=", "base64") + }); + await page.locator("#image-upload").setInputFiles({ + name: "reference.png", + mimeType: "image/png", + buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAFElEQVR4nGPkWfSfAQkwMaABFQAAZQ4BAagN7JwAAAAASUVORK5CYII=", "base64") + }); + await expect(page.locator(".image-editor")).toHaveCount(2); + await page.locator('.image-editor').nth(1).getByRole("button", { name: "主体图" }).click(); + await page.locator('.image-editor').nth(1).locator(".image-note").fill("保留参考图的材质细节"); + await page.getByRole("button", { name: "上移第 2 张图" }).click(); + await expect(page.locator(".image-title").first()).toHaveText("reference.png"); + await page.locator("#role-rule").fill("主体图决定轮廓,参考图只提供材质。"); + await page.locator("#prompt").fill("browser ordered image edit success"); + await page.screenshot({ path: testInfo.outputPath("image-editor.png") }); + await page.locator("#generate-button").click(); + await expect(page).toHaveURL(/\/generations\/\d+$/); + const editID = generationID(page.url()); + const detailResponse = await page.request.get(`/api/generations/${editID}`); + expect(detailResponse.ok()).toBe(true); + const detail = await detailResponse.json(); + expect(detail.role_rule).toBe("主体图决定轮廓,参考图只提供材质。"); + expect(detail.inputs.map(input => [input.position, input.role, input.note, input.name])).toEqual([ + [0, "primary", "保留参考图的材质细节", "reference.png"], + [1, "reference", null, "primary.png"] + ]); + fixture("running", editID); + await page.reload(); + await expect(page.getByText("正在生成内容")).toBeVisible(); + fixture("image-success", editID); + await expect(page.locator(".result-gallery img")).toBeVisible({ timeout: 5_000 }); + await expect(page.locator("#result-section")).not.toHaveAttribute("hx-get"); + + await page.goto("/"); + await page.locator('[data-mode="generate"]').click(); + await expect(page.locator('[data-mode="generate"]')).toHaveAttribute("aria-pressed", "true"); await page.locator("#prompt").fill("browser classified failure"); await page.locator("#generate-button").click(); await expect(page).toHaveURL(/\/generations\/\d+$/); diff --git a/portal/web/src/icons.js b/portal/web/src/icons.js index a6a788b..d7e8e76 100644 --- a/portal/web/src/icons.js +++ b/portal/web/src/icons.js @@ -1,11 +1,11 @@ import { - AlertCircle, Check, Clock3, Copy, Download, Eye, EyeOff, + AlertCircle, ArrowDown, ArrowUp, Check, Clock3, Copy, Download, Eye, EyeOff, FileText, Image, LogOut, Plus, RefreshCw, Search, ShieldCheck, X, createIcons } from "lucide"; const icons = { - AlertCircle, Check, Clock3, Copy, Download, Eye, EyeOff, + AlertCircle, ArrowDown, ArrowUp, Check, Clock3, Copy, Download, Eye, EyeOff, FileText, Image, LogOut, Plus, RefreshCw, Search, ShieldCheck, X }; diff --git a/portal/web/src/input.css b/portal/web/src/input.css index 6f09b50..d6476ad 100644 --- a/portal/web/src/input.css +++ b/portal/web/src/input.css @@ -52,7 +52,7 @@ button:disabled { cursor: not-allowed; opacity: .48; } .account-actions { display: flex; align-items: center; gap: 8px; } .user-name { max-width: 130px; overflow: hidden; color: var(--text-muted); text-overflow: ellipsis; white-space: nowrap; } -.button, .icon-button, .mode-button, .history-item { min-height: 44px; border-radius: var(--radius); transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease; } +.button, .icon-button, .mode-button, .history-item, .order-button, .role-button { min-height: 44px; border-radius: var(--radius); transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease; } .button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 44px; padding: 10px 16px; font-weight: 650; border: 1px solid transparent; text-decoration: none; } .button-primary { color: #fff; background: var(--primary); border-color: var(--primary); } .button-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); } @@ -75,7 +75,7 @@ button:disabled { cursor: not-allowed; opacity: .48; } .search-field .input { padding-left: 38px; } .input::placeholder, .textarea::placeholder { color: #6f7d83; } .input:hover, .textarea:hover { border-color: #879a9e; } -.history-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; } +.history-list { display: grid; gap: 6px; max-height: calc(100dvh - 284px); margin: 0; padding: 0; overflow: auto; list-style: none; } .history-item { display: grid; grid-template-columns: 36px minmax(0,1fr); gap: 10px; width: 100%; padding: 10px; text-align: left; color: var(--text); background: transparent; border: 1px solid transparent; text-decoration: none; } .history-item:hover { background: var(--surface); border-color: var(--border); } .history-item[aria-current="page"] { background: var(--primary-soft); border-color: #9bc7c3; } @@ -87,7 +87,10 @@ button:disabled { cursor: not-allowed; opacity: .48; } .status-dot.pending { background: var(--warning); } .status-dot.running { background: var(--info); } .status-dot.failed { background: var(--danger); } -.history-end, .history-empty { margin: 16px 0 0; color: var(--text-soft); font-size: 13px; text-align: center; } +.history-end, .history-empty, .history-feedback { margin: 14px 0 0; color: var(--text-soft); font-size: 13px; text-align: center; } +.history-controls { display: grid; } +.history-controls .load-more { margin-top: 10px; font-size: 13px; } +.history-sentinel { width: 100%; height: 1px; } .main { grid-area: main; min-width: 0; padding: 24px clamp(16px,3vw,40px) 48px; } .main-inner { width: 100%; max-width: 1120px; margin: 0 auto; } @@ -98,6 +101,7 @@ h1 { margin: 0; font-size: clamp(24px,3vw,32px); line-height: 1.2; letter-spacin h2 { margin: 0; font-size: 18px; line-height: 1.35; letter-spacing: 0; } h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; } .content-grid { display: grid; grid-template-columns: minmax(0,1.45fr) minmax(320px,.75fr); align-items: start; gap: 20px; } +.current-result { margin-bottom: 20px; } .panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 1px 2px rgb(23 33 38 / 3%); } .panel-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 60px; padding: 12px 16px; border-bottom: 1px solid var(--border); } .panel-heading-group { min-width: 0; } @@ -125,28 +129,61 @@ h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; } .text-result p { max-width: 70ch; margin: 0; white-space: pre-wrap; line-height: 1.75; } .notice { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; padding: 12px 14px; color: var(--warning); background: var(--warning-soft); border: 1px solid #e5c59e; border-radius: 6px; } -.composer { position: sticky; top: calc(var(--header-height) + 20px); } -.composer-body { padding: 16px; } +.composer-body { display: grid; gap: 18px; padding: 18px; } .mode-switch { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px; padding: 4px; background: var(--surface-strong); border: 1px solid var(--border); border-radius: 8px; } .mode-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 10px; color: var(--text-muted); background: transparent; border: 1px solid transparent; font-weight: 650; } .mode-button[aria-pressed="true"] { color: var(--primary); background: var(--surface); border-color: var(--border-strong); box-shadow: 0 1px 2px rgb(23 33 38 / 8%); } -.form-field { margin-top: 16px; } +.form-field { min-width: 0; } .field-help,.field-error { margin: 6px 0 0; font-size: 13px; } .field-help { color: var(--text-soft); } .field-error { color: var(--danger); font-weight: 600; } -.upload-zone { position: relative; display: grid; min-height: 92px; padding: 14px; place-items: center; text-align: center; color: var(--text-muted); background: var(--surface-subtle); border: 1px dashed #8fa2a5; border-radius: 6px; cursor: pointer; } +.upload-zone { position: relative; display: grid; min-height: 108px; padding: 16px; place-items: center; text-align: center; color: var(--primary); background: var(--surface-subtle); border: 1px dashed #8fa2a5; border-radius: 6px; cursor: pointer; } .upload-zone:hover { color: var(--primary); background: var(--primary-soft); border-color: var(--primary); } -.upload-zone input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; } +.upload-zone input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); } .upload-zone:focus-within { outline: 3px solid var(--focus); outline-offset: 2px; } -.upload-zone strong { display: block; color: var(--text); } -.upload-list { display: grid; gap: 8px; margin-top: 10px; } -.upload-item { display: grid; grid-template-columns: 48px minmax(0,1fr) 44px; align-items: center; gap: 10px; min-height: 58px; padding: 6px; background: var(--surface-subtle); border: 1px solid var(--border); border-radius: 6px; } -.upload-thumb { width: 48px; height: 48px; object-fit: cover; background: #dfe7e6; border-radius: 4px; } -.upload-name { display: block; overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } -.upload-role { display: block; color: var(--text-soft); font-size: 12px; } +.upload-zone span { display: flex; align-items: center; flex-direction: column; gap: 5px; } +.upload-zone strong { color: var(--text); font-size: 14px; } +.upload-zone small { color: var(--text-soft); } +.role-rule-box { padding: 12px; background: var(--surface-subtle); border: 1px solid var(--border); border-radius: 6px; } +.role-rule-box summary { cursor: pointer; font-weight: 650; } +.role-rule-box summary + div { display: grid; gap: 10px; margin-top: 12px; } +.section-divider { height: 1px; margin: 2px 0; background: var(--border); } +.subheading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; } +.subheading h3 { font-size: 15px; } +.subheading p, .thumb-note { margin: 0; color: var(--text-soft); font-size: 12px; } +.image-list { display: grid; gap: 12px; margin-top: 12px; } +.image-editor { display: grid; grid-template-columns: 96px minmax(0,1fr) 44px; align-items: start; gap: 12px; padding: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; } +.image-editor.dragging { opacity: .62; } +.image-editor.drag-over { border-color: var(--primary); box-shadow: 0 0 0 3px rgb(0 107 102 / 10%); } +.image-thumb { position: relative; aspect-ratio: 1; overflow: hidden; background: #dfe7e6; border-radius: 5px; } +.image-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; } +.image-index { position: absolute; top: 6px; left: 6px; display: grid; width: 24px; height: 24px; place-items: center; color: #fff; background: rgb(23 33 38 / 86%); border-radius: 50%; font-size: 12px; font-weight: 700; } +.image-editor-main { display: grid; min-width: 0; gap: 8px; } +.image-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.image-title { min-width: 0; overflow: hidden; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } +.drag-hint { color: var(--text-soft); font-size: 12px; } +.role-group { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 6px; } +.role-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 7px; color: var(--text-muted); background: var(--surface); border: 1px solid var(--border-strong); font-size: 13px; font-weight: 650; } +.role-button[aria-pressed="true"] { color: var(--primary); background: var(--primary-soft); border-color: var(--primary); } +.order-controls { display: grid; gap: 6px; } +.order-button { display: grid; width: 44px; height: 44px; padding: 0; place-items: center; color: var(--text-muted); background: var(--surface); border: 1px solid var(--border); } +.order-button:hover { color: var(--primary); background: var(--primary-soft); border-color: #9bc7c3; } +.remove-image { color: var(--danger); } .composer-footer { display: grid; gap: 10px; margin-top: 18px; } .privacy-note { display: flex; align-items: flex-start; gap: 8px; margin: 0; color: var(--text-soft); font-size: 12px; } .privacy-note .icon { width: 16px; height: 16px; flex-basis: 16px; margin-top: 1px; } +.prompt-panel { position: sticky; top: calc(var(--header-height) + 20px); } +.prompt-flow { display: grid; gap: 0; padding: 16px 16px 16px 30px; } +.flow-step { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 10px; padding: 0 0 16px; border-left: 1px solid var(--border); } +.flow-step:last-child { padding-bottom: 0; border-left-color: transparent; } +.flow-number { z-index: 1; display: grid; width: 28px; height: 28px; margin-left: -14px; place-items: center; color: var(--primary); background: var(--primary-soft); border: 1px solid #9bc7c3; border-radius: 50%; font-size: 13px; font-weight: 750; } +.flow-copy { padding-top: 2px; } +.flow-copy strong { display: block; font-size: 14px; } +.flow-copy p { margin: 3px 0 0; color: var(--text-muted); font-size: 13px; } +.read-only { display: block; margin-top: 8px; padding: 8px 10px; color: #41565a; background: var(--surface-subtle); border: 1px solid var(--border); border-radius: 4px; font-family: ui-monospace,SFMono-Regular,Consolas,monospace; font-size: 12px; line-height: 1.55; } +.prompt-preview { margin: 0 16px 16px; padding: 12px; background: var(--surface-subtle); border: 1px solid var(--border); border-radius: 6px; } +.prompt-preview summary { cursor: pointer; font-size: 14px; font-weight: 650; } +.prompt-preview p { margin: 10px 0 0; color: #41565a; font-size: 13px; line-height: 1.65; } .modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; padding: 20px; place-items: center; background: rgb(23 33 38 / 58%); } .modal { width: min(100%,440px); padding: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); } @@ -172,9 +209,9 @@ h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; } .password-wrap .input { padding-right: 52px; } .password-wrap .icon-button { position: absolute; top: 0; right: 0; } -@media (max-width: 1100px) { +@media (max-width: 1023px) { .content-grid { grid-template-columns: 1fr; } - .composer { position: static; } + .prompt-panel { position: static; } } @media (min-width: 768px) { .workspace { grid-template-areas: "history main"; } @@ -189,12 +226,17 @@ h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; } .main { padding: 18px 12px 24px; } .history-panel { border-top: 1px solid var(--border); border-right: 0; padding: 20px 12px 32px; } .history-sticky { position: static; } + .history-list { max-height: none; } .result-body { min-height: 390px; padding: 12px; } .result-frame,.result-frame img { min-height: 270px; } .result-actions { align-items: stretch; flex-direction: column; } .action-group { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); } .state-message { min-height: 300px; padding: 20px 12px; } .text-result { min-height: 300px; padding: 20px; } + .image-editor { grid-template-columns: 76px minmax(0,1fr); gap: 10px; } + .order-controls { grid-column: 1 / -1; grid-template-columns: repeat(3,minmax(0,1fr)); } + .order-button { width: 100%; } + .drag-hint { display: none; } .toast { right: 16px; bottom: 16px; left: 16px; max-width: none; } .login-view { grid-template-columns: 1fr; } .login-context { min-height: auto; padding: 24px 20px; } diff --git a/portal/web/static/app.css b/portal/web/static/app.css index 657cd42..0b23d5f 100644 --- a/portal/web/static/app.css +++ b/portal/web/static/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.static{position:static}.block{display:block}.hidden{display:none}.inline{display:inline}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{--bg:#f3f6f6;--surface:#fff;--surface-subtle:#f8faf9;--surface-strong:#edf3f2;--text:#172126;--text-muted:#526169;--text-soft:#6a777e;--border:#d6dfe1;--border-strong:#b8c6c9;--primary:#006b66;--primary-hover:#005853;--primary-soft:#e4f2f0;--info:#245f87;--info-soft:#e8f1f7;--warning:#81501f;--warning-soft:#fff0df;--danger:#a63630;--danger-soft:#fcebea;--success:#2d6b42;--success-soft:#e7f3eb;--focus:#147bba;--shadow:0 6px 20px #17212614;--radius:6px;--header-height:64px}*{box-sizing:border-box}html{background:var(--bg);min-width:320px}body{min-width:320px;min-height:100dvh;color:var(--text);background:var(--bg);letter-spacing:0;margin:0;font-family:Inter,Segoe UI,Microsoft YaHei,sans-serif;font-size:16px;line-height:1.5;overflow-x:hidden}button,input,textarea{font:inherit;letter-spacing:0}button,input[type=file]{touch-action:manipulation}button,a{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.48}[x-cloak]{display:none!important}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}.skip-link{z-index:1000;color:#fff;background:var(--primary);border-radius:var(--radius);padding:10px 14px;position:fixed;top:8px;left:8px;transform:translateY(-140%)}.skip-link:focus{transform:translateY(0)}.sr-only{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.hidden{display:none!important}.icon{stroke-width:2px;pointer-events:none;flex:0 0 20px;width:20px;height:20px}.topbar{z-index:40;height:var(--header-height);border-bottom:1px solid var(--border);background:#fffffffa;grid-template-columns:296px minmax(0,1fr) auto;align-items:center;padding:0 20px;display:grid;position:sticky;top:0}.brand{min-width:0;color:var(--text);align-items:center;gap:10px;font-size:20px;font-weight:750;text-decoration:none;display:inline-flex}.brand-mark{color:#fff;background:var(--primary);border-radius:6px;place-items:center;width:34px;height:34px;font-size:17px;font-weight:800;display:grid}.primary-nav{align-self:stretch;align-items:center;gap:8px;display:flex}.nav-item{min-height:44px;color:var(--primary);border-bottom:3px solid var(--primary);align-items:center;gap:8px;padding:0 14px;font-weight:650;text-decoration:none;display:inline-flex}.account-actions{align-items:center;gap:8px;display:flex}.user-name{max-width:130px;color:var(--text-muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.button,.icon-button,.mode-button,.history-item{border-radius:var(--radius);min-height:44px;transition:background-color .18s,border-color .18s,color .18s,box-shadow .18s}.button{border:1px solid #0000;justify-content:center;align-items:center;gap:8px;min-width:44px;padding:10px 16px;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{color:#fff;background:var(--primary);border-color:var(--primary)}.button-primary:hover{background:var(--primary-hover);border-color:var(--primary-hover)}.button-secondary{color:var(--text);background:var(--surface);border-color:var(--border-strong)}.button-secondary:hover{background:var(--surface-strong);border-color:#8fa1a5}.button-block{width:100%}.icon-button{width:44px;height:44px;color:var(--text-muted);background:0 0;border:1px solid #0000;place-items:center;padding:0;display:inline-grid}.icon-button:hover{color:var(--text);background:var(--surface-strong);border-color:var(--border)}.workspace{min-height:calc(100dvh - var(--header-height));grid-template-columns:296px minmax(0,1fr);display:grid}.history-panel{border-right:1px solid var(--border);background:#f8faf9;grid-area:history;min-width:0;padding:20px 16px 28px}.history-sticky{top:calc(var(--header-height) + 16px);position:sticky}.field-label{color:var(--text);margin-bottom:6px;font-size:14px;font-weight:650;display:block}.history-search-label{margin-top:14px}.search-field{margin:6px 0 16px;position:relative}.search-field .icon{width:18px;height:18px;color:var(--text-soft);position:absolute;top:50%;left:12px;transform:translateY(-50%)}.input,.textarea{width:100%;color:var(--text);background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius)}.input{min-height:44px;padding:9px 12px}.textarea{resize:vertical;min-height:128px;padding:12px}.search-field .input{padding-left:38px}.input::placeholder,.textarea::placeholder{color:#6f7d83}.input:hover,.textarea:hover{border-color:#879a9e}.history-list{gap:6px;margin:0;padding:0;list-style:none;display:grid}.history-item{text-align:left;width:100%;color:var(--text);background:0 0;border:1px solid #0000;grid-template-columns:36px minmax(0,1fr);gap:10px;padding:10px;text-decoration:none;display:grid}.history-item:hover{background:var(--surface);border-color:var(--border)}.history-item[aria-current=page]{background:var(--primary-soft);border-color:#9bc7c3}.history-icon{width:36px;height:36px;color:var(--primary);background:var(--surface);border:1px solid var(--border);border-radius:6px;place-items:center;display:grid}.history-copy{min-width:0}.history-title{text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:650;display:block;overflow:hidden}.history-meta{color:var(--text-soft);align-items:center;gap:7px;margin-top:3px;font-size:12px;display:flex}.status-dot{background:var(--success);border-radius:50%;flex:0 0 7px;width:7px;height:7px}.status-dot.pending{background:var(--warning)}.status-dot.running{background:var(--info)}.status-dot.failed{background:var(--danger)}.history-end,.history-empty{color:var(--text-soft);text-align:center;margin:16px 0 0;font-size:13px}.main{grid-area:main;min-width:0;padding:24px clamp(16px,3vw,40px) 48px}.main-inner{width:100%;max-width:1120px;margin:0 auto}.page-heading{margin-bottom:18px}.eyebrow{color:var(--primary);margin:0 0 4px;font-size:13px;font-weight:700}h1,h2,h3,p{overflow-wrap:anywhere}h1{letter-spacing:0;margin:0;font-size:clamp(24px,3vw,32px);line-height:1.2}h2{letter-spacing:0;margin:0;font-size:18px;line-height:1.35}h3{letter-spacing:0;margin:0;font-size:16px;line-height:1.4}.content-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr);align-items:start;gap:20px;display:grid}.panel{background:var(--surface);border:1px solid var(--border);border-radius:8px;min-width:0;box-shadow:0 1px 2px #17212608}.panel-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;gap:12px;min-height:60px;padding:12px 16px;display:flex}.panel-heading-group{min-width:0}.panel-heading-group p{color:var(--text-muted);margin:2px 0 0;font-size:13px}.status-badge{min-height:28px;color:var(--success);background:var(--success-soft);white-space:nowrap;border:1px solid #b6d6c0;border-radius:999px;align-items:center;gap:7px;padding:3px 9px;font-size:13px;font-weight:700;display:inline-flex}.status-badge[data-tone=pending]{color:var(--warning);background:var(--warning-soft);border-color:#e5c59e}.status-badge[data-tone=running]{color:var(--info);background:var(--info-soft);border-color:#b6cedd}.status-badge[data-tone=failed]{color:var(--danger);background:var(--danger-soft);border-color:#e1b6b3}.result-body{place-items:stretch stretch;min-height:470px;padding:18px;display:grid;position:relative}.result-frame{border:1px solid var(--border);background:#e9eeee;border-radius:6px;place-items:center;min-height:360px;display:grid;position:relative;overflow:hidden}.result-frame img{object-fit:contain;background:#e9eeee;width:100%;height:100%;min-height:360px;display:block}.result-gallery{gap:14px;display:grid}.result-actions{justify-content:space-between;align-items:center;gap:12px;margin-top:14px;display:flex}.result-meta{color:var(--text-muted);font-size:13px}.action-group{flex-wrap:wrap;gap:8px;display:flex}.state-message{text-align:center;align-content:center;place-items:center;max-width:520px;min-height:340px;margin:auto;padding:28px;display:grid}.state-icon{width:56px;height:56px;color:var(--primary);background:var(--primary-soft);border:1px solid #a6d0cc;border-radius:8px;place-items:center;margin-bottom:16px;display:grid}.state-icon .icon{width:28px;height:28px}.state-icon.danger{color:var(--danger);background:var(--danger-soft);border-color:#e1b6b3}.state-message p{max-width:440px;color:var(--text-muted);margin:8px 0 18px}.progress-track{background:#dce6e5;border-radius:999px;width:min(280px,80vw);height:6px;margin-top:10px;overflow:hidden}.progress-bar{background:var(--primary);transform-origin:0;width:44%;height:100%;animation:1.6s ease-in-out infinite alternate progress}@keyframes progress{0%{transform:scaleX(.35)}to{transform:scaleX(1)}}.text-result{border:1px solid var(--border);background:#fbfcfc;border-radius:6px;flex-direction:column;gap:14px;min-height:360px;padding:28px;display:flex}.text-result p{white-space:pre-wrap;max-width:70ch;margin:0;line-height:1.75}.notice{color:var(--warning);background:var(--warning-soft);border:1px solid #e5c59e;border-radius:6px;align-items:flex-start;gap:10px;margin-bottom:14px;padding:12px 14px;display:flex}.composer{top:calc(var(--header-height) + 20px);position:sticky}.composer-body{padding:16px}.mode-switch{background:var(--surface-strong);border:1px solid var(--border);border-radius:8px;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px;padding:4px;display:grid}.mode-button{color:var(--text-muted);background:0 0;border:1px solid #0000;justify-content:center;align-items:center;gap:8px;padding:8px 10px;font-weight:650;display:inline-flex}.mode-button[aria-pressed=true]{color:var(--primary);background:var(--surface);border-color:var(--border-strong);box-shadow:0 1px 2px #17212614}.form-field{margin-top:16px}.field-help,.field-error{margin:6px 0 0;font-size:13px}.field-help{color:var(--text-soft)}.field-error{color:var(--danger);font-weight:600}.upload-zone{text-align:center;min-height:92px;color:var(--text-muted);background:var(--surface-subtle);cursor:pointer;border:1px dashed #8fa2a5;border-radius:6px;place-items:center;padding:14px;display:grid;position:relative}.upload-zone:hover{color:var(--primary);background:var(--primary-soft);border-color:var(--primary)}.upload-zone input{opacity:0;cursor:pointer;width:100%;height:100%;position:absolute;inset:0}.upload-zone:focus-within{outline:3px solid var(--focus);outline-offset:2px}.upload-zone strong{color:var(--text);display:block}.upload-list{gap:8px;margin-top:10px;display:grid}.upload-item{background:var(--surface-subtle);border:1px solid var(--border);border-radius:6px;grid-template-columns:48px minmax(0,1fr) 44px;align-items:center;gap:10px;min-height:58px;padding:6px;display:grid}.upload-thumb{object-fit:cover;background:#dfe7e6;border-radius:4px;width:48px;height:48px}.upload-name{text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:650;display:block;overflow:hidden}.upload-role{color:var(--text-soft);font-size:12px;display:block}.composer-footer{gap:10px;margin-top:18px;display:grid}.privacy-note{color:var(--text-soft);align-items:flex-start;gap:8px;margin:0;font-size:12px;display:flex}.privacy-note .icon{flex-basis:16px;width:16px;height:16px;margin-top:1px}.modal-backdrop{z-index:100;background:#17212694;place-items:center;padding:20px;display:grid;position:fixed;inset:0}.modal{background:var(--surface);border:1px solid var(--border);width:min(100%,440px);box-shadow:var(--shadow);border-radius:8px;padding:24px}.modal p{color:var(--text-muted)}.modal-actions{justify-content:flex-end;gap:8px;margin-top:20px;display:flex}.toast{z-index:120;color:#fff;max-width:min(420px,100vw - 32px);box-shadow:var(--shadow);background:#203038;border-radius:6px;align-items:center;gap:10px;padding:12px 14px;display:flex;position:fixed;bottom:20px;right:20px}.login-view{background:var(--surface);grid-template-columns:minmax(280px,.9fr) minmax(360px,1.1fr);min-height:100dvh;display:grid}.login-context{color:#f4fbfa;background:#173a3a;flex-direction:column;justify-content:space-between;min-height:100dvh;padding:clamp(28px,6vw,72px);display:flex}.login-context .brand{color:#fff}.login-copy{max-width:560px}.login-copy h1{font-size:clamp(32px,5vw,56px)}.login-copy p{color:#c5d9d7;max-width:52ch;margin:18px 0 0;font-size:18px}.login-status-list{gap:10px;margin:30px 0 0;padding:0;list-style:none;display:grid}.login-status-list li{align-items:center;gap:10px;display:flex}.login-status-list .icon{color:#8ed1c8}.login-context small{color:#a8c2c0}.login-form-wrap{place-items:center;min-width:0;padding:28px;display:grid}.login-form{width:min(100%,420px)}.login-form h2{margin-bottom:6px;font-size:28px}.login-form>p{color:var(--text-muted);margin:0 0 24px}.password-wrap{position:relative}.password-wrap .input{padding-right:52px}.password-wrap .icon-button{position:absolute;top:0;right:0}@media (max-width:1100px){.content-grid{grid-template-columns:1fr}.composer{position:static}}@media (min-width:768px){.workspace{grid-template-areas:"history main"}}@media (max-width:767px){:root{--header-height:56px}.topbar{height:auto;min-height:var(--header-height);grid-template-columns:minmax(0,1fr) auto;padding:0 12px}.primary-nav{display:none}.brand{font-size:18px}.user-name{display:none}.workspace{grid-template-columns:minmax(0,1fr);grid-template-areas:"main""history"}.main{padding:18px 12px 24px}.history-panel{border-top:1px solid var(--border);border-right:0;padding:20px 12px 32px}.history-sticky{position:static}.result-body{min-height:390px;padding:12px}.result-frame,.result-frame img{min-height:270px}.result-actions{flex-direction:column;align-items:stretch}.action-group{grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.state-message{min-height:300px;padding:20px 12px}.text-result{min-height:300px;padding:20px}.toast{max-width:none;bottom:16px;left:16px;right:16px}.login-view{grid-template-columns:1fr}.login-context{min-height:auto;padding:24px 20px}.login-copy h1{margin-top:56px;font-size:32px}.login-copy p{font-size:16px}.login-status-list,.login-context small{display:none}.login-form-wrap{padding:32px 20px 48px}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.static{position:static}.block{display:block}.hidden{display:none}.inline{display:inline}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{--bg:#f3f6f6;--surface:#fff;--surface-subtle:#f8faf9;--surface-strong:#edf3f2;--text:#172126;--text-muted:#526169;--text-soft:#6a777e;--border:#d6dfe1;--border-strong:#b8c6c9;--primary:#006b66;--primary-hover:#005853;--primary-soft:#e4f2f0;--info:#245f87;--info-soft:#e8f1f7;--warning:#81501f;--warning-soft:#fff0df;--danger:#a63630;--danger-soft:#fcebea;--success:#2d6b42;--success-soft:#e7f3eb;--focus:#147bba;--shadow:0 6px 20px #17212614;--radius:6px;--header-height:64px}*{box-sizing:border-box}html{background:var(--bg);min-width:320px}body{min-width:320px;min-height:100dvh;color:var(--text);background:var(--bg);letter-spacing:0;margin:0;font-family:Inter,Segoe UI,Microsoft YaHei,sans-serif;font-size:16px;line-height:1.5;overflow-x:hidden}button,input,textarea{font:inherit;letter-spacing:0}button,input[type=file]{touch-action:manipulation}button,a{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.48}[x-cloak]{display:none!important}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}.skip-link{z-index:1000;color:#fff;background:var(--primary);border-radius:var(--radius);padding:10px 14px;position:fixed;top:8px;left:8px;transform:translateY(-140%)}.skip-link:focus{transform:translateY(0)}.sr-only{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.hidden{display:none!important}.icon{stroke-width:2px;pointer-events:none;flex:0 0 20px;width:20px;height:20px}.topbar{z-index:40;height:var(--header-height);border-bottom:1px solid var(--border);background:#fffffffa;grid-template-columns:296px minmax(0,1fr) auto;align-items:center;padding:0 20px;display:grid;position:sticky;top:0}.brand{min-width:0;color:var(--text);align-items:center;gap:10px;font-size:20px;font-weight:750;text-decoration:none;display:inline-flex}.brand-mark{color:#fff;background:var(--primary);border-radius:6px;place-items:center;width:34px;height:34px;font-size:17px;font-weight:800;display:grid}.primary-nav{align-self:stretch;align-items:center;gap:8px;display:flex}.nav-item{min-height:44px;color:var(--primary);border-bottom:3px solid var(--primary);align-items:center;gap:8px;padding:0 14px;font-weight:650;text-decoration:none;display:inline-flex}.account-actions{align-items:center;gap:8px;display:flex}.user-name{max-width:130px;color:var(--text-muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.button,.icon-button,.mode-button,.history-item,.order-button,.role-button{border-radius:var(--radius);min-height:44px;transition:background-color .18s,border-color .18s,color .18s,box-shadow .18s}.button{border:1px solid #0000;justify-content:center;align-items:center;gap:8px;min-width:44px;padding:10px 16px;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{color:#fff;background:var(--primary);border-color:var(--primary)}.button-primary:hover{background:var(--primary-hover);border-color:var(--primary-hover)}.button-secondary{color:var(--text);background:var(--surface);border-color:var(--border-strong)}.button-secondary:hover{background:var(--surface-strong);border-color:#8fa1a5}.button-block{width:100%}.icon-button{width:44px;height:44px;color:var(--text-muted);background:0 0;border:1px solid #0000;place-items:center;padding:0;display:inline-grid}.icon-button:hover{color:var(--text);background:var(--surface-strong);border-color:var(--border)}.workspace{min-height:calc(100dvh - var(--header-height));grid-template-columns:296px minmax(0,1fr);display:grid}.history-panel{border-right:1px solid var(--border);background:#f8faf9;grid-area:history;min-width:0;padding:20px 16px 28px}.history-sticky{top:calc(var(--header-height) + 16px);position:sticky}.field-label{color:var(--text);margin-bottom:6px;font-size:14px;font-weight:650;display:block}.history-search-label{margin-top:14px}.search-field{margin:6px 0 16px;position:relative}.search-field .icon{width:18px;height:18px;color:var(--text-soft);position:absolute;top:50%;left:12px;transform:translateY(-50%)}.input,.textarea{width:100%;color:var(--text);background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius)}.input{min-height:44px;padding:9px 12px}.textarea{resize:vertical;min-height:128px;padding:12px}.search-field .input{padding-left:38px}.input::placeholder,.textarea::placeholder{color:#6f7d83}.input:hover,.textarea:hover{border-color:#879a9e}.history-list{gap:6px;max-height:calc(100dvh - 284px);margin:0;padding:0;list-style:none;display:grid;overflow:auto}.history-item{text-align:left;width:100%;color:var(--text);background:0 0;border:1px solid #0000;grid-template-columns:36px minmax(0,1fr);gap:10px;padding:10px;text-decoration:none;display:grid}.history-item:hover{background:var(--surface);border-color:var(--border)}.history-item[aria-current=page]{background:var(--primary-soft);border-color:#9bc7c3}.history-icon{width:36px;height:36px;color:var(--primary);background:var(--surface);border:1px solid var(--border);border-radius:6px;place-items:center;display:grid}.history-copy{min-width:0}.history-title{text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:650;display:block;overflow:hidden}.history-meta{color:var(--text-soft);align-items:center;gap:7px;margin-top:3px;font-size:12px;display:flex}.status-dot{background:var(--success);border-radius:50%;flex:0 0 7px;width:7px;height:7px}.status-dot.pending{background:var(--warning)}.status-dot.running{background:var(--info)}.status-dot.failed{background:var(--danger)}.history-end,.history-empty,.history-feedback{color:var(--text-soft);text-align:center;margin:14px 0 0;font-size:13px}.history-controls{display:grid}.history-controls .load-more{margin-top:10px;font-size:13px}.history-sentinel{width:100%;height:1px}.main{grid-area:main;min-width:0;padding:24px clamp(16px,3vw,40px) 48px}.main-inner{width:100%;max-width:1120px;margin:0 auto}.page-heading{margin-bottom:18px}.eyebrow{color:var(--primary);margin:0 0 4px;font-size:13px;font-weight:700}h1,h2,h3,p{overflow-wrap:anywhere}h1{letter-spacing:0;margin:0;font-size:clamp(24px,3vw,32px);line-height:1.2}h2{letter-spacing:0;margin:0;font-size:18px;line-height:1.35}h3{letter-spacing:0;margin:0;font-size:16px;line-height:1.4}.content-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr);align-items:start;gap:20px;display:grid}.current-result{margin-bottom:20px}.panel{background:var(--surface);border:1px solid var(--border);border-radius:8px;min-width:0;box-shadow:0 1px 2px #17212608}.panel-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;gap:12px;min-height:60px;padding:12px 16px;display:flex}.panel-heading-group{min-width:0}.panel-heading-group p{color:var(--text-muted);margin:2px 0 0;font-size:13px}.status-badge{min-height:28px;color:var(--success);background:var(--success-soft);white-space:nowrap;border:1px solid #b6d6c0;border-radius:999px;align-items:center;gap:7px;padding:3px 9px;font-size:13px;font-weight:700;display:inline-flex}.status-badge[data-tone=pending]{color:var(--warning);background:var(--warning-soft);border-color:#e5c59e}.status-badge[data-tone=running]{color:var(--info);background:var(--info-soft);border-color:#b6cedd}.status-badge[data-tone=failed]{color:var(--danger);background:var(--danger-soft);border-color:#e1b6b3}.result-body{place-items:stretch stretch;min-height:470px;padding:18px;display:grid;position:relative}.result-frame{border:1px solid var(--border);background:#e9eeee;border-radius:6px;place-items:center;min-height:360px;display:grid;position:relative;overflow:hidden}.result-frame img{object-fit:contain;background:#e9eeee;width:100%;height:100%;min-height:360px;display:block}.result-gallery{gap:14px;display:grid}.result-actions{justify-content:space-between;align-items:center;gap:12px;margin-top:14px;display:flex}.result-meta{color:var(--text-muted);font-size:13px}.action-group{flex-wrap:wrap;gap:8px;display:flex}.state-message{text-align:center;align-content:center;place-items:center;max-width:520px;min-height:340px;margin:auto;padding:28px;display:grid}.state-icon{width:56px;height:56px;color:var(--primary);background:var(--primary-soft);border:1px solid #a6d0cc;border-radius:8px;place-items:center;margin-bottom:16px;display:grid}.state-icon .icon{width:28px;height:28px}.state-icon.danger{color:var(--danger);background:var(--danger-soft);border-color:#e1b6b3}.state-message p{max-width:440px;color:var(--text-muted);margin:8px 0 18px}.progress-track{background:#dce6e5;border-radius:999px;width:min(280px,80vw);height:6px;margin-top:10px;overflow:hidden}.progress-bar{background:var(--primary);transform-origin:0;width:44%;height:100%;animation:1.6s ease-in-out infinite alternate progress}@keyframes progress{0%{transform:scaleX(.35)}to{transform:scaleX(1)}}.text-result{border:1px solid var(--border);background:#fbfcfc;border-radius:6px;flex-direction:column;gap:14px;min-height:360px;padding:28px;display:flex}.text-result p{white-space:pre-wrap;max-width:70ch;margin:0;line-height:1.75}.notice{color:var(--warning);background:var(--warning-soft);border:1px solid #e5c59e;border-radius:6px;align-items:flex-start;gap:10px;margin-bottom:14px;padding:12px 14px;display:flex}.composer-body{gap:18px;padding:18px;display:grid}.mode-switch{background:var(--surface-strong);border:1px solid var(--border);border-radius:8px;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px;padding:4px;display:grid}.mode-button{color:var(--text-muted);background:0 0;border:1px solid #0000;justify-content:center;align-items:center;gap:8px;padding:8px 10px;font-weight:650;display:inline-flex}.mode-button[aria-pressed=true]{color:var(--primary);background:var(--surface);border-color:var(--border-strong);box-shadow:0 1px 2px #17212614}.form-field{min-width:0}.field-help,.field-error{margin:6px 0 0;font-size:13px}.field-help{color:var(--text-soft)}.field-error{color:var(--danger);font-weight:600}.upload-zone{text-align:center;min-height:108px;color:var(--primary);background:var(--surface-subtle);cursor:pointer;border:1px dashed #8fa2a5;border-radius:6px;place-items:center;padding:16px;display:grid;position:relative}.upload-zone:hover{color:var(--primary);background:var(--primary-soft);border-color:var(--primary)}.upload-zone input{clip:rect(0,0,0,0);width:1px;height:1px;position:absolute;overflow:hidden}.upload-zone:focus-within{outline:3px solid var(--focus);outline-offset:2px}.upload-zone span{flex-direction:column;align-items:center;gap:5px;display:flex}.upload-zone strong{color:var(--text);font-size:14px}.upload-zone small{color:var(--text-soft)}.role-rule-box{background:var(--surface-subtle);border:1px solid var(--border);border-radius:6px;padding:12px}.role-rule-box summary{cursor:pointer;font-weight:650}.role-rule-box summary+div{gap:10px;margin-top:12px;display:grid}.section-divider{background:var(--border);height:1px;margin:2px 0}.subheading{justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px;display:flex}.subheading h3{font-size:15px}.subheading p,.thumb-note{color:var(--text-soft);margin:0;font-size:12px}.image-list{gap:12px;margin-top:12px;display:grid}.image-editor{background:var(--surface);border:1px solid var(--border);border-radius:6px;grid-template-columns:96px minmax(0,1fr) 44px;align-items:start;gap:12px;padding:12px;display:grid}.image-editor.dragging{opacity:.62}.image-editor.drag-over{border-color:var(--primary);box-shadow:0 0 0 3px #006b661a}.image-thumb{aspect-ratio:1;background:#dfe7e6;border-radius:5px;position:relative;overflow:hidden}.image-thumb img{object-fit:cover;width:100%;height:100%;display:block}.image-index{color:#fff;background:#172126db;border-radius:50%;place-items:center;width:24px;height:24px;font-size:12px;font-weight:700;display:grid;position:absolute;top:6px;left:6px}.image-editor-main{gap:8px;min-width:0;display:grid}.image-title-row{justify-content:space-between;align-items:center;gap:8px;display:flex}.image-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-weight:700;overflow:hidden}.drag-hint{color:var(--text-soft);font-size:12px}.role-group{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;display:grid}.role-button{color:var(--text-muted);background:var(--surface);border:1px solid var(--border-strong);justify-content:center;align-items:center;gap:6px;padding:7px;font-size:13px;font-weight:650;display:inline-flex}.role-button[aria-pressed=true]{color:var(--primary);background:var(--primary-soft);border-color:var(--primary)}.order-controls{gap:6px;display:grid}.order-button{width:44px;height:44px;color:var(--text-muted);background:var(--surface);border:1px solid var(--border);place-items:center;padding:0;display:grid}.order-button:hover{color:var(--primary);background:var(--primary-soft);border-color:#9bc7c3}.remove-image{color:var(--danger)}.composer-footer{gap:10px;margin-top:18px;display:grid}.privacy-note{color:var(--text-soft);align-items:flex-start;gap:8px;margin:0;font-size:12px;display:flex}.privacy-note .icon{flex-basis:16px;width:16px;height:16px;margin-top:1px}.prompt-panel{top:calc(var(--header-height) + 20px);position:sticky}.prompt-flow{gap:0;padding:16px 16px 16px 30px;display:grid}.flow-step{border-left:1px solid var(--border);grid-template-columns:28px minmax(0,1fr);gap:10px;padding:0 0 16px;display:grid}.flow-step:last-child{border-left-color:#0000;padding-bottom:0}.flow-number{z-index:1;width:28px;height:28px;color:var(--primary);background:var(--primary-soft);border:1px solid #9bc7c3;border-radius:50%;place-items:center;margin-left:-14px;font-size:13px;font-weight:750;display:grid}.flow-copy{padding-top:2px}.flow-copy strong{font-size:14px;display:block}.flow-copy p{color:var(--text-muted);margin:3px 0 0;font-size:13px}.read-only{color:#41565a;background:var(--surface-subtle);border:1px solid var(--border);border-radius:4px;margin-top:8px;padding:8px 10px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;line-height:1.55;display:block}.prompt-preview{background:var(--surface-subtle);border:1px solid var(--border);border-radius:6px;margin:0 16px 16px;padding:12px}.prompt-preview summary{cursor:pointer;font-size:14px;font-weight:650}.prompt-preview p{color:#41565a;margin:10px 0 0;font-size:13px;line-height:1.65}.modal-backdrop{z-index:100;background:#17212694;place-items:center;padding:20px;display:grid;position:fixed;inset:0}.modal{background:var(--surface);border:1px solid var(--border);width:min(100%,440px);box-shadow:var(--shadow);border-radius:8px;padding:24px}.modal p{color:var(--text-muted)}.modal-actions{justify-content:flex-end;gap:8px;margin-top:20px;display:flex}.toast{z-index:120;color:#fff;max-width:min(420px,100vw - 32px);box-shadow:var(--shadow);background:#203038;border-radius:6px;align-items:center;gap:10px;padding:12px 14px;display:flex;position:fixed;bottom:20px;right:20px}.login-view{background:var(--surface);grid-template-columns:minmax(280px,.9fr) minmax(360px,1.1fr);min-height:100dvh;display:grid}.login-context{color:#f4fbfa;background:#173a3a;flex-direction:column;justify-content:space-between;min-height:100dvh;padding:clamp(28px,6vw,72px);display:flex}.login-context .brand{color:#fff}.login-copy{max-width:560px}.login-copy h1{font-size:clamp(32px,5vw,56px)}.login-copy p{color:#c5d9d7;max-width:52ch;margin:18px 0 0;font-size:18px}.login-status-list{gap:10px;margin:30px 0 0;padding:0;list-style:none;display:grid}.login-status-list li{align-items:center;gap:10px;display:flex}.login-status-list .icon{color:#8ed1c8}.login-context small{color:#a8c2c0}.login-form-wrap{place-items:center;min-width:0;padding:28px;display:grid}.login-form{width:min(100%,420px)}.login-form h2{margin-bottom:6px;font-size:28px}.login-form>p{color:var(--text-muted);margin:0 0 24px}.password-wrap{position:relative}.password-wrap .input{padding-right:52px}.password-wrap .icon-button{position:absolute;top:0;right:0}@media (max-width:1023px){.content-grid{grid-template-columns:1fr}.prompt-panel{position:static}}@media (min-width:768px){.workspace{grid-template-areas:"history main"}}@media (max-width:767px){:root{--header-height:56px}.topbar{height:auto;min-height:var(--header-height);grid-template-columns:minmax(0,1fr) auto;padding:0 12px}.primary-nav{display:none}.brand{font-size:18px}.user-name{display:none}.workspace{grid-template-columns:minmax(0,1fr);grid-template-areas:"main""history"}.main{padding:18px 12px 24px}.history-panel{border-top:1px solid var(--border);border-right:0;padding:20px 12px 32px}.history-sticky{position:static}.history-list{max-height:none}.result-body{min-height:390px;padding:12px}.result-frame,.result-frame img{min-height:270px}.result-actions{flex-direction:column;align-items:stretch}.action-group{grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.state-message{min-height:300px;padding:20px 12px}.text-result{min-height:300px;padding:20px}.image-editor{grid-template-columns:76px minmax(0,1fr);gap:10px}.order-controls{grid-column:1/-1;grid-template-columns:repeat(3,minmax(0,1fr))}.order-button{width:100%}.drag-hint{display:none}.toast{max-width:none;bottom:16px;left:16px;right:16px}.login-view{grid-template-columns:1fr}.login-context{min-height:auto;padding:24px 20px}.login-copy h1{margin-top:56px;font-size:32px}.login-copy p{font-size:16px}.login-status-list,.login-context small{display:none}.login-form-wrap{padding:32px 20px 48px}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file diff --git a/portal/web/static/app.js b/portal/web/static/app.js index 1f55a47..b522555 100644 --- a/portal/web/static/app.js +++ b/portal/web/static/app.js @@ -2,11 +2,16 @@ "use strict"; const csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content || ""; - const show = (element) => element?.classList.remove("hidden"); - const hide = (element) => element?.classList.add("hidden"); + const show = element => element?.classList.remove("hidden"); + const hide = element => element?.classList.add("hidden"); + const maxImages = () => Number(document.querySelector('meta[name="max-images"]')?.content || 8); + const maxPromptBytes = () => Number(document.querySelector('meta[name="max-prompt-bytes"]')?.content || 8000); let toastTimer; - let selectedFiles = []; - let previewURLs = []; + let selectedImages = []; + let dragIndex = null; + let historyLoading = false; + let historyObserver; + let legacyTextRetry = false; function toast(message) { const element = document.querySelector("#toast"); @@ -24,121 +29,248 @@ function initializeAlpine() { window.Alpine.data("composer", () => ({ - mode: "image", - textPressed: "false", - imagePressed: "true", + mode: "edit", + generatePressed: "false", + editPressed: "true", imageClass: "", - setText() { - this.mode = "text"; - this.textPressed = "true"; - this.imagePressed = "false"; + setGenerate() { + this.mode = "generate"; + this.generatePressed = "true"; + this.editPressed = "false"; this.imageClass = "hidden"; - document.querySelector("#generation-kind").value = "text"; + document.querySelector("#generation-mode").value = "generate"; + handleModeChanged("generate"); }, - setImage() { - this.mode = "image"; - this.textPressed = "false"; - this.imagePressed = "true"; + setEdit() { + this.mode = "edit"; + this.generatePressed = "false"; + this.editPressed = "true"; this.imageClass = ""; - document.querySelector("#generation-kind").value = "image"; + document.querySelector("#generation-mode").value = "edit"; + handleModeChanged("edit"); } })); } - function renderUploads() { - const list = document.querySelector("#upload-list"); - if (!list) return; - previewURLs.forEach(URL.revokeObjectURL); - previewURLs = []; - list.replaceChildren(); - selectedFiles.forEach((file, index) => { - const row = document.createElement("div"); - row.className = "upload-item"; - const image = document.createElement("img"); - image.className = "upload-thumb"; - image.width = 48; - image.height = 48; - image.alt = index === 0 ? "主图缩略图" : `第 ${index + 1} 张参考图缩略图`; - image.src = URL.createObjectURL(file); - previewURLs.push(image.src); - const copy = document.createElement("span"); - const name = document.createElement("span"); - name.className = "upload-name"; - name.textContent = file.name; - const role = document.createElement("span"); - role.className = "upload-role"; - role.textContent = index === 0 ? "主图 · 第 1 张" : `参考图 · 第 ${index + 1} 张`; - copy.append(name, role); - const remove = document.createElement("button"); - remove.className = "icon-button remove-upload"; - remove.type = "button"; - remove.dataset.index = String(index); - remove.setAttribute("aria-label", `移除 ${file.name}`); - remove.title = "移除图片"; - const icon = document.createElement("i"); - icon.className = "icon"; - icon.dataset.lucide = "x"; - remove.append(icon); - row.append(image, copy, remove); - list.append(row); - }); - renderIcons(list); - } - - function validateFiles(files) { - const allowed = new Set(["image/jpeg", "image/png", "image/webp"]); - const maxImages = Number(document.querySelector('meta[name="max-images"]')?.content || 8); - if (files.length > maxImages) return `一次最多添加 ${maxImages} 张图片。`; - const invalid = files.find((file) => !allowed.has(file.type)); - if (invalid) return `${invalid.name}:格式不支持,请改用 JPG、PNG 或 WebP。`; - return ""; + function handleModeChanged(mode) { + legacyTextRetry = false; + const label = document.querySelector("#generate-button span"); + if (label) label.textContent = mode === "generate" ? "开始文生图" : "开始图片编辑"; + hide(document.querySelector("#upload-error")); + hide(document.querySelector("#role-rule-error")); + updatePromptPreview(); } function setMode(mode) { document.querySelector(`[data-mode="${mode}"]`)?.click(); } + function clientID() { + const value = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `img-${value}`.slice(0, 64); + } + + function iconButton(name, label, action, imageID, disabled = false) { + const button = document.createElement("button"); + button.className = "order-button"; + if (action === "remove") button.classList.add("remove-image"); + button.type = "button"; + button.dataset.action = action; + button.dataset.imageId = imageID; + button.disabled = disabled; + button.setAttribute("aria-label", label); + button.title = label; + const icon = document.createElement("i"); + icon.className = "icon"; + icon.dataset.lucide = name; + button.append(icon); + return button; + } + + function roleButton(image, role, label, iconName) { + const button = document.createElement("button"); + button.className = "role-button"; + button.type = "button"; + button.dataset.action = "role"; + button.dataset.role = role; + button.dataset.imageId = image.id; + button.setAttribute("aria-pressed", String(image.role === role)); + const icon = document.createElement("i"); + icon.className = "icon"; + icon.dataset.lucide = iconName; + button.append(icon, document.createTextNode(label)); + return button; + } + + function releaseImage(image) { + if (image.previewURL) URL.revokeObjectURL(image.previewURL); + } + + function renderImages() { + const list = document.querySelector("#image-list"); + if (!list) return; + list.replaceChildren(); + selectedImages.forEach((image, index) => { + const card = document.createElement("article"); + card.className = "image-editor"; + card.draggable = true; + card.dataset.imageId = image.id; + card.setAttribute("aria-label", `第 ${index + 1} 张输入图片:${image.file.name}`); + + const preview = document.createElement("div"); + preview.className = "image-thumb"; + const picture = document.createElement("img"); + picture.src = image.previewURL; + picture.alt = `第 ${index + 1} 张输入图片预览`; + picture.width = 96; + picture.height = 96; + const position = document.createElement("span"); + position.className = "image-index"; + position.textContent = String(index + 1); + preview.append(picture, position); + + const main = document.createElement("div"); + main.className = "image-editor-main"; + const titleRow = document.createElement("div"); + titleRow.className = "image-title-row"; + const title = document.createElement("span"); + title.className = "image-title"; + title.title = image.file.name; + title.textContent = image.file.name; + const hint = document.createElement("span"); + hint.className = "drag-hint"; + hint.textContent = "可拖动"; + titleRow.append(title, hint); + const roles = document.createElement("div"); + roles.className = "role-group"; + roles.setAttribute("role", "group"); + roles.setAttribute("aria-label", `${image.file.name} 的角色`); + roles.append(roleButton(image, "primary", "主体图", "image"), roleButton(image, "reference", "参考图", "file-text")); + const noteLabel = document.createElement("label"); + noteLabel.className = "sr-only"; + noteLabel.htmlFor = `note-${image.id}`; + noteLabel.textContent = `${image.file.name} 的备注`; + const note = document.createElement("input"); + note.className = "input image-note"; + note.id = `note-${image.id}`; + note.dataset.imageId = image.id; + note.maxLength = 500; + note.value = image.note; + note.placeholder = "填写图片备注,最多 500 字符"; + main.append(titleRow, roles, noteLabel, note); + + const controls = document.createElement("div"); + controls.className = "order-controls"; + controls.append( + iconButton("arrow-up", `上移第 ${index + 1} 张图`, "up", image.id, index === 0), + iconButton("arrow-down", `下移第 ${index + 1} 张图`, "down", image.id, index === selectedImages.length - 1), + iconButton("x", `移除第 ${index + 1} 张图`, "remove", image.id) + ); + card.append(preview, main, controls); + list.append(card); + }); + const count = document.querySelector("#image-count"); + if (count) count.textContent = `${selectedImages.length} / ${maxImages()}`; + renderIcons(list); + updatePromptPreview(); + } + + function validateFiles(files) { + const allowed = new Set(["image/jpeg", "image/png", "image/webp"]); + if (files.length > maxImages()) return `一次最多添加 ${maxImages()} 张图片。`; + const invalid = files.find(file => !allowed.has(file.type)); + return invalid ? `${invalid.name}:格式不支持,请改用 JPG、PNG 或 WebP。` : ""; + } + + function ensurePrimary() { + if (selectedImages.length && !selectedImages.some(image => image.role === "primary")) selectedImages[0].role = "primary"; + } + + function updatePromptPreview() { + const prompt = document.querySelector("#prompt")?.value.trim() || ""; + const rule = document.querySelector("#role-rule")?.value.trim() || ""; + const mode = document.querySelector("#generation-mode")?.value || "edit"; + const ruleSummary = document.querySelector("#rule-summary"); + const imageSummary = document.querySelector("#image-summary"); + const preview = document.querySelector("#prompt-preview-text"); + if (!ruleSummary || !imageSummary || !preview) return; + if (mode === "generate") { + ruleSummary.textContent = "文生图不使用图片角色规则。"; + imageSummary.textContent = "文生图不包含输入图片。"; + preview.textContent = `管理端默认模板 → ${prompt || "(尚未填写生成要求)"}。`; + return; + } + ruleSummary.textContent = rule ? "非空,将覆盖默认图片角色规则。" : "留空,当前只使用管理端默认规则。"; + const primaryCount = selectedImages.filter(image => image.role === "primary").length; + imageSummary.textContent = selectedImages.length ? `按当前顺序附加:${primaryCount} 张主体图,${selectedImages.length - primaryCount} 张参考图。` : "尚未添加输入图片。"; + const images = selectedImages.map((image, index) => `第 ${index + 1} 张为${image.role === "primary" ? "主体图" : "参考图"}${image.note ? `,备注:${image.note}` : ""}`).join(";"); + preview.textContent = `管理端默认规则 → ${rule || "(未填写用户规则)"} → ${images || "尚未添加输入图片"}。`; + } + + function moveImage(id, direction) { + const index = selectedImages.findIndex(image => image.id === id); + const target = direction === "up" ? index - 1 : index + 1; + if (index < 0 || target < 0 || target >= selectedImages.length) return; + [selectedImages[index], selectedImages[target]] = [selectedImages[target], selectedImages[index]]; + renderImages(); + document.querySelector(`[data-image-id="${CSS.escape(id)}"] [data-action="${direction}"]`)?.focus(); + toast(direction === "up" ? "图片已上移" : "图片已下移"); + } + + function setImageRole(id, role) { + const image = selectedImages.find(item => item.id === id); + if (!image) return; + if (role === "primary") { + selectedImages.forEach(item => { item.role = item.id === id ? "primary" : "reference"; }); + } else if (image.role === "primary") { + const replacement = selectedImages.find(item => item.id !== id); + if (!replacement) { toast("图片编辑必须保留一张主体图"); return; } + replacement.role = "primary"; + image.role = "reference"; + } + renderImages(); + document.querySelector(`[data-image-id="${CSS.escape(id)}"] [data-role="${role}"]`)?.focus(); + toast("图片角色已更新"); + } + async function submitGeneration(event) { event.preventDefault(); const prompt = document.querySelector("#prompt"); const promptError = document.querySelector("#prompt-error"); const uploadError = document.querySelector("#upload-error"); + const ruleError = document.querySelector("#role-rule-error"); const button = document.querySelector("#generate-button"); - const mode = document.querySelector("#generation-kind").value; + const mode = document.querySelector("#generation-mode").value; const promptBytes = new TextEncoder().encode(prompt.value).length; - const maxPromptBytes = Number(document.querySelector('meta[name="max-prompt-bytes"]')?.content || 8000); - hide(promptError); - hide(uploadError); - if (!prompt.value.trim() || promptBytes > maxPromptBytes) { - promptError.textContent = promptBytes > maxPromptBytes ? `生成要求不能超过 ${maxPromptBytes} 字节。` : "请输入生成要求。"; - show(promptError); - prompt.focus(); - return; + hide(promptError); hide(uploadError); hide(ruleError); + if (!prompt.value.trim() || promptBytes > maxPromptBytes()) { + promptError.textContent = promptBytes > maxPromptBytes() ? `生成要求不能超过 ${maxPromptBytes()} 字节。` : "请输入生成要求。"; + show(promptError); prompt.focus(); return; } - if (mode === "image") { - const fileError = selectedFiles.length === 0 ? "请至少添加 1 张图片。" : validateFiles(selectedFiles); - if (fileError) { - uploadError.textContent = fileError; - show(uploadError); - document.querySelector("#image-upload").focus(); - return; - } + const roleRule = document.querySelector("#role-rule").value; + if (mode === "edit") { + const fileError = selectedImages.length === 0 ? "图片编辑至少需要 1 张图片。" : validateFiles(selectedImages.map(image => image.file)); + if (fileError) { uploadError.textContent = fileError; show(uploadError); document.querySelector("#image-upload").focus(); return; } + if (selectedImages.filter(image => image.role === "primary").length !== 1) { uploadError.textContent = "图片编辑必须且只能设置 1 张主体图。"; show(uploadError); document.querySelector("#image-upload").focus(); return; } + if (new TextEncoder().encode(roleRule).length > maxPromptBytes()) { ruleError.textContent = `图片角色规则不能超过 ${maxPromptBytes()} 字节。`; show(ruleError); document.querySelector("#role-rule").focus(); return; } } button.disabled = true; - button.querySelector("span").textContent = "提交中"; + button.querySelector("span").textContent = "正在提交"; const idempotencyKey = crypto.randomUUID?.() || `web-${Date.now()}-${Math.random().toString(16).slice(2)}`; - let body; + let endpoint = "/api/generations/image"; let headers = { "X-CSRF-Token": csrfToken() }; - let endpoint = "/api/generations/text"; - if (mode === "text") { + let body; + if (legacyTextRetry) { + endpoint = "/api/generations/text"; headers["Content-Type"] = "application/json"; body = JSON.stringify({ idempotency_key: idempotencyKey, prompt: prompt.value }); } else { - endpoint = "/api/generations/image"; body = new FormData(); body.set("idempotency_key", idempotencyKey); body.set("prompt", prompt.value); - selectedFiles.forEach((file) => body.append("images", file, file.name)); + const editing = mode === "edit"; + body.set("metadata", JSON.stringify({ capability: editing ? "image_edit" : "image_generate", role_rule: editing ? roleRule : "", images: editing ? selectedImages.map((image, position) => ({ client_id: image.id, position, role: image.role, note: image.note })) : [] })); + if (editing) selectedImages.forEach(image => body.append(`files[${image.id}]`, image.file, image.file.name)); } try { const response = await fetch(endpoint, { method: "POST", headers, body }); @@ -146,14 +278,14 @@ if (response.status === 401) { showAuthExpired(); return; } if (!response.ok) { const error = payload.error || {}; - if (error.file) { - uploadError.textContent = `${error.file.name}:图片未通过校验,请检查格式和大小。`; - show(uploadError); - document.querySelector("#image-upload").focus(); + const imageErrorCodes = new Set(["invalid_metadata", "invalid_capability", "invalid_client_id", "invalid_primary_count"]); + if (error.file || String(error.code || "").includes("image") || imageErrorCodes.has(error.code)) { + uploadError.textContent = error.file ? `${error.file.name}:图片未通过校验,请检查格式和大小。` : (error.message || "图片信息不符合要求,请检查角色和顺序。"); + show(uploadError); document.querySelector("#image-upload").focus(); + } else if (error.code === "invalid_role_rule") { + ruleError.textContent = "图片角色规则不符合要求。"; show(ruleError); document.querySelector("#role-rule").focus(); } else { - promptError.textContent = error.message || "提交失败,请检查输入后重试。"; - show(promptError); - prompt.focus(); + promptError.textContent = error.message || "提交失败,请检查输入后重试。"; show(promptError); prompt.focus(); } return; } @@ -162,7 +294,7 @@ toast("提交失败,请检查网络后重试"); } finally { button.disabled = false; - button.querySelector("span").textContent = "开始生成"; + button.querySelector("span").textContent = mode === "generate" ? "开始文生图" : "开始图片编辑"; } } @@ -173,32 +305,33 @@ if (!data || !prompt) return; prompt.value = data.dataset.prompt || ""; prompt.dispatchEvent(new Event("input")); - setMode(data.dataset.kind || "text"); - selectedFiles = []; - if (data.dataset.kind === "image") { + selectedImages.forEach(releaseImage); + selectedImages = []; + const inputs = Array.from(section.querySelectorAll(".retry-input")); + if (data.dataset.kind === "text") { setMode("generate"); legacyTextRetry = true; } else { setMode(inputs.length ? "edit" : "generate"); } + document.querySelector("#role-rule").value = data.dataset.roleRule || ""; + if (inputs.length) { button.disabled = true; try { - const inputs = Array.from(section.querySelectorAll(".retry-input")); for (const input of inputs) { const response = await fetch(input.dataset.inputUrl); if (!response.ok) throw new Error("input fetch failed"); const blob = await response.blob(); - selectedFiles.push(new File([blob], input.dataset.inputName || "image", { type: input.dataset.inputMime || blob.type })); + const file = new File([blob], input.dataset.inputName || "image", { type: input.dataset.inputMime || blob.type }); + selectedImages.push({ id: clientID(), file, role: input.dataset.inputRole || "reference", note: input.dataset.inputNote || "", previewURL: URL.createObjectURL(file) }); } - renderUploads(); + ensurePrimary(); renderImages(); } catch (_) { - toast("原图读取失败,请重新选择图片"); - } finally { - button.disabled = false; - } + selectedImages.forEach(releaseImage); selectedImages = []; renderImages(); toast("原图读取失败,请重新选择图片"); + } finally { button.disabled = false; } } + updatePromptPreview(); document.querySelector("#composer-title")?.scrollIntoView({ behavior: "smooth", block: "start" }); prompt.focus(); } function showAuthExpired() { - const modal = document.querySelector("#auth-modal"); - show(modal); + show(document.querySelector("#auth-modal")); document.querySelector("#relogin-button")?.focus(); } @@ -211,6 +344,93 @@ element.querySelector(".result-body")?.prepend(notice); } + function statusText(status) { + return { pending: "排队中", running: "生成中", succeeded: "已完成", failed: "失败" }[status] || "未知"; + } + + function relativeTime(value) { + const elapsed = Math.max(0, Date.now() - new Date(value).getTime()); + if (elapsed < 60000) return "刚刚"; + if (elapsed < 3600000) return `${Math.floor(elapsed / 60000)} 分钟前`; + if (elapsed < 86400000) return `${Math.floor(elapsed / 3600000)} 小时前`; + return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value)); + } + + function historyItem(row) { + const item = document.createElement("li"); + item.dataset.historySearch = `${row.user_prompt || ""} ${row.kind || ""}`; + const link = document.createElement("a"); + link.className = "history-item"; + link.href = `/generations/${row.id}`; + const iconWrap = document.createElement("span"); + iconWrap.className = "history-icon"; + const icon = document.createElement("i"); + icon.className = "icon"; + icon.dataset.lucide = row.kind === "image" ? "image" : "file-text"; + iconWrap.append(icon); + const copy = document.createElement("span"); + copy.className = "history-copy"; + const title = document.createElement("span"); + title.className = "history-title"; + title.textContent = row.user_prompt || `任务 #${row.id}`; + title.title = title.textContent; + const meta = document.createElement("span"); + meta.className = "history-meta"; + const dot = document.createElement("span"); + dot.className = `status-dot ${row.status === "succeeded" ? "success" : row.status || ""}`; + meta.append(dot, document.createTextNode(`${statusText(row.status)} · ${row.kind === "image" ? "图片" : "文本"} · ${relativeTime(row.created_at)}`)); + copy.append(title, meta); + link.append(iconWrap, copy); + item.append(link); + return item; + } + + function applyHistorySearch() { + const query = document.querySelector("#history-search")?.value.trim().toLowerCase() || ""; + document.querySelectorAll("[data-history-search]").forEach(item => item.classList.toggle("hidden", !item.dataset.historySearch.toLowerCase().includes(query))); + } + + async function loadHistory() { + const controls = document.querySelector("#history-controls"); + const button = document.querySelector("#load-more-history"); + const list = document.querySelector("#history-list"); + if (!controls || !button || !list || historyLoading || controls.dataset.hasMore !== "true") return; + historyLoading = true; + hide(document.querySelector("#history-error")); + button.disabled = true; + button.querySelector("span").textContent = "正在加载"; + try { + const response = await fetch(`/api/generations?limit=${encodeURIComponent(controls.dataset.limit || "20")}&cursor=${encodeURIComponent(controls.dataset.cursor || "")}`, { headers: { "Accept": "application/json" } }); + if (response.status === 401) { showAuthExpired(); return; } + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error?.code || "history_failed"); + const sentinel = document.querySelector("#history-sentinel"); + (payload.items || []).forEach(row => list.insertBefore(historyItem(row), sentinel)); + controls.dataset.cursor = payload.next_cursor || ""; + controls.dataset.hasMore = String(Boolean(payload.has_more)); + if (payload.has_more) show(button); else { hide(button); show(document.querySelector("#history-end")); } + applyHistorySearch(); renderIcons(list); + } catch (_) { + show(document.querySelector("#history-error")); show(button); + } finally { + historyLoading = false; + button.disabled = false; + button.querySelector("span").textContent = "加载更多历史"; + } + } + + function initializeHistory() { + const list = document.querySelector("#history-list"); + const sentinel = document.querySelector("#history-sentinel"); + document.querySelector("#history-search")?.addEventListener("input", applyHistorySearch); + document.querySelector("#load-more-history")?.addEventListener("click", loadHistory); + if (!list || !sentinel || typeof IntersectionObserver === "undefined") return; + historyObserver = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) loadHistory(); + }, { root: list, rootMargin: "0px 0px 80px 0px" }); + historyObserver.observe(sentinel); + } + function initializeLogin() { const form = document.querySelector("#login-form"); const toggle = document.querySelector("#toggle-password"); @@ -220,79 +440,91 @@ input.type = showing ? "password" : "text"; toggle.setAttribute("aria-label", showing ? "显示密码" : "隐藏密码"); toggle.setAttribute("title", showing ? "显示密码" : "隐藏密码"); - toggle.querySelector("svg")?.remove(); - toggle.querySelector("i")?.remove(); - const icon = document.createElement("i"); - icon.className = "icon"; - icon.dataset.lucide = showing ? "eye" : "eye-off"; - toggle.append(icon); - renderIcons(toggle); + toggle.querySelector("svg")?.remove(); toggle.querySelector("i")?.remove(); + const icon = document.createElement("i"); icon.className = "icon"; icon.dataset.lucide = showing ? "eye" : "eye-off"; toggle.append(icon); renderIcons(toggle); }); - form?.addEventListener("submit", async (event) => { + form?.addEventListener("submit", async event => { event.preventDefault(); - const email = document.querySelector("#email"); - const password = document.querySelector("#password"); - const error = document.querySelector("#login-error"); - const emailError = document.querySelector("#email-error"); + const email = document.querySelector("#email"); const password = document.querySelector("#password"); + const error = document.querySelector("#login-error"); const emailError = document.querySelector("#email-error"); hide(error); hide(emailError); if (!email.validity.valid) { show(emailError); email.focus(); return; } if (!password.value) { show(error); password.focus(); return; } - const button = document.querySelector("#login-button"); - button.disabled = true; - button.querySelector("span").textContent = "登录中"; + const button = document.querySelector("#login-button"); button.disabled = true; button.querySelector("span").textContent = "登录中"; try { - const response = await fetch("/api/session/login", { method:"POST", headers:{"Content-Type":"application/json","X-CSRF-Token":csrfToken()}, body:JSON.stringify({email:email.value,password:password.value}) }); + const response = await fetch("/api/session/login", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() }, body: JSON.stringify({ email: email.value, password: password.value }) }); if (!response.ok) { show(error); password.focus(); return; } const requested = document.body.dataset.returnTo || "/"; window.location.assign(requested.startsWith("/") && !requested.startsWith("//") ? requested : "/"); - } catch (_) { error.textContent="登录请求失败,请检查网络后重试。";show(error); } - finally { button.disabled=false;button.querySelector("span").textContent="登录"; } + } catch (_) { error.textContent = "登录请求失败,请检查网络后重试。"; show(error); } + finally { button.disabled = false; button.querySelector("span").textContent = "登录"; } }); } function initializeApp() { - document.querySelector("#prompt")?.addEventListener("input", (event) => { + document.querySelector("#prompt")?.addEventListener("input", event => { document.querySelector("#prompt-count").textContent = String(new TextEncoder().encode(event.target.value).length); - hide(document.querySelector("#prompt-error")); + hide(document.querySelector("#prompt-error")); updatePromptPreview(); }); - document.querySelector("#image-upload")?.addEventListener("change", (event) => { + document.querySelector("#role-rule")?.addEventListener("input", () => { hide(document.querySelector("#role-rule-error")); updatePromptPreview(); }); + document.querySelector("#image-upload")?.addEventListener("change", event => { const added = Array.from(event.target.files || []); - const candidate = selectedFiles.concat(added); - const error = validateFiles(candidate); + const error = validateFiles(selectedImages.map(image => image.file).concat(added)); const output = document.querySelector("#upload-error"); - if (error) { output.textContent=error;show(output);event.target.focus();return; } - hide(output);selectedFiles=candidate;event.target.value="";renderUploads(); + if (error) { output.textContent = error; show(output); event.target.value = ""; event.target.focus(); return; } + hide(output); + added.forEach(file => selectedImages.push({ id: clientID(), file, role: selectedImages.length ? "reference" : "primary", note: "", previewURL: URL.createObjectURL(file) })); + event.target.value = ""; ensurePrimary(); renderImages(); }); - document.querySelector("#upload-list")?.addEventListener("click", (event) => { - const button = event.target.closest(".remove-upload"); - if (!button) return; - selectedFiles.splice(Number(button.dataset.index),1);renderUploads();toast("图片已移除"); + const imageList = document.querySelector("#image-list"); + imageList?.addEventListener("input", event => { + const input = event.target.closest(".image-note"); if (!input) return; + const image = selectedImages.find(item => item.id === input.dataset.imageId); if (image) image.note = input.value; + updatePromptPreview(); }); + imageList?.addEventListener("click", event => { + const button = event.target.closest("button[data-action]"); if (!button) return; + const id = button.dataset.imageId; + if (button.dataset.action === "role") { setImageRole(id, button.dataset.role); return; } + if (button.dataset.action === "up" || button.dataset.action === "down") { moveImage(id, button.dataset.action); return; } + if (button.dataset.action === "remove") { + if (selectedImages.length === 1) { toast("图片编辑至少保留一张图片"); return; } + const index = selectedImages.findIndex(image => image.id === id); if (index < 0) return; + releaseImage(selectedImages[index]); selectedImages.splice(index, 1); ensurePrimary(); renderImages(); toast("图片已移除"); + } + }); + imageList?.addEventListener("dragstart", event => { + const card = event.target.closest(".image-editor"); if (!card) return; + dragIndex = selectedImages.findIndex(image => image.id === card.dataset.imageId); card.classList.add("dragging"); event.dataTransfer.effectAllowed = "move"; + }); + imageList?.addEventListener("dragover", event => { const card = event.target.closest(".image-editor"); if (card) { event.preventDefault(); card.classList.add("drag-over"); } }); + imageList?.addEventListener("dragleave", event => event.target.closest(".image-editor")?.classList.remove("drag-over")); + imageList?.addEventListener("drop", event => { + const card = event.target.closest(".image-editor"); if (!card || dragIndex === null) return; + event.preventDefault(); const target = selectedImages.findIndex(image => image.id === card.dataset.imageId); + if (target >= 0 && target !== dragIndex) { const [moved] = selectedImages.splice(dragIndex, 1); selectedImages.splice(target, 0, moved); renderImages(); toast("图片顺序已调整"); } + dragIndex = null; + }); + imageList?.addEventListener("dragend", () => { dragIndex = null; imageList.querySelectorAll(".image-editor").forEach(card => card.classList.remove("dragging", "drag-over")); }); document.querySelector("#composer-form")?.addEventListener("submit", submitGeneration); - document.querySelector("#history-search")?.addEventListener("input", (event) => { - const query=event.target.value.trim().toLowerCase(); - document.querySelectorAll("[data-history-search]").forEach((item)=>item.classList.toggle("hidden",!item.dataset.historySearch.toLowerCase().includes(query))); + initializeHistory(); + document.querySelector("#logout-button")?.addEventListener("click", async () => { await fetch("/api/session/logout", { method: "POST", headers: { "X-CSRF-Token": csrfToken() } }).catch(() => {}); window.location.assign("/login"); }); + document.addEventListener("click", async event => { + const retry = event.target.closest(".retry-button"); if (retry) { await retryGeneration(retry); return; } + const copy = event.target.closest(".copy-button"); if (copy) { const text = copy.closest("#result-section")?.querySelector(".text-result")?.innerText || ""; try { await navigator.clipboard.writeText(text); toast("文本已复制"); } catch (_) { toast("浏览器未允许复制,请手动选择文本"); } } }); - document.querySelector("#logout-button")?.addEventListener("click", async () => { - await fetch("/api/session/logout",{method:"POST",headers:{"X-CSRF-Token":csrfToken()}}).catch(()=>{}); - window.location.assign("/login"); - }); - document.addEventListener("click", async (event) => { - const retry=event.target.closest(".retry-button");if(retry){await retryGeneration(retry);return;} - const copy=event.target.closest(".copy-button");if(copy){const text=copy.closest("#result-section")?.querySelector(".text-result")?.innerText||"";try{await navigator.clipboard.writeText(text);toast("文本已复制");}catch(_){toast("浏览器未允许复制,请手动选择文本");}} - }); - document.querySelector("#dismiss-auth")?.addEventListener("click",()=>hide(document.querySelector("#auth-modal"))); - document.querySelector("#relogin-button")?.addEventListener("click",()=>window.location.assign(`/login?return_to=${encodeURIComponent(window.location.pathname)}`)); - document.addEventListener("keydown",(event)=>{if(event.key==="Escape"){hide(document.querySelector("#auth-modal"));document.querySelector("#main-content")?.focus();}}); - document.body.addEventListener("htmx:afterSwap",(event)=>renderIcons(event.detail.target)); - const handlePollingError=(event)=>{ - const status=event.detail.xhr?.status||0;const element=event.detail.elt; - if(status===401){showAuthExpired();return;} - showPollingError(element); - window.setTimeout(()=>window.htmx?.trigger(element,"retryPoll"),3000); + document.querySelector("#dismiss-auth")?.addEventListener("click", () => hide(document.querySelector("#auth-modal"))); + document.querySelector("#relogin-button")?.addEventListener("click", () => window.location.assign(`/login?return_to=${encodeURIComponent(window.location.pathname)}`)); + document.addEventListener("keydown", event => { if (event.key === "Escape") { hide(document.querySelector("#auth-modal")); document.querySelector("#main-content")?.focus(); } }); + document.body.addEventListener("htmx:afterSwap", event => renderIcons(event.detail.target)); + const handlePollingError = event => { + const status = event.detail.xhr?.status || 0; const element = event.detail.elt; + if (status === 401) { showAuthExpired(); return; } + showPollingError(element); window.setTimeout(() => window.htmx?.trigger(element, "retryPoll"), 3000); }; - document.body.addEventListener("htmx:responseError",handlePollingError); - document.body.addEventListener("htmx:sendError",handlePollingError); + document.body.addEventListener("htmx:responseError", handlePollingError); + document.body.addEventListener("htmx:sendError", handlePollingError); + updatePromptPreview(); } document.addEventListener("alpine:init", initializeAlpine); @@ -301,4 +533,5 @@ if (document.body.dataset.page === "app") initializeApp(); renderIcons(); }); + window.addEventListener("beforeunload", () => { selectedImages.forEach(releaseImage); historyObserver?.disconnect(); }); })(); diff --git a/portal/web/static/vendor/icons.min.js b/portal/web/static/vendor/icons.min.js index f24ec06..f00f14a 100644 --- a/portal/web/static/vendor/icons.min.js +++ b/portal/web/static/vendor/icons.min.js @@ -1,7 +1,7 @@ -(()=>{var u={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};var L=([e,a,t])=>{let r=document.createElementNS("http://www.w3.org/2000/svg",e);return Object.keys(a).forEach(o=>{r.setAttribute(o,String(a[o]))}),t?.length&&t.forEach(o=>{let s=L(o);r.appendChild(s)}),r},y=(e,a={})=>{let r={...u,...a};return L(["svg",r,e])};var R=e=>{for(let a in e)if(a.startsWith("aria-")||a==="role"||a==="title")return!0;return!1};var T=(...e)=>e.filter((a,t,r)=>!!a&&a.trim()!==""&&r.indexOf(a)===t).join(" ").trim();var q=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(a,t,r)=>r?r.toUpperCase():t.toLowerCase());var b=e=>{let a=q(e);return a.charAt(0).toUpperCase()+a.slice(1)};var G=e=>Array.from(e.attributes).reduce((a,t)=>(a[t.name]=t.value,a),{}),U=e=>typeof e=="string"?e:!e||!e.class?"":e.class&&typeof e.class=="string"?e.class.split(" "):e.class&&Array.isArray(e.class)?e.class:"",p=(e,{nameAttr:a,icons:t,attrs:r})=>{let o=e.getAttribute(a);if(o==null)return;let s=b(o),f=t[s];if(!f)return console.warn(`${e.outerHTML} icon name was not found in the provided icons object.`);let l=G(e),O=R(l)?{}:{"aria-hidden":"true"},F={...u,"data-lucide":o,...O,...r,...l},v=U(l),H=U(r),D=T("lucide",`lucide-${o}`,...v,...H);D&&Object.assign(F,{class:D});let E=y(f,F);return e.parentNode?.replaceChild(E,e)};var m=[["path",{d:"M20 6 9 17l-5-5"}]];var d=[["circle",{cx:"12",cy:"12",r:"10"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16"}]];var x=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M12 6v6h4"}]];var i=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"}]];var n=[["path",{d:"M12 15V3"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["path",{d:"m7 10 5 5 5-5"}]];var c=[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"}],["path",{d:"m2 2 20 20"}]];var C=[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"}],["circle",{cx:"12",cy:"12",r:"3"}]];var h=[["path",{d:"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"}],["path",{d:"M14 2v5a1 1 0 0 0 1 1h5"}],["path",{d:"M10 9H8"}],["path",{d:"M16 13H8"}],["path",{d:"M16 17H8"}]];var S=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2"}],["circle",{cx:"9",cy:"9",r:"2"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"}]];var g=[["path",{d:"m16 17 5-5-5-5"}],["path",{d:"M21 12H9"}],["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"}]];var w=[["path",{d:"M5 12h14"}],["path",{d:"M12 5v14"}]];var k=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}],["path",{d:"M21 3v5h-5"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}],["path",{d:"M8 16H3v5"}]];var P=[["path",{d:"m21 21-4.34-4.34"}],["circle",{cx:"11",cy:"11",r:"8"}]];var A=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"}],["path",{d:"m9 12 2 2 4-4"}]];var M=[["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]];var B=({icons:e={},nameAttr:a="data-lucide",attrs:t={},root:r=document,inTemplates:o}={})=>{if(!Object.values(e).length)throw new Error(`Please provide an icons object. +(()=>{var u={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};var R=([e,a,t])=>{let r=document.createElementNS("http://www.w3.org/2000/svg",e);return Object.keys(a).forEach(o=>{r.setAttribute(o,String(a[o]))}),t?.length&&t.forEach(o=>{let s=R(o);r.appendChild(s)}),r},T=(e,a={})=>{let r={...u,...a};return R(["svg",r,e])};var q=e=>{for(let a in e)if(a.startsWith("aria-")||a==="role"||a==="title")return!0;return!1};var b=(...e)=>e.filter((a,t,r)=>!!a&&a.trim()!==""&&r.indexOf(a)===t).join(" ").trim();var U=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(a,t,r)=>r?r.toUpperCase():t.toLowerCase());var O=e=>{let a=U(e);return a.charAt(0).toUpperCase()+a.slice(1)};var I=e=>Array.from(e.attributes).reduce((a,t)=>(a[t.name]=t.value,a),{}),v=e=>typeof e=="string"?e:!e||!e.class?"":e.class&&typeof e.class=="string"?e.class.split(" "):e.class&&Array.isArray(e.class)?e.class:"",p=(e,{nameAttr:a,icons:t,attrs:r})=>{let o=e.getAttribute(a);if(o==null)return;let s=O(o),f=t[s];if(!f)return console.warn(`${e.outerHTML} icon name was not found in the provided icons object.`);let l=I(e),H=q(l)?{}:{"aria-hidden":"true"},L={...u,"data-lucide":o,...H,...r,...l},E=v(l),G=v(r),y=b("lucide",`lucide-${o}`,...E,...G);y&&Object.assign(L,{class:y});let V=T(f,L);return e.parentNode?.replaceChild(V,e)};var m=[["path",{d:"M12 5v14"}],["path",{d:"m19 12-7 7-7-7"}]];var x=[["path",{d:"m5 12 7-7 7 7"}],["path",{d:"M12 19V5"}]];var i=[["path",{d:"M20 6 9 17l-5-5"}]];var d=[["circle",{cx:"12",cy:"12",r:"10"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16"}]];var n=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M12 6v6h4"}]];var c=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"}]];var C=[["path",{d:"M12 15V3"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["path",{d:"m7 10 5 5 5-5"}]];var h=[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"}],["path",{d:"m2 2 20 20"}]];var S=[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"}],["circle",{cx:"12",cy:"12",r:"3"}]];var g=[["path",{d:"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"}],["path",{d:"M14 2v5a1 1 0 0 0 1 1h5"}],["path",{d:"M10 9H8"}],["path",{d:"M16 13H8"}],["path",{d:"M16 17H8"}]];var w=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2"}],["circle",{cx:"9",cy:"9",r:"2"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"}]];var k=[["path",{d:"m16 17 5-5-5-5"}],["path",{d:"M21 12H9"}],["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"}]];var P=[["path",{d:"M5 12h14"}],["path",{d:"M12 5v14"}]];var A=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}],["path",{d:"M21 3v5h-5"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}],["path",{d:"M8 16H3v5"}]];var M=[["path",{d:"m21 21-4.34-4.34"}],["circle",{cx:"11",cy:"11",r:"8"}]];var B=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"}],["path",{d:"m9 12 2 2 4-4"}]];var F=[["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]];var D=({icons:e={},nameAttr:a="data-lucide",attrs:t={},root:r=document,inTemplates:o}={})=>{if(!Object.values(e).length)throw new Error(`Please provide an icons object. If you want to use all the icons you can import it like: \`import { createIcons, icons } from 'lucide'; -lucide.createIcons({icons});\``);if(typeof r>"u")throw new Error("`createIcons()` only works in a browser environment.");if(Array.from(r.querySelectorAll(`[${a}]`)).forEach(f=>p(f,{nameAttr:a,icons:e,attrs:t})),o&&Array.from(r.querySelectorAll("template")).forEach(l=>B({icons:e,nameAttr:a,attrs:t,root:l.content,inTemplates:o})),a==="data-lucide"){let f=r.querySelectorAll("[icon-name]");f.length>0&&(console.warn("[Lucide] Some icons were found with the now deprecated icon-name attribute. These will still be replaced for backwards compatibility, but will no longer be supported in v1.0 and you should switch to data-lucide"),Array.from(f).forEach(l=>p(l,{nameAttr:"icon-name",icons:e,attrs:t})))}};var V={AlertCircle:d,Check:m,Clock3:x,Copy:i,Download:n,Eye:C,EyeOff:c,FileText:h,Image:S,LogOut:g,Plus:w,RefreshCw:k,Search:P,ShieldCheck:A,X:M};window.renderChorusIcons=(e=document)=>B({icons:V,attrs:{"aria-hidden":"true"},root:e});document.addEventListener("DOMContentLoaded",()=>window.renderChorusIcons());})(); +lucide.createIcons({icons});\``);if(typeof r>"u")throw new Error("`createIcons()` only works in a browser environment.");if(Array.from(r.querySelectorAll(`[${a}]`)).forEach(f=>p(f,{nameAttr:a,icons:e,attrs:t})),o&&Array.from(r.querySelectorAll("template")).forEach(l=>D({icons:e,nameAttr:a,attrs:t,root:l.content,inTemplates:o})),a==="data-lucide"){let f=r.querySelectorAll("[icon-name]");f.length>0&&(console.warn("[Lucide] Some icons were found with the now deprecated icon-name attribute. These will still be replaced for backwards compatibility, but will no longer be supported in v1.0 and you should switch to data-lucide"),Array.from(f).forEach(l=>p(l,{nameAttr:"icon-name",icons:e,attrs:t})))}};var W={AlertCircle:d,ArrowDown:m,ArrowUp:x,Check:i,Clock3:n,Copy:c,Download:C,Eye:S,EyeOff:h,FileText:g,Image:w,LogOut:k,Plus:P,RefreshCw:A,Search:M,ShieldCheck:B,X:F};window.renderChorusIcons=(e=document)=>D({icons:W,attrs:{"aria-hidden":"true"},root:e});document.addEventListener("DOMContentLoaded",()=>window.renderChorusIcons());})(); /*! Bundled license information: lucide/dist/esm/defaultAttributes.mjs: @@ -11,6 +11,8 @@ lucide/dist/esm/shared/src/utils/mergeClasses.mjs: lucide/dist/esm/shared/src/utils/toCamelCase.mjs: lucide/dist/esm/shared/src/utils/toPascalCase.mjs: lucide/dist/esm/replaceElement.mjs: +lucide/dist/esm/icons/arrow-down.mjs: +lucide/dist/esm/icons/arrow-up.mjs: lucide/dist/esm/icons/check.mjs: lucide/dist/esm/icons/circle-alert.mjs: lucide/dist/esm/icons/clock-3.mjs: diff --git a/portal/web/templates/app.html b/portal/web/templates/app.html index 7107b78..7195dc9 100644 --- a/portal/web/templates/app.html +++ b/portal/web/templates/app.html @@ -29,37 +29,44 @@

生成历史

- {{if .History}} +
+ + +

已显示全部历史,没有更多记录

+
{{else}}

还没有生成记录。提交第一条任务后会显示在这里。

{{end}}

生成工作台

{{if .Current}}{{shortPrompt .Current.Generation.UserPrompt}}{{else}}新建生成{{end}}

+ {{if .Current}}
{{template "result" .}}
{{end}}
-
{{template "result" .}}

生成内容

选择类型并描述需要的结果

- - + +
- -

0 字节

+ +

0 字节 · 输入内容仅用于当前任务。

-
参考图片
-
+
图片角色规则 可选,覆盖管理端默认规则

留空时使用管理端默认规则;每张图片的角色和备注仍会按顺序附加。

+
+

输入图片

拖动可调整顺序;也可使用上移和下移按钮。

0 / {{.MaxImages}}
+
diff --git a/portal/web/templates/result.html b/portal/web/templates/result.html index 73a7cb1..d65cfef 100644 --- a/portal/web/templates/result.html +++ b/portal/web/templates/result.html @@ -7,6 +7,6 @@ {{else if eq .Current.Generation.Status "failed"}}

这次生成没有完成

{{if .Current.Generation.ErrorMessage}}{{value .Current.Generation.ErrorMessage}}{{else}}生成服务暂时不可用,输入已经保留。{{end}}

{{else if eq .Current.Generation.Kind "text"}}
{{range .Current.Outputs}}{{if .TextContent}}

{{value .TextContent}}

{{end}}{{end}}
文本结果
{{else}}{{end}} - {{if .Current}}{{range .Current.Inputs}}{{end}}{{end}} + {{if .Current}}{{range .Current.Inputs}}{{end}}{{end}} {{end}} diff --git a/portal/web/web.go b/portal/web/web.go index 12aeb24..7a49e0d 100644 --- a/portal/web/web.go +++ b/portal/web/web.go @@ -23,6 +23,9 @@ type Page struct { CSRFToken string ReturnTo string History []model.Generation + HistoryCursor string + HistoryHasMore bool + HistoryLimit int Current *service.Detail MaxPromptBytes int MaxImages int