This is an automated email from the ASF dual-hosted git repository.

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-website.git


The following commit(s) were added to refs/heads/master by this push:
     new 38f72092900 [feature] Add AI-assisted query profile analysis page 
(#4025)
38f72092900 is described below

commit 38f72092900d5763ca06410c2c86a2a325aaa96e
Author: Mairui Li <[email protected]>
AuthorDate: Fri Jul 31 17:55:35 2026 +0800

    [feature] Add AI-assisted query profile analysis page (#4025)
    
    ## What changed
    
    - add a `/profile-analysis` page for uploading an Apache Doris Query
    Profile and choosing the response language
    - submit analyses through the asynchronous job API and show queued,
    running, completed, and failed states
    - persist only recovery identifiers in `sessionStorage` so a refresh can
    resume the same job
    - recover ambiguous create requests by `clientRequestId` without
    replaying the Profile upload
    - add explicit privacy consent, prohibited-content guidance, third-party
    AI disclosure, and deletion timing
    - render model output as untrusted Markdown with an element allowlist,
    safe-link policy, no remote images, and a client-side answer-size limit
    - require hCaptcha before submission and send the one-time
    `hcaptchaToken` in the multipart request
    - use `https://agent.velodb.io` as the default API origin while
    retaining build-time environment overrides
    
    ## Why
    
    This provides a public, refresh-safe workflow for AI-assisted Doris
    Query Profile diagnosis while keeping uploads, model output, and
    recovery state inside explicit security and privacy boundaries.
    
    The hCaptcha token is reset after the single create attempt. If that
    POST has an ambiguous network outcome, the client uses the read-only
    recovery endpoint instead of replaying a potentially consumed token or
    creating another logical job.
    
    ## Validation
    
    - `node --test src/components/profile-analysis/*.test.js` — 42 tests
    passed
    - targeted TypeScript check for all Profile Analysis modules passed
    - `git diff --check upstream/master...HEAD` passed
    - simulated and then performed a clean rebase onto the latest
    `apache/doris-website:master`
    - exercised the public HTTPS API with a 692 KiB Profile: `QUEUED ->
    RUNNING -> COMPLETED`
    - verified browser CORS preflight for multipart POST and
    `Idempotency-Key`
    
    ## Deployment notes
    
    - `PROFILE_ANALYSIS_API_BASE_URL` can override the public API origin at
    build time.
    - `PROFILE_ANALYSIS_HCAPTCHA_SITE_KEY` is a public browser configuration
    value. The matching hCaptcha secret remains backend-only and is not
    included in this repository.
    - The backend must enable hCaptcha verification before production
    enforcement is considered complete.
---
 docusaurus.config.js                               |  10 +-
 package.json                                       |   3 +
 .../profile-analysis/AnalysisMarkdown.tsx          |  84 ++++
 src/components/profile-analysis/AnalysisResult.tsx |  21 +
 src/components/profile-analysis/AnalysisStatus.tsx |  24 ++
 .../profile-analysis/ProfileAnalysis.scss          | 354 ++++++++++++++++
 .../profile-analysis/ProfileAnalyzer.tsx           |  64 +++
 .../profile-analysis/ProfileUploader.tsx           | 268 +++++++++++++
 .../profile-analysis/profile-analysis.api.test.js  | 340 ++++++++++++++++
 .../profile-analysis/profile-analysis.api.ts       | 247 ++++++++++++
 .../profile-analysis.components.test.js            | 217 ++++++++++
 .../profile-analysis.recovery.test.js              | 226 +++++++++++
 .../profile-analysis/profile-analysis.recovery.ts  | 162 ++++++++
 .../profile-analysis.storage.test.js               |  95 +++++
 .../profile-analysis/profile-analysis.storage.ts   | 124 ++++++
 .../profile-analysis/profile-analysis.types.ts     |  42 ++
 .../profile-analysis/use-profile-analysis.test.js  | 196 +++++++++
 .../profile-analysis/use-profile-analysis.ts       | 445 +++++++++++++++++++++
 src/pages/profile-analysis/index.tsx               |  16 +
 yarn.lock                                          |  29 +-
 20 files changed, 2965 insertions(+), 2 deletions(-)

diff --git a/docusaurus.config.js b/docusaurus.config.js
index 29efda68275..1e0260a76d3 100644
--- a/docusaurus.config.js
+++ b/docusaurus.config.js
@@ -217,7 +217,15 @@ const config = {
         },
     ],
     projectName: 'apache/doris-website', // Usually your repo name.
-    customFields: {},
+    customFields: {
+        // The public HTTPS reverse proxy is the default browser entry point.
+        // Local development can still replace this build-time value when 
needed.
+        profileAnalysisApiBaseUrl: process.env.PROFILE_ANALYSIS_API_BASE_URL 
?? 'https://agent.velodb.io',
+        // hCaptcha site keys are public browser configuration. Never put the
+        // matching secret in this repository or in a Docusaurus environment 
variable.
+        profileAnalysisHCaptchaSiteKey:
+            process.env.PROFILE_ANALYSIS_HCAPTCHA_SITE_KEY ?? 
'40f4820a-dc48-466a-b106-960a57ac5bd0',
+    },
     future: {
         experimental_faster: true,
     },
diff --git a/package.json b/package.json
index e881e09fbca..361bfc3ffd5 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
         "@docusaurus/plugin-pwa": "3.6.3",
         "@docusaurus/preset-classic": "3.6.3",
         "@emotion/css": "^11.13.5",
+        "@hcaptcha/react-hcaptcha": "^2.0.2",
         "@mdx-js/react": "^3.0.0",
         "@yang1666204/docusaurus-search-local": "0.0.7",
         "antd": "^5.12.2",
@@ -67,7 +68,9 @@
         "react": "^18.2.0",
         "react-dom": "^18.2.0",
         "react-github-button": "^0.1.11",
+        "react-markdown": "^10.1.0",
         "rehype-katex": "7",
+        "remark-gfm": "^4.0.1",
         "remark-math": "6",
         "sass": "^1.63.2",
         "sass-migrator": "^2.2.1",
diff --git a/src/components/profile-analysis/AnalysisMarkdown.tsx 
b/src/components/profile-analysis/AnalysisMarkdown.tsx
new file mode 100644
index 00000000000..22c1798c8d3
--- /dev/null
+++ b/src/components/profile-analysis/AnalysisMarkdown.tsx
@@ -0,0 +1,84 @@
+import React, { JSX } from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+
+interface AnalysisMarkdownProps {
+    children: string;
+}
+
+const ALLOWED_ELEMENTS = [
+    'p',
+    'h1',
+    'h2',
+    'h3',
+    'h4',
+    'h5',
+    'h6',
+    'strong',
+    'em',
+    'ul',
+    'ol',
+    'li',
+    'code',
+    'pre',
+    'blockquote',
+    'table',
+    'thead',
+    'tbody',
+    'tr',
+    'th',
+    'td',
+    'hr',
+    'br',
+    'del',
+    'a',
+];
+
+function isSafeLink(href: string | undefined): boolean {
+    if (!href) return false;
+    if (href.startsWith('#') || href.startsWith('/')) return 
!href.startsWith('//');
+    try {
+        const url = new URL(href);
+        return (
+            url.protocol === 'https:' &&
+            (url.hostname === 'doris.apache.org' ||
+                (url.hostname === 'github.com' && 
url.pathname.startsWith('/apache/doris')))
+        );
+    } catch {
+        return false;
+    }
+}
+
+/**
+ * Render the model response as untrusted, runtime Markdown.
+ *
+ * Raw HTML is intentionally discarded and the default react-markdown URL
+ * transform is retained so executable protocols such as javascript: are not
+ * exposed as links.
+ */
+export function AnalysisMarkdown({ children }: AnalysisMarkdownProps): 
JSX.Element {
+    return (
+        <div className="profile-analysis__markdown markdown">
+            <ReactMarkdown
+                remarkPlugins={[remarkGfm]}
+                skipHtml
+                allowedElements={ALLOWED_ELEMENTS}
+                components={{
+                    h1: 'h3',
+                    h2: 'h3',
+                    img: () => null,
+                    a: ({ href, children }) =>
+                        isSafeLink(href) ? (
+                            <a href={href} rel="noopener noreferrer">
+                                {children}
+                            </a>
+                        ) : (
+                            <span>{children}</span>
+                        ),
+                }}
+            >
+                {children}
+            </ReactMarkdown>
+        </div>
+    );
+}
diff --git a/src/components/profile-analysis/AnalysisResult.tsx 
b/src/components/profile-analysis/AnalysisResult.tsx
new file mode 100644
index 00000000000..3d97504db92
--- /dev/null
+++ b/src/components/profile-analysis/AnalysisResult.tsx
@@ -0,0 +1,21 @@
+import React, { JSX } from 'react';
+import { AnalysisMarkdown } from './AnalysisMarkdown';
+import type { AgentMessage } from './profile-analysis.types';
+
+interface AnalysisResultProps {
+    result: AgentMessage;
+}
+
+export function AnalysisResult({ result }: AnalysisResultProps): JSX.Element {
+    return (
+        <section className="profile-analysis__result" 
aria-labelledby="profile-analysis-result-title">
+            <h2 id="profile-analysis-result-title">Analysis result</h2>
+            <div className="profile-analysis__ai-warning" role="note">
+                <strong>AI-generated result:</strong> Verify the evidence 
against the original Profile and have a
+                qualified engineer review recommendations before making 
production changes. Do not execute commands
+                or configuration changes automatically.
+            </div>
+            <AnalysisMarkdown>{result.text}</AnalysisMarkdown>
+        </section>
+    );
+}
diff --git a/src/components/profile-analysis/AnalysisStatus.tsx 
b/src/components/profile-analysis/AnalysisStatus.tsx
new file mode 100644
index 00000000000..4c947fca97d
--- /dev/null
+++ b/src/components/profile-analysis/AnalysisStatus.tsx
@@ -0,0 +1,24 @@
+import React, { JSX } from 'react';
+import type { AnalysisState } from './profile-analysis.types';
+
+interface AnalysisStatusProps {
+    state: Extract<AnalysisState, 'restoring' | 'recovering' | 'submitting' | 
'queued' | 'analyzing' | 'completed'>;
+    jobsAhead: number | null;
+}
+
+export function AnalysisStatus({ state, jobsAhead }: AnalysisStatusProps): 
JSX.Element {
+    let label = 'Analyzing';
+    if (state === 'restoring') label = 'Restoring analysis…';
+    if (state === 'recovering') label = 'Connection interrupted · recovering 
analysis…';
+    if (state === 'submitting') label = 'Uploading profile…';
+    if (state === 'queued') {
+        label = jobsAhead === null ? 'Queued' : `Queued · ${jobsAhead} 
${jobsAhead === 1 ? 'job' : 'jobs'} ahead`;
+    }
+    if (state === 'completed') label = 'Completed';
+    return (
+        <div className="profile-analysis__status" role="status" 
aria-live="polite">
+            {state !== 'completed' && <span 
className="profile-analysis__spinner" aria-hidden="true" />}
+            <span>{label}</span>
+        </div>
+    );
+}
diff --git a/src/components/profile-analysis/ProfileAnalysis.scss 
b/src/components/profile-analysis/ProfileAnalysis.scss
new file mode 100644
index 00000000000..0a114ba1617
--- /dev/null
+++ b/src/components/profile-analysis/ProfileAnalysis.scss
@@ -0,0 +1,354 @@
+.profile-analysis {
+    width: 100%;
+    max-width: 960px;
+    margin: 0 auto;
+    padding: 2rem 0 4rem;
+
+    &__header {
+        margin-bottom: 2rem;
+
+        h1 {
+            margin-bottom: 0.75rem;
+            font-size: clamp(2rem, 4vw, 3rem);
+            line-height: 1.15;
+        }
+
+        > p:last-child {
+            max-width: 720px;
+            margin: 0;
+            color: var(--ifm-color-emphasis-700);
+            font-size: 1.05rem;
+            line-height: 1.7;
+        }
+    }
+
+    &__eyebrow {
+        margin-bottom: 0.5rem;
+        color: var(--ifm-color-primary);
+        font-size: 0.8rem;
+        font-weight: 700;
+        letter-spacing: 0.08em;
+        text-transform: uppercase;
+    }
+
+    &__uploader,
+    &__result {
+        margin-bottom: 1.5rem;
+        padding: 1.5rem;
+        border: 1px solid var(--ifm-color-emphasis-300);
+        border-radius: 12px;
+        background: var(--ifm-background-surface-color);
+        box-shadow: 0 8px 28px rgb(0 0 0 / 5%);
+
+        h2 {
+            margin-bottom: 0.5rem;
+            font-size: 1.35rem;
+        }
+    }
+
+    &__help {
+        margin-bottom: 1rem;
+        color: var(--ifm-color-emphasis-700);
+    }
+
+    &__privacy-notice {
+        margin-bottom: 1.25rem;
+        padding: 1rem;
+        border: 1px solid var(--ifm-color-emphasis-300);
+        border-radius: 8px;
+        background: var(--ifm-color-emphasis-100);
+
+        h3 {
+            margin-bottom: 0.5rem;
+            font-size: 1rem;
+        }
+
+        ul {
+            margin-bottom: 0.75rem;
+            padding-left: 1.25rem;
+        }
+    }
+
+    &__consent {
+        display: flex;
+        gap: 0.6rem;
+        align-items: flex-start;
+        margin: 0;
+        font-weight: 600;
+        cursor: pointer;
+
+        input {
+            flex: 0 0 auto;
+            margin-top: 0.25rem;
+        }
+    }
+
+    &__language {
+        display: flex;
+        gap: 1rem;
+        align-items: center;
+        margin: 0 0 1rem;
+        padding: 0;
+        border: 0;
+
+        legend {
+            margin-bottom: 0.5rem;
+            font-weight: 600;
+        }
+
+        label {
+            display: inline-flex;
+            gap: 0.4rem;
+            align-items: center;
+            margin: 0;
+            cursor: pointer;
+        }
+
+        input {
+            margin: 0;
+        }
+
+        &:disabled,
+        &[disabled] {
+            opacity: 0.65;
+        }
+    }
+
+    &__drop-zone {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        min-height: 180px;
+        padding: 1.5rem;
+        border: 2px dashed var(--ifm-color-emphasis-400);
+        border-radius: 10px;
+        background: var(--ifm-color-emphasis-100);
+        cursor: pointer;
+        text-align: center;
+        transition: border-color 160ms ease, background-color 160ms ease;
+
+        &:hover,
+        &:focus-within {
+            border-color: var(--ifm-color-primary);
+            background: var(--ifm-color-primary-contrast-background);
+        }
+
+        &--disabled {
+            cursor: not-allowed;
+            opacity: 0.65;
+        }
+    }
+
+    &__drop-zone-title {
+        margin-bottom: 0.25rem;
+        font-weight: 700;
+    }
+
+    &__drop-zone-note {
+        margin-bottom: 1rem;
+        color: var(--ifm-color-emphasis-700);
+        font-size: 0.9rem;
+    }
+
+    &__file-input {
+        position: absolute;
+        width: 1px;
+        height: 1px;
+        padding: 0;
+        margin: -1px;
+        overflow: hidden;
+        clip: rect(0, 0, 0, 0);
+        clip-path: inset(50%);
+        white-space: nowrap;
+        border: 0;
+    }
+
+    &__validation-error {
+        margin-top: 1rem;
+        color: var(--ifm-color-danger-darkest);
+    }
+
+    &__file {
+        display: flex;
+        gap: 1rem;
+        align-items: center;
+        justify-content: space-between;
+        min-width: 0;
+        margin-top: 1rem;
+        padding: 0.8rem 1rem;
+        border-radius: 8px;
+        background: var(--ifm-color-emphasis-100);
+    }
+
+    &__file-name {
+        min-width: 0;
+        overflow: hidden;
+        font-weight: 600;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+    }
+
+    &__file-size {
+        flex: 0 0 auto;
+        color: var(--ifm-color-emphasis-700);
+        font-size: 0.9rem;
+    }
+
+    &__captcha {
+        max-width: 100%;
+        margin-top: 1.25rem;
+        overflow-x: auto;
+
+        small {
+            display: block;
+            margin-top: 0.5rem;
+            color: var(--ifm-color-emphasis-700);
+        }
+    }
+
+    &__captcha-label {
+        margin-bottom: 0.5rem;
+        font-weight: 600;
+    }
+
+    &__analyze-button {
+        margin-top: 1.25rem;
+    }
+
+    &__status,
+    &__warning,
+    &__error {
+        display: flex;
+        gap: 0.75rem;
+        align-items: center;
+        margin-bottom: 1.5rem;
+        padding: 1rem;
+        border-radius: 8px;
+    }
+
+    &__status {
+        color: var(--ifm-color-primary-darkest);
+        background: var(--ifm-color-primary-contrast-background);
+    }
+
+    &__warning {
+        color: var(--ifm-color-warning-darkest);
+        background: var(--ifm-color-warning-contrast-background);
+    }
+
+    &__spinner {
+        width: 1.1rem;
+        height: 1.1rem;
+        flex: 0 0 auto;
+        border: 2px solid var(--ifm-color-emphasis-300);
+        border-top-color: var(--ifm-color-primary);
+        border-radius: 50%;
+        animation: profile-analysis-spin 800ms linear infinite;
+    }
+
+    &__error {
+        align-items: flex-start;
+        color: var(--ifm-color-danger-darkest);
+        background: var(--ifm-color-danger-contrast-background);
+
+        strong {
+            flex: 0 0 auto;
+        }
+    }
+
+    &__markdown {
+        color: var(--ifm-font-color-base);
+        padding-left: 0;
+        overflow-wrap: anywhere;
+
+        > :first-child {
+            margin-top: 0 !important;
+        }
+
+        > :last-child {
+            margin-bottom: 0 !important;
+        }
+
+        h1,
+        h2,
+        h3,
+        h4,
+        h5,
+        h6,
+        strong {
+            color: var(--ifm-font-color-base);
+        }
+
+        pre,
+        table {
+            max-width: 100%;
+            overflow-x: auto;
+        }
+
+        pre {
+            white-space: pre;
+        }
+
+        table {
+            display: block;
+        }
+
+        a {
+            overflow-wrap: anywhere;
+        }
+    }
+
+    &__ai-warning {
+        margin-bottom: 1rem;
+        padding: 0.9rem 1rem;
+        border-left: 4px solid var(--ifm-color-warning);
+        border-radius: 4px;
+        color: var(--ifm-color-warning-darkest);
+        background: var(--ifm-color-warning-contrast-background);
+    }
+
+    @media (max-width: 768px) {
+        padding-top: 1rem;
+
+        &__uploader,
+        &__result {
+            padding: 1rem;
+        }
+
+        &__drop-zone {
+            min-height: 150px;
+            padding: 1rem;
+        }
+
+        &__language {
+            align-items: flex-start;
+            flex-direction: column;
+            gap: 0.5rem;
+        }
+
+        &__file,
+        &__warning,
+        &__error {
+            align-items: flex-start;
+            flex-direction: column;
+            gap: 0.35rem;
+        }
+
+        &__analyze-button {
+            width: 100%;
+        }
+    }
+}
+
+@keyframes profile-analysis-spin {
+    to {
+        transform: rotate(360deg);
+    }
+}
+
+@media (prefers-reduced-motion: reduce) {
+    .profile-analysis__spinner {
+        animation: none;
+    }
+}
diff --git a/src/components/profile-analysis/ProfileAnalyzer.tsx 
b/src/components/profile-analysis/ProfileAnalyzer.tsx
new file mode 100644
index 00000000000..e378b5bfec7
--- /dev/null
+++ b/src/components/profile-analysis/ProfileAnalyzer.tsx
@@ -0,0 +1,64 @@
+import React, { JSX } from 'react';
+import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
+import { AnalysisResult } from './AnalysisResult';
+import { AnalysisStatus } from './AnalysisStatus';
+import { ProfileUploader } from './ProfileUploader';
+import { useProfileAnalysis } from './use-profile-analysis';
+import './ProfileAnalysis.scss';
+
+export function ProfileAnalyzer(): JSX.Element {
+    const { siteConfig } = useDocusaurusContext();
+    const configuredApiBaseUrl = 
siteConfig.customFields?.profileAnalysisApiBaseUrl;
+    const apiBaseUrl = typeof configuredApiBaseUrl === 'string' ? 
configuredApiBaseUrl : '';
+    const configuredHCaptchaSiteKey = 
siteConfig.customFields?.profileAnalysisHCaptchaSiteKey;
+    const hcaptchaSiteKey =
+        typeof configuredHCaptchaSiteKey === 'string' ? 
configuredHCaptchaSiteKey : '';
+    const analysis = useProfileAnalysis(apiBaseUrl);
+    const isBusy = analysis.isBusy;
+    const busyState =
+        analysis.state === 'restoring' ||
+        analysis.state === 'recovering' ||
+        analysis.state === 'submitting' ||
+        analysis.state === 'queued' ||
+        analysis.state === 'analyzing'
+            ? analysis.state
+            : null;
+
+    return (
+        <div className="profile-analysis">
+            <header className="profile-analysis__header">
+                <p className="profile-analysis__eyebrow">Query diagnostics</p>
+                <h1>Apache Doris Profile Analysis</h1>
+                <p>
+                    Upload one Query Profile to receive an independent 
AI-assisted diagnosis. Each upload starts a
+                    new analysis and does not create a conversation history.
+                </p>
+            </header>
+
+            <ProfileUploader
+                file={analysis.file}
+                language={analysis.language}
+                disabled={isBusy}
+                hcaptchaSiteKey={hcaptchaSiteKey}
+                onFileChange={analysis.selectFile}
+                onLanguageChange={analysis.setLanguage}
+                onAnalyze={analysis.analyze}
+            />
+
+            {busyState && <AnalysisStatus state={busyState} 
jobsAhead={analysis.jobsAhead} />}
+            {analysis.state === 'completed' && <AnalysisStatus 
state="completed" jobsAhead={null} />}
+            {analysis.recoveryWarning && (
+                <div className="profile-analysis__warning" role="status">
+                    {analysis.recoveryWarning}
+                </div>
+            )}
+            {analysis.error && (
+                <div className="profile-analysis__error" role="alert">
+                    <strong>Analysis failed.</strong>
+                    <span>{analysis.error}</span>
+                </div>
+            )}
+            {analysis.result && <AnalysisResult result={analysis.result} />}
+        </div>
+    );
+}
diff --git a/src/components/profile-analysis/ProfileUploader.tsx 
b/src/components/profile-analysis/ProfileUploader.tsx
new file mode 100644
index 00000000000..e852b3d8a34
--- /dev/null
+++ b/src/components/profile-analysis/ProfileUploader.tsx
@@ -0,0 +1,268 @@
+import HCaptcha from '@hcaptcha/react-hcaptcha';
+import React, { ChangeEvent, DragEvent, JSX, useCallback, useRef, useState } 
from 'react';
+import type { ResponseLanguage } from './profile-analysis.types';
+
+export const MAX_PROFILE_FILE_SIZE_BYTES = 10 * 1024 * 1024;
+
+interface ProfileUploaderProps {
+    file: File | null;
+    language: ResponseLanguage;
+    disabled: boolean;
+    hcaptchaSiteKey: string;
+    onFileChange: (file: File | null) => void;
+    onLanguageChange: (language: ResponseLanguage) => void;
+    onAnalyze: (hcaptchaToken: string, resetCaptcha: () => void) => void;
+}
+
+export function validateProfileFile(file: File): string | null {
+    if (!file.name.toLowerCase().endsWith('.txt')) {
+        return 'Select a .txt file. Other file types are not supported.';
+    }
+    if (file.size > MAX_PROFILE_FILE_SIZE_BYTES) {
+        return 'The selected file is larger than 10 MiB.';
+    }
+    return null;
+}
+
+export function formatProfileFileSize(sizeInBytes: number): string {
+    if (sizeInBytes < 1024) {
+        return `${sizeInBytes} B`;
+    }
+    if (sizeInBytes < 1024 * 1024) {
+        return `${(sizeInBytes / 1024).toFixed(1)} KiB`;
+    }
+    return `${(sizeInBytes / (1024 * 1024)).toFixed(1)} MiB`;
+}
+
+export function ProfileUploader({
+    file,
+    language,
+    disabled,
+    hcaptchaSiteKey,
+    onFileChange,
+    onLanguageChange,
+    onAnalyze,
+}: ProfileUploaderProps): JSX.Element {
+    const [validationError, setValidationError] = useState<string | 
null>(null);
+    const [consentAccepted, setConsentAccepted] = useState(false);
+    const [hcaptchaToken, setHCaptchaToken] = useState<string | null>(null);
+    const [hcaptchaError, setHCaptchaError] = useState<string | null>(null);
+    const hcaptchaRef = useRef<HCaptcha>(null);
+
+    const resetCaptcha = useCallback(() => {
+        hcaptchaRef.current?.resetCaptcha();
+        setHCaptchaToken(null);
+        setHCaptchaError(null);
+    }, []);
+
+    const acceptFile = (nextFile: File | null) => {
+        if (!nextFile) {
+            setValidationError(null);
+            onFileChange(null);
+            return;
+        }
+
+        const nextError = validateProfileFile(nextFile);
+        setValidationError(nextError);
+        onFileChange(nextError ? null : nextFile);
+    };
+
+    const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
+        acceptFile(event.currentTarget.files?.item(0) ?? null);
+        event.currentTarget.value = '';
+    };
+
+    const handleDrop = (event: DragEvent<HTMLLabelElement>) => {
+        event.preventDefault();
+        if (disabled || !consentAccepted) {
+            return;
+        }
+        if (event.dataTransfer.files.length > 1) {
+            setValidationError('Select only one Profile file at a time.');
+            onFileChange(null);
+            return;
+        }
+        acceptFile(event.dataTransfer.files.item(0));
+    };
+
+    return (
+        <section className="profile-analysis__uploader" 
aria-labelledby="profile-analysis-upload-title">
+            <h2 id="profile-analysis-upload-title">Upload a Query Profile</h2>
+            <p id="profile-analysis-file-help" 
className="profile-analysis__help">
+                Choose one UTF-8 .txt file up to 10 MiB after reviewing and 
accepting the notice below.
+            </p>
+
+            <div className="profile-analysis__privacy-notice" 
id="profile-analysis-privacy-notice">
+                <h3>Privacy and AI processing notice</h3>
+                <ul>
+                    <li>
+                        Your Query Profile and the fixed analysis instructions 
are sent to this service and
+                        OpenAI&apos;s model service as a third-party provider 
to generate an AI-assisted diagnosis.
+                        OpenAI&apos;s approved production-account data-region, 
use, and retention terms apply
+                        separately from this application&apos;s deletion 
policy.
+                    </li>
+                    <li>
+                        Do not upload passwords, API keys, access tokens, 
personal data, customer-confidential
+                        data, regulated data, or any content you are not 
authorized to disclose.
+                        Profiles can contain SQL literals, usernames, Query 
IDs, IP addresses, hostnames, schema
+                        names, and cluster topology; redact these values 
before uploading.
+                    </li>
+                    <li>
+                        On the application server, the normal-path workspace 
is deleted immediately after the
+                        analysis succeeds or fails. Abnormal residual 
workspaces are deleted within 1 hour.
+                    </li>
+                </ul>
+                <label className="profile-analysis__consent">
+                    <input
+                        type="checkbox"
+                        checked={consentAccepted}
+                        disabled={disabled}
+                        aria-describedby="profile-analysis-privacy-notice"
+                        onChange={event => {
+                            const accepted = event.currentTarget.checked;
+                            setConsentAccepted(accepted);
+                            if (!accepted) {
+                                resetCaptcha();
+                                onFileChange(null);
+                            }
+                        }}
+                    />
+                    I have read this notice, am authorized to upload the 
Profile, and consent to the described
+                    third-party AI processing.
+                </label>
+            </div>
+
+            <fieldset className="profile-analysis__language" 
disabled={disabled}>
+                <legend>Response language</legend>
+                <label>
+                    <input
+                        type="radio"
+                        name="profile-analysis-language"
+                        value="en"
+                        checked={language === 'en'}
+                        onChange={() => onLanguageChange('en')}
+                    />
+                    English
+                </label>
+                <label>
+                    <input
+                        type="radio"
+                        name="profile-analysis-language"
+                        value="zh-CN"
+                        checked={language === 'zh-CN'}
+                        onChange={() => onLanguageChange('zh-CN')}
+                    />
+                    Simplified Chinese
+                </label>
+            </fieldset>
+
+            <label
+                className={`profile-analysis__drop-zone${
+                    disabled || !consentAccepted ? ' 
profile-analysis__drop-zone--disabled' : ''
+                }`}
+                onDragOver={event => event.preventDefault()}
+                onDrop={handleDrop}
+            >
+                <span className="profile-analysis__drop-zone-title">Choose a 
file or drag it here</span>
+                <span className="profile-analysis__drop-zone-note">Apache 
Doris Query Profile in .txt format</span>
+                <input
+                    className="profile-analysis__file-input"
+                    type="file"
+                    accept=".txt,text/plain"
+                    aria-label="Choose an Apache Doris Query Profile file"
+                    aria-describedby="profile-analysis-file-help"
+                    disabled={disabled || !consentAccepted}
+                    onChange={handleInputChange}
+                />
+            </label>
+
+            {validationError && (
+                <div className="profile-analysis__validation-error" 
role="alert">
+                    {validationError}
+                </div>
+            )}
+
+            {file && (
+                <div className="profile-analysis__file" aria-live="polite">
+                    <span className="profile-analysis__file-name" 
title={file.name}>
+                        {file.name}
+                    </span>
+                    <span 
className="profile-analysis__file-size">{formatProfileFileSize(file.size)}</span>
+                </div>
+            )}
+
+            {consentAccepted && (
+                <div className="profile-analysis__captcha">
+                    <p id="profile-analysis-captcha-help" 
className="profile-analysis__captcha-label">
+                        Complete the human verification before starting the 
analysis.
+                    </p>
+                    {hcaptchaSiteKey ? (
+                        <HCaptcha
+                            ref={hcaptchaRef}
+                            sitekey={hcaptchaSiteKey}
+                            reCaptchaCompat={false}
+                            sentry={false}
+                            onVerify={token => {
+                                setHCaptchaToken(token);
+                                setHCaptchaError(null);
+                            }}
+                            onExpire={() => {
+                                setHCaptchaToken(null);
+                                setHCaptchaError('Verification expired. 
Complete it again.');
+                            }}
+                            onChalExpired={() => {
+                                setHCaptchaToken(null);
+                                setHCaptchaError('Verification expired. 
Complete it again.');
+                            }}
+                            onError={() => {
+                                setHCaptchaToken(null);
+                                setHCaptchaError(
+                                    'Human verification could not load. Check 
your connection and try again.',
+                                );
+                            }}
+                        />
+                    ) : (
+                        <div className="profile-analysis__validation-error" 
role="alert">
+                            Human verification is not configured. Contact the 
site administrator.
+                        </div>
+                    )}
+                    <small>
+                        This site is protected by hCaptcha and its{' '}
+                        <a
+                            href="https://www.hcaptcha.com/privacy";
+                            target="_blank"
+                            rel="noopener noreferrer"
+                        >
+                            Privacy Policy
+                        </a>{' '}
+                        and{' '}
+                        <a
+                            href="https://www.hcaptcha.com/terms";
+                            target="_blank"
+                            rel="noopener noreferrer"
+                        >
+                            Terms of Service
+                        </a>{' '}
+                        apply.
+                    </small>
+                    {hcaptchaError && (
+                        <div className="profile-analysis__validation-error" 
role="alert">
+                            {hcaptchaError}
+                        </div>
+                    )}
+                </div>
+            )}
+
+            <button
+                className="button button--primary 
profile-analysis__analyze-button"
+                type="button"
+                disabled={!file || disabled || !consentAccepted || 
!hcaptchaToken}
+                onClick={() => {
+                    if (hcaptchaToken) onAnalyze(hcaptchaToken, resetCaptcha);
+                }}
+            >
+                {disabled ? 'Processing…' : 'Analyze Profile'}
+            </button>
+        </section>
+    );
+}
diff --git a/src/components/profile-analysis/profile-analysis.api.test.js 
b/src/components/profile-analysis/profile-analysis.api.test.js
new file mode 100644
index 00000000000..6ee818a1d9d
--- /dev/null
+++ b/src/components/profile-analysis/profile-analysis.api.test.js
@@ -0,0 +1,340 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const Module = require('node:module');
+const path = require('node:path');
+const test = require('node:test');
+const { File } = require('node:buffer');
+const typescript = require('typescript');
+
+const previousTypeScriptLoader = require.extensions['.ts'];
+require.extensions['.ts'] = (module, filename) => {
+    const source = fs.readFileSync(filename, 'utf8');
+    const output = typescript.transpileModule(source, {
+        compilerOptions: {
+            module: typescript.ModuleKind.CommonJS,
+            target: typescript.ScriptTarget.ES2020,
+        },
+    }).outputText;
+    module._compile(output, filename);
+};
+
+const apiPath = path.join(__dirname, 'profile-analysis.api.ts');
+const compiledApi = typescript.transpileModule(fs.readFileSync(apiPath, 
'utf8'), {
+    compilerOptions: {
+        module: typescript.ModuleKind.CommonJS,
+        target: typescript.ScriptTarget.ES2020,
+    },
+}).outputText;
+const apiModule = new Module(apiPath, module);
+apiModule.filename = apiPath;
+apiModule.paths = Module._nodeModulePaths(path.dirname(apiPath));
+apiModule._compile(compiledApi, apiPath);
+require.extensions['.ts'] = previousTypeScriptLoader;
+
+const {
+    createAnalysisJob,
+    getAnalysisJob,
+    getAnalysisJobByClientRequestId,
+    MAX_FINAL_ANSWER_BYTES,
+    PRIVACY_NOTICE_VERSION,
+    ProfileAnalysisApiError,
+} = apiModule.exports;
+
+const clientRequestId = 'ca9ee8aa-3f47-4aab-a151-f3a39c5a6193';
+const jobId = '550e8400-e29b-41d4-a716-446655440000';
+const hcaptchaToken = 'test-hcaptcha-token';
+
+function jsonResponse(body, init = {}) {
+    return new Response(JSON.stringify(body), {
+        status: init.status ?? 200,
+        headers: { 'Content-Type': 'application/json' },
+    });
+}
+
+test('creates an analysis job with the selected file and response language', 
async t => {
+    const originalFetch = global.fetch;
+    t.after(() => {
+        global.fetch = originalFetch;
+    });
+
+    const file = new File(['Query Profile text'], 'query-profile.txt', { type: 
'text/plain' });
+
+    global.fetch = async (url, options) => {
+        assert.equal(url, 'https://agent.velodb.io/api/profile/analysis-jobs');
+        assert.equal(options.method, 'POST');
+        assert.deepEqual(options.headers, { 'Idempotency-Key': clientRequestId 
});
+        assert.equal(options.headers['Content-Type'], undefined, 'the browser 
must set the multipart boundary');
+        assert.ok(options.body instanceof FormData);
+
+        const uploadedFile = options.body.get('file');
+        assert.ok(uploadedFile);
+        assert.equal(uploadedFile.name, 'query-profile.txt');
+        assert.equal(await uploadedFile.text(), 'Query Profile text');
+        assert.equal(options.body.get('language'), 'zh-CN');
+        assert.equal(options.body.get('consent'), 'true');
+        assert.equal(options.body.get('privacyNoticeVersion'), 
PRIVACY_NOTICE_VERSION);
+        assert.equal(options.body.get('hcaptchaToken'), hcaptchaToken);
+        return new Response(JSON.stringify({ jobId, status: 'QUEUED' }), {
+            status: 202,
+            headers: { 'Content-Type': 'application/json', 'Retry-After': '3' 
},
+        });
+    };
+
+    assert.deepEqual(
+        await createAnalysisJob(
+            'https://agent.velodb.io/',
+            file,
+            'zh-CN',
+            clientRequestId,
+            hcaptchaToken,
+        ),
+        {
+        jobId,
+        status: 'QUEUED',
+        retryAfterMs: 3000,
+        },
+    );
+});
+
+test('parses queued, running, completed, and failed job snapshots', async t => 
{
+    const originalFetch = global.fetch;
+    t.after(() => { global.fetch = originalFetch; });
+    const responses = [
+        { jobId, status: 'QUEUED', jobsAhead: 3 },
+        { jobId, status: 'RUNNING' },
+        { jobId, status: 'COMPLETED', result: { id: 'item_26', type: 
'agent_message', text: 'Done' } },
+        { jobId, status: 'FAILED', error: { code: 'CODEX_EXECUTION_FAILED', 
message: 'Failed safely.' } },
+    ];
+    global.fetch = async (url, options) => {
+        assert.equal(url, `/api/profile/analysis-jobs/${jobId}`);
+        assert.equal(options.method, 'GET');
+        return jsonResponse(responses.shift());
+    };
+    assert.deepEqual(await getAnalysisJob('', jobId), { jobId, status: 
'QUEUED', jobsAhead: 3 });
+    assert.deepEqual(await getAnalysisJob('', jobId), { jobId, status: 
'RUNNING' });
+    assert.equal((await getAnalysisJob('', jobId)).status, 'COMPLETED');
+    assert.equal((await getAnalysisJob('', jobId)).status, 'FAILED');
+});
+
+test('recovers a job id from a client request id without re-uploading the 
profile', async t => {
+    const originalFetch = global.fetch;
+    t.after(() => { global.fetch = originalFetch; });
+    global.fetch = async (url, options) => {
+        assert.equal(url, 
`/api/profile/analysis-job-requests/${clientRequestId}`);
+        assert.equal(options.method, 'GET');
+        return jsonResponse({ jobId, status: 'RUNNING' });
+    };
+
+    assert.deepEqual(await getAnalysisJobByClientRequestId('', 
clientRequestId), {
+        jobId,
+        status: 'RUNNING',
+    });
+});
+
+test('preserves Retry-After on a temporary client-request recovery 404', async 
t => {
+    const originalFetch = global.fetch;
+    t.after(() => { global.fetch = originalFetch; });
+    global.fetch = async () =>
+        new Response(JSON.stringify({
+            code: 'ANALYSIS_JOB_NOT_FOUND',
+            message: 'The analysis job was not found or has expired.',
+        }), {
+            status: 404,
+            headers: { 'Content-Type': 'application/json', 'Retry-After': '1' 
},
+        });
+
+    await assert.rejects(
+        getAnalysisJobByClientRequestId('', clientRequestId),
+        error => {
+            assert.ok(error instanceof ProfileAnalysisApiError);
+            assert.equal(error.status, 404);
+            assert.equal(error.retryAfterMs, 1000);
+            return true;
+        },
+    );
+});
+
+test('accepts a terminal status when an idempotent create response is 
replayed', async t => {
+    const originalFetch = global.fetch;
+    t.after(() => { global.fetch = originalFetch; });
+    global.fetch = async () =>
+        new Response(JSON.stringify({ jobId, status: 'COMPLETED' }), {
+            status: 202,
+            headers: { 'Content-Type': 'application/json', 'Retry-After': '2' 
},
+        });
+
+    assert.equal(
+        (
+            await createAnalysisJob(
+                '',
+                new File(['profile'], 'profile.txt'),
+                'en',
+                clientRequestId,
+                hcaptchaToken,
+            )
+        ).status,
+        'COMPLETED',
+    );
+});
+
+test('preserves a structured backend error on non-2xx responses', async t => {
+    const originalFetch = global.fetch;
+    t.after(() => {
+        global.fetch = originalFetch;
+    });
+    global.fetch = async () =>
+        jsonResponse({ code: 'INVALID_PROFILE', message: 'The file is not a 
Doris profile.' }, { status: 422 });
+
+    await assert.rejects(
+        createAnalysisJob(
+            'https://agent.velodb.io',
+            new File(['bad'], 'bad.txt'),
+            'en',
+            clientRequestId,
+            hcaptchaToken,
+        ),
+        error => {
+        assert.ok(error instanceof ProfileAnalysisApiError);
+        assert.equal(error.status, 422);
+        assert.equal(error.code, 'INVALID_PROFILE');
+        assert.equal(error.message, 'The file is not a Doris profile.');
+        return true;
+        },
+    );
+});
+
+test('uses a safe fallback when an error response is not JSON', async t => {
+    const originalFetch = global.fetch;
+    t.after(() => {
+        global.fetch = originalFetch;
+    });
+    global.fetch = async () => new Response('<html>Bad gateway</html>', { 
status: 502 });
+
+    await assert.rejects(
+        createAnalysisJob(
+            '',
+            new File(['profile'], 'profile.txt'),
+            'en',
+            clientRequestId,
+            hcaptchaToken,
+        ),
+        error => {
+        assert.ok(error instanceof ProfileAnalysisApiError);
+        assert.equal(error.status, 502);
+        assert.equal(error.code, 'HTTP_ERROR');
+        assert.doesNotMatch(error.message, /<html>/);
+        return true;
+        },
+    );
+});
+
+test('rejects a successful response that is not an agent message', async t => {
+    const originalFetch = global.fetch;
+    t.after(() => {
+        global.fetch = originalFetch;
+    });
+    global.fetch = async () => jsonResponse({ jobId, status: 'unexpected' }, { 
status: 202 });
+
+    await assert.rejects(
+        createAnalysisJob(
+            '',
+            new File(['profile'], 'profile.txt'),
+            'en',
+            clientRequestId,
+            hcaptchaToken,
+        ),
+        error => {
+        assert.ok(error instanceof ProfileAnalysisApiError);
+        assert.equal(error.status, 502);
+        assert.equal(error.code, 'INVALID_SERVER_RESPONSE');
+        return true;
+        },
+    );
+});
+
+test('rejects a completed answer over the frontend UTF-8 byte limit', async t 
=> {
+    const originalFetch = global.fetch;
+    t.after(() => {
+        global.fetch = originalFetch;
+    });
+    const oversized = 'a'.repeat(MAX_FINAL_ANSWER_BYTES + 1);
+    global.fetch = async () =>
+        jsonResponse({
+            jobId,
+            status: 'COMPLETED',
+            result: { id: 'item_26', type: 'agent_message', text: oversized },
+        });
+
+    await assert.rejects(getAnalysisJob('', jobId), error => {
+        assert.ok(error instanceof ProfileAnalysisApiError);
+        assert.equal(error.code, 'INVALID_SERVER_RESPONSE');
+        return true;
+    });
+});
+
+test('rejects an API response body over the client hard limit', async t => {
+    const originalFetch = global.fetch;
+    t.after(() => {
+        global.fetch = originalFetch;
+    });
+    global.fetch = async () =>
+        new Response(JSON.stringify({ padding: 'x'.repeat(140 * 1024) }), {
+            status: 200,
+            headers: { 'Content-Type': 'application/json' },
+        });
+
+    await assert.rejects(getAnalysisJob('', jobId), error => {
+        assert.ok(error instanceof ProfileAnalysisApiError);
+        assert.equal(error.code, 'INVALID_SERVER_RESPONSE');
+        return true;
+    });
+});
+
+test('normalizes network failures into a stable API error', async t => {
+    const originalFetch = global.fetch;
+    t.after(() => {
+        global.fetch = originalFetch;
+    });
+    global.fetch = async () => {
+        throw new TypeError('fetch failed');
+    };
+
+    await assert.rejects(
+        createAnalysisJob(
+            '',
+            new File(['profile'], 'profile.txt'),
+            'en',
+            clientRequestId,
+            hcaptchaToken,
+        ),
+        error => {
+        assert.ok(error instanceof ProfileAnalysisApiError);
+        assert.equal(error.status, 0);
+        assert.equal(error.code, 'NETWORK_ERROR');
+        assert.doesNotMatch(error.message, /fetch failed/);
+        return true;
+        },
+    );
+});
+
+test('rejects a missing hCaptcha token before sending the Profile', async t => 
{
+    const originalFetch = global.fetch;
+    let fetchCalls = 0;
+    t.after(() => {
+        global.fetch = originalFetch;
+    });
+    global.fetch = async () => {
+        fetchCalls += 1;
+        throw new Error('must not be called');
+    };
+
+    await assert.rejects(
+        createAnalysisJob('', new File(['profile'], 'profile.txt'), 'en', 
clientRequestId, '  '),
+        error => {
+            assert.ok(error instanceof ProfileAnalysisApiError);
+            assert.equal(error.code, 'CAPTCHA_MISSING');
+            return true;
+        },
+    );
+    assert.equal(fetchCalls, 0);
+});
diff --git a/src/components/profile-analysis/profile-analysis.api.ts 
b/src/components/profile-analysis/profile-analysis.api.ts
new file mode 100644
index 00000000000..bf6eb7b3913
--- /dev/null
+++ b/src/components/profile-analysis/profile-analysis.api.ts
@@ -0,0 +1,247 @@
+import type {
+    AgentMessage,
+    AnalysisJobSnapshot,
+    AnalysisJobStatus,
+    ApiErrorBody,
+    CreateAnalysisJobResponse,
+    RecoveredAnalysisJobResponse,
+    ResponseLanguage,
+} from './profile-analysis.types';
+import { isUuid } from './profile-analysis.storage';
+
+const ANALYSIS_JOBS_PATH = '/api/profile/analysis-jobs';
+const ANALYSIS_JOB_REQUESTS_PATH = '/api/profile/analysis-job-requests';
+const DEFAULT_POLL_INTERVAL_MS = 2_000;
+export const PRIVACY_NOTICE_VERSION = '2026-07-22';
+export const MAX_FINAL_ANSWER_BYTES = 64 * 1024;
+const MAX_API_RESPONSE_BYTES = 128 * 1024;
+
+export class ProfileAnalysisApiError extends Error {
+    constructor(
+        public readonly status: number,
+        public readonly code: string,
+        message: string,
+        public readonly retryAfterMs?: number,
+    ) {
+        super(message);
+        this.name = 'ProfileAnalysisApiError';
+    }
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+    return typeof value === 'object' && value !== null;
+}
+
+function isAgentMessage(value: unknown): value is AgentMessage {
+    return (
+        isRecord(value) &&
+        typeof value.id === 'string' &&
+        value.id.length > 0 &&
+        new TextEncoder().encode(value.id).byteLength <= 128 &&
+        value.type === 'agent_message' &&
+        typeof value.text === 'string' &&
+        value.text.trim().length > 0 &&
+        new TextEncoder().encode(value.text).byteLength <= 
MAX_FINAL_ANSWER_BYTES
+    );
+}
+
+function isApiErrorBody(value: unknown): value is ApiErrorBody {
+    return isRecord(value) && typeof value.code === 'string' && typeof 
value.message === 'string';
+}
+
+function isJobId(value: unknown): value is string {
+    return isUuid(value);
+}
+
+function isAnalysisJobStatus(value: unknown): value is AnalysisJobStatus {
+    return value === 'QUEUED' || value === 'RUNNING' || value === 'COMPLETED' 
|| value === 'FAILED';
+}
+
+function invalidResponse(): ProfileAnalysisApiError {
+    return new ProfileAnalysisApiError(
+        502,
+        'INVALID_SERVER_RESPONSE',
+        'The profile analysis service returned an invalid response.',
+    );
+}
+
+function retryAfterMs(response: Response): number | undefined {
+    const seconds = Number(response.headers.get('Retry-After'));
+    return Number.isFinite(seconds) && seconds > 0 ? seconds * 1_000 : 
undefined;
+}
+
+function apiUrl(apiBaseUrl: string, path: string): string {
+    return `${apiBaseUrl.replace(/\/+$/, '')}${path}`;
+}
+
+async function fetchJson(url: string, init: RequestInit): Promise<{ response: 
Response; body: unknown }> {
+    let response: Response;
+    try {
+        response = await fetch(url, init);
+    } catch (error) {
+        if (isAbortError(error)) {
+            throw error;
+        }
+        throw new ProfileAnalysisApiError(
+            0,
+            'NETWORK_ERROR',
+            'Unable to reach the profile analysis service. Please try again.',
+        );
+    }
+
+    const body = await readJson(response);
+    if (!response.ok) {
+        if (isApiErrorBody(body)) {
+            throw new ProfileAnalysisApiError(
+                response.status,
+                body.code,
+                body.message,
+                retryAfterMs(response),
+            );
+        }
+        throw new ProfileAnalysisApiError(
+            response.status,
+            'HTTP_ERROR',
+            `Profile analysis failed (HTTP ${response.status}). Please try 
again.`,
+            retryAfterMs(response),
+        );
+    }
+    return { response, body };
+}
+
+async function readJson(response: Response): Promise<unknown> {
+    if (!response.body) return undefined;
+
+    try {
+        const reader = response.body.getReader();
+        const chunks: Uint8Array[] = [];
+        let totalBytes = 0;
+        while (true) {
+            const { done, value } = await reader.read();
+            if (done) break;
+            totalBytes += value.byteLength;
+            if (totalBytes > MAX_API_RESPONSE_BYTES) {
+                await reader.cancel();
+                throw invalidResponse();
+            }
+            chunks.push(value);
+        }
+
+        const bytes = new Uint8Array(totalBytes);
+        let offset = 0;
+        for (const chunk of chunks) {
+            bytes.set(chunk, offset);
+            offset += chunk.byteLength;
+        }
+        if (bytes.byteLength === 0) return undefined;
+        return JSON.parse(new TextDecoder('utf-8', { fatal: true 
}).decode(bytes));
+    } catch {
+        if (response.bodyUsed) {
+            // Do not expose parser, proxy, or upstream response details.
+        }
+        return undefined;
+    }
+}
+
+function isAbortError(error: unknown): boolean {
+    return error instanceof Error && error.name === 'AbortError';
+}
+
+export async function createAnalysisJob(
+    apiBaseUrl: string,
+    file: File,
+    language: ResponseLanguage,
+    clientRequestId: string,
+    hcaptchaToken: string,
+    signal?: AbortSignal,
+): Promise<CreateAnalysisJobResponse> {
+    if (!isUuid(clientRequestId)) throw invalidResponse();
+    if (!hcaptchaToken.trim()) {
+        throw new ProfileAnalysisApiError(
+            400,
+            'CAPTCHA_MISSING',
+            'Complete the human verification before analyzing the Profile.',
+        );
+    }
+
+    const formData = new FormData();
+    formData.append('file', file);
+    formData.append('language', language);
+    formData.append('consent', 'true');
+    formData.append('privacyNoticeVersion', PRIVACY_NOTICE_VERSION);
+    formData.append('hcaptchaToken', hcaptchaToken);
+
+    const { response, body } = await fetchJson(apiUrl(apiBaseUrl, 
ANALYSIS_JOBS_PATH), {
+        method: 'POST',
+        headers: { 'Idempotency-Key': clientRequestId },
+        body: formData,
+        signal,
+    });
+
+    if (
+        response.status !== 202 ||
+        !isRecord(body) ||
+        !isJobId(body.jobId) ||
+        !isAnalysisJobStatus(body.status)
+    ) {
+        throw invalidResponse();
+    }
+
+    const retryAfterSeconds = Number(response.headers.get('Retry-After'));
+    return {
+        jobId: body.jobId,
+        status: body.status,
+        retryAfterMs:
+            Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
+                ? retryAfterSeconds * 1_000
+                : DEFAULT_POLL_INTERVAL_MS,
+    };
+}
+
+export async function getAnalysisJobByClientRequestId(
+    apiBaseUrl: string,
+    clientRequestId: string,
+    signal?: AbortSignal,
+): Promise<RecoveredAnalysisJobResponse> {
+    if (!isUuid(clientRequestId)) throw invalidResponse();
+
+    const { body } = await fetchJson(
+        apiUrl(apiBaseUrl, 
`${ANALYSIS_JOB_REQUESTS_PATH}/${encodeURIComponent(clientRequestId)}`),
+        { method: 'GET', signal },
+    );
+    if (!isRecord(body) || !isJobId(body.jobId) || 
!isAnalysisJobStatus(body.status)) {
+        throw invalidResponse();
+    }
+    return { jobId: body.jobId, status: body.status };
+}
+
+export async function getAnalysisJob(
+    apiBaseUrl: string,
+    jobId: string,
+    signal?: AbortSignal,
+): Promise<AnalysisJobSnapshot> {
+    const { body } = await fetchJson(
+        apiUrl(apiBaseUrl, 
`${ANALYSIS_JOBS_PATH}/${encodeURIComponent(jobId)}`),
+        { method: 'GET', signal },
+    );
+
+    if (!isRecord(body) || body.jobId !== jobId) {
+        throw invalidResponse();
+    }
+
+    switch (body.status) {
+        case 'QUEUED':
+            if (!Number.isInteger(body.jobsAhead) || (body.jobsAhead as 
number) < 0) throw invalidResponse();
+            return { jobId, status: 'QUEUED', jobsAhead: body.jobsAhead as 
number };
+        case 'RUNNING':
+            return { jobId, status: 'RUNNING' };
+        case 'COMPLETED':
+            if (!isAgentMessage(body.result)) throw invalidResponse();
+            return { jobId, status: 'COMPLETED', result: body.result };
+        case 'FAILED':
+            if (!isApiErrorBody(body.error)) throw invalidResponse();
+            return { jobId, status: 'FAILED', error: body.error };
+        default:
+            throw invalidResponse();
+    }
+}
diff --git 
a/src/components/profile-analysis/profile-analysis.components.test.js 
b/src/components/profile-analysis/profile-analysis.components.test.js
new file mode 100644
index 00000000000..a6c0e1e3ee2
--- /dev/null
+++ b/src/components/profile-analysis/profile-analysis.components.test.js
@@ -0,0 +1,217 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const test = require('node:test');
+const { File } = require('node:buffer');
+const React = require('react');
+const { renderToStaticMarkup } = require('react-dom/server');
+const typescript = require('typescript');
+const hcaptchaSiteKey = '10000000-ffff-ffff-ffff-000000000001';
+
+const previousTypeScriptLoader = require.extensions['.ts'];
+const previousTsxLoader = require.extensions['.tsx'];
+const compileTypeScript = (module, filename) => {
+    const output = typescript.transpileModule(fs.readFileSync(filename, 
'utf8'), {
+        compilerOptions: {
+            esModuleInterop: true,
+            jsx: typescript.JsxEmit.React,
+            module: typescript.ModuleKind.CommonJS,
+            target: typescript.ScriptTarget.ES2020,
+        },
+    }).outputText;
+    module._compile(output, filename);
+};
+require.extensions['.ts'] = compileTypeScript;
+require.extensions['.tsx'] = compileTypeScript;
+
+const { AnalysisResult } = require('./AnalysisResult.tsx');
+const { AnalysisStatus } = require('./AnalysisStatus.tsx');
+const {
+    MAX_PROFILE_FILE_SIZE_BYTES,
+    ProfileUploader,
+    formatProfileFileSize,
+    validateProfileFile,
+} = require('./ProfileUploader.tsx');
+
+require.extensions['.ts'] = previousTypeScriptLoader;
+require.extensions['.tsx'] = previousTsxLoader;
+
+test('accepts case-insensitive txt files and rejects other types or oversized 
files', () => {
+    assert.equal(validateProfileFile(new File(['profile'], 
'query.PROFILE.TXT')), null);
+    assert.match(validateProfileFile(new File(['profile'], 'query.pdf')), 
/\.txt/);
+
+    const oversizedFile = {
+        name: 'query.txt',
+        size: MAX_PROFILE_FILE_SIZE_BYTES + 1,
+    };
+    assert.match(validateProfileFile(oversizedFile), /10 MiB/);
+});
+
+test('formats file sizes for display', () => {
+    assert.equal(formatProfileFileSize(800), '800 B');
+    assert.equal(formatProfileFileSize(1536), '1.5 KiB');
+    assert.equal(formatProfileFileSize(2 * 1024 * 1024), '2.0 MiB');
+});
+
+test('disables Analyze until a file exists and while analysis is running', () 
=> {
+    const withoutFile = renderToStaticMarkup(
+        React.createElement(ProfileUploader, {
+            file: null,
+            language: 'en',
+            disabled: false,
+            hcaptchaSiteKey,
+            onFileChange() {},
+            onLanguageChange() {},
+            onAnalyze() {},
+        }),
+    );
+    assert.match(withoutFile, /<button[^>]*disabled=""[^>]*>Analyze 
Profile<\/button>/);
+
+    const analyzing = renderToStaticMarkup(
+        React.createElement(ProfileUploader, {
+            file: new File(['profile'], 'query.txt'),
+            language: 'zh-CN',
+            disabled: true,
+            hcaptchaSiteKey,
+            onFileChange() {},
+            onLanguageChange() {},
+            onAnalyze() {},
+        }),
+    );
+    assert.match(analyzing, /<input[^>]*disabled=""/);
+    assert.match(analyzing, 
/<button[^>]*disabled=""[^>]*>Processing…<\/button>/);
+});
+
+test('requires an unchecked privacy consent and displays third-party, 
prohibited-content, and deletion notices', () => {
+    const markup = renderToStaticMarkup(
+        React.createElement(ProfileUploader, {
+            file: null,
+            language: 'en',
+            disabled: false,
+            hcaptchaSiteKey,
+            onFileChange() {},
+            onLanguageChange() {},
+            onAnalyze() {},
+        }),
+    );
+
+    assert.match(markup, /type="checkbox"/);
+    assert.doesNotMatch(markup, /type="checkbox"[^>]*checked/);
+    assert.match(markup, /OpenAI.*third-party provider/);
+    assert.match(markup, /Do not upload passwords, API keys, access tokens, 
personal data/);
+    assert.match(markup, /deleted immediately/);
+    assert.match(markup, /deleted within 1 hour/);
+    assert.match(markup, /type="file"[^>]*disabled=""/);
+});
+
+test('uses an English accessible label instead of exposing localized native 
file-input text', () => {
+    const markup = renderToStaticMarkup(
+        React.createElement(ProfileUploader, {
+            file: null,
+            language: 'en',
+            disabled: false,
+            hcaptchaSiteKey,
+            onFileChange() {},
+            onLanguageChange() {},
+            onAnalyze() {},
+        }),
+    );
+
+    assert.match(markup, /aria-label="Choose an Apache Doris Query Profile 
file"/);
+
+    const styles = fs.readFileSync(path.join(__dirname, 
'ProfileAnalysis.scss'), 'utf8');
+    assert.match(styles, /&__file-input\s*{[^}]*clip-path:\s*inset\(50%\)/s);
+});
+
+test('renders an English response-language selector with English selected by 
default', () => {
+    const markup = renderToStaticMarkup(
+        React.createElement(ProfileUploader, {
+            file: null,
+            language: 'en',
+            disabled: false,
+            hcaptchaSiteKey,
+            onFileChange() {},
+            onLanguageChange() {},
+            onAnalyze() {},
+        }),
+    );
+
+    assert.match(markup, /<legend>Response language<\/legend>/);
+    assert.match(markup, /<input[^>]*checked=""[^>]*value="en"/);
+    assert.match(markup, />English<\/label>/);
+    assert.match(markup, />Simplified Chinese<\/label>/);
+});
+
+test('exposes the waiting state to assistive technology', () => {
+    const markup = renderToStaticMarkup(React.createElement(AnalysisStatus, { 
state: 'queued', jobsAhead: 3 }));
+    assert.match(markup, /role="status"/);
+    assert.match(markup, /aria-live="polite"/);
+    assert.match(markup, /Queued · 3 jobs ahead/);
+});
+
+test('renders the page-refresh recovery state', () => {
+    const markup = renderToStaticMarkup(React.createElement(AnalysisStatus, { 
state: 'restoring', jobsAhead: null }));
+    assert.match(markup, /role="status"/);
+    assert.match(markup, /Restoring analysis…/);
+});
+
+test('renders the connection recovery state without presenting a terminal 
failure', () => {
+    const markup = renderToStaticMarkup(React.createElement(AnalysisStatus, { 
state: 'recovering', jobsAhead: null }));
+    assert.match(markup, /role="status"/);
+    assert.match(markup, /Connection interrupted · recovering analysis…/);
+});
+
+test('renders runtime Markdown while discarding raw HTML and unsafe links', () 
=> {
+    const markup = renderToStaticMarkup(
+        React.createElement(AnalysisResult, {
+            result: {
+                id: 'item_26',
+                type: 'agent_message',
+                text: [
+                    '## Conclusion',
+                    '',
+                    'Latency is dominated by **FE planning**.',
+                    '',
+                    '- Plan Time: `452 ms`',
+                    '',
+                    '| Metric | Value |',
+                    '| --- | ---: |',
+                    '| Plan Time | 452 ms |',
+                    '',
+                    '~~unverified~~',
+                    '',
+                    '<script>alert("profile")</script>',
+                    '[unsafe](javascript:alert("profile"))',
+                    '![remote](https://evil.example/pixel.png)',
+                    '[insecure](http://evil.example/path)',
+                    '[unapproved](https://evil.example/path)',
+                    '[safe](https://doris.apache.org/docs/)',
+                ].join('\n'),
+            },
+        }),
+    );
+    assert.match(markup, /<h3>Conclusion<\/h3>/);
+    assert.match(markup, /<strong>FE planning<\/strong>/);
+    assert.match(markup, /<li>Plan Time: <code>452 ms<\/code><\/li>/);
+    assert.match(markup, /<table>/);
+    assert.match(markup, /<th>Metric<\/th>/);
+    assert.match(markup, /<del>unverified<\/del>/);
+    assert.doesNotMatch(markup, /<script>/);
+    assert.doesNotMatch(markup, /alert\(&quot;profile&quot;\)/);
+    assert.doesNotMatch(markup, /href="javascript:/);
+    assert.doesNotMatch(markup, /<img/);
+    assert.doesNotMatch(markup, /href="http:\/\/evil/);
+    assert.doesNotMatch(markup, /href="https:\/\/evil/);
+    assert.match(markup, /href="https:\/\/doris.apache.org\/docs\/"/);
+    assert.match(markup, /rel="noopener noreferrer"/);
+    assert.match(markup, /AI-generated result:/);
+    assert.match(markup, /qualified engineer review/);
+    assert.match(markup, /aria-labelledby="profile-analysis-result-title"/);
+});
+
+test('the page composes the analyzer inside the Doris Layout without adding 
navigation changes', () => {
+    const pageSource = fs.readFileSync(path.join(__dirname, 
'../../pages/profile-analysis/index.tsx'), 'utf8');
+    assert.match(pageSource, /import Layout from '@theme\/Layout'/);
+    assert.match(pageSource, /<ProfileAnalyzer \/>/);
+    assert.match(pageSource, /<main className="container margin-vert--lg">/);
+});
diff --git a/src/components/profile-analysis/profile-analysis.recovery.test.js 
b/src/components/profile-analysis/profile-analysis.recovery.test.js
new file mode 100644
index 00000000000..6caa4592619
--- /dev/null
+++ b/src/components/profile-analysis/profile-analysis.recovery.test.js
@@ -0,0 +1,226 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const Module = require('node:module');
+const path = require('node:path');
+const test = require('node:test');
+const typescript = require('typescript');
+
+const previousTypeScriptLoader = require.extensions['.ts'];
+require.extensions['.ts'] = (module, filename) => {
+    const source = fs.readFileSync(filename, 'utf8');
+    const output = typescript.transpileModule(source, {
+        compilerOptions: {
+            module: typescript.ModuleKind.CommonJS,
+            target: typescript.ScriptTarget.ES2020,
+        },
+    }).outputText;
+    module._compile(output, filename);
+};
+
+const recoveryPath = path.join(__dirname, 'profile-analysis.recovery.ts');
+const compiledRecovery = 
typescript.transpileModule(fs.readFileSync(recoveryPath, 'utf8'), {
+    compilerOptions: {
+        module: typescript.ModuleKind.CommonJS,
+        target: typescript.ScriptTarget.ES2020,
+    },
+}).outputText;
+const recoveryModule = new Module(recoveryPath, module);
+recoveryModule.filename = recoveryPath;
+recoveryModule.paths = Module._nodeModulePaths(path.dirname(recoveryPath));
+recoveryModule._compile(compiledRecovery, recoveryPath);
+const { ProfileAnalysisApiError } = require('./profile-analysis.api.ts');
+require.extensions['.ts'] = previousTypeScriptLoader;
+
+const {
+    CREATE_RECOVERY_GRACE_MS,
+    createOrRecoverAnalysisJob,
+    pollAnalysisJobWithRecovery,
+    recoverAnalysisJobWithinGrace,
+    retryDelayMs,
+} = recoveryModule.exports;
+
+const jobId = '550e8400-e29b-41d4-a716-446655440000';
+
+test('recovers an accepted job after an ambiguous create without creating a 
new logical request', async () => {
+    let createCalls = 0;
+    let recoverCalls = 0;
+    let recoveringCalls = 0;
+
+    const result = await createOrRecoverAnalysisJob({
+        create: async () => {
+            createCalls += 1;
+            throw new ProfileAnalysisApiError(0, 'NETWORK_ERROR', 'network');
+        },
+        recover: async () => {
+            recoverCalls += 1;
+            return { jobId, status: 'RUNNING' };
+        },
+        wait: async () => {},
+        onRecovering: () => {
+            recoveringCalls += 1;
+        },
+        createdAt: Date.now(),
+        random: () => 0.5,
+    });
+
+    assert.deepEqual(result, { jobId, status: 'RUNNING', retryAfterMs: 2000 });
+    assert.equal(createCalls, 1);
+    assert.equal(recoverCalls, 1);
+    assert.equal(recoveringCalls, 1);
+});
+
+test('never replays create with a consumed captcha token and recovers through 
temporary 404s', async () => {
+    let createCalls = 0;
+    let recoverCalls = 0;
+    let now = 10_000;
+    const waits = [];
+
+    const result = await createOrRecoverAnalysisJob({
+        create: async () => {
+            createCalls += 1;
+            throw new ProfileAnalysisApiError(502, 'HTTP_ERROR', 'gateway');
+        },
+        recover: async () => {
+            recoverCalls += 1;
+            if (recoverCalls === 1) {
+                throw new ProfileAnalysisApiError(404, 
'ANALYSIS_JOB_NOT_FOUND', 'not found', 1000);
+            }
+            return { jobId, status: 'QUEUED' };
+        },
+        wait: async milliseconds => {
+            waits.push(milliseconds);
+            now += milliseconds;
+        },
+        onRecovering: () => {},
+        createdAt: now,
+        now: () => now,
+        random: () => 0.5,
+    });
+
+    assert.equal(result.jobId, jobId);
+    assert.equal(createCalls, 1);
+    assert.equal(recoverCalls, 2);
+    assert.deepEqual(waits, [2000]);
+});
+
+test('surfaces a captcha verifier outage without attempting job recovery', 
async () => {
+    let recoverCalls = 0;
+    await assert.rejects(
+        createOrRecoverAnalysisJob({
+            create: async () => {
+                throw new ProfileAnalysisApiError(
+                    503,
+                    'CAPTCHA_UNAVAILABLE',
+                    'Human verification is temporarily unavailable.',
+                    30_000,
+                );
+            },
+            recover: async () => {
+                recoverCalls += 1;
+                throw new Error('must not be called');
+            },
+            wait: async () => {},
+            onRecovering: () => {},
+            createdAt: Date.now(),
+        }),
+        error =>
+            error instanceof ProfileAnalysisApiError &&
+            error.code === 'CAPTCHA_UNAVAILABLE',
+    );
+    assert.equal(recoverCalls, 0);
+});
+
+test('keeps a fresh client request through temporary 404s until the job 
appears', async () => {
+    let now = 10_000;
+    let recoverCalls = 0;
+    const waits = [];
+
+    const recovered = await recoverAnalysisJobWithinGrace({
+        createdAt: now,
+        now: () => now,
+        recover: async () => {
+            recoverCalls += 1;
+            if (recoverCalls < 3) {
+                throw new ProfileAnalysisApiError(404, 
'ANALYSIS_JOB_NOT_FOUND', 'not found', 1000);
+            }
+            return { jobId, status: 'QUEUED' };
+        },
+        wait: async milliseconds => {
+            waits.push(milliseconds);
+            now += milliseconds;
+        },
+        onRecovering: () => {},
+        random: () => 0.5,
+    });
+
+    assert.deepEqual(recovered, { jobId, status: 'QUEUED' });
+    assert.equal(recoverCalls, 3);
+    assert.deepEqual(waits, [2000, 4000]);
+});
+
+test('keeps polling the same job after three transport failures and recovers 
on the fourth GET', async () => {
+    let getCalls = 0;
+    let recoveringCalls = 0;
+    const waits = [];
+    const progress = [];
+
+    const terminal = await pollAnalysisJobWithRecovery({
+        get: async () => {
+            getCalls += 1;
+            if (getCalls <= 3) {
+                throw new ProfileAnalysisApiError(503, 'HTTP_ERROR', 
'temporarily unavailable');
+            }
+            if (getCalls === 4) {
+                return { jobId, status: 'RUNNING' };
+            }
+            return {
+                jobId,
+                status: 'COMPLETED',
+                result: { id: 'item-1', type: 'agent_message', text: 'done' },
+            };
+        },
+        wait: async milliseconds => {
+            waits.push(milliseconds);
+        },
+        onRecovering: () => {
+            recoveringCalls += 1;
+        },
+        onProgress: job => {
+            progress.push(job.status);
+        },
+        pollIntervalMs: 2000,
+        random: () => 0.5,
+    });
+
+    assert.equal(terminal.status, 'COMPLETED');
+    assert.equal(getCalls, 5);
+    assert.equal(recoveringCalls, 1);
+    assert.deepEqual(progress, ['RUNNING']);
+    assert.deepEqual(waits, [2000, 4000, 8000, 2000]);
+});
+
+test('treats a recovery 404 as final after the grace window', async () => {
+    const createdAt = 20_000;
+    await assert.rejects(
+        recoverAnalysisJobWithinGrace({
+            createdAt,
+            now: () => createdAt + CREATE_RECOVERY_GRACE_MS,
+            recover: async () => {
+                throw new ProfileAnalysisApiError(404, 
'ANALYSIS_JOB_NOT_FOUND', 'not found', 1000);
+            },
+            wait: async () => {
+                assert.fail('an expired recovery record must not wait again');
+            },
+            onRecovering: () => {},
+        }),
+        error => error instanceof ProfileAnalysisApiError && error.status === 
404,
+    );
+});
+
+test('uses capped exponential retry delays with bounded jitter', () => {
+    assert.equal(retryDelayMs(1, 2000, 0.5), 2000);
+    assert.equal(retryDelayMs(2, 2000, 0.5), 4000);
+    assert.equal(retryDelayMs(10, 2000, 0.5), 30000);
+    assert.equal(retryDelayMs(1, 2000, 0), 1600);
+    assert.equal(retryDelayMs(1, 2000, 1), 2400);
+});
diff --git a/src/components/profile-analysis/profile-analysis.recovery.ts 
b/src/components/profile-analysis/profile-analysis.recovery.ts
new file mode 100644
index 00000000000..257e79b1589
--- /dev/null
+++ b/src/components/profile-analysis/profile-analysis.recovery.ts
@@ -0,0 +1,162 @@
+import { ProfileAnalysisApiError } from './profile-analysis.api';
+import type {
+    AnalysisJobSnapshot,
+    CreateAnalysisJobResponse,
+    RecoveredAnalysisJobResponse,
+} from './profile-analysis.types';
+
+export const DEFAULT_ANALYSIS_POLL_INTERVAL_MS = 2_000;
+export const CREATE_RECOVERY_GRACE_MS = 60_000;
+export const RECOVERING_FAILURE_THRESHOLD = 3;
+const MAX_RETRY_DELAY_MS = 30_000;
+
+export function isRetryableTransportFailure(reason: unknown): boolean {
+    return (
+        reason instanceof ProfileAnalysisApiError &&
+        (reason.code === 'NETWORK_ERROR' || reason.status >= 500)
+    );
+}
+
+export function retryDelayMs(
+    failureCount: number,
+    baseDelayMs = DEFAULT_ANALYSIS_POLL_INTERVAL_MS,
+    randomValue = Math.random(),
+): number {
+    const exponent = Math.max(0, Math.min(failureCount - 1, 10));
+    const uncappedDelay = baseDelayMs * 2 ** exponent;
+    const cappedDelay = Math.min(uncappedDelay, MAX_RETRY_DELAY_MS);
+    const jitterMultiplier = 0.8 + Math.min(1, Math.max(0, randomValue)) * 0.4;
+    return Math.max(1, Math.round(cappedDelay * jitterMultiplier));
+}
+
+interface CreateOrRecoverOperations {
+    create(): Promise<CreateAnalysisJobResponse>;
+    recover(): Promise<RecoveredAnalysisJobResponse>;
+    wait(milliseconds: number): Promise<void>;
+    onRecovering(): void;
+    createdAt: number;
+    now?: () => number;
+    random?: () => number;
+}
+
+export async function createOrRecoverAnalysisJob(
+    operations: CreateOrRecoverOperations,
+): Promise<CreateAnalysisJobResponse> {
+    try {
+        return await operations.create();
+    } catch (createFailure) {
+        // hCaptcha tokens are single-use. A verifier outage is authoritative 
and
+        // retrying/recovering would hide the actionable backend error.
+        if (
+            createFailure instanceof ProfileAnalysisApiError &&
+            createFailure.code === 'CAPTCHA_UNAVAILABLE'
+        ) {
+            throw createFailure;
+        }
+        if (!isRetryableTransportFailure(createFailure)) throw createFailure;
+    }
+
+    // The POST may have reached Spring even when the browser did not receive 
the
+    // response. Never replay it with the consumed hCaptcha token. Resolve the
+    // original Idempotency-Key through the read-only recovery endpoint 
instead.
+    operations.onRecovering();
+    const recovered = await recoverAnalysisJobWithinGrace({
+        recover: operations.recover,
+        wait: operations.wait,
+        onRecovering: operations.onRecovering,
+        createdAt: operations.createdAt,
+        now: operations.now,
+        random: operations.random,
+    });
+    return {
+        ...recovered,
+        retryAfterMs: DEFAULT_ANALYSIS_POLL_INTERVAL_MS,
+    };
+}
+
+interface RecoverWithinGraceOperations {
+    recover(): Promise<RecoveredAnalysisJobResponse>;
+    wait(milliseconds: number): Promise<void>;
+    onRecovering(): void;
+    createdAt: number;
+    now?: () => number;
+    random?: () => number;
+}
+
+export async function recoverAnalysisJobWithinGrace(
+    operations: RecoverWithinGraceOperations,
+): Promise<RecoveredAnalysisJobResponse> {
+    let failureCount = 0;
+    const now = operations.now ?? Date.now;
+
+    while (true) {
+        try {
+            return await operations.recover();
+        } catch (reason) {
+            const isNotFound =
+                reason instanceof ProfileAnalysisApiError &&
+                reason.status === 404;
+            if (!isNotFound && !isRetryableTransportFailure(reason)) throw 
reason;
+
+            if (isNotFound && now() - operations.createdAt >= 
CREATE_RECOVERY_GRACE_MS) {
+                throw reason;
+            }
+
+            failureCount += 1;
+            operations.onRecovering();
+            const retryAfter =
+                reason instanceof ProfileAnalysisApiError
+                    ? reason.retryAfterMs
+                    : undefined;
+            await operations.wait(
+                Math.max(
+                    retryAfter ?? 0,
+                    retryDelayMs(
+                        failureCount,
+                        DEFAULT_ANALYSIS_POLL_INTERVAL_MS,
+                        operations.random?.(),
+                    ),
+                ),
+            );
+        }
+    }
+}
+
+interface PollWithRecoveryOperations {
+    get(): Promise<AnalysisJobSnapshot>;
+    wait(milliseconds: number): Promise<void>;
+    onRecovering(): void;
+    onProgress(job: Extract<AnalysisJobSnapshot, { status: 'QUEUED' | 
'RUNNING' }>): void;
+    pollIntervalMs: number;
+    random?: () => number;
+}
+
+export async function pollAnalysisJobWithRecovery(
+    operations: PollWithRecoveryOperations,
+): Promise<Extract<AnalysisJobSnapshot, { status: 'COMPLETED' | 'FAILED' }>> {
+    let consecutiveFailures = 0;
+    while (true) {
+        try {
+            const job = await operations.get();
+            consecutiveFailures = 0;
+            if (job.status === 'COMPLETED' || job.status === 'FAILED') {
+                return job;
+            }
+            operations.onProgress(job);
+            await operations.wait(operations.pollIntervalMs);
+        } catch (reason) {
+            if (!isRetryableTransportFailure(reason)) throw reason;
+            consecutiveFailures += 1;
+            if (consecutiveFailures >= RECOVERING_FAILURE_THRESHOLD) {
+                operations.onRecovering();
+            }
+            await operations.wait(
+                retryDelayMs(
+                    consecutiveFailures,
+                    operations.pollIntervalMs,
+                    operations.random?.(),
+                ),
+            );
+        }
+    }
+}
diff --git a/src/components/profile-analysis/profile-analysis.storage.test.js 
b/src/components/profile-analysis/profile-analysis.storage.test.js
new file mode 100644
index 00000000000..82fb37e0d5f
--- /dev/null
+++ b/src/components/profile-analysis/profile-analysis.storage.test.js
@@ -0,0 +1,95 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const Module = require('node:module');
+const path = require('node:path');
+const test = require('node:test');
+const typescript = require('typescript');
+
+const storagePath = path.join(__dirname, 'profile-analysis.storage.ts');
+const compiledStorage = 
typescript.transpileModule(fs.readFileSync(storagePath, 'utf8'), {
+    compilerOptions: {
+        module: typescript.ModuleKind.CommonJS,
+        target: typescript.ScriptTarget.ES2020,
+    },
+}).outputText;
+const storageModule = new Module(storagePath, module);
+storageModule.filename = storagePath;
+storageModule.paths = Module._nodeModulePaths(path.dirname(storagePath));
+storageModule._compile(compiledStorage, storagePath);
+
+const {
+    ACTIVE_ANALYSIS_STORAGE_KEY,
+    clearStoredAnalysisJob,
+    parseStoredAnalysisJob,
+    readStoredAnalysisJob,
+    writeStoredAnalysisJob,
+} = storageModule.exports;
+
+const now = Date.now();
+const validRecord = {
+    version: 1,
+    clientRequestId: 'ca9ee8aa-3f47-4aab-a151-f3a39c5a6193',
+    jobId: '550e8400-e29b-41d4-a716-446655440000',
+    createdAt: now,
+    fileName: 'query-profile.txt',
+    language: 'zh-CN',
+};
+
+function memorySessionStorage() {
+    const values = new Map();
+    return {
+        getItem(key) {
+            return values.has(key) ? values.get(key) : null;
+        },
+        setItem(key, value) {
+            values.set(key, String(value));
+        },
+        removeItem(key) {
+            values.delete(key);
+        },
+    };
+}
+
+test('accepts strictly validated recovery metadata without profile contents', 
() => {
+    assert.deepEqual(parseStoredAnalysisJob(JSON.stringify(validRecord), now), 
validRecord);
+    assert.equal(parseStoredAnalysisJob('not-json', now), null);
+    assert.equal(parseStoredAnalysisJob(JSON.stringify({ ...validRecord, 
jobId: '../../secret' }), now), null);
+    assert.equal(parseStoredAnalysisJob(JSON.stringify({ ...validRecord, 
language: 'unknown' }), now), null);
+    assert.equal(parseStoredAnalysisJob(JSON.stringify({ ...validRecord, 
profileText: 'secret profile' }), now), null);
+    assert.equal(
+        parseStoredAnalysisJob(JSON.stringify({ ...validRecord, createdAt: now 
- 25 * 60 * 60 * 1_000 }), now),
+        null,
+    );
+});
+
+test('writes, reads, and clears only the active recovery record', t => {
+    const previousWindow = global.window;
+    const sessionStorage = memorySessionStorage();
+    global.window = { sessionStorage };
+    t.after(() => {
+        global.window = previousWindow;
+    });
+
+    assert.equal(writeStoredAnalysisJob(validRecord), true);
+    
assert.equal(sessionStorage.getItem(ACTIVE_ANALYSIS_STORAGE_KEY).includes('Query
 Profile text'), false);
+    assert.deepEqual(readStoredAnalysisJob(now), { available: true, record: 
validRecord });
+    assert.equal(clearStoredAnalysisJob(), true);
+    assert.deepEqual(readStoredAnalysisJob(now), { available: true, record: 
null });
+});
+
+test('degrades safely when session storage is unavailable', t => {
+    const previousWindow = global.window;
+    global.window = {};
+    Object.defineProperty(global.window, 'sessionStorage', {
+        get() {
+            throw new Error('storage blocked');
+        },
+    });
+    t.after(() => {
+        global.window = previousWindow;
+    });
+
+    assert.deepEqual(readStoredAnalysisJob(now), { available: false, record: 
null });
+    assert.equal(writeStoredAnalysisJob(validRecord), false);
+    assert.equal(clearStoredAnalysisJob(), false);
+});
diff --git a/src/components/profile-analysis/profile-analysis.storage.ts 
b/src/components/profile-analysis/profile-analysis.storage.ts
new file mode 100644
index 00000000000..cbdb13ebc8a
--- /dev/null
+++ b/src/components/profile-analysis/profile-analysis.storage.ts
@@ -0,0 +1,124 @@
+import type { ResponseLanguage } from './profile-analysis.types';
+
+export const ACTIVE_ANALYSIS_STORAGE_KEY = 'profile-analysis.active-job.v1';
+export const ACTIVE_ANALYSIS_STORAGE_VERSION = 1;
+
+const MAX_RECOVERY_RECORD_AGE_MS = 24 * 60 * 60 * 1_000;
+const MAX_CLOCK_SKEW_MS = 5 * 60 * 1_000;
+const MAX_FILE_NAME_LENGTH = 255;
+const UUID_PATTERN = 
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+const ALLOWED_STORAGE_FIELDS = new Set([
+    'version',
+    'clientRequestId',
+    'jobId',
+    'createdAt',
+    'fileName',
+    'language',
+]);
+
+export interface StoredAnalysisJob {
+    version: 1;
+    clientRequestId: string;
+    jobId?: string;
+    createdAt: number;
+    fileName?: string;
+    language: ResponseLanguage;
+}
+
+export interface StoredAnalysisJobReadResult {
+    available: boolean;
+    record: StoredAnalysisJob | null;
+}
+
+function browserSessionStorage(): Storage | null {
+    if (typeof window === 'undefined') return null;
+    return window.sessionStorage;
+}
+
+export function isUuid(value: unknown): value is string {
+    return typeof value === 'string' && UUID_PATTERN.test(value);
+}
+
+export function createClientRequestId(): string {
+    const randomUuid = globalThis.crypto?.randomUUID;
+    if (typeof randomUuid !== 'function') {
+        throw new Error('This browser cannot create a secure analysis request 
identifier.');
+    }
+    return randomUuid.call(globalThis.crypto);
+}
+
+export function readStoredAnalysisJob(now = Date.now()): 
StoredAnalysisJobReadResult {
+    let storage: Storage | null;
+    try {
+        storage = browserSessionStorage();
+        if (!storage) return { available: false, record: null };
+        const raw = storage.getItem(ACTIVE_ANALYSIS_STORAGE_KEY);
+        if (raw === null) return { available: true, record: null };
+
+        const record = parseStoredAnalysisJob(raw, now);
+        if (!record) storage.removeItem(ACTIVE_ANALYSIS_STORAGE_KEY);
+        return { available: true, record };
+    } catch {
+        return { available: false, record: null };
+    }
+}
+
+export function writeStoredAnalysisJob(record: StoredAnalysisJob): boolean {
+    if (!isStoredAnalysisJob(record, Date.now())) return false;
+    try {
+        const storage = browserSessionStorage();
+        if (!storage) return false;
+        storage.setItem(ACTIVE_ANALYSIS_STORAGE_KEY, JSON.stringify(record));
+        return true;
+    } catch {
+        return false;
+    }
+}
+
+export function clearStoredAnalysisJob(): boolean {
+    try {
+        const storage = browserSessionStorage();
+        if (!storage) return false;
+        storage.removeItem(ACTIVE_ANALYSIS_STORAGE_KEY);
+        return true;
+    } catch {
+        return false;
+    }
+}
+
+export function parseStoredAnalysisJob(raw: string, now = Date.now()): 
StoredAnalysisJob | null {
+    try {
+        const value: unknown = JSON.parse(raw);
+        return isStoredAnalysisJob(value, now) ? value : null;
+    } catch {
+        return null;
+    }
+}
+
+function isStoredAnalysisJob(value: unknown, now: number): value is 
StoredAnalysisJob {
+    if (typeof value !== 'object' || value === null) return false;
+    const record = value as Record<string, unknown>;
+    if (Object.keys(record).some(key => !ALLOWED_STORAGE_FIELDS.has(key))) 
return false;
+    if (
+        record.version !== ACTIVE_ANALYSIS_STORAGE_VERSION ||
+        !isUuid(record.clientRequestId) ||
+        !Number.isInteger(record.createdAt) ||
+        (record.createdAt as number) <= 0 ||
+        (record.createdAt as number) > now + MAX_CLOCK_SKEW_MS ||
+        now - (record.createdAt as number) > MAX_RECOVERY_RECORD_AGE_MS ||
+        (record.language !== 'en' && record.language !== 'zh-CN')
+    ) {
+        return false;
+    }
+    if (record.jobId !== undefined && !isUuid(record.jobId)) return false;
+    if (
+        record.fileName !== undefined &&
+        (typeof record.fileName !== 'string' ||
+            record.fileName.length === 0 ||
+            record.fileName.length > MAX_FILE_NAME_LENGTH ||
+            /[/\\\u0000-\u001f\u007f]/.test(record.fileName))
+    ) {
+        return false;
+    }
+    return true;
+}
diff --git a/src/components/profile-analysis/profile-analysis.types.ts 
b/src/components/profile-analysis/profile-analysis.types.ts
new file mode 100644
index 00000000000..32f7559fcc1
--- /dev/null
+++ b/src/components/profile-analysis/profile-analysis.types.ts
@@ -0,0 +1,42 @@
+export interface AgentMessage {
+    id: string;
+    type: 'agent_message';
+    text: string;
+}
+
+export interface ApiErrorBody {
+    code: string;
+    message: string;
+}
+
+export type ResponseLanguage = 'en' | 'zh-CN';
+
+export type AnalysisState =
+    | 'restoring'
+    | 'recovering'
+    | 'idle'
+    | 'ready'
+    | 'submitting'
+    | 'queued'
+    | 'analyzing'
+    | 'completed'
+    | 'failed';
+
+export type AnalysisJobStatus = 'QUEUED' | 'RUNNING' | 'COMPLETED' | 'FAILED';
+
+export interface CreateAnalysisJobResponse {
+    jobId: string;
+    status: AnalysisJobStatus;
+    retryAfterMs: number;
+}
+
+export interface RecoveredAnalysisJobResponse {
+    jobId: string;
+    status: AnalysisJobStatus;
+}
+
+export type AnalysisJobSnapshot =
+    | { jobId: string; status: 'QUEUED'; jobsAhead: number }
+    | { jobId: string; status: 'RUNNING' }
+    | { jobId: string; status: 'COMPLETED'; result: AgentMessage }
+    | { jobId: string; status: 'FAILED'; error: ApiErrorBody };
diff --git a/src/components/profile-analysis/use-profile-analysis.test.js 
b/src/components/profile-analysis/use-profile-analysis.test.js
new file mode 100644
index 00000000000..f4d6dfd991c
--- /dev/null
+++ b/src/components/profile-analysis/use-profile-analysis.test.js
@@ -0,0 +1,196 @@
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const typescript = require('typescript');
+
+const previousTypeScriptLoader = require.extensions['.ts'];
+require.extensions['.ts'] = (module, filename) => {
+    const source = require('node:fs').readFileSync(filename, 'utf8');
+    const output = typescript.transpileModule(source, {
+        compilerOptions: {
+            esModuleInterop: true,
+            module: typescript.ModuleKind.CommonJS,
+            target: typescript.ScriptTarget.ES2020,
+        },
+    }).outputText;
+    module._compile(output, filename);
+};
+
+const {
+    getProfileAnalysisErrorMessage,
+    initialProfileAnalysisSnapshot,
+    profileAnalysisReducer,
+} = require('./use-profile-analysis.ts');
+const { ProfileAnalysisApiError } = require('./profile-analysis.api.ts');
+
+require.extensions['.ts'] = previousTypeScriptLoader;
+
+const firstFile = { name: 'first.txt' };
+const secondFile = { name: 'second.txt' };
+const result = { id: 'item_26', type: 'agent_message', text: 'Final diagnosis' 
};
+const idleSnapshot = profileAnalysisReducer(initialProfileAnalysisSnapshot, { 
type: 'restore_empty' });
+
+test('moves from idle through ready, analyzing, and completed', () => {
+    const ready = profileAnalysisReducer(idleSnapshot, { type: 'select', file: 
firstFile });
+    assert.deepEqual(ready, {
+        state: 'ready',
+        file: firstFile,
+        language: 'en',
+        jobId: null,
+        jobsAhead: null,
+        result: null,
+        error: null,
+        recoveryWarning: null,
+    });
+
+    const submitting = profileAnalysisReducer(ready, { type: 'start' });
+    assert.equal(submitting.state, 'submitting');
+    const queued = profileAnalysisReducer(submitting, { type: 'job_created', 
jobId: 'job-1', status: 'QUEUED' });
+    const analyzing = profileAnalysisReducer(queued, {
+        type: 'job_status', job: { jobId: 'job-1', status: 'RUNNING' },
+    });
+
+    const completed = profileAnalysisReducer(analyzing, { type: 'complete', 
result });
+    assert.equal(completed.state, 'completed');
+    assert.equal(completed.result, result);
+    assert.equal(completed.error, null);
+});
+
+test('does not start without a file or replace a file while analyzing', () => {
+    assert.equal(profileAnalysisReducer(idleSnapshot, { type: 'start' }), 
idleSnapshot);
+
+    const ready = profileAnalysisReducer(idleSnapshot, { type: 'select', file: 
firstFile });
+    const analyzing = profileAnalysisReducer(ready, { type: 'start' });
+    assert.equal(profileAnalysisReducer(analyzing, { type: 'select', file: 
secondFile }), analyzing);
+});
+
+test('stores failures and clears the old result and error when a new file is 
selected', () => {
+    const completed = {
+        state: 'completed',
+        file: firstFile,
+        language: 'en',
+        jobId: 'old-job',
+        jobsAhead: null,
+        result,
+        error: null,
+        recoveryWarning: null,
+    };
+    const failed = profileAnalysisReducer(completed, { type: 'fail', error: 
'Analyzer unavailable' });
+    assert.deepEqual(failed, {
+        state: 'failed',
+        file: firstFile,
+        language: 'en',
+        jobId: 'old-job',
+        jobsAhead: null,
+        result: null,
+        error: 'Analyzer unavailable',
+        recoveryWarning: null,
+    });
+
+    const next = profileAnalysisReducer(failed, { type: 'select', file: 
secondFile });
+    assert.deepEqual(next, {
+        state: 'ready',
+        file: secondFile,
+        language: 'en',
+        jobId: null,
+        jobsAhead: null,
+        result: null,
+        error: null,
+        recoveryWarning: null,
+    });
+});
+
+test('stores response language per request, clears stale output, and freezes 
it while analyzing', () => {
+    const completed = {
+        state: 'completed',
+        file: firstFile,
+        language: 'en',
+        jobId: 'old-job',
+        jobsAhead: null,
+        result,
+        error: null,
+        recoveryWarning: null,
+    };
+    const chinese = profileAnalysisReducer(completed, { type: 'set_language', 
language: 'zh-CN' });
+    assert.deepEqual(chinese, {
+        state: 'ready',
+        file: firstFile,
+        language: 'zh-CN',
+        jobId: null,
+        jobsAhead: null,
+        result: null,
+        error: null,
+        recoveryWarning: null,
+    });
+
+    const analyzing = profileAnalysisReducer(chinese, { type: 'start' });
+    assert.equal(
+        profileAnalysisReducer(analyzing, { type: 'set_language', language: 
'en' }),
+        analyzing,
+    );
+});
+
+test('restores persisted job metadata before polling resumes', () => {
+    const restoring = profileAnalysisReducer(initialProfileAnalysisSnapshot, {
+        type: 'restore_record',
+        jobId: '550e8400-e29b-41d4-a716-446655440000',
+        language: 'zh-CN',
+    });
+    assert.equal(restoring.state, 'restoring');
+    assert.equal(restoring.file, null);
+    assert.equal(restoring.language, 'zh-CN');
+    assert.equal(restoring.jobId, '550e8400-e29b-41d4-a716-446655440000');
+
+    const running = profileAnalysisReducer(restoring, {
+        type: 'job_status',
+        job: {
+            jobId: '550e8400-e29b-41d4-a716-446655440000',
+            status: 'RUNNING',
+        },
+    });
+    assert.equal(running.state, 'analyzing');
+});
+
+test('warns when session storage is unavailable without failing the analysis 
state', () => {
+    const warned = profileAnalysisReducer(idleSnapshot, { type: 
'storage_unavailable' });
+    assert.equal(warned.state, 'idle');
+    assert.match(warned.recoveryWarning, /cannot be restored after a page 
refresh/);
+});
+
+test('keeps an uncertain analysis busy while its original identifiers are 
recovered', () => {
+    const running = {
+        state: 'analyzing',
+        file: firstFile,
+        language: 'en',
+        jobId: '550e8400-e29b-41d4-a716-446655440000',
+        jobsAhead: null,
+        result: null,
+        error: null,
+        recoveryWarning: null,
+    };
+    const recovering = profileAnalysisReducer(running, { type: 'recovering' });
+    assert.equal(recovering.state, 'recovering');
+    assert.equal(recovering.jobId, running.jobId);
+    assert.equal(recovering.file, firstFile);
+    assert.equal(profileAnalysisReducer(recovering, { type: 'start' }), 
recovering);
+    assert.equal(profileAnalysisReducer(recovering, { type: 'select', file: 
secondFile }), recovering);
+});
+
+test('normalizes unknown failures without exposing non-error values', () => {
+    assert.equal(getProfileAnalysisErrorMessage(new Error('Backend timed 
out')), 'Backend timed out');
+    assert.equal(getProfileAnalysisErrorMessage({ secret: 'internal detail' 
}), 'Profile analysis failed. Please try again.');
+});
+
+test('maps hCaptcha backend errors to actionable messages', () => {
+    assert.match(
+        getProfileAnalysisErrorMessage(
+            new ProfileAnalysisApiError(403, 'CAPTCHA_INVALID', 'backend 
detail'),
+        ),
+        /failed or expired/,
+    );
+    assert.match(
+        getProfileAnalysisErrorMessage(
+            new ProfileAnalysisApiError(503, 'CAPTCHA_UNAVAILABLE', 'backend 
detail'),
+        ),
+        /temporarily unavailable/,
+    );
+});
diff --git a/src/components/profile-analysis/use-profile-analysis.ts 
b/src/components/profile-analysis/use-profile-analysis.ts
new file mode 100644
index 00000000000..53cf446e588
--- /dev/null
+++ b/src/components/profile-analysis/use-profile-analysis.ts
@@ -0,0 +1,445 @@
+import { useCallback, useEffect, useReducer, useRef } from 'react';
+import {
+    createAnalysisJob,
+    getAnalysisJob,
+    getAnalysisJobByClientRequestId,
+    ProfileAnalysisApiError,
+} from './profile-analysis.api';
+import {
+    clearStoredAnalysisJob,
+    createClientRequestId,
+    readStoredAnalysisJob,
+    type StoredAnalysisJob,
+    writeStoredAnalysisJob,
+} from './profile-analysis.storage';
+import {
+    createOrRecoverAnalysisJob,
+    DEFAULT_ANALYSIS_POLL_INTERVAL_MS,
+    pollAnalysisJobWithRecovery,
+    recoverAnalysisJobWithinGrace,
+} from './profile-analysis.recovery';
+import type {
+    AgentMessage,
+    AnalysisJobSnapshot,
+    AnalysisJobStatus,
+    AnalysisState,
+    ResponseLanguage,
+} from './profile-analysis.types';
+
+const STORAGE_UNAVAILABLE_WARNING = 'This analysis cannot be restored after a 
page refresh in this browser.';
+
+interface ProfileAnalysisSnapshot {
+    state: AnalysisState;
+    file: File | null;
+    language: ResponseLanguage;
+    jobId: string | null;
+    jobsAhead: number | null;
+    result: AgentMessage | null;
+    error: string | null;
+    recoveryWarning: string | null;
+}
+
+type ProfileAnalysisAction =
+    | { type: 'restore_empty' }
+    | { type: 'restore_record'; jobId: string | null; language: 
ResponseLanguage }
+    | { type: 'recovering' }
+    | { type: 'storage_unavailable' }
+    | { type: 'select'; file: File | null }
+    | { type: 'set_language'; language: ResponseLanguage }
+    | { type: 'start' }
+    | { type: 'job_created'; jobId: string; status: AnalysisJobStatus }
+    | { type: 'job_status'; job: AnalysisJobSnapshot }
+    | { type: 'complete'; result: AgentMessage }
+    | { type: 'fail'; error: string };
+
+export const initialProfileAnalysisSnapshot: ProfileAnalysisSnapshot = {
+    state: 'restoring',
+    file: null,
+    language: 'en',
+    jobId: null,
+    jobsAhead: null,
+    result: null,
+    error: null,
+    recoveryWarning: null,
+};
+
+export function profileAnalysisReducer(
+    snapshot: ProfileAnalysisSnapshot,
+    action: ProfileAnalysisAction,
+): ProfileAnalysisSnapshot {
+    switch (action.type) {
+        case 'restore_empty':
+            return snapshot.state === 'restoring' ? { ...snapshot, state: 
'idle' } : snapshot;
+        case 'restore_record':
+            return {
+                ...snapshot,
+                state: 'restoring',
+                file: null,
+                language: action.language,
+                jobId: action.jobId,
+                jobsAhead: null,
+                result: null,
+                error: null,
+            };
+        case 'recovering':
+            return {
+                ...snapshot,
+                state: 'recovering',
+                jobsAhead: null,
+                error: null,
+            };
+        case 'storage_unavailable':
+            return { ...snapshot, recoveryWarning: STORAGE_UNAVAILABLE_WARNING 
};
+        case 'select':
+            if (isBusy(snapshot.state)) {
+                return snapshot;
+            }
+            return {
+                state: action.file ? 'ready' : 'idle',
+                file: action.file,
+                language: snapshot.language,
+                result: null,
+                error: null,
+                jobId: null,
+                jobsAhead: null,
+                recoveryWarning: snapshot.recoveryWarning,
+            };
+        case 'set_language':
+            if (isBusy(snapshot.state)) {
+                return snapshot;
+            }
+            return {
+                ...snapshot,
+                language: action.language,
+                state: snapshot.file ? 'ready' : 'idle',
+                jobId: null,
+                jobsAhead: null,
+                result: null,
+                error: null,
+            };
+        case 'start':
+            if (!snapshot.file || isBusy(snapshot.state)) {
+                return snapshot;
+            }
+            return {
+                ...snapshot,
+                state: 'submitting',
+                jobId: null,
+                jobsAhead: null,
+                result: null,
+                error: null,
+            };
+        case 'job_created':
+            return {
+                ...snapshot,
+                // A replayed idempotent POST may report a terminal status 
without
+                // carrying the result body. The authoritative GET below 
resolves it.
+                state: action.status === 'QUEUED' ? 'queued' : 'analyzing',
+                jobId: action.jobId,
+                jobsAhead: null,
+            };
+        case 'job_status':
+            if (action.job.status === 'QUEUED') {
+                return {
+                    ...snapshot,
+                    state: 'queued',
+                    jobId: action.job.jobId,
+                    jobsAhead: action.job.jobsAhead,
+                };
+            }
+            if (action.job.status === 'RUNNING') {
+                return { ...snapshot, state: 'analyzing', jobId: 
action.job.jobId, jobsAhead: null };
+            }
+            return snapshot;
+        case 'complete':
+            return {
+                ...snapshot,
+                state: 'completed',
+                result: action.result,
+                error: null,
+                jobsAhead: null,
+            };
+        case 'fail':
+            return {
+                ...snapshot,
+                state: 'failed',
+                result: null,
+                error: action.error,
+                jobsAhead: null,
+            };
+    }
+}
+
+function isBusy(state: AnalysisState): boolean {
+    return (
+        state === 'restoring' ||
+        state === 'recovering' ||
+        state === 'submitting' ||
+        state === 'queued' ||
+        state === 'analyzing'
+    );
+}
+
+function wait(milliseconds: number, signal: AbortSignal): Promise<void> {
+    return new Promise((resolve, reject) => {
+        const handleAbort = () => {
+            window.clearTimeout(timeout);
+            reject(new DOMException('The operation was aborted.', 
'AbortError'));
+        };
+        const timeout = window.setTimeout(() => {
+            signal.removeEventListener('abort', handleAbort);
+            resolve();
+        }, milliseconds);
+        signal.addEventListener('abort', handleAbort, { once: true });
+    });
+}
+
+function isAbortError(reason: unknown): boolean {
+    return reason instanceof Error && reason.name === 'AbortError';
+}
+
+export function getProfileAnalysisErrorMessage(reason: unknown): string {
+    if (reason instanceof ProfileAnalysisApiError) {
+        switch (reason.code) {
+            case 'CAPTCHA_MISSING':
+                return 'Complete the human verification before analyzing the 
Profile.';
+            case 'CAPTCHA_INVALID':
+                return 'Human verification failed or expired. Complete it 
again and retry.';
+            case 'CAPTCHA_UNAVAILABLE':
+                return 'Human verification is temporarily unavailable. Please 
try again later.';
+        }
+    }
+    if (reason instanceof Error) {
+        return reason.message;
+    }
+    return 'Profile analysis failed. Please try again.';
+}
+
+export function useProfileAnalysis(apiBaseUrl: string) {
+    const [snapshot, dispatch] = useReducer(profileAnalysisReducer, 
initialProfileAnalysisSnapshot);
+    const abortControllerRef = useRef<AbortController | null>(null);
+    const mountedRef = useRef(true);
+
+    const releaseController = useCallback((controller: AbortController) => {
+        if (abortControllerRef.current === controller) {
+            abortControllerRef.current = null;
+        }
+    }, []);
+
+    const pollJob = useCallback(
+        async (jobId: string, pollIntervalMs: number, controller: 
AbortController): Promise<void> => {
+            const terminal = await pollAnalysisJobWithRecovery({
+                get: () => getAnalysisJob(apiBaseUrl, jobId, 
controller.signal),
+                wait: milliseconds => wait(milliseconds, controller.signal),
+                onRecovering: () => {
+                    if (mountedRef.current && abortControllerRef.current === 
controller) {
+                        dispatch({ type: 'recovering' });
+                    }
+                },
+                onProgress: job => {
+                    if (mountedRef.current && abortControllerRef.current === 
controller) {
+                        dispatch({ type: 'job_status', job });
+                    }
+                },
+                pollIntervalMs,
+            });
+            if (!mountedRef.current || abortControllerRef.current !== 
controller) return;
+            if (terminal.status === 'COMPLETED') {
+                dispatch({ type: 'complete', result: terminal.result });
+            } else {
+                dispatch({ type: 'fail', error: terminal.error.message });
+            }
+        },
+        [apiBaseUrl],
+    );
+
+    const selectFile = useCallback((file: File | null) => {
+        if (abortControllerRef.current) {
+            return;
+        }
+        clearStoredAnalysisJob();
+        dispatch({ type: 'select', file });
+    }, []);
+
+    const setLanguage = useCallback((language: ResponseLanguage) => {
+        if (abortControllerRef.current) {
+            return;
+        }
+        clearStoredAnalysisJob();
+        dispatch({ type: 'set_language', language });
+    }, []);
+
+    const analyze = useCallback(async (hcaptchaToken: string, resetCaptcha: () 
=> void) => {
+        if (!snapshot.file || abortControllerRef.current || 
!hcaptchaToken.trim()) {
+            return;
+        }
+
+        const controller = new AbortController();
+        abortControllerRef.current = controller;
+        dispatch({ type: 'start' });
+
+        try {
+            const clientRequestId = createClientRequestId();
+            const recoveryRecord: StoredAnalysisJob = {
+                version: 1,
+                clientRequestId,
+                createdAt: Date.now(),
+                fileName: snapshot.file.name,
+                language: snapshot.language,
+            };
+            if (!writeStoredAnalysisJob(recoveryRecord)) {
+                dispatch({ type: 'storage_unavailable' });
+            }
+
+            let captchaReset = false;
+            const resetCaptchaAfterCreateAttempt = () => {
+                if (captchaReset) return;
+                captchaReset = true;
+                resetCaptcha();
+            };
+            const created = await createOrRecoverAnalysisJob({
+                create: async () => {
+                    try {
+                        return await createAnalysisJob(
+                            apiBaseUrl,
+                            snapshot.file as File,
+                            snapshot.language,
+                            clientRequestId,
+                            hcaptchaToken,
+                            controller.signal,
+                        );
+                    } finally {
+                        // Reset as soon as the single POST settles. Recovery 
and job
+                        // polling may continue for minutes and do not use 
this token.
+                        resetCaptchaAfterCreateAttempt();
+                    }
+                },
+                recover: () =>
+                    getAnalysisJobByClientRequestId(
+                        apiBaseUrl,
+                        clientRequestId,
+                        controller.signal,
+                    ),
+                wait: milliseconds => wait(milliseconds, controller.signal),
+                onRecovering: () => {
+                    if (mountedRef.current && abortControllerRef.current === 
controller) {
+                        dispatch({ type: 'recovering' });
+                    }
+                },
+                createdAt: recoveryRecord.createdAt,
+            });
+            if (!mountedRef.current || abortControllerRef.current !== 
controller) return;
+
+            const storedWithJobId: StoredAnalysisJob = { ...recoveryRecord, 
jobId: created.jobId };
+            if (!writeStoredAnalysisJob(storedWithJobId)) {
+                dispatch({ type: 'storage_unavailable' });
+            }
+            dispatch({ type: 'job_created', jobId: created.jobId, status: 
created.status });
+            await pollJob(created.jobId, created.retryAfterMs, controller);
+        } catch (reason) {
+            if (
+                reason instanceof ProfileAnalysisApiError &&
+                ((reason.status >= 400 && reason.status < 500) ||
+                    reason.code === 'CAPTCHA_UNAVAILABLE')
+            ) {
+                clearStoredAnalysisJob();
+            }
+            if (
+                !isAbortError(reason) &&
+                mountedRef.current &&
+                abortControllerRef.current === controller
+            ) {
+                dispatch({ type: 'fail', error: 
getProfileAnalysisErrorMessage(reason) });
+            }
+        } finally {
+            releaseController(controller);
+        }
+    }, [apiBaseUrl, pollJob, releaseController, snapshot.file, 
snapshot.language]);
+
+    useEffect(() => {
+        mountedRef.current = true;
+        const stored = readStoredAnalysisJob();
+        if (!stored.available) {
+            dispatch({ type: 'storage_unavailable' });
+        }
+        if (!stored.record) {
+            dispatch({ type: 'restore_empty' });
+            return () => {
+                mountedRef.current = false;
+            };
+        }
+
+        const controller = new AbortController();
+        abortControllerRef.current = controller;
+        dispatch({
+            type: 'restore_record',
+            jobId: stored.record.jobId ?? null,
+            language: stored.record.language,
+        });
+
+        const restore = async () => {
+            try {
+                let jobId = stored.record?.jobId;
+                if (!jobId && stored.record) {
+                    const recovered = await recoverAnalysisJobWithinGrace({
+                        recover: () =>
+                            getAnalysisJobByClientRequestId(
+                                apiBaseUrl,
+                                stored.record!.clientRequestId,
+                                controller.signal,
+                            ),
+                        wait: milliseconds => wait(milliseconds, 
controller.signal),
+                        onRecovering: () => {
+                            if (mountedRef.current && 
abortControllerRef.current === controller) {
+                                dispatch({ type: 'recovering' });
+                            }
+                        },
+                        createdAt: stored.record.createdAt,
+                    });
+                    jobId = recovered.jobId;
+                    if (!writeStoredAnalysisJob({ ...stored.record, jobId })) {
+                        dispatch({ type: 'storage_unavailable' });
+                    }
+                    if (!mountedRef.current || abortControllerRef.current !== 
controller) return;
+                    dispatch({ type: 'restore_record', jobId, language: 
stored.record.language });
+                }
+                if (!jobId) {
+                    throw new ProfileAnalysisApiError(
+                        404,
+                        'ANALYSIS_JOB_NOT_FOUND',
+                        'The previous analysis could not be recovered. Please 
select the file and try again.',
+                    );
+                }
+                await pollJob(jobId, DEFAULT_ANALYSIS_POLL_INTERVAL_MS, 
controller);
+            } catch (reason) {
+                if (reason instanceof ProfileAnalysisApiError && reason.status 
=== 404) {
+                    clearStoredAnalysisJob();
+                }
+                if (
+                    !isAbortError(reason) &&
+                    mountedRef.current &&
+                    abortControllerRef.current === controller
+                ) {
+                    dispatch({ type: 'fail', error: 
getProfileAnalysisErrorMessage(reason) });
+                }
+            } finally {
+                releaseController(controller);
+            }
+        };
+
+        void restore();
+        return () => {
+            mountedRef.current = false;
+            controller.abort();
+            releaseController(controller);
+        };
+    }, [apiBaseUrl, pollJob, releaseController]);
+
+    return {
+        ...snapshot,
+        isBusy: isBusy(snapshot.state),
+        selectFile,
+        setLanguage,
+        analyze,
+    };
+}
diff --git a/src/pages/profile-analysis/index.tsx 
b/src/pages/profile-analysis/index.tsx
new file mode 100644
index 00000000000..b98453fa8c1
--- /dev/null
+++ b/src/pages/profile-analysis/index.tsx
@@ -0,0 +1,16 @@
+import React, { JSX } from 'react';
+import Layout from '@theme/Layout';
+import { ProfileAnalyzer } from 
'@site/src/components/profile-analysis/ProfileAnalyzer';
+
+export default function ProfileAnalysisPage(): JSX.Element {
+    return (
+        <Layout
+            title="Profile Analysis - Apache Doris"
+            description="Analyze an Apache Doris Query Profile with an 
AI-assisted diagnostic workflow."
+        >
+            <main className="container margin-vert--lg">
+                <ProfileAnalyzer />
+            </main>
+        </Layout>
+    );
+}
diff --git a/yarn.lock b/yarn.lock
index 950389caeef..6c9ce497ef7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2575,6 +2575,11 @@
   dependencies:
     "@hapi/hoek" "^9.0.0"
 
+"@hcaptcha/react-hcaptcha@^2.0.2":
+  version "2.0.2"
+  resolved 
"https://registry.yarnpkg.com/@hcaptcha/react-hcaptcha/-/react-hcaptcha-2.0.2.tgz#56a455388a58be20f69c45b71476cf35ac6f45a3";
+  integrity 
sha512-VbuH6VJ6m3BHmVBHs0fL9t+suZd7PQEqCzqL2BiUbBvbHI3XfvSgdiug2QiEPN8zskbPTIV/FfGPF53JCckrow==
+
 "@isaacs/cliui@^8.0.2":
   version "8.0.2"
   resolved 
"https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550";
@@ -6942,6 +6947,11 @@ html-tags@^3.3.1:
   resolved 
"https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce";
   integrity 
sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==
 
+html-url-attributes@^3.0.0:
+  version "3.0.1"
+  resolved 
"https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87";
+  integrity 
sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==
+
 html-void-elements@^3.0.0:
   version "3.0.0"
   resolved 
"https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7";
@@ -10537,6 +10547,23 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
   dependencies:
     "@types/react" "*"
 
+react-markdown@^10.1.0:
+  version "10.1.0"
+  resolved 
"https://registry.yarnpkg.com/react-markdown/-/react-markdown-10.1.0.tgz#e22bc20faddbc07605c15284255653c0f3bad5ca";
+  integrity 
sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==
+  dependencies:
+    "@types/hast" "^3.0.0"
+    "@types/mdast" "^4.0.0"
+    devlop "^1.0.0"
+    hast-util-to-jsx-runtime "^2.0.0"
+    html-url-attributes "^3.0.0"
+    mdast-util-to-hast "^13.0.0"
+    remark-parse "^11.0.0"
+    remark-rehype "^11.0.0"
+    unified "^11.0.0"
+    unist-util-visit "^5.0.0"
+    vfile "^6.0.0"
+
 react-router-config@^5.1.1:
   version "5.1.1"
   resolved 
"https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988";
@@ -10834,7 +10861,7 @@ remark-frontmatter@^5.0.0:
     micromark-extension-frontmatter "^2.0.0"
     unified "^11.0.0"
 
-remark-gfm@^4.0.0:
+remark-gfm@^4.0.0, remark-gfm@^4.0.1:
   version "4.0.1"
   resolved 
"https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.1.tgz#33227b2a74397670d357bf05c098eaf8513f0d6b";
   integrity 
sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to