|
2292 | 2292 | } |
2293 | 2293 |
|
2294 | 2294 | /* ── GEMINI REST API ─────────────────────────────────────────── */ |
2295 | | -/* ── THINKING DISPLAY HELPER ───────────────────────────────────── |
| 2295 | + |
| 2296 | +/* ── REPETITION DETECTOR ────────────────────────────────────────── |
| 2297 | + Checks whether the model has repeated a 75-token (~300-char) window |
| 2298 | + at least twice in the ANSWER portion of the accumulated text. |
| 2299 | + Repetition is only flagged if no thinking block ended BETWEEN the |
| 2300 | + two occurrences (a model legitimately restating something after |
| 2301 | + reasoning is fine). |
| 2302 | +
|
| 2303 | + Returns true when repetition is detected and the stream should be |
| 2304 | + aborted. Call once per streaming delta with the current raw answer |
| 2305 | + text (NOT the composed thinking+answer string). |
| 2306 | +
|
| 2307 | + State is kept in the returned object so callers can reset between |
| 2308 | + requests by calling _createRepetitionState(). */ |
| 2309 | +function _createRepetitionState() { |
| 2310 | + return { |
| 2311 | + lastThinkingEndIdx: 0, // char index in answer where the last </think> was closed |
| 2312 | + checked: false, // true once we have enough text to start checking |
| 2313 | + }; |
| 2314 | +} |
| 2315 | + |
| 2316 | +const REPETITION_TOKEN_WINDOW = 75; // tokens ≈ chars / 4 → 300 chars |
| 2317 | +const REPETITION_WINDOW_CHARS = REPETITION_TOKEN_WINDOW * 4; |
| 2318 | + |
| 2319 | +function _checkRepetition(answerText, thinkingEndedSinceLastCheck, state) { |
| 2320 | + // Need at least 2× window to find a repeat |
| 2321 | + if (answerText.length < REPETITION_WINDOW_CHARS * 2) return false; |
| 2322 | + |
| 2323 | + // If thinking ended since we last checked, reset the "last thinking end" marker |
| 2324 | + // so a repetition across a thinking boundary is forgiven. |
| 2325 | + if (thinkingEndedSinceLastCheck) { |
| 2326 | + state.lastThinkingEndIdx = answerText.length; |
| 2327 | + } |
| 2328 | + |
| 2329 | + // Only inspect the portion of the answer produced AFTER the last thinking block ended |
| 2330 | + const relevantText = answerText.slice(state.lastThinkingEndIdx); |
| 2331 | + if (relevantText.length < REPETITION_WINDOW_CHARS * 2) return false; |
| 2332 | + |
| 2333 | + // Slide a window of REPETITION_WINDOW_CHARS over relevantText and look for |
| 2334 | + // a duplicate earlier in the same text. |
| 2335 | + const window = relevantText.slice(-REPETITION_WINDOW_CHARS); |
| 2336 | + const searchIn = relevantText.slice(0, relevantText.length - REPETITION_WINDOW_CHARS); |
| 2337 | + if (searchIn.indexOf(window) !== -1) return true; |
| 2338 | + |
| 2339 | + return false; |
| 2340 | +} |
| 2341 | + |
| 2342 | +/* ── REPETITION STOP HELPER ─────────────────────────────────────── |
| 2343 | + Call when repetition is detected. Aborts the stream and injects a |
| 2344 | + user-side "you're repeating yourself" bubble + sends it as the next |
| 2345 | + user message to the model, so the model can recover. */ |
| 2346 | +function _handleRepetition(abortController) { |
| 2347 | + if (abortController) { |
| 2348 | + try { abortController.abort(); } catch(e) {} |
| 2349 | + } |
| 2350 | + const repeatMsg = "You're repeating yourself. If necessary, write an EOF token."; |
| 2351 | + // Show as a right-side user bubble immediately |
| 2352 | + addUserBubble(repeatMsg); |
| 2353 | + // Send to model as next user message |
| 2354 | + Bridge.sendMessage(repeatMsg); |
| 2355 | + window.onGenerationStateChanged(true, false); |
| 2356 | +} |
| 2357 | + |
| 2358 | +/* ── THINKING / ANSWER SEPARATOR (INLINE) ─────────────────────── */ |
2296 | 2359 | Combines a model's thinking/reasoning text with its final answer into a single |
2297 | 2360 | plain-text string, since the native chat bubble only renders plain text (no |
2298 | 2361 | separate channel for reasoning exists in the WebView<->native bridge). The |
|
2446 | 2509 | window.__customModelAbortController = new AbortController(); |
2447 | 2510 | let acc = ''; |
2448 | 2511 | let thinkingAcc = ''; |
| 2512 | + const _geminiRepState = _createRepetitionState(); |
| 2513 | + let _geminiThinkingEndedSinceCheck = false; |
2449 | 2514 | try { |
2450 | 2515 | let response = await fetch(url, { |
2451 | 2516 | method: 'POST', |
|
2507 | 2572 | const json = JSON.parse(data); |
2508 | 2573 | const parts = json.candidates?.[0]?.content?.parts || []; |
2509 | 2574 | let text = ''; |
| 2575 | + let gotThinkingEnd = false; |
2510 | 2576 | for (const part of parts) { |
2511 | 2577 | if (!part.text) continue; |
2512 | | - if (part.thought) thinkingAcc += part.text; |
2513 | | - else text += part.text; |
| 2578 | + if (part.thought) { |
| 2579 | + const prevLen = thinkingAcc.length; |
| 2580 | + thinkingAcc += part.text; |
| 2581 | + } else { |
| 2582 | + text += part.text; |
| 2583 | + } |
2514 | 2584 | } |
| 2585 | + // Track whether a thinking block finished (thought→non-thought transition) |
| 2586 | + if (text && thinkingAcc) gotThinkingEnd = true; |
| 2587 | + if (gotThinkingEnd) _geminiThinkingEndedSinceCheck = true; |
2515 | 2588 | if (text || thinkingAcc) { |
2516 | 2589 | acc += text; |
2517 | 2590 | Bridge.onCustomModelPartialResponse(_composeThinkingAndAnswer(thinkingAcc, acc)); |
| 2591 | + if (_checkRepetition(acc, _geminiThinkingEndedSinceCheck, _geminiRepState)) { |
| 2592 | + _geminiThinkingEndedSinceCheck = false; |
| 2593 | + _handleRepetition(window.__customModelAbortController); |
| 2594 | + return; |
| 2595 | + } |
| 2596 | + _geminiThinkingEndedSinceCheck = false; |
2518 | 2597 | } |
2519 | 2598 | } catch(e2) { /* ignore bad SSE chunk */ } |
2520 | 2599 | } |
|
2649 | 2728 | let rawContentAcc = ''; |
2650 | 2729 | let structuredThinkingAcc = ''; |
2651 | 2730 | let puterFirstAnswerSeen = false; |
| 2731 | + const _puterRepState = _createRepetitionState(); |
| 2732 | + let _puterThinkingEndedSinceCheck = false; |
2652 | 2733 | const reader = response.body.getReader(); |
2653 | 2734 | const decoder = new TextDecoder(); |
2654 | 2735 | let buf = ''; |
|
2670 | 2751 | const delta = typeof d.content === 'string' ? d.content : _reasoningText(d.content); |
2671 | 2752 | // Once the first answer token has been seen, any further structured |
2672 | 2753 | // reasoning_content is also treated as answer (appended to raw content). |
| 2754 | + const hadThinking = !!structuredThinkingAcc; |
2673 | 2755 | if (thinkingDelta) { |
2674 | 2756 | if (puterFirstAnswerSeen) { |
2675 | 2757 | rawContentAcc += thinkingDelta; |
|
2681 | 2763 | rawContentAcc += delta; |
2682 | 2764 | puterFirstAnswerSeen = true; |
2683 | 2765 | } |
| 2766 | + // Thinking ended if we had thinking before and now have answer delta |
| 2767 | + if (hadThinking && delta) _puterThinkingEndedSinceCheck = true; |
2684 | 2768 | if (thinkingDelta || delta) { |
2685 | 2769 | const inline = _separateInlineThinking(rawContentAcc); |
2686 | 2770 | Bridge.onCustomModelPartialResponse( |
2687 | 2771 | _composeThinkingAndAnswer(structuredThinkingAcc + inline.thinking, inline.answer) |
2688 | 2772 | ); |
| 2773 | + const answerSoFar = inline.answer; |
| 2774 | + if (_checkRepetition(answerSoFar, _puterThinkingEndedSinceCheck, _puterRepState)) { |
| 2775 | + _puterThinkingEndedSinceCheck = false; |
| 2776 | + _handleRepetition(window.__customModelAbortController); |
| 2777 | + return; |
| 2778 | + } |
| 2779 | + _puterThinkingEndedSinceCheck = false; |
2689 | 2780 | } |
2690 | 2781 | } catch(e2) { /* ignore bad SSE chunk */ } |
2691 | 2782 | } |
|
2892 | 2983 | window.__customModelAbortController = new AbortController(); |
2893 | 2984 | let acc = ''; |
2894 | 2985 | let thinkingAcc = ''; |
| 2986 | + const _mistralRepState = _createRepetitionState(); |
| 2987 | + let _mistralThinkingEndedSinceCheck = false; |
2895 | 2988 | try { |
2896 | 2989 | const { response } = await _mistralCoordinatedFetch( |
2897 | 2990 | apiKeys, maxAttempts, minIntervalMs, |
|
2958 | 3051 | } |
2959 | 3052 | // Once the first answer token has been seen, any further structured |
2960 | 3053 | // thinking deltas are treated as answer content, not reasoning. |
| 3054 | + const _mHadThinking = !!thinkingAcc && !acc; |
2961 | 3055 | if (thinkingDelta) { |
2962 | 3056 | if (acc) { acc += thinkingDelta; } else { thinkingAcc += thinkingDelta; } |
2963 | 3057 | } |
2964 | 3058 | if (delta) acc += delta; |
2965 | | - if (thinkingDelta || delta) Bridge.onCustomModelPartialResponse(_composeThinkingAndAnswer(thinkingAcc, acc)); |
| 3059 | + if (_mHadThinking && acc) _mistralThinkingEndedSinceCheck = true; |
| 3060 | + if (thinkingDelta || delta) { |
| 3061 | + Bridge.onCustomModelPartialResponse(_composeThinkingAndAnswer(thinkingAcc, acc)); |
| 3062 | + if (_checkRepetition(acc, _mistralThinkingEndedSinceCheck, _mistralRepState)) { |
| 3063 | + _mistralThinkingEndedSinceCheck = false; |
| 3064 | + _handleRepetition(window.__customModelAbortController); |
| 3065 | + return; |
| 3066 | + } |
| 3067 | + _mistralThinkingEndedSinceCheck = false; |
| 3068 | + } |
2966 | 3069 | } catch(e2) { /* ignore bad SSE chunk */ } |
2967 | 3070 | } |
2968 | 3071 | } |
|
3022 | 3125 | window.__customModelAbortController = new AbortController(); |
3023 | 3126 | let acc = ''; |
3024 | 3127 | let thinkingAcc = ''; |
| 3128 | + const _cerebrasRepState = _createRepetitionState(); |
| 3129 | + let _cerebrasThinkingEndedSinceCheck = false; |
3025 | 3130 | try { |
3026 | 3131 | let response = await fetch('https://api.cerebras.ai/v1/chat/completions', { |
3027 | 3132 | method: 'POST', |
|
3086 | 3191 | const thinkingDelta = d.reasoning_content || d.reasoning || ''; |
3087 | 3192 | const delta = d.content || ''; |
3088 | 3193 | // Once the first answer token has been seen, further thinking deltas are answer. |
| 3194 | + const _cHadThinking = !!thinkingAcc && !acc; |
3089 | 3195 | if (thinkingDelta) { |
3090 | 3196 | if (acc) { acc += thinkingDelta; } else { thinkingAcc += thinkingDelta; } |
3091 | 3197 | } |
3092 | 3198 | if (delta) acc += delta; |
3093 | | - if (thinkingDelta || delta) Bridge.onCustomModelPartialResponse(_composeThinkingAndAnswer(thinkingAcc, acc)); |
| 3199 | + if (_cHadThinking && acc) _cerebrasThinkingEndedSinceCheck = true; |
| 3200 | + if (thinkingDelta || delta) { |
| 3201 | + Bridge.onCustomModelPartialResponse(_composeThinkingAndAnswer(thinkingAcc, acc)); |
| 3202 | + if (_checkRepetition(acc, _cerebrasThinkingEndedSinceCheck, _cerebrasRepState)) { |
| 3203 | + _cerebrasThinkingEndedSinceCheck = false; |
| 3204 | + _handleRepetition(window.__customModelAbortController); |
| 3205 | + return; |
| 3206 | + } |
| 3207 | + _cerebrasThinkingEndedSinceCheck = false; |
| 3208 | + } |
3094 | 3209 | } catch(e2) { /* ignore bad SSE chunk */ } |
3095 | 3210 | } |
3096 | 3211 | } |
|
3191 | 3306 |
|
3192 | 3307 | let acc = ''; |
3193 | 3308 | let thinkingAcc = ''; |
| 3309 | + const _groqRepState = _createRepetitionState(); |
| 3310 | + let _groqThinkingEndedSinceCheck = false; |
3194 | 3311 | const reader = response.body.getReader(); |
3195 | 3312 | const decoder = new TextDecoder(); |
3196 | 3313 | let buf = ''; |
|
3211 | 3328 | const thinkingDelta = d.reasoning_content || d.reasoning || ''; |
3212 | 3329 | const delta = d.content || ''; |
3213 | 3330 | // Once the first answer token has been seen, further thinking deltas are answer. |
| 3331 | + const _vHadThinking = !!thinkingAcc && !acc; |
3214 | 3332 | if (thinkingDelta) { |
3215 | 3333 | if (acc) { acc += thinkingDelta; } else { thinkingAcc += thinkingDelta; } |
3216 | 3334 | } |
3217 | 3335 | if (delta) acc += delta; |
3218 | | - if (thinkingDelta || delta) Bridge.onCustomModelPartialResponse(_composeThinkingAndAnswer(thinkingAcc, acc)); |
| 3336 | + if (_vHadThinking && acc) _vercelThinkingEndedSinceCheck = true; |
| 3337 | + if (thinkingDelta || delta) { |
| 3338 | + Bridge.onCustomModelPartialResponse(_composeThinkingAndAnswer(thinkingAcc, acc)); |
| 3339 | + if (_checkRepetition(acc, _vercelThinkingEndedSinceCheck, _vercelRepState)) { |
| 3340 | + _vercelThinkingEndedSinceCheck = false; |
| 3341 | + _handleRepetition(window.__customModelAbortController); |
| 3342 | + return; |
| 3343 | + } |
| 3344 | + _vercelThinkingEndedSinceCheck = false; |
| 3345 | + } |
3219 | 3346 | } catch(e2) { /* ignore bad SSE chunk */ } |
3220 | 3347 | } |
3221 | 3348 | } |
|
3288 | 3415 | window.__customModelAbortController = new AbortController(); |
3289 | 3416 | let acc = ''; |
3290 | 3417 | let thinkingAcc = ''; |
| 3418 | + const _vercelRepState = _createRepetitionState(); |
| 3419 | + let _vercelThinkingEndedSinceCheck = false; |
3291 | 3420 | try { |
3292 | 3421 | let response = await fetch(vercelChatEndpoint, { |
3293 | 3422 | method: 'POST', |
|
3351 | 3480 | const thinkingDelta = d.reasoning_content || d.reasoning || ''; |
3352 | 3481 | const delta = d.content || ''; |
3353 | 3482 | // Once the first answer token has been seen, further thinking deltas are answer. |
| 3483 | + const _gHadThinking = !!thinkingAcc && !acc; |
3354 | 3484 | if (thinkingDelta) { |
3355 | 3485 | if (acc) { acc += thinkingDelta; } else { thinkingAcc += thinkingDelta; } |
3356 | 3486 | } |
3357 | 3487 | if (delta) acc += delta; |
3358 | | - if (thinkingDelta || delta) Bridge.onCustomModelPartialResponse(_composeThinkingAndAnswer(thinkingAcc, acc)); |
| 3488 | + if (_gHadThinking && acc) _groqThinkingEndedSinceCheck = true; |
| 3489 | + if (thinkingDelta || delta) { |
| 3490 | + Bridge.onCustomModelPartialResponse(_composeThinkingAndAnswer(thinkingAcc, acc)); |
| 3491 | + if (_checkRepetition(acc, _groqThinkingEndedSinceCheck, _groqRepState)) { |
| 3492 | + _groqThinkingEndedSinceCheck = false; |
| 3493 | + _handleRepetition(window.__customModelAbortController); |
| 3494 | + return; |
| 3495 | + } |
| 3496 | + _groqThinkingEndedSinceCheck = false; |
| 3497 | + } |
3359 | 3498 | } catch(e2) { /* ignore bad SSE chunk */ } |
3360 | 3499 | } |
3361 | 3500 | } |
|
3445 | 3584 | window.__customModelAbortController = new AbortController(); |
3446 | 3585 | let acc = ''; |
3447 | 3586 | let thinkingAcc = ''; |
| 3587 | + const _oaicRepState = _createRepetitionState(); |
| 3588 | + let _oaicThinkingEndedSinceCheck = false; |
3448 | 3589 | try { |
3449 | 3590 | let response = await fetch(endpoint, { |
3450 | 3591 | method: 'POST', |
|
3517 | 3658 | const thinkingDelta = d.reasoning_content || d.reasoning || ''; |
3518 | 3659 | const delta = d.content || ''; |
3519 | 3660 | // Once the first answer token has been seen, further thinking deltas are answer. |
| 3661 | + const _oHadThinking = !!thinkingAcc && !acc; |
3520 | 3662 | if (thinkingDelta) { |
3521 | 3663 | if (acc) { acc += thinkingDelta; } else { thinkingAcc += thinkingDelta; } |
3522 | 3664 | } |
3523 | 3665 | if (delta) acc += delta; |
3524 | | - if (thinkingDelta || delta) Bridge.onCustomModelPartialResponse(_composeThinkingAndAnswer(thinkingAcc, acc)); |
| 3666 | + if (_oHadThinking && acc) _oaicThinkingEndedSinceCheck = true; |
| 3667 | + if (thinkingDelta || delta) { |
| 3668 | + Bridge.onCustomModelPartialResponse(_composeThinkingAndAnswer(thinkingAcc, acc)); |
| 3669 | + if (_checkRepetition(acc, _oaicThinkingEndedSinceCheck, _oaicRepState)) { |
| 3670 | + _oaicThinkingEndedSinceCheck = false; |
| 3671 | + _handleRepetition(window.__customModelAbortController); |
| 3672 | + return; |
| 3673 | + } |
| 3674 | + _oaicThinkingEndedSinceCheck = false; |
| 3675 | + } |
3525 | 3676 | } catch(e2) { /* ignore */ } |
3526 | 3677 | } |
3527 | 3678 | } |
|
0 commit comments