@@ -13,9 +13,18 @@ const exportMarkdownButton = document.querySelector("#export-markdown");
1313const modelEnabledInput = document . querySelector ( "#model_enabled" ) ;
1414const modelAffectsScoreInput = document . querySelector ( "#model_affects_score" ) ;
1515const ollamaModelInput = document . querySelector ( "#ollama_model" ) ;
16- const ollamaModelOptions = document . querySelector ( "#ollama-model-options" ) ;
1716
1817const DEFAULT_OLLAMA_MODEL = "gpt-oss-safeguard:20b" ;
18+ const FALLBACK_OLLAMA_MODELS = [
19+ DEFAULT_OLLAMA_MODEL ,
20+ "gpt-oss:20b" ,
21+ "qwen3-coder:30b" ,
22+ "qwen3.5:35b-a3b" ,
23+ "gemma4:26b" ,
24+ ] ;
25+ const RULE_ONLY_TIMEOUT_MS = 30000 ;
26+ const LOCAL_MODEL_TIMEOUT_MS = 210000 ;
27+ const EMBEDDING_MODEL_MARKERS = [ "embed" , "bge-" ] ;
1928const MODEL_STATUSES = [ "disabled" , "unavailable" , "invalid_response" , "ok" ] ;
2029const ANALYSIS_STEPS = [
2130 [ "intake" , "Input normalized" , "Copy, campaign context, modules, and optional landing inputs are prepared for review." ] ,
@@ -44,11 +53,11 @@ form.addEventListener("submit", async (event) => {
4453 setSubmitting ( true ) ;
4554
4655 try {
47- const response = await fetch ( "/analyze" , {
56+ const response = await fetchWithTimeout ( "/analyze" , {
4857 method : "POST" ,
4958 headers : { "content-type" : "application/json" } ,
5059 body : JSON . stringify ( payload ) ,
51- } ) ;
60+ } , requestTimeoutMs ( payload ) ) ;
5261
5362 if ( ! response . ok ) {
5463 const detail = await response . text ( ) ;
@@ -76,6 +85,26 @@ form.addEventListener("submit", async (event) => {
7685 }
7786} ) ;
7887
88+ async function fetchWithTimeout ( url , options , timeoutMs ) {
89+ const controller = new AbortController ( ) ;
90+ const timer = window . setTimeout ( ( ) => controller . abort ( ) , timeoutMs ) ;
91+ try {
92+ return await fetch ( url , { ...options , signal : controller . signal } ) ;
93+ } catch ( error ) {
94+ if ( error && error . name === "AbortError" ) {
95+ const seconds = Math . round ( timeoutMs / 1000 ) ;
96+ throw new Error ( `Review timed out after ${ seconds } s. Try a smaller local model or run again after the model has warmed up.` ) ;
97+ }
98+ throw error ;
99+ } finally {
100+ window . clearTimeout ( timer ) ;
101+ }
102+ }
103+
104+ function requestTimeoutMs ( payload ) {
105+ return payload . model_enabled ? LOCAL_MODEL_TIMEOUT_MS : RULE_ONLY_TIMEOUT_MS ;
106+ }
107+
79108form . addEventListener (
80109 "invalid" ,
81110 ( ) => {
@@ -137,19 +166,19 @@ async function discoverModels() {
137166 const payload = await response . json ( ) ;
138167 const models = normalizeModelList ( payload ) ;
139168 const defaultModel = modelName ( payload ?. default_model ) || DEFAULT_OLLAMA_MODEL ;
140- populateModelOptions ( models . length > 0 ? [ defaultModel , ...models ] : [ defaultModel ] ) ;
169+ populateModelOptions ( [ defaultModel , ...models , ... FALLBACK_OLLAMA_MODELS ] ) ;
141170 if ( ! ollamaModelInput . value . trim ( ) || ollamaModelInput . value === DEFAULT_OLLAMA_MODEL ) {
142171 ollamaModelInput . value = defaultModel ;
143172 }
144173 } catch {
145- populateModelOptions ( [ DEFAULT_OLLAMA_MODEL ] ) ;
174+ populateModelOptions ( FALLBACK_OLLAMA_MODELS ) ;
146175 if ( ! ollamaModelInput . value . trim ( ) ) ollamaModelInput . value = DEFAULT_OLLAMA_MODEL ;
147176 }
148177}
149178
150179function normalizeModelList ( payload ) {
151180 const source = Array . isArray ( payload ) ? payload : payload && Array . isArray ( payload . models ) ? payload . models : [ ] ;
152- return [ ...new Set ( source . map ( modelName ) . filter ( Boolean ) ) ] ;
181+ return [ ...new Set ( source . map ( modelName ) . filter ( isReviewModelOption ) ) ] ;
153182}
154183
155184function modelName ( item ) {
@@ -160,14 +189,31 @@ function modelName(item) {
160189 return "" ;
161190}
162191
192+ function isReviewModelOption ( value ) {
193+ if ( ! value ) return false ;
194+ const normalized = value . toLowerCase ( ) ;
195+ return ! EMBEDDING_MODEL_MARKERS . some ( ( marker ) => normalized . includes ( marker ) ) ;
196+ }
197+
163198function populateModelOptions ( models ) {
164199 const values = uniqueModelOptions ( models ) ;
165- ollamaModelOptions . innerHTML = values . map ( ( model ) => `<option value="${ escapeHtml ( model ) } "></option>` ) . join ( "" ) ;
200+ const currentValue = ollamaModelInput . value . trim ( ) ;
201+ ollamaModelInput . innerHTML = values
202+ . map ( ( model ) => {
203+ const safe = escapeHtml ( model ) ;
204+ return `<option value="${ safe } ">${ safe } </option>` ;
205+ } )
206+ . join ( "" ) ;
207+ if ( currentValue && values . includes ( currentValue ) ) {
208+ ollamaModelInput . value = currentValue ;
209+ } else {
210+ ollamaModelInput . value = values . includes ( DEFAULT_OLLAMA_MODEL ) ? DEFAULT_OLLAMA_MODEL : values [ 0 ] || "" ;
211+ }
166212}
167213
168214function uniqueModelOptions ( models ) {
169215 const values = [ ] ;
170- for ( const model of [ ...models , DEFAULT_OLLAMA_MODEL ] ) {
216+ for ( const model of [ ...models , ... FALLBACK_OLLAMA_MODELS ] ) {
171217 const value = modelName ( model ) ;
172218 if ( value && ! values . includes ( value ) ) values . push ( value ) ;
173219 }
0 commit comments