Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions server/src/chat/routes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { FastifyInstance } from 'fastify'
import fs from 'node:fs'
import path from 'node:path'
import { suggestNext } from './suggest.js'
import { getConversation } from '../conversations.js'
import { listRuns } from '../runs.js'
import { GATEWAY_BASE, MAX_TOOL_ITERATIONS, SIDECAR_ENDPOINT } from '../config.js'
import { GATEWAY_BASE, MAX_TOOL_ITERATIONS, SIDECAR_ENDPOINT, INDEX_BASE } from '../config.js'
import { aiHealth, gatewayConfigured, isSidecarModel, listModels, listSidecarModels, modelFailure, probeProvider, probeableProvider, extractUnlockUrl, invalidateProviderProbes, sidecarConfigured, sidecarTarget, StreamedTurnError, streamTurn, WireMessage, WireToolCall, type TokenUsage } from './gateway.js'
import { TOOL_CALLS, TOOL_SPECS, activeToolSpecs, activeToolSpecsWithRemote, commandFor, customToolSpecs, executeTool, expandSlashCommand, skillToolSpec } from './tools.js'
import { systemPrompt } from './context.js'
Expand Down Expand Up @@ -220,7 +222,18 @@ function estimateTokens(messages: WireMessage[]): number {
* request; the process lifetime is the right scope, since a model that
* starts accepting the cap gets it back on the next deployment.
*/
const noTokenCap = new Set<string>()
const NO_CAP_FILE = path.join(INDEX_BASE, 'no-token-cap.json')
const noTokenCap = ((): Set<string> => {
// Remembered on disk: a provider that refuses a token cap refuses it
// every time, and re-learning it after each restart costs the first
// turn of a conversation a failed attempt.
try { return new Set(JSON.parse(fs.readFileSync(NO_CAP_FILE, 'utf8')) as string[]) } catch { return new Set() }
})()
function rememberNoTokenCap(model: string): void {
if (!model || noTokenCap.has(model)) return
noTokenCap.add(model)
try { fs.writeFileSync(NO_CAP_FILE, JSON.stringify([...noTokenCap])) } catch { /* memory still holds it for this process */ }
}

const BASE_ATTRS = 'Path=/; HttpOnly; Secure; SameSite=None'

Expand Down Expand Up @@ -956,9 +969,16 @@ ${ctx}` : ctx
// Once a provider rejects the token cap, every later turn of this
// reply skips it up front instead of paying a failed attempt each.
let dropTokenCap = false
// The unstreamed retry exists for serves that fail a generation after
// the 200 is committed. A rejected parameter is not that: the turn
// never started, and answering it without streaming leaves the reader
// watching a still "Thinking" line for the whole reply. Only a real
// generation failure gives up streaming.
let retryUnstreamed = false
const turnWithRetry = async (payload: Record<string, unknown>) => {
for (let attempt = 0; ; attempt++) {
const p: Record<string, unknown> = attempt === 0 && !noStream ? { ...payload } : { ...payload, stream: false }
const streamThis = !noStream && (attempt === 0 || !retryUnstreamed)
const p: Record<string, unknown> = streamThis ? { ...payload } : { ...payload, stream: false }
const emulating = foldSystem && Array.isArray(p.tools) && (p.tools as unknown[]).length > 0
if (foldSystem) {
p.messages = emulatedPrompt(
Expand Down Expand Up @@ -1027,12 +1047,18 @@ ${ctx}` : ctx
const generationError = err instanceof StreamedTurnError
|| /error occurred while generating/i.test(errText)
|| rejectedTokenCap
if (generationError && attempt < 2 && !abort.signal.aborted) {
// A retry after text has already reached the reader appends a
// second answer to the first: the client accumulates content
// events and has no way to unsay them. So a turn that has
// spoken is never retried; the error is surfaced instead.
const spoken = finalContent.length > 0
if (generationError && attempt < 2 && !abort.signal.aborted && !spoken) {
if (!(err instanceof StreamedTurnError)) dropTokenCap = true
if (!rejectedTokenCap) retryUnstreamed = true
// Only an explicit parameter rejection is worth remembering:
// the masked generation error also covers sampling failures,
// which say nothing about whether the cap is supported.
if (rejectedTokenCap) noTokenCap.add(String(body.model ?? ''))
if (rejectedTokenCap) rememberNoTokenCap(String(body.model ?? ''))
req.log.warn({ attempt, dropTokenCap, err: String(err?.message ?? err) }, 'serve failed generating; retrying turn')
continue
}
Expand Down
34 changes: 21 additions & 13 deletions web/src/components/VoiceOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
/**
* Voice conversations, as a feature preview: the Unmute deployment's own
* interface, in an overlay over the chat. Unmute is a separate service
* (speech recognition, turn-taking, synthesis, and the model behind them,
* which is this Studio's own assistant on the /v1 endpoint), so the
* overlay is an iframe with microphone permission delegated to it. The
* transcript stays with Unmute for now; recording it as a Studio
* conversation is the next step once the voice turns flow through here.
* Voice conversations, as a feature preview. The Unmute deployment is its
* own platform session on its own domain, so an iframe of it is a
* cross-site frame: the session proxy asks for its own sign-in inside the
* frame, and mobile browsers block the cookie that would carry the
* existing session across. Opening it at top level is where a platform
* session works, so the button does that, and this panel explains what
* is about to happen and what the voice can reach.
*
* Serving it from the Studio's own origin instead is the better answer
* and needs a proxy plus a base path in Unmute's frontend build; until
* that lands, one tab is honest and works everywhere.
*/
export function VoiceOverlay({ url, onClose }: { url: string; onClose: () => void }) {
return (
<div className="voice-overlay" role="dialog" aria-label="Voice conversation">
<div className="voice-overlay-bar">
<span className="voice-overlay-title">Voice · feature preview</span>
<a className="link-button" href={url} target="_blank" rel="noopener noreferrer">open in a new tab</a>
<button className="btn-secondary" onClick={onClose}>Close</button>
<div className="voice-panel card" role="dialog" aria-label="Voice conversation">
<h3>Talk with the assistant</h3>
<p className="muted view-sub">
Voice opens in its own tab: it listens, decides when you have finished a thought, answers aloud, and can be
interrupted. The assistant behind it is this one, with the same knowledge base, tools, and workflows.
</p>
<p className="muted view-sub">Feature preview. The conversation stays in that tab and is not recorded here yet.</p>
<div className="query-actions">
<a className="btn-primary" href={url} target="_blank" rel="noopener noreferrer" onClick={onClose}>Open voice</a>
<button className="btn-secondary" onClick={onClose}>Not now</button>
</div>
<iframe className="voice-overlay-frame" src={url} title="Voice conversation" allow="microphone; autoplay; camera" />
</div>
)
}
52 changes: 48 additions & 4 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -821,10 +821,20 @@ code, pre, kbd, .text-body, .viewer-path, .sql-box, [data-streamdown='code-block
.chat-scope-anchor, .chat-persona-anchor { position: relative; height: auto; }
.chat-persona-anchor .scope-menu { right: 0; }
@media (max-width: 760px) {
/* No room for both beside the model picker; the persona label drops to
its icon and the pair tucks tighter. */
.chat-persona-anchor .scope-btn { padding: 4px 7px; }
.chat-persona-anchor .scope-btn span.persona-label { display: none; }
/* These controls float over the package's own header, which holds the
model picker. On a phone the picker is wide enough to run under them,
so every label drops to its icon and the picker is given a ceiling and
allowed to truncate. The selector reaches into the package's markup,
which is why it is written defensively: if the class ever changes the
rule simply stops applying and the labels stay hidden. */
.chat-controls { gap: 4px; top: 10px; right: 10px; }
.chat-controls .scope-btn { padding: 4px 7px; }
.chat-controls .scope-btn span { display: none; }
.chat-canvas main [class*="border-b"] button[class*="min-w-"] {
min-width: 0 !important;
max-width: calc(100vw - 190px);
overflow: hidden;
}
}
/* ---- Activity (thinking) drawer: the package renders it as a fixed 400px
right panel. Our width variable overrides it so the drag handle below can
Expand Down Expand Up @@ -1270,6 +1280,15 @@ code, pre, kbd, .text-body, .viewer-path, .sql-box, [data-streamdown='code-block
background: var(--empty-brand-icon) center / contain no-repeat;
}

/* Voice preview: a small panel, since the session opens in its own tab. */
.voice-panel {
position: absolute; z-index: 40; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: min(440px, calc(100vw - 32px)); padding: 18px 20px;
box-shadow: 0 12px 32px rgba(16, 24, 40, 0.24);
}
.voice-panel h3 { margin: 0 0 10px; font-size: 13px; font-weight: 650; color: var(--pw-navy); }
[data-theme='dark'] .voice-panel h3 { color: var(--pw-text); }

/* Voice preview: Unmute's interface over the chat. */
.voice-overlay { position: absolute; inset: 0; z-index: 40; display: flex; flex-direction: column; background: #000; }
.voice-overlay-bar { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--pw-panel); border-bottom: 1px solid var(--pw-border); }
Expand Down Expand Up @@ -1671,6 +1690,31 @@ code, pre, kbd, .text-body, .viewer-path, .sql-box, [data-streamdown='code-block
.ov-bar-value { grid-column: 1 / -1; text-align: right; }
.hist-view { grid-template-columns: 1fr; grid-template-areas: "head" "tiles" "main" "rail"; }
.viewer-body { padding: 12px 14px; }
/* Settings and Help are a 220px section rail beside a scrolling
article. On a phone that rail eats most of the width and, with the
page no longer scrolling, the article had nowhere to go: the page
looked stuck. The rail becomes a horizontal strip of section chips
above the article, and the article keeps the scrolling, which is
what it does at every width. */
.help-docs { flex-direction: column; }
.help-nav {
width: auto; flex: 0 0 auto; flex-direction: row; gap: 6px;
border-right: 0; border-bottom: 1px solid var(--pw-border);
padding: 8px 10px; overflow-x: auto; overflow-y: hidden;
scrollbar-width: none;
}
.help-nav::-webkit-scrollbar { display: none; }
.help-nav-head { display: none; }
.help-nav button { white-space: nowrap; flex: 0 0 auto; }
.help-content, .settings-content { padding: 14px 14px 32px; min-height: 0; }
.settings-view { padding: 8px; }
/* Every view scrolls inside itself now that the page cannot: a view
whose own content is taller than the screen was simply clipped. */
.view > * { min-height: 0; }
.overview-view, .help-content, .settings-content, .query-view, .hist-view {
overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
}
.query-view, .hist-view { overflow-y: auto; }
/* The library's resizable rail stacks above the listing; a drag divider
makes no sense stacked, and the rail's inline width from a desktop
resize must not survive the rotation. */
Expand Down
Loading