feat(frontend): build analysis form flow
This commit is contained in:
parent
1cc2a3edde
commit
41e645392c
File diff suppressed because it is too large
Load Diff
|
|
@ -52,6 +52,7 @@ function todayIso(): string {
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [options, setOptions] = useState<OptionsResponse | null>(null);
|
const [options, setOptions] = useState<OptionsResponse | null>(null);
|
||||||
const [loadingOptions, setLoadingOptions] = useState(true);
|
const [loadingOptions, setLoadingOptions] = useState(true);
|
||||||
|
const [optionsError, setOptionsError] = useState<string | null>(null);
|
||||||
const [ticker, setTicker] = useState("SPY");
|
const [ticker, setTicker] = useState("SPY");
|
||||||
const [analysisDate, setAnalysisDate] = useState(todayIso());
|
const [analysisDate, setAnalysisDate] = useState(todayIso());
|
||||||
const [researchDepth, setResearchDepth] = useState(1);
|
const [researchDepth, setResearchDepth] = useState(1);
|
||||||
|
|
@ -65,8 +66,11 @@ export default function App() {
|
||||||
const [quickThink, setQuickThink] = useState("");
|
const [quickThink, setQuickThink] = useState("");
|
||||||
const [deepThink, setDeepThink] = useState("");
|
const [deepThink, setDeepThink] = useState("");
|
||||||
const [googleThinkingLevel, setGoogleThinkingLevel] = useState("high");
|
const [googleThinkingLevel, setGoogleThinkingLevel] = useState("high");
|
||||||
|
const [openaiReasoningEffort, setOpenaiReasoningEffort] = useState("medium");
|
||||||
|
const [backendUrlOverride, setBackendUrlOverride] = useState("");
|
||||||
const [job, setJob] = useState<AnalysisJob | null>(null);
|
const [job, setJob] = useState<AnalysisJob | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
const providers = options?.providers ?? [];
|
const providers = options?.providers ?? [];
|
||||||
const providerMeta = useMemo(
|
const providerMeta = useMemo(
|
||||||
|
|
@ -78,7 +82,11 @@ export default function App() {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
setOptionsError(null);
|
||||||
const response = await fetch(`${API_BASE}/api/options`);
|
const response = await fetch(`${API_BASE}/api/options`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load options (${response.status})`);
|
||||||
|
}
|
||||||
const data: OptionsResponse = await response.json();
|
const data: OptionsResponse = await response.json();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -89,6 +97,11 @@ export default function App() {
|
||||||
setQuickThink(google.models[0]);
|
setQuickThink(google.models[0]);
|
||||||
setDeepThink(google.models[0]);
|
setDeepThink(google.models[0]);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (mounted) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to load options";
|
||||||
|
setOptionsError(message);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setLoadingOptions(false);
|
setLoadingOptions(false);
|
||||||
|
|
@ -116,14 +129,42 @@ export default function App() {
|
||||||
if (!job || (job.status !== "queued" && job.status !== "running")) {
|
if (!job || (job.status !== "queued" && job.status !== "running")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(async () => {
|
const timer = setInterval(async () => {
|
||||||
const response = await fetch(`${API_BASE}/api/analysis/jobs/${job.id}`);
|
try {
|
||||||
const updated = (await response.json()) as AnalysisJob;
|
const response = await fetch(`${API_BASE}/api/analysis/jobs/${job.id}`);
|
||||||
setJob(updated);
|
if (!response.ok) {
|
||||||
|
throw new Error(`Polling failed (${response.status})`);
|
||||||
|
}
|
||||||
|
const updated = (await response.json()) as AnalysisJob;
|
||||||
|
setJob(updated);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Polling failed";
|
||||||
|
setJob((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
status: "failed",
|
||||||
|
error: message,
|
||||||
|
completed_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}, 2500);
|
}, 2500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearInterval(timer);
|
||||||
}, [job]);
|
}, [job]);
|
||||||
|
|
||||||
|
const formValid = useMemo(() => {
|
||||||
|
const normalizedTicker = ticker.trim();
|
||||||
|
return (
|
||||||
|
normalizedTicker.length > 0 &&
|
||||||
|
analysisDate.length === 10 &&
|
||||||
|
selectedAnalysts.length > 0 &&
|
||||||
|
provider.length > 0
|
||||||
|
);
|
||||||
|
}, [ticker, analysisDate, selectedAnalysts, provider]);
|
||||||
|
|
||||||
const toggleAnalyst = (value: string) => {
|
const toggleAnalyst = (value: string) => {
|
||||||
setSelectedAnalysts((current) => {
|
setSelectedAnalysts((current) => {
|
||||||
if (current.includes(value)) {
|
if (current.includes(value)) {
|
||||||
|
|
@ -134,20 +175,28 @@ export default function App() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitJob = async () => {
|
const submitJob = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!formValid) {
|
||||||
|
setSubmitError("Complete ticker, date, and analyst selection before submitting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
setJob(null);
|
setJob(null);
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
ticker,
|
ticker: ticker.trim().toUpperCase(),
|
||||||
analysis_date: analysisDate,
|
analysis_date: analysisDate,
|
||||||
analysts: selectedAnalysts,
|
analysts: selectedAnalysts,
|
||||||
research_depth: researchDepth,
|
research_depth: researchDepth,
|
||||||
llm_provider: provider,
|
llm_provider: provider,
|
||||||
backend_url: providerMeta?.base_url,
|
backend_url: backendUrlOverride.trim() || providerMeta?.base_url,
|
||||||
quick_think_llm: quickThink,
|
quick_think_llm: quickThink,
|
||||||
deep_think_llm: deepThink,
|
deep_think_llm: deepThink,
|
||||||
google_thinking_level: provider === "google" ? googleThinkingLevel : null,
|
google_thinking_level: provider === "google" ? googleThinkingLevel : null,
|
||||||
|
openai_reasoning_effort:
|
||||||
|
provider === "openai" ? openaiReasoningEffort : null,
|
||||||
};
|
};
|
||||||
const response = await fetch(`${API_BASE}/api/analysis/jobs`, {
|
const response = await fetch(`${API_BASE}/api/analysis/jobs`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -161,19 +210,7 @@ export default function App() {
|
||||||
setJob(created);
|
setJob(created);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
setJob({
|
setSubmitError(message);
|
||||||
id: "n/a",
|
|
||||||
status: "failed",
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
request: {
|
|
||||||
ticker,
|
|
||||||
analysis_date: analysisDate,
|
|
||||||
analysts: selectedAnalysts,
|
|
||||||
research_depth: researchDepth,
|
|
||||||
llm_provider: provider,
|
|
||||||
},
|
|
||||||
error: message,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -195,102 +232,138 @@ export default function App() {
|
||||||
<section className="grid">
|
<section className="grid">
|
||||||
<aside className="panel stack">
|
<aside className="panel stack">
|
||||||
<h2>Run Setup</h2>
|
<h2>Run Setup</h2>
|
||||||
|
{optionsError && <div className="card error">{optionsError}</div>}
|
||||||
|
|
||||||
<label>
|
<form className="stack" onSubmit={submitJob}>
|
||||||
Ticker
|
|
||||||
<input value={ticker} onChange={(e) => setTicker(e.target.value.toUpperCase())} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Analysis Date
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={analysisDate}
|
|
||||||
onChange={(e) => setAnalysisDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Provider
|
|
||||||
<select value={provider} onChange={(e) => setProvider(e.target.value)}>
|
|
||||||
{providers.map((item) => (
|
|
||||||
<option value={item.id} key={item.id}>
|
|
||||||
{item.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Quick-Thinking Model
|
|
||||||
<select value={quickThink} onChange={(e) => setQuickThink(e.target.value)}>
|
|
||||||
{providerMeta?.models?.map((model) => (
|
|
||||||
<option value={model} key={model}>
|
|
||||||
{model}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Deep-Thinking Model
|
|
||||||
<select value={deepThink} onChange={(e) => setDeepThink(e.target.value)}>
|
|
||||||
{providerMeta?.models?.map((model) => (
|
|
||||||
<option value={model} key={model}>
|
|
||||||
{model}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{provider === "google" && (
|
|
||||||
<label>
|
<label>
|
||||||
Gemini Thinking Mode
|
Ticker
|
||||||
<select
|
<input
|
||||||
value={googleThinkingLevel}
|
value={ticker}
|
||||||
onChange={(e) => setGoogleThinkingLevel(e.target.value)}
|
onChange={(e) => setTicker(e.target.value.toUpperCase())}
|
||||||
>
|
placeholder="e.g. NVDA"
|
||||||
<option value="high">high</option>
|
/>
|
||||||
<option value="minimal">minimal</option>
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Analysis Date
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={analysisDate}
|
||||||
|
onChange={(e) => setAnalysisDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Provider
|
||||||
|
<select value={provider} onChange={(e) => setProvider(e.target.value)}>
|
||||||
|
{providers.map((item) => (
|
||||||
|
<option value={item.id} key={item.id}>
|
||||||
|
{item.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Research Depth
|
Provider Base URL Override (optional)
|
||||||
<div className="depth">
|
<input
|
||||||
{[1, 3, 5].map((depth) => (
|
value={backendUrlOverride}
|
||||||
<button
|
onChange={(e) => setBackendUrlOverride(e.target.value)}
|
||||||
key={depth}
|
placeholder={providerMeta?.base_url || "https://..."}
|
||||||
type="button"
|
/>
|
||||||
className={researchDepth === depth ? "active" : ""}
|
</label>
|
||||||
onClick={() => setResearchDepth(depth)}
|
|
||||||
|
<label>
|
||||||
|
Quick-Thinking Model
|
||||||
|
<select value={quickThink} onChange={(e) => setQuickThink(e.target.value)}>
|
||||||
|
{providerMeta?.models?.map((model) => (
|
||||||
|
<option value={model} key={model}>
|
||||||
|
{model}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Deep-Thinking Model
|
||||||
|
<select value={deepThink} onChange={(e) => setDeepThink(e.target.value)}>
|
||||||
|
{providerMeta?.models?.map((model) => (
|
||||||
|
<option value={model} key={model}>
|
||||||
|
{model}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{provider === "google" && (
|
||||||
|
<label>
|
||||||
|
Gemini Thinking Mode
|
||||||
|
<select
|
||||||
|
value={googleThinkingLevel}
|
||||||
|
onChange={(e) => setGoogleThinkingLevel(e.target.value)}
|
||||||
>
|
>
|
||||||
{depth === 1 ? "Shallow" : depth === 3 ? "Medium" : "Deep"}
|
<option value="high">high</option>
|
||||||
</button>
|
<option value="minimal">minimal</option>
|
||||||
))}
|
</select>
|
||||||
</div>
|
</label>
|
||||||
</label>
|
)}
|
||||||
|
|
||||||
<div className="stack">
|
{provider === "openai" && (
|
||||||
<span className="mono">Analyst Team</span>
|
<label>
|
||||||
<div className="checks">
|
OpenAI Reasoning Effort
|
||||||
{(options?.analysts || []).map((analyst) => (
|
<select
|
||||||
<label key={analyst}>
|
value={openaiReasoningEffort}
|
||||||
<input
|
onChange={(e) => setOpenaiReasoningEffort(e.target.value)}
|
||||||
type="checkbox"
|
>
|
||||||
checked={selectedAnalysts.includes(analyst)}
|
<option value="low">low</option>
|
||||||
onChange={() => toggleAnalyst(analyst)}
|
<option value="medium">medium</option>
|
||||||
/>
|
<option value="high">high</option>
|
||||||
{analyst}
|
</select>
|
||||||
</label>
|
</label>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="run" type="button" onClick={submitJob} disabled={isSubmitting || loadingOptions}>
|
<label>
|
||||||
{isSubmitting ? "Submitting..." : "Start Analysis Job"}
|
Research Depth
|
||||||
</button>
|
<div className="depth">
|
||||||
|
{[1, 3, 5].map((depth) => (
|
||||||
|
<button
|
||||||
|
key={depth}
|
||||||
|
type="button"
|
||||||
|
className={researchDepth === depth ? "active" : ""}
|
||||||
|
onClick={() => setResearchDepth(depth)}
|
||||||
|
>
|
||||||
|
{depth === 1 ? "Shallow" : depth === 3 ? "Medium" : "Deep"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="stack">
|
||||||
|
<span className="mono">Analyst Team</span>
|
||||||
|
<div className="checks">
|
||||||
|
{(options?.analysts || []).map((analyst) => (
|
||||||
|
<label key={analyst}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedAnalysts.includes(analyst)}
|
||||||
|
onChange={() => toggleAnalyst(analyst)}
|
||||||
|
/>
|
||||||
|
{analyst}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError && <div className="card error">{submitError}</div>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="run"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || loadingOptions || !formValid}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Submitting..." : "Start Analysis Job"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"}
|
||||||
Loading…
Reference in New Issue