diff --git a/apps/server/src/lib/agents-config.ts b/apps/server/src/lib/agents-config.ts index 2ed8bf1..b7c3724 100644 --- a/apps/server/src/lib/agents-config.ts +++ b/apps/server/src/lib/agents-config.ts @@ -70,9 +70,9 @@ export function validateProviderConfig(input: Record): Provider * 1. `~/.agents/config.json` (or `AGENTS_CONFIG_PATH`) * 2. `process.env` (which already includes `.env` loaded by the server bootstrap) * - * The disk file may contain only a provider selection; missing fields are - * backfilled from the running process environment so users don't have to - * manually copy values from `.env` into the settings dialog. + * The disk file may contain only a provider selection; missing fields for every + * provider are backfilled from the running process environment so users can + * switch providers in the settings dialog without manually copying `.env`. */ export async function readProviderConfig(): Promise> { const path = providerConfigPath(); @@ -92,10 +92,13 @@ export async function readProviderConfig(): Promise> { const provider: ProviderConfigProvider = diskConfig.AGENTS_PROVIDER ?? resolveActiveProvider(); const merged: Partial = { AGENTS_PROVIDER: provider }; - for (const field of AGENTS_PROVIDER_FIELDS[provider]) { - const diskValue = diskConfig[field.key]?.trim(); - const envValue = process.env[field.key]?.trim(); - merged[field.key] = diskValue || envValue; + for (const candidateProvider of AGENTS_CONFIG_PROVIDERS) { + for (const field of AGENTS_PROVIDER_FIELDS[candidateProvider]) { + const diskValue = diskConfig[field.key]?.trim(); + const envValue = process.env[field.key]?.trim(); + const value = diskValue || envValue; + if (value) merged[field.key] = value; + } } return merged; } diff --git a/apps/server/tests/agents-config.test.ts b/apps/server/tests/agents-config.test.ts index 7b30666..f80a03e 100644 --- a/apps/server/tests/agents-config.test.ts +++ b/apps/server/tests/agents-config.test.ts @@ -54,16 +54,23 @@ describe("agents-config", () => { const prevProvider = process.env.AGENTS_PROVIDER; const prevKey = process.env.DASHSCOPE_API_KEY; const prevWs = process.env.BAILIAN_WORKSPACE_ID; + const prevQoderPat = process.env.QODER_PAT; + const prevArkKey = process.env.ARK_API_KEY; + const prevAnthropicKey = process.env.ANTHROPIC_API_KEY; process.env.AGENTS_CONFIG_PATH = path; delete process.env.AGENTS_PROVIDER; process.env.DASHSCOPE_API_KEY = "from-env-key"; process.env.BAILIAN_WORKSPACE_ID = "from-env-ws"; + process.env.QODER_PAT = "from-env-qoder"; + delete process.env.ARK_API_KEY; + delete process.env.ANTHROPIC_API_KEY; try { const config = await readProviderConfig(); expect(config).toEqual({ AGENTS_PROVIDER: "bailian", DASHSCOPE_API_KEY: "from-env-key", BAILIAN_WORKSPACE_ID: "from-env-ws", + QODER_PAT: "from-env-qoder", }); } finally { if (prevConfigPath === undefined) delete process.env.AGENTS_CONFIG_PATH; @@ -74,6 +81,12 @@ describe("agents-config", () => { else process.env.DASHSCOPE_API_KEY = prevKey; if (prevWs === undefined) delete process.env.BAILIAN_WORKSPACE_ID; else process.env.BAILIAN_WORKSPACE_ID = prevWs; + if (prevQoderPat === undefined) delete process.env.QODER_PAT; + else process.env.QODER_PAT = prevQoderPat; + if (prevArkKey === undefined) delete process.env.ARK_API_KEY; + else process.env.ARK_API_KEY = prevArkKey; + if (prevAnthropicKey === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = prevAnthropicKey; await rm(dir, { recursive: true, force: true }); } }); @@ -84,9 +97,15 @@ describe("agents-config", () => { const prevConfigPath = process.env.AGENTS_CONFIG_PATH; const prevKey = process.env.DASHSCOPE_API_KEY; const prevWs = process.env.BAILIAN_WORKSPACE_ID; + const prevQoderPat = process.env.QODER_PAT; + const prevArkKey = process.env.ARK_API_KEY; + const prevAnthropicKey = process.env.ANTHROPIC_API_KEY; process.env.AGENTS_CONFIG_PATH = path; process.env.DASHSCOPE_API_KEY = "env-key"; process.env.BAILIAN_WORKSPACE_ID = "env-ws"; + delete process.env.QODER_PAT; + delete process.env.ARK_API_KEY; + delete process.env.ANTHROPIC_API_KEY; try { await writeFile( path, @@ -113,6 +132,12 @@ describe("agents-config", () => { else process.env.DASHSCOPE_API_KEY = prevKey; if (prevWs === undefined) delete process.env.BAILIAN_WORKSPACE_ID; else process.env.BAILIAN_WORKSPACE_ID = prevWs; + if (prevQoderPat === undefined) delete process.env.QODER_PAT; + else process.env.QODER_PAT = prevQoderPat; + if (prevArkKey === undefined) delete process.env.ARK_API_KEY; + else process.env.ARK_API_KEY = prevArkKey; + if (prevAnthropicKey === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = prevAnthropicKey; await rm(dir, { recursive: true, force: true }); } }); diff --git a/apps/webui/src/App.tsx b/apps/webui/src/App.tsx index c428988..aa4361c 100644 --- a/apps/webui/src/App.tsx +++ b/apps/webui/src/App.tsx @@ -157,7 +157,7 @@ export default function Home() { if (bottomBarVisibleRef.current) { bottomBarRef.current?.expand(); } else { - setTimeout(() => composerHandleRef.current?.focus(), 80); + setTimeout(() => composerHandleRef.current?.focusStart(), 80); } }, [roleCards], diff --git a/apps/webui/src/app/desktop.css b/apps/webui/src/app/desktop.css index 2eb24c4..c32f37f 100644 --- a/apps/webui/src/app/desktop.css +++ b/apps/webui/src/app/desktop.css @@ -6,7 +6,7 @@ .page-shell { position: relative; min-height: 100vh; - padding: 72px 28px 54px; + padding: 53px 28px 54px; background-color: rgb(245, 246, 250); } @@ -130,7 +130,7 @@ body.has-banner .topbar { z-index: 50; display: flex; align-items: center; - justify-content: flex-end; + justify-content: space-between; padding: 14px 32px; background: #ffffff; backdrop-filter: none; @@ -138,12 +138,29 @@ body.has-banner .topbar { border-bottom: 1px solid rgba(0, 0, 0, 0.06); } +.identity, .top-actions { display: flex; align-items: center; gap: 10px; } +.top-actions { + margin-left: auto; +} + +.logo-link { + display: flex; + align-items: center; + text-decoration: none; +} + +.logo-img { + height: 24px; + width: auto; + display: block; +} + .icon-link { display: inline-grid; place-items: center; @@ -565,6 +582,10 @@ main { } /* Hero title with selected role */ +.greeting-mobile { + display: none; +} + .greeting-with-role { animation: fadeUp 0.4s ease; } @@ -1431,6 +1452,11 @@ select.provision-dialog-input { color: #dc2626; } +.toast-error .toast-icon { + background: rgba(220, 38, 38, 0.12); + color: #dc2626; +} + .toast-content { flex: 1; min-width: 0; @@ -1510,7 +1536,7 @@ select.provision-dialog-input { } .spin { - animation: spinAnim 1s linear infinite; + animation: spin 1s linear infinite; } /* ===== Schedule Panel ===== */ @@ -1650,6 +1676,19 @@ select.provision-dialog-input { box-shadow 0.2s ease; } +.composer-card { + display: contents; +} + +.composer-card-shell, +.composer-card-inner { + display: contents; +} + +.composer-mobile-placeholder { + display: none; +} + .composer:focus-within { border-color: var(--ink); box-shadow: 0 0 0 3px rgba(11, 12, 15, 0.06); @@ -1775,19 +1814,12 @@ select.provision-dialog-input { .prompt-row { position: relative; - display: grid; - grid-template-columns: minmax(0, 1fr); - align-items: start; + display: flex; + align-items: flex-start; min-height: 56px; min-width: 0; } -.prompt-row > .prompt-editor-slot, -.prompt-row > .prompt-hint-row { - grid-column: 1; - grid-row: 1; -} - /* Uploaded file thumbnails */ .uploaded-files { display: flex; @@ -1927,6 +1959,12 @@ select.provision-dialog-input { align-items: center; } +.composer-footer .composer-send-wrap { + position: absolute; + bottom: 14px; + right: 20px; +} + /* Hidden on desktop - only shown on mobile */ .composer-add-wrap { display: none; @@ -2273,23 +2311,15 @@ select.provision-dialog-input { } .prompt-hint-row { - position: relative; - z-index: 1; - width: 100%; - max-width: 100%; - padding-top: 4px; + position: absolute; + left: 0; + top: 4px; + display: inline-flex; + align-items: center; + gap: 8px; pointer-events: none; - max-height: 180px; - overflow-x: hidden; - overflow-y: auto; -} - -/* 有输入时提示层不占位,由编辑器撑开高度 */ -.prompt-hint-row:has(.prompt-ghost.hidden) { - visibility: hidden; - max-height: 0; - padding: 0; - overflow: hidden; + width: calc(100% - 40px); + max-width: calc(100% - 40px); } .prompt-ghost { @@ -2297,9 +2327,10 @@ select.provision-dialog-input { font-size: 15px; font-weight: 400; line-height: 1.6; - white-space: pre-wrap; - word-break: break-word; - overflow-wrap: anywhere; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; transition: opacity 0.35s ease, transform 0.3s ease; @@ -2326,7 +2357,6 @@ select.provision-dialog-input { gap: 4px; height: 20px; min-width: 38px; - margin-left: 8px; padding: 0 6px; border: 1px solid var(--line); border-radius: 4px; @@ -2338,7 +2368,6 @@ select.provision-dialog-input { box-shadow: none; opacity: 1; transition: opacity 0.3s ease; - vertical-align: middle; flex-shrink: 0; } @@ -2632,44 +2661,32 @@ select.provision-dialog-input { .bottom-bar-expanded .bar-input-wrap { position: relative; - display: grid; - grid-template-columns: minmax(0, 1fr); - align-items: start; + display: flex; + align-items: flex-start; min-height: 56px; min-width: 0; } -.bottom-bar-expanded .bar-input-wrap > .prompt-editor-slot, -.bottom-bar-expanded .bar-input-wrap > .bar-hint-row { - grid-column: 1; - grid-row: 1; -} - .bottom-bar-expanded .bar-hint-row { - position: relative; - z-index: 1; - width: 100%; - max-width: 100%; + position: absolute; + top: 0; + left: 0; + display: inline-flex; + align-items: center; + gap: 8px; pointer-events: none; + width: 100%; font-size: 15px; line-height: 1.6; - max-height: 180px; - overflow-x: hidden; - overflow-y: auto; -} - -.bottom-bar-expanded .bar-hint-row:has(.prompt-ghost.hidden) { - visibility: hidden; - max-height: 0; - overflow: hidden; } .bottom-bar-expanded .bar-hint-row .prompt-ghost { color: var(--muted-2); font-weight: 400; - white-space: pre-wrap; - word-break: break-word; - overflow-wrap: anywhere; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; transition: opacity 0.3s ease, transform 0.3s ease; @@ -2689,6 +2706,26 @@ select.provision-dialog-input { transform: translateY(0); } +.bottom-bar-expanded .bar-hint-row .shortcut-hint { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--muted-2); + border: none; + background: transparent; + padding: 0; + height: auto; + box-shadow: none; +} + +.bottom-bar-expanded .bar-hint-row .shortcut-hint.hidden { + opacity: 0; +} + +.bottom-bar-expanded .bar-hint-row kbd { + color: var(--muted); +} + .bottom-bar-expanded .bar-mode-buttons { display: flex; align-items: center; @@ -3018,7 +3055,6 @@ dialog.case-modal-overlay { padding: 20px 64px 20px 28px; border-bottom: 1px solid var(--line); flex-shrink: 0; - gap: 12px; } .case-modal-title { @@ -3556,22 +3592,6 @@ dialog.case-modal-overlay { font-size: 13px; } -.run-stream-cursor { - display: inline-block; - width: 2px; - height: 1em; - margin-left: 1px; - vertical-align: text-bottom; - background: var(--muted); - animation: run-stream-cursor-blink 0.9s step-end infinite; -} - -@keyframes run-stream-cursor-blink { - 50% { - opacity: 0; - } -} - .run-events-load-older { display: flex; align-items: center; @@ -4675,44 +4695,13 @@ dialog.case-modal-overlay { border-radius: 0; } -.task-run-modal .case-modal-result { - display: flex; - flex-direction: column; -} - -.task-run-modal .case-result-main { - flex: 1; - min-height: 0; - height: auto; - margin: 0; - padding: 24px; - overflow-x: hidden; - overflow-y: auto; - overscroll-behavior: contain; - -webkit-overflow-scrolling: touch; -} - -.task-run-modal .artifact-view { - height: auto; - min-height: 0; -} - -.task-run-modal .artifact-view--fill { - height: 100%; -} - -.task-run-modal .artifact-gallery { - flex: 0 0 auto; - min-height: min(36vh, 320px); -} - -.task-run-modal .artifact-main-link { - flex: 0 0 auto; - min-height: min(32vh, 280px); - max-height: min(50vh, 480px); +/* 单栏对话布局:对话区占满全宽 */ +.task-run-modal .case-modal-chat { + border-left: none; + border-right: none; } -/* Artifact view (lives inside the dark left result pane) */ +/* Artifact view (lives inside the dark result pane) */ .artifact-view { display: flex; flex-direction: column; @@ -5088,6 +5077,225 @@ dialog.case-modal-overlay { font-weight: 600; } +/* ── Inline Artifact Cards (inside timeline) ── */ + +.inline-artifact-group { + display: flex; + flex-direction: column; + gap: 8px; + max-width: min(90%, 100%); +} + +.inline-artifact-images { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.inline-artifact-img-wrap { + display: block; + width: 160px; + height: 120px; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--line); + padding: 0; + background: var(--soft); + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.inline-artifact-img-wrap:hover { + border-color: var(--line-2); + box-shadow: 0 2px 8px rgba(11, 12, 15, 0.08); +} + +.inline-artifact-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.inline-artifact-video-wrap { + display: flex; + align-items: center; + gap: 10px; + width: 200px; + height: 48px; + padding: 0 14px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--soft); + cursor: pointer; + color: var(--ink); + transition: border-color 0.15s, background 0.15s; +} + +.inline-artifact-video-wrap:hover { + border-color: var(--line-2); + background: #fff; +} + +.inline-artifact-play { + flex-shrink: 0; + color: var(--muted); +} + +.inline-artifact-video-label { + flex: 1; + min-width: 0; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inline-artifact-card { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--soft); + cursor: pointer; + font-size: 13px; + color: var(--ink); + max-width: 320px; + transition: border-color 0.15s, background 0.15s; +} + +.inline-artifact-card:hover { + border-color: var(--line-2); + background: #fff; +} + +.inline-artifact-card:disabled { + opacity: 0.6; + cursor: default; +} + +.inline-artifact-icon { + flex-shrink: 0; + color: var(--muted); +} + +.inline-artifact-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inline-artifact-action { + flex-shrink: 0; + color: var(--muted-2); +} + +.inline-artifact-expired { + justify-content: center; + flex-direction: column; + gap: 4px; + width: 160px; + height: 120px; + background: var(--soft); +} + +.inline-artifact-expired-badge { + font-size: 11px; + font-weight: 500; + color: #b91c1c; + background: rgba(185, 28, 28, 0.08); + padding: 2px 8px; + border-radius: 999px; +} + +.inline-artifact-expired-hint { + font-size: 11px; + color: var(--muted-2); +} + +.inline-artifact-error { + margin: 4px 0 0; + font-size: 12px; + color: #b91c1c; +} + +/* ── Lightbox (fullscreen media preview) ── */ + +.lightbox-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.85); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + animation: fadeIn 0.15s ease; + cursor: pointer; +} + +.lightbox-backdrop { + position: absolute; + inset: 0; + border: none; + background: transparent; + cursor: pointer; +} + +.lightbox-close { + position: absolute; + top: 16px; + right: 16px; + z-index: 10; + width: 36px; + height: 36px; + border: none; + border-radius: 999px; + background: rgba(255, 255, 255, 0.15); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.15s; +} + +.lightbox-close:hover { + background: rgba(255, 255, 255, 0.3); +} + +.lightbox-content { + position: relative; + z-index: 1; + max-width: 92vw; + max-height: 90vh; + cursor: default; +} + +.lightbox-img { + max-width: 92vw; + max-height: 90vh; + object-fit: contain; + border-radius: 8px; + display: block; +} + +.lightbox-video { + max-width: 92vw; + max-height: 90vh; + border-radius: 8px; + display: block; + background: #000; +} + @media (max-width: 768px) { .case-modal-overlay:has(.task-run-modal) { align-items: flex-end; @@ -5096,26 +5304,10 @@ dialog.case-modal-overlay { .case-modal.task-run-modal { width: 100vw; max-width: 100vw; - height: 92vh; - border-radius: 16px 16px 0 0; - } - - .case-modal.task-run-modal.full { height: 100vh; border-radius: 0; } - .task-run-modal .run-mobile-tabs { - display: flex; - border-bottom: 1px solid var(--line); - flex-shrink: 0; - } - - .task-run-modal .case-modal-body { - flex-direction: column; - } - - .task-run-modal .case-modal-result, .task-run-modal .case-modal-chat { flex: 1; height: auto; @@ -5123,14 +5315,6 @@ dialog.case-modal-overlay { border-left: none; border-top: none; } - - .task-run-modal[data-mtab="product"] .case-modal-chat { - display: none; - } - - .task-run-modal[data-mtab="chat"] .case-modal-result { - display: none; - } } /* Warm banner — non-blocking app-entry warming progress */ @@ -5175,6 +5359,43 @@ dialog.case-modal-overlay { background: var(--line); } +/* Login guide banner — 未登录大账号试用态引导登录 */ +.login-guide-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + background: var(--soft); + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 13px; + animation: fadeIn 0.3s ease; +} + +.login-guide-banner-text { + flex: 1; +} + +.login-guide-banner-action { + border: 1px solid var(--ink); + background: var(--ink); + color: var(--cta-fg); + font-size: 13px; + padding: 4px 14px; + border-radius: var(--radius-pill); + cursor: pointer; + flex-shrink: 0; +} + +.login-guide-banner-action:hover:not(:disabled) { + background: var(--cta-hover); +} + +.login-guide-banner-action:disabled { + opacity: 0.6; + cursor: default; +} + /* Prompt editor (TipTap) */ .prompt-editor-hidden-slot { position: fixed; @@ -5223,8 +5444,17 @@ dialog.case-modal-overlay { margin: 0; } +.prompt-editor-slot .ProseMirror:not(.ProseMirror-focused) { + min-height: 24px; + max-height: 24px; + overflow: hidden; +} + .prompt-editor-slot .ProseMirror-focused { outline: none; + min-height: 24px; + max-height: 24px; + overflow: hidden; } .mention-tag { diff --git a/apps/webui/src/app/mobile.css b/apps/webui/src/app/mobile.css index 2acb395..2de2f78 100644 --- a/apps/webui/src/app/mobile.css +++ b/apps/webui/src/app/mobile.css @@ -46,11 +46,14 @@ body { min-height: 100svh; + font-size: 16px; + line-height: 1.5; } .page-shell { + --mobile-composer-reserve: 220px; min-height: 100svh; - padding: 12px 16px 120px; + padding: 12px 16px calc(var(--mobile-composer-reserve) + env(safe-area-inset-bottom)); overflow-x: hidden; display: flex; flex-direction: column; @@ -87,7 +90,7 @@ flex: 1; display: flex; flex-direction: column; - justify-content: center; + justify-content: flex-start; padding-top: 0; gap: 0; width: 100% !important; @@ -110,7 +113,8 @@ } h1 { - font-size: 24px; + font-size: 32px; + line-height: 1.25; } .subhead { @@ -131,66 +135,187 @@ } .greeting { - margin: 0 0 12px; + margin: 18px 0 12px; + text-align: center; } - /* Role fan: keep fan layout, smaller on mobile */ + /* Role cards: mobile follows the reference two-column card flow, not the desktop fan. */ .role-fan { - height: 300px; - margin-bottom: 0; - overflow: hidden; + height: auto; + margin: 12px 0 0; + overflow: visible; + border: none; + padding: 0; + perspective: none; + pointer-events: auto; + width: 100%; + } + + .role-fan-legend { + display: none; + } + + .role-fan-track { + position: static; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: 10px; + width: 100%; + height: auto; } .role-card { - width: 120px; - height: 170px; - margin: -85px 0 0 -60px; - padding: 5px 5px 6px; - border-radius: 12px; + position: relative; + top: auto; + left: auto; + width: 100%; + height: auto !important; + min-height: 0; + margin: 0; + padding: 10px 10px 12px; + border-radius: 16px; + transform: none !important; + box-shadow: 0 4px 14px rgba(11, 12, 15, 0.05); } .role-photo { - border-radius: 8px; + position: relative; + width: 100%; + aspect-ratio: 3 / 4; + border-radius: 12px; + overflow: hidden; + } + + .role-photo img { + position: absolute; + inset: 0; + width: 100%; + height: 100% !important; + object-fit: cover; + object-position: center 18%; } .role-card .role-name { - font-size: 10px; + flex: none; + margin-bottom: 8px; + font-size: 15px; + font-weight: 600; + text-align: center; } .role-card .role-tagline { font-size: 8px; } - /* Composer: fixed bottom, compact */ + /* Composer: reference-style bottom tray with a full input card. */ .composer { position: fixed; - left: 12px; - right: 12px; - bottom: 12px; + left: 0; + right: 0; + bottom: 0; z-index: 20; width: auto !important; max-width: none !important; min-height: auto; - padding: 10px 12px; - border-radius: 20px; - background: #ffffff; - box-shadow: 0 12px 40px rgba(11, 12, 15, 0.1); + padding: 0 16px calc(10px + env(safe-area-inset-bottom)); + border: none; + border-radius: 0; + background: rgba(245, 246, 250, 0.9); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: none; overflow: visible; - display: grid; - grid-template-columns: auto 1fr auto; - grid-template-rows: 1fr; - gap: 0 8px; - align-items: center; + display: flex; + flex-direction: column; + gap: 8px; animation: none !important; transform: none !important; } + .composer:focus-within { + border: none; + box-shadow: none; + } + .composer .composer-notice { - display: none; + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + margin: 0; + font-size: 13px; + color: #b0b3ba; + line-height: 1.4; } .composer > .composer-top-row { - display: none; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding: 8px 2px; + } + + .composer-top-row .task-box-btn { + flex-shrink: 0; + width: 32px; + height: 32px; + border: none; + background: #ffffff; + color: #6b7280; + box-shadow: 0 2px 10px rgba(11, 12, 15, 0.08); + } + + .composer-card-shell { + display: block; + --composer-shine-border-width: 1.5px; + position: relative; + padding: 0; + border-radius: 24px; + background: transparent; + overflow: visible; + box-shadow: 0 2px 8px rgba(11, 12, 15, 0.04); + } + + .composer-card-shell::before { + content: ""; + position: absolute; + inset: 0; + z-index: 2; + box-sizing: border-box; + border-radius: 24px; + padding: var(--composer-shine-border-width); + pointer-events: none; + background-image: radial-gradient( + transparent, + transparent, + #45ebac, + #8ec5ff, + #ab8aff, + transparent, + transparent + ); + background-size: 300% 300%; + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + } + + .composer-card-inner { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + min-height: 132px; + padding: 0; + border-radius: 24px; + background: #ffffff; } .agent-status { @@ -209,71 +334,96 @@ /* Mobile add button: left of input */ .composer-add-wrap { - display: flex; - grid-column: 1; - grid-row: 1; - align-self: center; + display: none; } .prompt-row { - min-height: 36px; - grid-column: 2; - grid-row: 1; + position: relative; + flex: 1; + min-height: 72px; min-width: 0; display: flex; - align-items: center; + align-items: flex-start; + margin-bottom: 0; + padding: 16px 16px 0; } .prompt-input { - font-size: 15px; - min-height: 36px !important; - height: 36px !important; - max-height: 36px !important; - line-height: 36px; + font-size: 16px; + min-height: 72px !important; + height: auto !important; + max-height: 120px !important; + line-height: 1.5; padding: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + overflow: auto; + white-space: pre-wrap; + text-overflow: unset; + color: #111827; } - /* Toolbar: float above the white card */ - .composer-footer { + .prompt-hint-row { position: absolute; - bottom: 100%; - left: 0; - right: 0; - margin-bottom: 10px; + inset: 0; + pointer-events: none; + display: flex; + align-items: flex-start; + padding: 16px 16px 0; + z-index: 1; + } + + .composer-mobile-placeholder { + display: block; + font-size: 16px; + line-height: 1.5; + color: #c4c8d0; + } + + .composer-mobile-placeholder.hidden, + .prompt-ghost, + .shortcut-hint { + display: none !important; + } + + .composer-footer { + position: static; display: flex; align-items: center; - justify-content: flex-start; - gap: 6px; - padding: 0; + gap: 8px; + margin: 0 -4px; + padding: 12px; border-bottom: none; } - /* Let TaskBox & ModelSelector flow as direct flex items of footer */ .mode-buttons { - display: contents; + display: flex; + align-items: center; + gap: 8px; + flex: 0 1 auto; + min-width: 0; } - /* TaskBox: pushed to far right (override PC hide rule) */ .mode-buttons .task-box-btn { - display: flex; - order: 99; - margin-left: auto; + display: none; } - /* Hide original + button and file input in toolbar (moved to input row) */ .tool-buttons > input[type="file"], .tool-buttons > .icon-btn { - display: none; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 8px; + border: none; + background: transparent; + color: #14151c; + border-radius: 999px; } .composer-send-wrap { position: static; - grid-column: 3; - grid-row: 1; - align-self: center; + flex-shrink: 0; + margin-left: auto; } /* Tool buttons: icons only on mobile */ @@ -448,37 +598,7 @@ } .prompt-ghost { - font-size: 14px; - flex: 1; - min-width: 0; - max-width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - word-break: normal; - overflow-wrap: normal; - line-height: 36px; - display: block; - } - - .prompt-hint-row { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - height: 36px; - max-width: 100%; - max-height: 36px; - padding-top: 0; - overflow: hidden; - display: flex; - align-items: center; - } - - .prompt-hint-row:has(.prompt-ghost.hidden) { - visibility: hidden; - max-height: 36px; + display: none; } /* BottomBar: hide on mobile, Composer handles input */ @@ -659,4 +779,20 @@ padding: 0 16px; font-size: 13px; } + + /* Task run uses a single timeline on mobile; keep it full-screen without desktop-only controls. */ + .task-run-modal .run-modal-fullscreen { + display: none; + } + + .task-run-modal .case-modal-body, + .task-run-modal .case-modal-chat { + min-height: 0; + height: auto; + width: 100%; + } + + .task-run-modal .case-modal-chat { + border-top: none; + } } diff --git a/apps/webui/src/components/Composer.tsx b/apps/webui/src/components/Composer.tsx index fa9a2ae..db48a8c 100644 --- a/apps/webui/src/components/Composer.tsx +++ b/apps/webui/src/components/Composer.tsx @@ -17,6 +17,7 @@ import { useSubmitTask } from "./useSubmitTask"; export interface ComposerHandle { focus: () => void; + focusStart: () => void; } interface ComposerProps { @@ -54,6 +55,7 @@ export default function Composer({ useImperativeHandle(ref, () => ({ focus: () => editor?.commands.focus(), + focusStart: () => editor?.commands.focus("start", { scrollIntoView: false }), })); const hasValue = !isEmpty || inputValue.length > 0; @@ -114,33 +116,42 @@ export default function Composer({ > -
- -
+
+
+
+ +
- + - + -
-
- -
-
- - +
+
+ +
+
+ + +
+
+ +
+
-
- -
{filePickerModal} diff --git a/apps/webui/src/components/ComposerInputShared.tsx b/apps/webui/src/components/ComposerInputShared.tsx index 523f4a8..796c662 100644 --- a/apps/webui/src/components/ComposerInputShared.tsx +++ b/apps/webui/src/components/ComposerInputShared.tsx @@ -80,8 +80,9 @@ export function PromptGhostEditor({
+ 输入你的想法 {ghostText} - +
diff --git a/apps/webui/src/components/Lightbox.tsx b/apps/webui/src/components/Lightbox.tsx new file mode 100644 index 0000000..ca43467 --- /dev/null +++ b/apps/webui/src/components/Lightbox.tsx @@ -0,0 +1,45 @@ +import { X } from "lucide-react"; +import { useEffect } from "react"; +import { createPortal } from "react-dom"; + +interface LightboxProps { + type: "image" | "video"; + url: string; + title?: string; + onClose: () => void; +} + +/** 图片/视频全屏预览浮层 */ +export function Lightbox({ type, url, title, onClose }: LightboxProps) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener("keydown", handler, true); + return () => document.removeEventListener("keydown", handler, true); + }, [onClose]); + + return createPortal( +
+ +
+ {type === "image" ? ( + {title + ) : ( + + )} +
+
, + document.body, + ); +} diff --git a/apps/webui/src/components/ModelSelector.tsx b/apps/webui/src/components/ModelSelector.tsx index 0d54d98..a31972d 100644 --- a/apps/webui/src/components/ModelSelector.tsx +++ b/apps/webui/src/components/ModelSelector.tsx @@ -70,33 +70,35 @@ export default function ModelSelector({ /> )} -
-
切换模型
-
- {models.map((model) => { - const isSelected = model.id === selectedModel.id; - return ( - - ); - })} + {open ? ( +
+
切换模型
+
+ {models.map((model) => { + const isSelected = model.id === selectedModel.id; + return ( + + ); + })} +
-
+ ) : null}
); } diff --git a/apps/webui/src/components/SettingsDialog.tsx b/apps/webui/src/components/SettingsDialog.tsx index 73e8200..d513806 100644 --- a/apps/webui/src/components/SettingsDialog.tsx +++ b/apps/webui/src/components/SettingsDialog.tsx @@ -4,6 +4,7 @@ import { AGENTS_CONFIG_PROVIDERS, type AgentsConfig, type AgentsConfigProvider, + type AgentsConfigSnapshot, loadAgentsConfig, persistAgentsConfig, providerFields, @@ -32,8 +33,20 @@ function emptyFields(provider: AgentsConfigProvider): Record { return Object.fromEntries(providerFields(provider).map((field) => [field.key, ""])); } +function fieldsFromConfig( + provider: AgentsConfigProvider, + config: AgentsConfigSnapshot | null | undefined, +): Record { + const nextFields = emptyFields(provider); + for (const field of providerFields(provider)) { + nextFields[field.key] = config?.[field.key] ?? ""; + } + return nextFields; +} + export default function SettingsDialog({ open, onClose }: SettingsDialogProps) { const dialogRef = useRef(null); + const [configSnapshot, setConfigSnapshot] = useState(null); const [provider, setProvider] = useState(""); const [fields, setFields] = useState>({}); const [visibleSecrets, setVisibleSecrets] = useState>({}); @@ -47,13 +60,10 @@ export default function SettingsDialog({ open, onClose }: SettingsDialogProps) { void loadAgentsConfig().then((config) => { if (cancelled) return; const savedProvider = isAgentsProvider(config?.AGENTS_PROVIDER) ? config.AGENTS_PROVIDER : ""; + setConfigSnapshot(config); setProvider(savedProvider); if (savedProvider) { - const nextFields = emptyFields(savedProvider); - for (const field of providerFields(savedProvider)) { - nextFields[field.key] = config?.[field.key] ?? ""; - } - setFields(nextFields); + setFields(fieldsFromConfig(savedProvider, config)); } else { setFields({}); } @@ -64,11 +74,14 @@ export default function SettingsDialog({ open, onClose }: SettingsDialogProps) { }; }, [open]); - const handleProviderChange = useCallback((nextProvider: AgentsConfigProvider | "") => { - setProvider(nextProvider); - setFields(nextProvider ? emptyFields(nextProvider) : {}); - setVisibleSecrets({}); - }, []); + const handleProviderChange = useCallback( + (nextProvider: AgentsConfigProvider | "") => { + setProvider(nextProvider); + setFields(nextProvider ? fieldsFromConfig(nextProvider, configSnapshot) : {}); + setVisibleSecrets({}); + }, + [configSnapshot], + ); const toggleSecretVisibility = useCallback((key: string) => { setVisibleSecrets((prev) => ({ ...prev, [key]: !prev[key] })); diff --git a/apps/webui/src/components/prompt-editor/PromptEditorProvider.tsx b/apps/webui/src/components/prompt-editor/PromptEditorProvider.tsx index 294adf4..79ff789 100644 --- a/apps/webui/src/components/prompt-editor/PromptEditorProvider.tsx +++ b/apps/webui/src/components/prompt-editor/PromptEditorProvider.tsx @@ -210,8 +210,8 @@ export function PromptEditorProvider({ // 外部 → Editor 同步(Role Card / 做同款) useEffect(() => { if (!editor) return; - if (inputValue === lastExternalValueRef.current) return; const currentPlain = editorToPlainMirror(editor); + if (inputValue === lastExternalValueRef.current && inputValue === currentPlain) return; if (inputValue === currentPlain) { lastExternalValueRef.current = inputValue; return; @@ -259,7 +259,11 @@ export function PromptEditorProvider({ const previous = slotsRef.current[id]; const wrap = editorWrapRef.current; if (!el && previous && wrap?.parentElement === previous) { - hiddenSlotRef.current?.appendChild(wrap); + queueMicrotask(() => { + if (wrap.parentElement === previous) { + hiddenSlotRef.current?.appendChild(wrap); + } + }); } slotsRef.current[id] = el; if (el && wrap && activeSlotRef.current === id) { diff --git a/apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx b/apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx new file mode 100644 index 0000000..d148a48 --- /dev/null +++ b/apps/webui/src/components/task-run-modal/InlineArtifactCard.tsx @@ -0,0 +1,229 @@ +import { Download, ExternalLink, FileText, Loader2, Play } from "lucide-react"; +import { type MouseEvent, useState } from "react"; +import { useArtifactAccess } from "@/lib/artifact-access-context"; +import { artifactDisplayName } from "@/lib/artifact-file-name"; +import { getFileDownloadUrl } from "@/lib/domain/file-api"; +import type { Artifact, ArtifactSegment, DeliveredFile } from "@/lib/view/artifact"; +import { Lightbox } from "../Lightbox"; + +function formatBytes(bytes?: number): string { + if (!bytes || bytes < 1) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +// --------------------------------------------------------------------------- +// 内部子组件 +// --------------------------------------------------------------------------- + +function InlineImageCard({ artifact, onPreview }: { artifact: Artifact; onPreview: () => void }) { + const access = useArtifactAccess(); + const expired = access?.isUrlExpired(artifact.url) ?? false; + const name = artifactDisplayName(artifact.url, artifact.title); + + if (expired) { + return ( + + ); + } + + return ( + + ); +} + +function InlineVideoCard({ artifact, onPreview }: { artifact: Artifact; onPreview: () => void }) { + const access = useArtifactAccess(); + const expired = access?.isUrlExpired(artifact.url) ?? false; + const name = artifactDisplayName(artifact.url, artifact.title); + + if (expired) { + return ( + + ); + } + + return ( + + ); +} + +function InlineAppCard({ artifact }: { artifact: Artifact }) { + const access = useArtifactAccess(); + const name = artifactDisplayName(artifact.url, artifact.title); + const expired = access?.isUrlExpired(artifact.url) ?? false; + + const onClick = (e: MouseEvent) => { + e.preventDefault(); + if (expired) { + access?.promptRegenerate(artifact.url, name); + } else { + access?.tryOpenUrl(artifact.url, name); + } + }; + + return ( + + ); +} + +function InlineFileCard({ artifact }: { artifact: Artifact }) { + const access = useArtifactAccess(); + const name = artifactDisplayName(artifact.url, artifact.title); + const expired = access?.isUrlExpired(artifact.url) ?? false; + + const onClick = (e: MouseEvent) => { + e.preventDefault(); + if (expired) { + access?.promptRegenerate(artifact.url, name); + } else { + access?.tryOpenUrl(artifact.url, name); + } + }; + + return ( + + ); +} + +function InlineDeliveredFileCard({ file }: { file: DeliveredFile }) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const size = formatBytes(file.size); + + const onDownload = async (e: MouseEvent) => { + e.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + try { + const url = await getFileDownloadUrl(file.file_id); + window.open(url, "_blank", "noopener,noreferrer"); + } catch (err) { + setError(err instanceof Error ? err.message : "下载失败"); + } finally { + setBusy(false); + } + }; + + return ( + <> + + {error &&

{error}

} + + ); +} + +// --------------------------------------------------------------------------- +// 主组件 +// --------------------------------------------------------------------------- + +interface InlineArtifactCardProps { + segments: ArtifactSegment[]; +} + +/** 对话流中的 Artifact 内联卡片组 */ +export function InlineArtifactCard({ segments }: InlineArtifactCardProps) { + const [lightbox, setLightbox] = useState<{ type: "image" | "video"; url: string; title?: string } | null>(null); + + return ( +
+ {segments.map((segment) => { + switch (segment.type) { + case "images": + return ( +
a.url).join("|")}`} className="inline-artifact-images"> + {segment.artifacts.map((img) => ( + setLightbox({ type: "image", url: img.url, title: img.title })} + /> + ))} +
+ ); + case "artifact": + switch (segment.artifact.kind) { + case "image": + return ( + + setLightbox({ type: "image", url: segment.artifact.url, title: segment.artifact.title }) + } + /> + ); + case "video": + return ( + + setLightbox({ type: "video", url: segment.artifact.url, title: segment.artifact.title }) + } + /> + ); + case "app": + return ; + case "file": + return ; + default: + return null; + } + case "delivered_file": + return ; + case "text": + // 纯文本已在 assistant 消息气泡中渲染,不再重复 + return null; + default: + return null; + } + })} + + {lightbox && ( + setLightbox(null)} /> + )} +
+ ); +} diff --git a/apps/webui/src/components/task-run-modal/RunHeader.tsx b/apps/webui/src/components/task-run-modal/RunHeader.tsx index c9cba64..da1658f 100644 --- a/apps/webui/src/components/task-run-modal/RunHeader.tsx +++ b/apps/webui/src/components/task-run-modal/RunHeader.tsx @@ -8,12 +8,10 @@ interface RunHeaderProps { statusLabel: string; isFull: boolean; onToggleFull: () => void; - onMakeSame?: (input: { prompt: string; agentId?: string }) => void; - onClose: () => void; } -/** 运行弹窗头部:状态、标题、全屏与做同款 */ -export function RunHeader({ task, phase, statusLabel, isFull, onToggleFull, onMakeSame, onClose }: RunHeaderProps) { +/** 运行弹窗头部:状态、标题、全屏 */ +export function RunHeader({ task, phase, statusLabel, isFull, onToggleFull }: RunHeaderProps) { return (
@@ -27,7 +25,7 @@ export function RunHeader({ task, phase, statusLabel, isFull, onToggleFull, onMa )} {statusLabel} -

{task.title}

+

{task.prompt || task.title}

-
); diff --git a/apps/webui/src/components/task-run-modal/RunTimeline.tsx b/apps/webui/src/components/task-run-modal/RunTimeline.tsx index df95070..194e56f 100644 --- a/apps/webui/src/components/task-run-modal/RunTimeline.tsx +++ b/apps/webui/src/components/task-run-modal/RunTimeline.tsx @@ -1,4 +1,4 @@ -import { Loader2 } from "lucide-react"; +import { Loader2, type LucideIcon } from "lucide-react"; import { formatTime } from "@/lib/view/run-event-display"; import type { RunPhase } from "@/lib/view/run-phase"; import { type RunTimelineItem, shouldShowAgentReplying } from "@/lib/view/run-timeline"; @@ -6,9 +6,7 @@ import { resolveTaskAttachedFiles } from "@/lib/view/user-message-files"; import type { Task } from "../TaskBox"; import UserMessageAttachments from "../UserMessageAttachments"; import UserMessageContent from "../UserMessageContent"; -import { useNewAgentMessageKeys } from "./hooks/useNewAgentMessageKeys"; import { RunTimelineItemView } from "./RunTimelineItemView"; -import type { LucideIcon } from "./types"; interface RunTimelineProps { task: Task; @@ -25,7 +23,7 @@ interface RunTimelineProps { onMessagesScroll: (e: React.UIEvent) => void; } -/** 运行弹窗右侧对话时间线 */ +/** 运行弹窗对话时间线 */ export function RunTimeline({ task, Icon, @@ -42,7 +40,6 @@ export function RunTimeline({ }: RunTimelineProps) { const isCreating = phase === "creating"; const isRunning = phase === "running"; - const displayTimeline = !isCreating && !isLoadingDetails; const showAgentReplying = shouldShowAgentReplying({ isRunning, sendSending, @@ -50,12 +47,6 @@ export function RunTimeline({ isLoadingDetails, timelineItems, }); - const revealKeys = useNewAgentMessageKeys({ - timelineItems, - sessionId: task.id, - open: true, - displayTimeline, - }); return ( <> @@ -103,9 +94,7 @@ export function RunTimeline({
) : ( - timelineItems.map((item) => ( - - )) + timelineItems.map((item) => ) )} {showAgentReplying && ( diff --git a/apps/webui/src/components/task-run-modal/RunTimelineItemView.tsx b/apps/webui/src/components/task-run-modal/RunTimelineItemView.tsx index ce21e24..9daa243 100644 --- a/apps/webui/src/components/task-run-modal/RunTimelineItemView.tsx +++ b/apps/webui/src/components/task-run-modal/RunTimelineItemView.tsx @@ -1,17 +1,15 @@ import type { RunTimelineItem } from "@/lib/view/run-timeline"; import ToolChainPanel from "../ToolChainPanel"; +import { InlineArtifactCard } from "./InlineArtifactCard"; import { SessionEventItem } from "./SessionEventItem"; -/** 时间线单项:message 气泡或工具链面板 */ -export function RunTimelineItemView({ - item, - revealContent = false, -}: { - item: RunTimelineItem; - revealContent?: boolean; -}) { +/** 时间线单项:message 气泡、工具链面板或内联产物卡片 */ +export function RunTimelineItemView({ item }: { item: RunTimelineItem }) { if (item.kind === "tool_chain") { return ; } - return ; + if (item.kind === "artifact") { + return ; + } + return ; } diff --git a/apps/webui/src/components/task-run-modal/TaskRunModal.tsx b/apps/webui/src/components/task-run-modal/TaskRunModal.tsx index 19a190c..a20ecf2 100644 --- a/apps/webui/src/components/task-run-modal/TaskRunModal.tsx +++ b/apps/webui/src/components/task-run-modal/TaskRunModal.tsx @@ -3,7 +3,6 @@ import { useEffect, useEffectEvent, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ArtifactAccessProvider } from "@/lib/artifact-access-context"; import { useBodyScrollLock } from "@/lib/use-body-scroll-lock"; -import { collectDeliveredFiles, extractArtifacts, lastAssistantText } from "@/lib/view/artifact"; import { deriveRunPhase, isRunLoadingDetails, runCreatingLabel, runStatusLabel } from "@/lib/view/run-phase"; import { buildRunTimeline } from "@/lib/view/run-timeline"; import { isTaskInProgress } from "@/lib/view/task-view"; @@ -11,16 +10,13 @@ import { typeIcons } from "./constants"; import { useRunFollowup } from "./hooks/useRunFollowup"; import { useRunTimelineScroll } from "./hooks/useRunTimelineScroll"; import { useTaskRunStream } from "./hooks/useTaskRunStream"; -import { RunArtifacts } from "./RunArtifacts"; import { RunHeader } from "./RunHeader"; import { RunInput } from "./RunInput"; -import { RunMobileTabs } from "./RunMobileTabs"; import { RunTimeline } from "./RunTimeline"; -import type { MobileTab, TaskRunModalProps } from "./types"; +import type { TaskRunModalProps } from "./types"; -export default function TaskRunModal({ open, task, onTaskUpdate, onClose, onMakeSame }: TaskRunModalProps) { +export default function TaskRunModal({ open, task, onTaskUpdate, onClose }: TaskRunModalProps) { const [isFull, setIsFull] = useState(false); - const [mobileTab, setMobileTab] = useState("product"); const submitInFlightRef = useRef(false); const taskRef = useRef(task); @@ -84,15 +80,6 @@ export default function TaskRunModal({ open, task, onTaskUpdate, onClose, onMake const Icon = typeIcons[task.type]; const isLoadingDetails = isRunLoadingDetails(task, phase); - const isCanceled = phase === "canceled"; - const resultText = lastAssistantText(task.events); - const { segments: artifactSegments } = extractArtifacts(resultText); - // Delivered artifact files (qoder DeliverArtifacts) carry only a file_id, so they can't be found - // by the URL scanner — collect them from event metadata and append as download cards. - const deliveredSegments = collectDeliveredFiles(task.events).map( - (file) => ({ type: "delivered_file", file }) as const, - ); - const segments = [...artifactSegments, ...deliveredSegments]; const timelineItems = buildRunTimeline(task.events, { isRunning }); const creatingLabel = runCreatingLabel(task); const statusLabel = runStatusLabel(task, phase); @@ -100,7 +87,7 @@ export default function TaskRunModal({ open, task, onTaskUpdate, onClose, onMake return createPortal(
-
+
@@ -111,22 +98,9 @@ export default function TaskRunModal({ open, task, onTaskUpdate, onClose, onMake statusLabel={statusLabel} isFull={isFull} onToggleFull={() => setIsFull((v) => !v)} - onMakeSame={onMakeSame} - onClose={onClose} /> - -
- -
; +export type LucideIcon = LucideIconType; +export type MobileTab = "product" | "chat"; export interface TaskRunModalProps { open: boolean; @@ -13,8 +14,6 @@ export interface TaskRunModalProps { onMakeSame?: (input: { prompt: string; agentId?: string }) => void; } -export type MobileTab = "product" | "chat"; - export interface SendState { sending: boolean; error: string | null; diff --git a/apps/webui/src/data/showcase.json b/apps/webui/src/data/showcase.json index 1d87b76..dddc5c2 100644 --- a/apps/webui/src/data/showcase.json +++ b/apps/webui/src/data/showcase.json @@ -46,7 +46,7 @@ "height": 280, "playbookName": "宠物逗猫棒互动图", "prompt": "如何挑选适合不同年龄段猫咪的逗猫棒?请从材质、长度、安全性等方面分析", - "playbookSlug": "writer", + "playbookSlug": "researcher", "imageId": 3, "media": { "type": "image", @@ -59,7 +59,7 @@ "height": 360, "playbookName": "商品360度视频", "prompt": "帮我设计一个商品360度旋转展示的网页小游戏,支持鼠标拖拽交互", - "playbookSlug": "engineer", + "playbookSlug": "designer", "imageId": 4, "media": { "type": "video", @@ -73,7 +73,7 @@ "height": 220, "playbookName": "宠物配饰穿戴", "prompt": "帮我创建一个宠物配饰电商落地页,展示项圈、衣服、鞋子三个分类", - "playbookSlug": "engineer", + "playbookSlug": "designer", "imageId": 5, "media": { "type": "image", @@ -86,7 +86,7 @@ "height": 300, "playbookName": "口播带货视频", "prompt": "帮我分析最近一个月口播带货视频的转化率数据,生成可视化图表", - "playbookSlug": "data-analyst", + "playbookSlug": "researcher", "imageId": 6, "media": { "type": "image", @@ -130,7 +130,7 @@ "height": 320, "playbookName": "仓鼠玩具互动", "prompt": "仓鼠适合什么类型的玩具?跑轮、隧道、啃咬玩具各有什么注意事项", - "playbookSlug": "writer", + "playbookSlug": "researcher", "imageId": 9, "media": { "type": "image", @@ -143,7 +143,7 @@ "height": 280, "playbookName": "项链互动套图", "prompt": "帮我创建一个珠宝项链的产品展示页面,支持点击切换不同款式", - "playbookSlug": "engineer", + "playbookSlug": "designer", "imageId": 10, "media": { "type": "image", @@ -161,7 +161,7 @@ "height": 240, "playbookName": "AR试戴体验", "prompt": "设计一个AR眼镜试戴的Web小游戏,用户可以选择不同款式实时预览", - "playbookSlug": "engineer", + "playbookSlug": "designer", "imageId": 11, "media": { "type": "image", @@ -174,7 +174,7 @@ "height": 300, "playbookName": "电商销售报表", "prompt": "帮我生成本季度电商各品类销售额对比报表,包含环比增长率分析", - "playbookSlug": "data-analyst", + "playbookSlug": "researcher", "imageId": 12, "media": { "type": "image", diff --git a/apps/webui/src/lib/api/contract.ts b/apps/webui/src/lib/api/contract.ts index a14a81d..d2cea4e 100644 --- a/apps/webui/src/lib/api/contract.ts +++ b/apps/webui/src/lib/api/contract.ts @@ -209,7 +209,7 @@ export type AgentsConfig = { [key: string]: string; }; -/** GET /api/config — effective provider config: on-disk ~/.agents/config.json with process.env (and therefore .env) fallback. */ +/** GET /api/config — effective provider config: on-disk ~/.agents/config.json with process.env/.env fallback for all provider fields. */ export type AgentsConfigSnapshot = Partial; export type SaveAgentsConfigOptions = { body: AgentsConfig }; diff --git a/apps/webui/src/lib/view/run-timeline.ts b/apps/webui/src/lib/view/run-timeline.ts index e27d567..65724c6 100644 --- a/apps/webui/src/lib/view/run-timeline.ts +++ b/apps/webui/src/lib/view/run-timeline.ts @@ -1,9 +1,11 @@ import type { SessionEvent } from "@openagentpack/sdk"; +import { type ArtifactSegment, collectDeliveredFiles, extractArtifacts } from "./artifact"; import { type DisplayBucket, displayBucketOf, eventData, eventText } from "./session-event-display"; export type RunTimelineItem = | { kind: "message"; event: SessionEvent; key: string } - | { kind: "tool_chain"; events: SessionEvent[]; key: string; isActive: boolean }; + | { kind: "tool_chain"; events: SessionEvent[]; key: string; isActive: boolean } + | { kind: "artifact"; segments: ArtifactSegment[]; key: string }; /** 工具名 → 统计摘要用的中文类别 */ const TOOL_SUMMARY_LABELS: Record = { @@ -248,7 +250,15 @@ export function buildToolChainRows(events: SessionEvent[]): ToolChainRow[] { return rows; } -/** 将 session events 拆成消息块与工具链块,供运行轨迹面板渲染。 */ +/** 对单条 assistant 消息提取 Artifact segments(过滤掉纯文本段) */ +function extractMessageArtifacts(event: SessionEvent): ArtifactSegment[] { + const text = eventText(event); + if (!text) return []; + const { segments } = extractArtifacts(text); + return segments.filter((s) => s.type !== "text"); +} + +/** 将 session events 拆成消息块、工具链块与产物块,供运行轨迹面板渲染。 */ export function buildRunTimeline(events: SessionEvent[], options: { isRunning?: boolean } = {}): RunTimelineItem[] { const items: RunTimelineItem[] = []; let chainBuffer: SessionEvent[] = []; @@ -257,7 +267,7 @@ export function buildRunTimeline(events: SessionEvent[], options: { isRunning?: const flushChain = (cursor: number) => { if (chainBuffer.length === 0) return; - // 仅有空 thinking 标记、无可展示行时不生成操作区块(避免「暂无工具操作记录」) + // Ignore provider-only thinking markers when they do not produce a visible operation row. if (buildToolChainRows(chainBuffer).length === 0) { chainBuffer = []; return; @@ -287,6 +297,18 @@ export function buildRunTimeline(events: SessionEvent[], options: { isRunning?: event, key: timelineEventKey(event, i), }); + + // 紧跟 assistant 消息后插入内联产物卡片 + if (event.role !== "user") { + const artifactSegments = extractMessageArtifacts(event); + if (artifactSegments.length > 0) { + items.push({ + kind: "artifact", + segments: artifactSegments, + key: `artifact:${timelineEventKey(event, i)}`, + }); + } + } continue; } @@ -307,9 +329,30 @@ export function buildRunTimeline(events: SessionEvent[], options: { isRunning?: } flushChain(events.length); + + // 末尾追加通过 Files API 交付的文件(DeliveredFile),它们无法从消息文本中提取 + const deliveredFiles = collectDeliveredFiles(events); + if (deliveredFiles.length > 0) { + const deliveredSegments: ArtifactSegment[] = deliveredFiles.map( + (file) => ({ type: "delivered_file", file }) as const, + ); + items.push({ + kind: "artifact", + segments: deliveredSegments, + key: `delivered:${deliveredFiles.map((f) => f.file_id).join("|")}`, + }); + } + return items; } +/** Extract visible message events for consumers that do not need tool/artifact rows. */ +export function getDisplayEvents(task: { events: SessionEvent[] }): SessionEvent[] { + return buildRunTimeline(task.events) + .filter((item): item is Extract => item.kind === "message") + .map((item) => item.event); +} + interface ShouldShowAgentReplyingOptions { isRunning: boolean; sendSending: boolean; @@ -337,10 +380,3 @@ export function shouldShowAgentReplying({ const last = timelineItems[timelineItems.length - 1]; return last?.kind === "message" && last.event.role === "user"; } - -/** 从 timeline 中提取纯 message 类 events(供去重测试与展示过滤) */ -export function getDisplayEvents(task: { events: SessionEvent[] }): SessionEvent[] { - return buildRunTimeline(task.events) - .filter((item): item is Extract => item.kind === "message") - .map((item) => item.event); -} diff --git a/apps/webui/tests/prompt-layout.test.ts b/apps/webui/tests/prompt-layout.test.ts new file mode 100644 index 0000000..b5bf6dd --- /dev/null +++ b/apps/webui/tests/prompt-layout.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; + +const desktopCss = await Bun.file(new URL("../src/app/desktop.css", import.meta.url)).text(); + +function ruleFor(selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = desktopCss.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`)); + expect(match, `missing CSS rule for ${selector}`).not.toBeNull(); + return match?.[1] ?? ""; +} + +test("long composer ghost text stays on one line", () => { + const hintRow = ruleFor(".prompt-hint-row"); + const ghost = ruleFor(".prompt-ghost"); + + expect(hintRow).toMatch(/\bwidth:\s*calc\(100%\s*-\s*40px\)/); + expect(ghost).toMatch(/\bwhite-space:\s*nowrap/); + expect(ghost).toMatch(/\boverflow:\s*hidden/); + expect(ghost).toMatch(/\btext-overflow:\s*ellipsis/); +}); + +test("long bottom-bar ghost text stays on one line", () => { + const hintRow = ruleFor(".bottom-bar-expanded .bar-hint-row"); + const ghost = ruleFor(".bottom-bar-expanded .bar-hint-row .prompt-ghost"); + + expect(hintRow).toMatch(/\bwidth:\s*100%/); + expect(ghost).toMatch(/\bwhite-space:\s*nowrap/); + expect(ghost).toMatch(/\boverflow:\s*hidden/); + expect(ghost).toMatch(/\btext-overflow:\s*ellipsis/); +}); + +test("prefilled prompt stays on one line even when the editor is focused", () => { + const collapsed = ruleFor(".prompt-editor-slot .ProseMirror:not(.ProseMirror-focused)"); + const focused = ruleFor(".prompt-editor-slot .ProseMirror-focused"); + + expect(collapsed).toMatch(/\bmin-height:\s*24px/); + expect(collapsed).toMatch(/\bmax-height:\s*24px/); + expect(collapsed).toMatch(/\boverflow:\s*hidden/); + expect(focused).toMatch(/\bmin-height:\s*24px/); + expect(focused).toMatch(/\bmax-height:\s*24px/); + expect(focused).toMatch(/\boverflow:\s*hidden/); +}); diff --git a/apps/webui/tests/role-prompt-focus.test.ts b/apps/webui/tests/role-prompt-focus.test.ts new file mode 100644 index 0000000..872b026 --- /dev/null +++ b/apps/webui/tests/role-prompt-focus.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test"; + +const appSource = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); +const composerSource = await Bun.file(new URL("../src/components/Composer.tsx", import.meta.url)).text(); + +test("role prompt selection focuses the beginning of the prefilled prompt", () => { + expect(appSource).toContain("composerHandleRef.current?.focusStart()"); + expect(composerSource).toContain('editor?.commands.focus("start", { scrollIntoView: false })'); +}); diff --git a/apps/webui/tests/settings-secret-layout.test.ts b/apps/webui/tests/settings-secret-layout.test.ts new file mode 100644 index 0000000..a2d4cff --- /dev/null +++ b/apps/webui/tests/settings-secret-layout.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; + +const desktopCss = await Bun.file(new URL("../src/app/desktop.css", import.meta.url)).text(); + +test("settings secret toggle stays anchored inside the password input", () => { + expect(desktopCss).toContain(".settings-secret-input {"); + expect(desktopCss).toMatch(/\.settings-secret-input\s*\{[^}]*position:\s*relative;/s); + expect(desktopCss).toMatch( + /\.settings-secret-toggle\s*\{[^}]*position:\s*absolute;[^}]*right:\s*8px;[^}]*top:\s*50%;/s, + ); + expect(desktopCss).toMatch(/\.settings-secret-input \.provision-dialog-input\s*\{[^}]*padding-right:\s*40px;/s); +});