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
7 changes: 4 additions & 3 deletions lib/agent-plugin/ui/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2526,7 +2526,8 @@ export function createAgentPanel(
const modelChoice = Div().build();
modelChoice.className = 'agent-model-choice';
modelChoice.append(modelSelect, modelMemory);
modelRow.replaceChildren(modelChoice, loadBtn);
const modelActions = Div().class('agent-model-actions').children(loadBtn).build();
modelRow.replaceChildren(modelChoice, modelActions);
localCard.append(modelRow, ggufRow);
const serviceCard = View('section').build();
serviceCard.className = 'agent-use-card agent-service-section';
Expand Down Expand Up @@ -2787,7 +2788,7 @@ export function createAgentPanel(
}
})();
});
localCard.append(release);
modelActions.append(release);
syncSource();
void refreshCache();
settings.replaceChildren(settingsHero, sourcePicker, localCard, serviceCard, cacheList, note, advanced);
Expand Down Expand Up @@ -2820,7 +2821,7 @@ export function createAgentPanel(
viewRevision++;
speech?.close();
panel.dataset.view = view;
if (view === 'settings') localCard.insertBefore(runtimeRow, release);
if (view === 'settings') modelRow.insertBefore(runtimeRow, modelActions);
else panel.insertBefore(runtimeRow, settings);
settings.classList.toggle('agent-panel-settings-hidden', view !== 'settings');
chat.el.hidden = view !== 'chat';
Expand Down
26 changes: 21 additions & 5 deletions lib/agent-plugin/ui/speech-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
const mic = document.createElement('button');
mic.type = 'button';
mic.className = 'agent-speech-mic';
mic.innerHTML =
const microphoneIcon =
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M6 10v2a6 6 0 0 0 12 0v-2M12 18v3M8 21h8"/></svg>';
mic.innerHTML = microphoneIcon;
const stopIcon =
'<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
const language = document.createElement('r-select') as HTMLElement & { value: string; disabled: boolean };
language.className = 'agent-speech-language';
language.setAttribute('placement', 'top');
Expand Down Expand Up @@ -60,15 +63,16 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'agent-speech-cancel';
feedback.append(status, cancel, hint);
root.append(mic, language);
feedback.append(status, hint);
root.append(mic, language, cancel);
chat.actionsEl.append(root);
// Keep feedback in normal composer flow; it must never cover the draft or editor.
const composer = chat.el.querySelector('.cui-composer');
if (composer) composer.insertBefore(feedback, composer.querySelector('.cui-composer-bar'));
else root.append(feedback);
let disclosed = false;
let stopping = false;
let listening = false;
let recognition: Recognition | undefined;
let busy = false;
let original = '';
Expand All @@ -84,6 +88,10 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
finishPromise = undefined;
};
const render = () => {
root.dataset.state = !busy ? 'idle' : stopping ? 'finishing' : listening ? 'listening' : 'starting';
feedback.dataset.state = root.dataset.state;
mic.innerHTML = busy ? stopIcon : microphoneIcon;
mic.classList.toggle('agent-speech-processing', busy && (!listening || stopping));
mic.setAttribute('aria-label', t(busy ? 'agentSpeechStop' : 'agentSpeechInput'));
mic.title = mic.getAttribute('aria-label') ?? '';
mic.setAttribute('aria-pressed', String(busy));
Expand All @@ -94,7 +102,10 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
language.setAttribute('aria-label', t('agentSpeechLanguage'));
language.disabled = busy;
hint.textContent = t('agentSpeechPrivacy');
cancel.textContent = t('agentSpeechCancel');
cancel.setAttribute('aria-label', t('agentSpeechCancel'));
cancel.title = t('agentSpeechCancel');
cancel.innerHTML =
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6"/></svg>';
};
const close = (discard = false, preserveDraft = false, ready = false) => {
clearTimeout(deadline);
Expand All @@ -108,6 +119,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
if (busy && !preserveDraft) chat.setInput(discard ? original : transcript ? base + transcript : original);
busy = false;
stopping = false;
listening = false;
hint.hidden = true;
status.textContent = '';
settleFinish(ready);
Expand Down Expand Up @@ -141,6 +153,7 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
if (base && !/\s$/.test(base)) base += ' ';
transcript = '';
stopping = false;
listening = false;
hint.hidden = disclosed;
disclosed = true;
const token = ++revision;
Expand All @@ -153,7 +166,10 @@ export function mountSpeechInput(chat: SpeechComposer, allowed: () => boolean) {
render();
status.textContent = t('agentSpeechStarting');
instance.onstart = () => {
if (token === revision) status.textContent = t('agentSpeechListening');
if (token !== revision || stopping) return;
listening = true;
status.textContent = t('agentSpeechListening');
render();
};
instance.onresult = (event) => {
if (token !== revision) return;
Expand Down
14 changes: 8 additions & 6 deletions packages/agent-core/src/llm/webllm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,12 @@ export class WebLLMProvider implements LocalLLMProvider {
await engine?.unload?.();
}

private enqueue<T>(operation: () => Promise<T>): Promise<T> {
private enqueue<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const request = this.queue.then(operation);
this.queue = request.catch(() => undefined);
return request;
// Release the caller on cancellation, but keep the engine queue bound to
// the underlying operation until its interrupt/drain completes.
return waitForSignals(request, [this.lifetime.signal, ...(signal ? [signal] : [])]);
}

/**
Expand Down Expand Up @@ -279,7 +281,7 @@ export class WebLLMProvider implements LocalLLMProvider {
}

async chat(messages: LLMMessage[], tools: LLMToolDef[], signal?: AbortSignal): Promise<LLMResponse> {
return this.enqueue(() => this.generate(messages, tools, undefined, signal));
return this.enqueue(() => this.generate(messages, tools, undefined, signal), signal);
}

private async generate(
Expand Down Expand Up @@ -361,7 +363,7 @@ export class WebLLMProvider implements LocalLLMProvider {
schema: Record<string, unknown>,
signal?: AbortSignal,
): Promise<LLMResponse> {
return this.enqueue(() => this.generate(messages, [], undefined, signal, schema));
return this.enqueue(() => this.generate(messages, [], undefined, signal, schema), signal);
}

generateJSONStream(
Expand All @@ -370,7 +372,7 @@ export class WebLLMProvider implements LocalLLMProvider {
onDelta: (text: string) => void,
signal?: AbortSignal,
): Promise<LLMResponse> {
return this.enqueue(() => this.generate(messages, [], onDelta, signal, schema));
return this.enqueue(() => this.generate(messages, [], onDelta, signal, schema), signal);
}

async chatStream(
Expand All @@ -379,7 +381,7 @@ export class WebLLMProvider implements LocalLLMProvider {
onDelta: (textDelta: string) => void,
signal?: AbortSignal,
): Promise<LLMResponse> {
return this.enqueue(() => this.generate(messages, tools, onDelta, signal));
return this.enqueue(() => this.generate(messages, tools, onDelta, signal), signal);
}
}

Expand Down
104 changes: 82 additions & 22 deletions styles/assistant.css
Original file line number Diff line number Diff line change
Expand Up @@ -540,13 +540,14 @@ r-dropdown[role='listbox'] {
border-color: transparent;
}
.agent-panel .cui-messages {
padding: 28px 22px;
padding: 24px 16px;
gap: var(--ran-space-4);
line-height: 1.75;
}
.agent-panel .cui-empty {
width: 100%;
max-width: 320px;
max-width: none;
margin: auto 0;
box-sizing: border-box;
padding: 20px 0;
font-size: 20px;
Expand Down Expand Up @@ -620,7 +621,7 @@ r-dropdown[role='listbox'] {
flex-basis: 44px;
}
.agent-panel .cui-messages {
padding: 24px 20px;
padding: 24px 16px;
}
.agent-panel .cui-footer {
padding-bottom: max(16px, env(safe-area-inset-bottom));
Expand Down Expand Up @@ -651,7 +652,7 @@ r-dropdown[role='listbox'] {
padding: 0 16px;
}
.agent-panel .cui-footer {
padding: 8px 12px;
padding: 8px 16px;
padding-bottom: max(8px, env(safe-area-inset-bottom));
}
.agent-panel .cui-composer {
Expand Down Expand Up @@ -705,7 +706,7 @@ r-dropdown[role='listbox'] {
.agent-panel .agent-runtime-row r-button::part(button) {
box-shadow: none;
}
.agent-device-section > .agent-runtime-row {
.agent-device-section .agent-runtime-row {
padding: var(--ran-space-3) 0;
}
@media (max-height: 600px) {
Expand Down Expand Up @@ -762,7 +763,7 @@ r-dropdown[role='listbox'] {
box-sizing: border-box;
min-height: 56px;
flex: 0 0 auto;
padding: 8px 20px;
padding: 8px 16px;
gap: 2px;
}
.agent-panel-title {
Expand All @@ -779,7 +780,7 @@ r-dropdown[role='listbox'] {
overflow: auto;
flex: 1 1 auto;
max-height: none;
padding: var(--ran-space-6);
padding: 16px;
gap: var(--ran-space-4);
border-bottom: 0;
scrollbar-width: thin;
Expand Down Expand Up @@ -945,7 +946,7 @@ r-dropdown[role='listbox'] {
}
.agent-panel .cui-footer {
border-top: 0;
padding: 12px 20px 18px;
padding: 12px 16px 16px;
}
.agent-panel .cui-composer {
border-color: var(--ran-color-border);
Expand Down Expand Up @@ -980,7 +981,7 @@ r-dropdown[role='listbox'] {
}
@media (max-width: 480px) {
.agent-panel-header {
padding: 8px 14px;
padding: 8px 16px;
}
.agent-panel-header button {
width: 34px;
Expand All @@ -989,7 +990,7 @@ r-dropdown[role='listbox'] {
}
.agent-panel-settings,
.agent-history-view {
padding: 20px;
padding: 16px;
}
.agent-panel-title {
font-size: 14px;
Expand Down Expand Up @@ -1070,21 +1071,24 @@ r-dropdown[role='listbox'] {
width: 100%;
min-width: 0;
--ran-input-border-radius: 8px;
--ran-input-min-height: 40px;
--ran-input-content-min-height: 38px;
--ran-input-min-height: 32px;
--ran-input-content-min-height: 30px;
--ran-input-content-font-size: 13px;
--ran-input-content-padding: 0 12px;
--ran-select-border-radius: 8px;
--ran-select-height: 40px;
--ran-select-height: 32px;
--ran-select-font-size: 13px;
}
.agent-panel-settings r-button {
--ran-btn-padding: 0;
}
.agent-panel-settings r-button::part(content) {
box-sizing: border-box;
min-height: 40px;
min-height: 32px;
display: flex;
align-items: center;
font-size: 13px;
padding: 9px 14px;
font-size: 12px;
padding: 5px 10px;
}
.agent-panel-load::part(content),
.agent-panel-endpoint-connect::part(content) {
Expand Down Expand Up @@ -1190,7 +1194,7 @@ r-dropdown[role='listbox'] {
}
.agent-release-memory {
align-self: flex-start;
margin-top: var(--ran-space-2);
margin-top: 0;
}
.agent-preferences {
color: var(--ran-color-text-secondary);
Expand Down Expand Up @@ -1276,8 +1280,8 @@ r-dropdown[role='listbox'] {
.agent-speech-mic {
display: grid;
place-items: center;
width: 36px;
height: 36px;
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
background: transparent;
Expand All @@ -1290,8 +1294,11 @@ r-dropdown[role='listbox'] {
color: var(--ran-color-text);
}
.agent-speech-cancel {
display: grid;
place-items: center;
width: 32px;
min-height: 32px;
padding: 4px 8px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
Expand All @@ -1308,8 +1315,8 @@ r-dropdown[role='listbox'] {
--ran-select-background-color: transparent;
--ran-select-border: 1px solid transparent;
--ran-select-hover-border: 1px solid transparent;
--ran-select-height: 36px;
--ran-select-min-height: 36px;
--ran-select-height: 32px;
--ran-select-min-height: 32px;
--ran-select-font-size: var(--ran-text-label-2);
--ran-select-color: var(--ran-color-text-secondary);
--ran-select-item-hover-color: var(--ran-color-text);
Expand Down Expand Up @@ -1482,3 +1489,56 @@ r-dropdown[role='listbox'] {
font-weight: 500;
margin: 0 0 var(--ran-space-3);
}

.agent-model-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ran-space-2);
}

/* Match the voice button's idle, listening and finishing states without
moving the draft or duplicating a Stop action above it. */
.agent-speech[data-state='listening'] .agent-speech-mic {
color: var(--ran-color-text);
background: var(--ran-color-bg-muted);
animation: agent-speech-pulse 1.4s ease-in-out infinite;
}
.agent-speech-processing svg {
display: none;
}
.agent-speech-processing::before {
content: '';
width: 14px;
height: 14px;
border: 1.5px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: agent-speech-spin 1s linear infinite;
}
.agent-speech-feedback[data-state='listening'] > [role='status'] {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.agent-speech-feedback[data-state='listening']:has(p[hidden]) {
padding: 0;
}
@keyframes agent-speech-pulse {
50% {
opacity: 0.6;
}
}
@keyframes agent-speech-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.agent-speech[data-state='listening'] .agent-speech-mic,
.agent-speech-processing::before {
animation: none;
}
}
Loading
Loading