feat: small-model utility calls on existing OpenCode providers (#2049)
Adds a server-side "small model" capability: direct, cheap LLM calls that reuse the user's existing OpenCode provider logins — the mechanism OpenCode uses internally for titles and summaries but does not expose through the SDK or plugins. Zero new dependencies; plain fetch with per-provider wire formats, credentials never leave the server. Core (packages/web/server/lib/small-model): - Resolution mirrors OpenCode's session scoping: explicit settings override → small_model from the OpenCode config → family scan within the session's provider → the session's own model. The global provider scan only serves callers without a session context, and background callers forbid it entirely (restrictToPreferredProvider), so conversation content never reaches a provider the user didn't pick — explicit choices excepted. - Per-provider auth replicating OpenCode's plugin loaders: GitHub Copilot (device token as bearer, no exchange), ChatGPT plan via the codex Responses API (single-flight OAuth refresh written back to auth.json), Anthropic messages, Google generateContent, generic OpenAI-compatible. - OpenCode's free models (opencode/big-pickle, *-free) are never called directly; unauthenticated providers are skipped by design. - Prompt clamping to the model's catalog context limit; thinking disabled where a wire switch exists (Z.AI/GLM, MiniMax-M3, Gemini Flash); robust content parsing with a clear error when a thinking model spends its whol budget on reasoning. - Settings → Sessions gains a Small Model group: use-default checkbox plus an override picker limited to authenticated providers, persisted with web/desktop/VS Code sanitization parity. Consumers: - Session assist: a server-side watcher on the global SSE hub generates a short recap and one suggested follow-up after a session idles quietly fo a minute, stored on session metadata (openchamber.assist). Freshness is keyed to the last assistant message id, so new activity invalidates the payload everywhere with no extra writes. The chat shows the recap under the last message after five quiet minutes and the suggestion as a dismissible chip above the composer (tap fills the input, never sends). Gated by a new Chat setting (default on) that is a hard generation switch. Language is anchored to the conversation itself, with a script-mismatch guard against model/backend language hallucination. - TTS: a third input mode, summarized — long replies are condensed to spoken prose before playback on any TTS engine. - Git: commit-message and PR generation moved off the active chat session onto the small model fed with real diffs and the commit list (bodies included), with a session-transport fallback for free-model-only setups. - Notes: Add to notes distills long selections into 1-3 dense sentences preserving exact identifiers, with verbatim fallback on failure. Fixes along the way: - The global event watcher now starts unconditionally; it was gated behind the desktop-notify env, leaving the server-side event hub dead in packaged apps. - OpenCode re-emits message.updated for old user messages after idle; the watcher no longer mistakes those for new activity. - Session metadata merges from a fresh read right before the PATCH, so writes made during the generation window (suggestion dismissals, review links) are preserved; the assist runtime stops during graceful shutdown.
This commit is contained in:
committed by
GitHub
parent
e5b03493da
commit
28f0736d69
@@ -14,6 +14,7 @@ import MessageList, { type MessageListHandle } from './MessageList';
|
||||
import { PermissionCard } from './PermissionCard';
|
||||
import { QuestionCard } from './QuestionCard';
|
||||
import { StatusRowContainer } from './StatusRowContainer';
|
||||
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
@@ -266,6 +267,8 @@ const ChatViewport = React.memo(({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionRecapNote sessionId={currentSessionId} isMobile={isMobile} />
|
||||
|
||||
<div className="mb-3">
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
findAttachmentCitationRanges,
|
||||
} from './attachmentCitations';
|
||||
import { getFileMentionAutocompleteQuery, type FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState';
|
||||
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
|
||||
import type { Part } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
@@ -4053,6 +4054,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
textareaRef.current?.focus({ preventScroll: isCapacitorApp() });
|
||||
}, []);
|
||||
|
||||
const applyAssistSuggestion = React.useCallback((text: string) => {
|
||||
setMessage(text);
|
||||
if (isMobile && !mobileComposerExpanded) {
|
||||
expandMobileComposer('focus');
|
||||
} else {
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}
|
||||
}, [expandMobileComposer, isMobile, mobileComposerExpanded]);
|
||||
|
||||
|
||||
const handleMobileNewSession = React.useCallback(() => {
|
||||
if (newSessionDraftOpen) return;
|
||||
openNewSessionDraft(currentDirectory ? { directoryOverride: currentDirectory } : undefined);
|
||||
@@ -4744,6 +4755,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
)}
|
||||
>
|
||||
{isMobile && !mobileComposerExpanded ? (
|
||||
<div className="flex flex-col">
|
||||
<SessionSuggestionChip
|
||||
sessionId={currentSessionId}
|
||||
hidden={hasContent || newSessionDraftOpen}
|
||||
onApply={applyAssistSuggestion}
|
||||
className="mb-1.5"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1"
|
||||
@@ -4818,7 +4836,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SessionSuggestionChip
|
||||
sessionId={currentSessionId}
|
||||
hidden={hasContent || newSessionDraftOpen}
|
||||
onApply={applyAssistSuggestion}
|
||||
className="mb-1.5"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col relative overflow-visible",
|
||||
@@ -5202,6 +5228,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Wrapper-level dictation engine + overlay: stays mounted across
|
||||
the pill ↔ composer swap so a recording started from the pill
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { useSessionAssistState } from '@/hooks/useSessionAssist';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SessionRecapNoteProps {
|
||||
sessionId: string;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
// Quiet one-paragraph recap of the agent's last reply, rendered right under
|
||||
// the last message (above the reserved bottom gap). Appears only after the
|
||||
// 5-minute quiet window, so the layout shift happens off-screen in practice.
|
||||
export const SessionRecapNote: React.FC<SessionRecapNoteProps> = React.memo(({ sessionId, isMobile }) => {
|
||||
const { visibleRecap } = useSessionAssistState(sessionId);
|
||||
const { t } = useI18n();
|
||||
|
||||
if (!visibleRecap) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-message-column">
|
||||
{/* The last assistant turn carries pb-8 — pull the recap up into that gap. */}
|
||||
<div className="-mt-6" aria-label={t('chat.recap.aria')}>
|
||||
<span className={`typography-meta text-muted-foreground/70 ${isMobile ? 'line-clamp-4' : 'line-clamp-2'}`}>
|
||||
<span className="italic text-muted-foreground/50">{t('chat.recap.label')} </span>
|
||||
{visibleRecap}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SessionRecapNote.displayName = 'SessionRecapNote';
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useSessionAssistState } from '@/hooks/useSessionAssist';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { patchSessionMetadata } from '@/sync/session-actions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SessionSuggestionChipProps {
|
||||
sessionId: string | null;
|
||||
/** The composer already has content — the suggestion must stay out of the way. */
|
||||
hidden: boolean;
|
||||
onApply: (text: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
// One small-model-suggested follow-up message, styled like the draft starter
|
||||
// chips. Tapping it fills the composer (no auto-send); the X patches the
|
||||
// suggestion out of the session metadata so it stays dismissed everywhere.
|
||||
export const SessionSuggestionChip: React.FC<SessionSuggestionChipProps> = React.memo(({ sessionId, hidden, onApply, className }) => {
|
||||
const { suggestion } = useSessionAssistState(sessionId ?? '');
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const [dismissing, setDismissing] = React.useState(false);
|
||||
|
||||
const handleDismiss = React.useCallback(async (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!sessionId || dismissing) return;
|
||||
setDismissing(true);
|
||||
try {
|
||||
await patchSessionMetadata(sessionId, undefined, (metadata) => {
|
||||
const namespace = isRecord(metadata.openchamber) ? metadata.openchamber : {};
|
||||
const assist = isRecord(namespace.assist) ? namespace.assist : {};
|
||||
const nextAssist = { ...assist };
|
||||
delete nextAssist.suggestion;
|
||||
return { ...metadata, openchamber: { ...namespace, assist: nextAssist } };
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to dismiss suggestion:', error);
|
||||
} finally {
|
||||
setDismissing(false);
|
||||
}
|
||||
}, [sessionId, dismissing]);
|
||||
|
||||
if (!suggestion || hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chipStyle: React.CSSProperties = {
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex w-full min-w-0 justify-center ${className ?? ''}`}>
|
||||
<div className="relative w-full min-w-0 max-w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply(suggestion)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
aria-label={t('chat.suggestion.applyAria')}
|
||||
className="group flex w-full min-w-0 select-none items-center gap-1.5 rounded-full border py-1.5 pl-3 pr-8 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
|
||||
style={chipStyle}
|
||||
>
|
||||
<Icon name="pencil-ai-2" className="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
|
||||
<span className="truncate">{suggestion}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm whitespace-pre-wrap">
|
||||
{suggestion}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => void handleDismiss(event)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
aria-label={t('chat.suggestion.dismissAria')}
|
||||
title={t('chat.suggestion.dismissAria')}
|
||||
className="absolute right-1.5 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SessionSuggestionChip.displayName = 'SessionSuggestionChip';
|
||||
@@ -10,6 +10,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
|
||||
import { summarizeSelectionForNotes } from '@/lib/smallModel';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
@@ -518,7 +519,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
try {
|
||||
setIsAddingToNotes(true);
|
||||
const noteText = selectedTextMarkdown || selectedText;
|
||||
// Long selections are distilled into a compact note by the small model;
|
||||
// short ones (and any generation failure) go in verbatim.
|
||||
const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId);
|
||||
const projectData = await getProjectNotesAndTodos(currentProjectRef);
|
||||
const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText);
|
||||
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
|
||||
|
||||
@@ -160,7 +160,6 @@ export const iconSpriteData = {
|
||||
"menu-fold-2": `<path d="M4.40347 3.90332L2.98926 5.31753L6.17124 8.49951L2.98926 11.6815L4.40347 13.0957L8.99967 8.49951L4.40347 3.90332ZM20.9997 19.9995V17.9995H2.99967V19.9995H20.9997ZM20.9997 12.9995V10.9995H11.9997V12.9995H20.9997ZM20.9997 5.99951V3.99951H11.9997V5.99951H20.9997Z" fill="currentColor"/>`,
|
||||
"menu-search": `<path d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z" fill="currentColor"/>`,
|
||||
"mic": `<path d="M11.9998 3C10.3429 3 8.99976 4.34315 8.99976 6V10C8.99976 11.6569 10.3429 13 11.9998 13C13.6566 13 14.9998 11.6569 14.9998 10V6C14.9998 4.34315 13.6566 3 11.9998 3ZM11.9998 1C14.7612 1 16.9998 3.23858 16.9998 6V10C16.9998 12.7614 14.7612 15 11.9998 15C9.23833 15 6.99976 12.7614 6.99976 10V6C6.99976 3.23858 9.23833 1 11.9998 1ZM3.05469 11H5.07065C5.55588 14.3923 8.47329 17 11.9998 17C15.5262 17 18.4436 14.3923 18.9289 11H20.9448C20.4837 15.1716 17.1714 18.4839 12.9998 18.9451V23H10.9998V18.9451C6.82814 18.4839 3.51584 15.1716 3.05469 11Z" fill="currentColor"/>`,
|
||||
"mic-off": `<path d="M16.4249 17.839L21.1925 22.6066L22.6068 21.1924L2.80777 1.3934L1.39355 2.80761L7.00016 8.41421V10C7.00016 12.7614 9.23873 15 12.0002 15C12.4825 15 12.9489 14.9317 13.3902 14.8042L14.9404 16.3544C14.0464 16.7688 13.0503 17 12.0002 17C8.47368 17 5.55627 14.3923 5.07105 11H3.05509C3.51623 15.1716 6.82854 18.4839 11.0002 18.9451V23H13.0002V18.9451C14.2341 18.8087 15.3929 18.4228 16.4249 17.839ZM11.5528 12.9669C10.2541 12.7727 9.22745 11.7461 9.03328 10.4473L11.5528 12.9669ZM19.3747 15.1604L17.9323 13.7179C18.4407 12.9084 18.788 11.9874 18.9293 11H20.9452C20.7754 12.5366 20.2187 13.9565 19.3747 15.1604ZM16.4658 12.2514L14.9173 10.703C14.9715 10.4775 15.0002 10.2421 15.0002 10V6C15.0002 4.34315 13.657 3 12.0002 3C10.7059 3 9.6031 3.81956 9.18237 4.96802L7.68575 3.47139C8.55427 1.99268 10.1613 1 12.0002 1C14.7616 1 17.0002 3.23858 17.0002 6V10C17.0002 10.8099 16.8076 11.5748 16.4658 12.2514Z" fill="currentColor"/>`,
|
||||
"more-2-fill": `<path d="M12 3C10.9 3 10 3.9 10 5C10 6.1 10.9 7 12 7C13.1 7 14 6.1 14 5C14 3.9 13.1 3 12 3ZM12 17C10.9 17 10 17.9 10 19C10 20.1 10.9 21 12 21C13.1 21 14 20.1 14 19C14 17.9 13.1 17 12 17ZM12 10C10.9 10 10 10.9 10 12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12C14 10.9 13.1 10 12 10Z" fill="currentColor"/>`,
|
||||
"more-2": `<path d="M12 3C11.175 3 10.5 3.675 10.5 4.5C10.5 5.325 11.175 6 12 6C12.825 6 13.5 5.325 13.5 4.5C13.5 3.675 12.825 3 12 3ZM12 18C11.175 18 10.5 18.675 10.5 19.5C10.5 20.325 11.175 21 12 21C12.825 21 13.5 20.325 13.5 19.5C13.5 18.675 12.825 18 12 18ZM12 10.5C11.175 10.5 10.5 11.175 10.5 12C10.5 12.825 11.175 13.5 12 13.5C12.825 13.5 13.5 12.825 13.5 12C13.5 11.175 12.825 10.5 12 10.5Z" fill="currentColor"/>`,
|
||||
"more": `<path d="M4.5 10.5C3.675 10.5 3 11.175 3 12C3 12.825 3.675 13.5 4.5 13.5C5.325 13.5 6 12.825 6 12C6 11.175 5.325 10.5 4.5 10.5ZM19.5 10.5C18.675 10.5 18 11.175 18 12C18 12.825 18.675 13.5 19.5 13.5C20.325 13.5 21 12.825 21 12C21 11.175 20.325 10.5 19.5 10.5ZM12 10.5C11.175 10.5 10.5 11.175 10.5 12C10.5 12.825 11.175 13.5 12 13.5C12.825 13.5 13.5 12.825 13.5 12C13.5 11.175 12.825 10.5 12 10.5Z" fill="currentColor"/>`,
|
||||
@@ -168,6 +167,7 @@ export const iconSpriteData = {
|
||||
"node-tree": `<path d="M10 2C10.5523 2 11 2.44772 11 3V7C11 7.55228 10.5523 8 10 8H8V10H13V9C13 8.44772 13.4477 8 14 8H20C20.5523 8 21 8.44772 21 9V13C21 13.5523 20.5523 14 20 14H14C13.4477 14 13 13.5523 13 13V12H8V18H13V17C13 16.4477 13.4477 16 14 16H20C20.5523 16 21 16.4477 21 17V21C21 21.5523 20.5523 22 20 22H14C13.4477 22 13 21.5523 13 21V20H7C6.44772 20 6 19.5523 6 19V8H4C3.44772 8 3 7.55228 3 7V3C3 2.44772 3.44772 2 4 2H10ZM19 18H15V20H19V18ZM19 10H15V12H19V10ZM9 4H5V6H9V4Z" fill="currentColor"/>`,
|
||||
"notification-3": `<path d="M20 17H22V19H2V17H4V10C4 5.58172 7.58172 2 12 2C16.4183 2 20 5.58172 20 10V17ZM18 17V10C18 6.68629 15.3137 4 12 4C8.68629 4 6 6.68629 6 10V17H18ZM9 21H15V23H9V21Z" fill="currentColor"/>`,
|
||||
"palette": `<path d="M12 2C17.5222 2 22 5.97778 22 10.8889C22 13.9556 19.5111 16.4444 16.4444 16.4444H14.4778C13.5556 16.4444 12.8111 17.1889 12.8111 18.1111C12.8111 18.5333 12.9778 18.9222 13.2333 19.2111C13.5 19.5111 13.6667 19.9 13.6667 20.3333C13.6667 21.2556 12.9 22 12 22C6.47778 22 2 17.5222 2 12C2 6.47778 6.47778 2 12 2ZM10.8111 18.1111C10.8111 16.0843 12.451 14.4444 14.4778 14.4444H16.4444C18.4065 14.4444 20 12.851 20 10.8889C20 7.1392 16.4677 4 12 4C7.58235 4 4 7.58235 4 12C4 16.19 7.2226 19.6285 11.324 19.9718C10.9948 19.4168 10.8111 18.7761 10.8111 18.1111ZM7.5 12C6.67157 12 6 11.3284 6 10.5C6 9.67157 6.67157 9 7.5 9C8.32843 9 9 9.67157 9 10.5C9 11.3284 8.32843 12 7.5 12ZM16.5 12C15.6716 12 15 11.3284 15 10.5C15 9.67157 15.6716 9 16.5 9C17.3284 9 18 9.67157 18 10.5C18 11.3284 17.3284 12 16.5 12ZM12 9C11.1716 9 10.5 8.32843 10.5 7.5C10.5 6.67157 11.1716 6 12 6C12.8284 6 13.5 6.67157 13.5 7.5C13.5 8.32843 12.8284 9 12 9Z" fill="currentColor"/>`,
|
||||
"pencil-ai-2": `<path d="M18.5293 15.3193C18.7058 14.8934 19.2942 14.8934 19.4707 15.3193L19.7236 15.9307C20.1556 16.9735 20.9615 17.8062 21.9746 18.2568L22.6914 18.5762C23.1022 18.7589 23.1022 19.3564 22.6914 19.5391L21.9326 19.877C20.9449 20.3163 20.1534 21.1194 19.7139 22.1279L19.4668 22.6934C19.2863 23.1075 18.7136 23.1075 18.5332 22.6934L18.2861 22.1279C17.8466 21.1194 17.0551 20.3163 16.0674 19.877L15.3076 19.5391C14.8974 19.3562 14.8974 18.759 15.3076 18.5762L16.0254 18.2568C17.0385 17.8062 17.8444 16.9735 18.2764 15.9307L18.5293 15.3193ZM16.4346 3.21193C16.8251 2.82141 17.4591 2.82141 17.8496 3.21193L20.6777 6.04103C21.0681 6.43157 21.0682 7.06464 20.6777 7.45509L7.24219 20.8897H3V16.6475L16.4346 3.21193ZM5 17.4756V18.8897H6.41406L15.7275 9.57618L14.3135 8.16212L5 17.4756ZM15.7275 6.74806L17.1426 8.16212L18.5566 6.74806L17.1426 5.334L15.7275 6.74806Z" fill="currentColor"/>`,
|
||||
"pencil-ai": `<path d="M16.4356 3.21188C16.8261 2.82185 17.4592 2.82157 17.8496 3.21188L20.6777 6.04099C21.0681 6.43152 21.0682 7.06457 20.6777 7.45505L7.2422 20.8896H3.00001V16.6475L16.4356 3.21188ZM5.00001 17.4756V18.8896H6.41407L15.7276 9.57615L14.3135 8.16208L5.00001 17.4756ZM4.5293 1.3193C4.70583 0.893505 5.29418 0.893508 5.47071 1.3193L5.72364 1.93063C6.15555 2.97342 6.96155 3.80613 7.97462 4.2568L8.69239 4.57614C9.10267 4.75896 9.10262 5.35616 8.69239 5.53903L7.93263 5.87692C6.94497 6.3162 6.15339 7.11943 5.71387 8.1279L5.4668 8.69334C5.28636 9.10747 4.71366 9.10747 4.53321 8.69334L4.28614 8.1279C3.84661 7.11943 3.05506 6.3162 2.06739 5.87692L1.30762 5.53903C0.897483 5.35617 0.897435 4.75896 1.30762 4.57614L2.0254 4.2568C3.03845 3.80614 3.84446 2.97344 4.27637 1.93063L4.5293 1.3193ZM15.7276 6.74802L17.1426 8.16208L18.5567 6.74802L17.1426 5.33395L15.7276 6.74802Z" fill="currentColor"/>`,
|
||||
"pencil": `<path d="M15.7279 9.57627L14.3137 8.16206L5 17.4758V18.89H6.41421L15.7279 9.57627ZM17.1421 8.16206L18.5563 6.74785L17.1421 5.33363L15.7279 6.74785L17.1421 8.16206ZM7.24264 20.89H3V16.6473L16.435 3.21231C16.8256 2.82179 17.4587 2.82179 17.8492 3.21231L20.6777 6.04074C21.0682 6.43126 21.0682 7.06443 20.6777 7.45495L7.24264 20.89Z" fill="currentColor"/>`,
|
||||
"picture-in-picture-2": `<path d="M21 3C21.5523 3 22 3.44772 22 4V11H20V5H4V19H10V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM21 13C21.5523 13 22 13.4477 22 14V20C22 20.5523 21.5523 21 21 21H13C12.4477 21 12 20.5523 12 20V14C12 13.4477 12.4477 13 13 13H21ZM20 15H14V19H20V15ZM6.70711 6.29289L8.95689 8.54289L11 6.5V12H5.5L7.54289 9.95689L5.29289 7.70711L6.70711 6.29289Z" fill="currentColor"/>`,
|
||||
@@ -211,7 +211,6 @@ export const iconSpriteData = {
|
||||
"star-fill": `<path d="M12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z" fill="currentColor"/>`,
|
||||
"star": `<path d="M12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26ZM12.0006 15.968L16.2473 18.3451L15.2988 13.5717L18.8719 10.2674L14.039 9.69434L12.0006 5.27502L9.96214 9.69434L5.12921 10.2674L8.70231 13.5717L7.75383 18.3451L12.0006 15.968Z" fill="currentColor"/>`,
|
||||
"sticky-note": `<path d="M21 15L15 20.996L4.00221 21C3.4487 21 3 20.5551 3 20.0066V3.9934C3 3.44476 3.44495 3 3.9934 3H20.0066C20.5552 3 21 3.45576 21 4.00247V15ZM19 5H5V19H13V14C13 13.4872 13.386 13.0645 13.8834 13.0067L14 13L19 12.999V5ZM18.171 14.999L15 15V18.169L18.171 14.999Z" fill="currentColor"/>`,
|
||||
"stop-circle": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM9 9H15V15H9V9Z" fill="currentColor"/>`,
|
||||
"stop": `<path d="M7 7V17H17V7H7ZM6 5H18C18.5523 5 19 5.44772 19 6V18C19 18.5523 18.5523 19 18 19H6C5.44772 19 5 18.5523 5 18V6C5 5.44772 5.44772 5 6 5Z" fill="currentColor"/>`,
|
||||
"subtract": `<path d="M5 11V13H19V11H5Z" fill="currentColor"/>`,
|
||||
"survey": `<path d="M17 2V4H20.0066C20.5552 4 21 4.44495 21 4.9934V21.0066C21 21.5552 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5551 3 21.0066V4.9934C3 4.44476 3.44495 4 3.9934 4H7V2H17ZM7 6H5V20H19V6H17V8H7V6ZM9 16V18H7V16H9ZM9 13V15H7V13H9ZM9 10V12H7V10H9ZM15 4H9V6H15V4Z" fill="currentColor"/>`,
|
||||
@@ -228,7 +227,6 @@ export const iconSpriteData = {
|
||||
"unpin": `<path d="M20.9701 17.1716 19.5559 18.5858 16.0214 15.0513 15.9476 15.1251 15.2405 18.6606 13.8263 20.0748 9.58369 15.8322 4.63394 20.7819 3.21973 19.3677 8.16947 14.418 3.92683 10.1753 5.34105 8.7611 8.87658 8.05399 8.95029 7.98028 5.41373 4.44371 6.82794 3.0295 20.9701 17.1716ZM10.3645 9.39449 9.86261 9.8964 7.04072 10.4608 13.5409 16.9609 14.1052 14.139 14.6071 13.6371 10.3645 9.39449ZM18.7761 9.46821 17.4356 10.8087 18.8498 12.2229 20.1903 10.8824 20.8974 11.5895 22.3116 10.1753 13.8263 1.69003 12.4121 3.10425 13.1192 3.81135 11.7787 5.15185 13.1929 6.56607 14.5334 5.22557 18.7761 9.46821Z" fill="currentColor"/>`,
|
||||
"user-3": `<path d="M20 22H18V20C18 18.3431 16.6569 17 15 17H9C7.34315 17 6 18.3431 6 20V22H4V20C4 17.2386 6.23858 15 9 15H15C17.7614 15 20 17.2386 20 20V22ZM12 13C8.68629 13 6 10.3137 6 7C6 3.68629 8.68629 1 12 1C15.3137 1 18 3.68629 18 7C18 10.3137 15.3137 13 12 13ZM12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" fill="currentColor"/>`,
|
||||
"user": `<path d="M4 22C4 17.5817 7.58172 14 12 14C16.4183 14 20 17.5817 20 22H18C18 18.6863 15.3137 16 12 16C8.68629 16 6 18.6863 6 22H4ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11Z" fill="currentColor"/>`,
|
||||
"voice-recognition": `<path d="M4.99805 15V19H8.99805V21H2.99805V15H4.99805ZM20.998 15V21H14.998V19H18.998V15H20.998ZM12.998 6V18H10.998V6H12.998ZM8.99805 9V15H6.99805V9H8.99805ZM16.998 9V15H14.998V9H16.998ZM8.99805 3V5H4.99805V9H2.99805V3H8.99805ZM20.998 3V9H18.998V5H14.998V3H20.998Z" fill="currentColor"/>`,
|
||||
"volume-up": `<path d="M6.60282 10.0001L10 7.22056V16.7796L6.60282 14.0001H3V10.0001H6.60282ZM2 16.0001H5.88889L11.1834 20.3319C11.2727 20.405 11.3846 20.4449 11.5 20.4449C11.7761 20.4449 12 20.2211 12 19.9449V4.05519C12 3.93977 11.9601 3.8279 11.887 3.73857C11.7121 3.52485 11.3971 3.49335 11.1834 3.66821L5.88889 8.00007H2C1.44772 8.00007 1 8.44778 1 9.00007V15.0001C1 15.5524 1.44772 16.0001 2 16.0001ZM23 12C23 15.292 21.5539 18.2463 19.2622 20.2622L17.8445 18.8444C19.7758 17.1937 21 14.7398 21 12C21 9.26016 19.7758 6.80629 17.8445 5.15557L19.2622 3.73779C21.5539 5.75368 23 8.70795 23 12ZM18 12C18 10.0883 17.106 8.38548 15.7133 7.28673L14.2842 8.71584C15.3213 9.43855 16 10.64 16 12C16 13.36 15.3213 14.5614 14.2842 15.2841L15.7133 16.7132C17.106 15.6145 18 13.9116 18 12Z" fill="currentColor"/>`,
|
||||
"window": `<path d="M21 3C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM20 11H4V19H20V11ZM20 5H4V9H20V5ZM11 6V8H9V6H11ZM7 6V8H5V6H7Z" fill="currentColor"/>`,
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
@@ -39,6 +39,9 @@ export const DefaultsSettings: React.FC = () => {
|
||||
const [defaultModel, setDefaultModel] = React.useState<string | undefined>();
|
||||
const [defaultVariant, setDefaultVariant] = React.useState<string | undefined>();
|
||||
const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>();
|
||||
const [smallModelUseDefault, setSmallModelUseDefault] = React.useState(true);
|
||||
const [smallModelOverride, setSmallModelOverride] = React.useState<string | undefined>();
|
||||
const [smallModelProviders, setSmallModelProviders] = React.useState<string[] | undefined>();
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
const parsedModel = React.useMemo(() => getDisplayModel(defaultModel), [defaultModel]);
|
||||
@@ -50,6 +53,8 @@ export const DefaultsSettings: React.FC = () => {
|
||||
defaultModel?: string;
|
||||
defaultVariant?: string;
|
||||
defaultAgent?: string;
|
||||
smallModelUseDefault?: boolean;
|
||||
smallModelOverride?: string;
|
||||
} | null = null;
|
||||
|
||||
if (!data) {
|
||||
@@ -59,13 +64,16 @@ export const DefaultsSettings: React.FC = () => {
|
||||
const result = await runtimeSettings.load();
|
||||
const settings = result?.settings;
|
||||
if (settings) {
|
||||
const raw = settings as Record<string, unknown>;
|
||||
data = {
|
||||
defaultModel: typeof settings.defaultModel === 'string' ? settings.defaultModel : undefined,
|
||||
defaultVariant:
|
||||
typeof (settings as Record<string, unknown>).defaultVariant === 'string'
|
||||
? ((settings as Record<string, unknown>).defaultVariant as string)
|
||||
typeof raw.defaultVariant === 'string'
|
||||
? (raw.defaultVariant as string)
|
||||
: undefined,
|
||||
defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined,
|
||||
smallModelUseDefault: typeof raw.smallModelUseDefault === 'boolean' ? raw.smallModelUseDefault : undefined,
|
||||
smallModelOverride: typeof raw.smallModelOverride === 'string' ? raw.smallModelOverride : undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -101,6 +109,10 @@ export const DefaultsSettings: React.FC = () => {
|
||||
if (model !== undefined) setDefaultModel(model);
|
||||
if (variant !== undefined) setDefaultVariant(variant);
|
||||
if (agent !== undefined) setDefaultAgent(agent);
|
||||
if (typeof data.smallModelUseDefault === 'boolean') setSmallModelUseDefault(data.smallModelUseDefault);
|
||||
if (typeof data.smallModelOverride === 'string' && data.smallModelOverride.trim()) {
|
||||
setSmallModelOverride(data.smallModelOverride.trim());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load defaults settings:', error);
|
||||
@@ -189,6 +201,53 @@ export const DefaultsSettings: React.FC = () => {
|
||||
[setAgent, setSettingsDefaultAgent]
|
||||
);
|
||||
|
||||
const handleSmallModelUseDefaultChange = React.useCallback(
|
||||
async (useDefault: boolean) => {
|
||||
setSmallModelUseDefault(useDefault);
|
||||
try {
|
||||
await updateDesktopSettings({ smallModelUseDefault: useDefault });
|
||||
} catch (error) {
|
||||
console.warn('Failed to save small model preference:', error);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSmallModelOverrideChange = React.useCallback(
|
||||
async (providerId: string, modelId: string) => {
|
||||
const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
|
||||
setSmallModelOverride(newValue);
|
||||
try {
|
||||
await updateDesktopSettings({ smallModelOverride: newValue ?? '' });
|
||||
} catch (error) {
|
||||
console.warn('Failed to save small model override:', error);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const parsedSmallModel = React.useMemo(() => getDisplayModel(smallModelOverride), [smallModelOverride]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (smallModelUseDefault || smallModelProviders !== undefined) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/small-model', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json().catch(() => null) as { authenticatedProviders?: unknown } | null;
|
||||
if (!cancelled && Array.isArray(payload?.authenticatedProviders)) {
|
||||
setSmallModelProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string'));
|
||||
}
|
||||
} catch {
|
||||
// leave undefined — picker falls back to showing all providers
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [smallModelUseDefault, smallModelProviders]);
|
||||
|
||||
const availableVariants = React.useMemo(() => {
|
||||
if (!parsedModel.providerId || !parsedModel.modelId) return [];
|
||||
const provider = providers.find((p) => p.id === parsedModel.providerId);
|
||||
@@ -305,6 +364,56 @@ export const DefaultsSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<div className="mt-6 mb-0.5 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.defaults.smallModel.title')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
<div className="mt-0 mb-1 typography-meta text-muted-foreground">
|
||||
{t('settings.openchamber.defaults.smallModel.description')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-settings-item="sessions.small-model"
|
||||
className="group flex cursor-pointer items-center gap-2 py-1"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={smallModelUseDefault}
|
||||
onClick={() => void handleSmallModelUseDefaultChange(!smallModelUseDefault)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void handleSmallModelUseDefaultChange(!smallModelUseDefault);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={smallModelUseDefault}
|
||||
onChange={(checked) => void handleSmallModelUseDefaultChange(checked)}
|
||||
ariaLabel={t('settings.openchamber.defaults.smallModel.useDefaultAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.smallModel.useDefault')}</span>
|
||||
</div>
|
||||
|
||||
{!smallModelUseDefault ? (
|
||||
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.smallModel.overrideModel')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<ModelSelector
|
||||
providerId={parsedSmallModel.providerId}
|
||||
modelId={parsedSmallModel.modelId}
|
||||
onChange={handleSmallModelOverrideChange}
|
||||
allowedProviderIds={smallModelProviders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -144,7 +144,7 @@ const VisualSectionContent: React.FC = () => {
|
||||
|
||||
// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Follow-up behavior, Persist draft
|
||||
const ChatSectionContent: React.FC = () => {
|
||||
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'collapsibleUserMessages', 'stickyUserHeader', 'wideChatLayout', 'splitAssistantMessageActions', 'diffLayout', 'dotfiles', 'fileViewerPreview', 'followUpBehavior', 'persistDraft', 'inputSpellcheck']} />;
|
||||
return <OpenChamberVisualSettings visibleSettings={['sessionAssist', 'chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'collapsibleUserMessages', 'stickyUserHeader', 'wideChatLayout', 'splitAssistantMessageActions', 'diffLayout', 'dotfiles', 'fileViewerPreview', 'followUpBehavior', 'persistDraft', 'inputSpellcheck']} />;
|
||||
};
|
||||
|
||||
// Sessions section: Default model & agent, Session retention
|
||||
|
||||
@@ -245,7 +245,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
|
||||
type VisibleSetting = 'sessionAssist' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
|
||||
|
||||
interface OpenChamberVisualSettingsProps {
|
||||
/** Which settings to show. If undefined, shows all. */
|
||||
@@ -259,6 +259,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const { browserTab } = usePwaDetection();
|
||||
const directoryShowHidden = useDirectoryShowHidden();
|
||||
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
|
||||
const sessionAssistEnabled = useUIStore(state => state.sessionAssistEnabled);
|
||||
const setSessionAssistEnabled = useUIStore(state => state.setSessionAssistEnabled);
|
||||
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
|
||||
const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks);
|
||||
const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks);
|
||||
@@ -1775,8 +1777,31 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
|
||||
{(shouldShow('sessionAssist') || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
|
||||
<section className="p-2 space-y-0.5">
|
||||
{shouldShow('sessionAssist') && (
|
||||
<div
|
||||
data-settings-item="chat.session-assist"
|
||||
className="group flex cursor-pointer items-center gap-2 py-0.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={sessionAssistEnabled}
|
||||
onClick={() => setSessionAssistEnabled(!sessionAssistEnabled)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setSessionAssistEnabled(!sessionAssistEnabled);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={sessionAssistEnabled}
|
||||
onChange={setSessionAssistEnabled}
|
||||
ariaLabel={t('settings.openchamber.visual.field.sessionAssistAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.sessionAssist')}</span>
|
||||
</div>
|
||||
)}
|
||||
{shouldShow('reasoning') && (
|
||||
<div
|
||||
data-settings-item="chat.reasoning-traces"
|
||||
|
||||
@@ -1133,6 +1133,15 @@ export const VoiceSettings: React.FC = () => {
|
||||
>
|
||||
{t('settings.voice.page.field.ttsInputModeRaw')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={ttsInputMode === 'summarized'}
|
||||
onClick={() => setTtsInputMode('summarized')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.field.ttsInputModeSummarized')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,36 @@ import { useSayTTS } from './useSayTTS';
|
||||
import { useLocalTTS } from './useLocalTTS';
|
||||
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
|
||||
import { sanitizeForTTS } from '@/lib/voice/summarize';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
// Below this length the reply is comfortable to listen to as-is; summarizing
|
||||
// would only add latency.
|
||||
const TTS_SUMMARIZE_MIN_CHARS = 600;
|
||||
|
||||
const SUMMARIZE_SYSTEM_PROMPT = 'Summarize the assistant reply for text-to-speech listening. Reply with 2-4 sentences of plain spoken prose in the same language as the reply. No markdown, no lists, no code — mention code changes briefly in words instead.';
|
||||
|
||||
async function summarizeForSpeech(
|
||||
text: string,
|
||||
preferred: { providerID?: string; modelID?: string },
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/small-model/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: text,
|
||||
system: SUMMARIZE_SYSTEM_PROMPT,
|
||||
...(preferred.providerID ? { preferredProviderID: preferred.providerID } : {}),
|
||||
...(preferred.modelID ? { preferredModelID: preferred.modelID } : {}),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const payload = await response.json().catch(() => null) as { text?: unknown } | null;
|
||||
return typeof payload?.text === 'string' && payload.text.trim() ? payload.text.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseMessageTTSReturn {
|
||||
/** Whether TTS is currently playing for this message */
|
||||
@@ -69,9 +99,24 @@ export function useMessageTTS(): UseMessageTTSReturn {
|
||||
setIsPlaying(true);
|
||||
|
||||
try {
|
||||
// Summarized mode: replace long replies with a short spoken-prose
|
||||
// summary from the small model; fall back to the sanitized
|
||||
// original when summarization is unavailable.
|
||||
let sourceText = text;
|
||||
if (ttsInputMode === 'summarized' && text.length >= TTS_SUMMARIZE_MIN_CHARS) {
|
||||
const { currentProviderId, currentModelId } = useConfigStore.getState();
|
||||
const summary = await summarizeForSpeech(text, {
|
||||
providerID: currentProviderId || undefined,
|
||||
modelID: currentModelId || undefined,
|
||||
});
|
||||
if (summary) {
|
||||
sourceText = summary;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldUseRaw = ttsInputMode === 'raw' && isServerProvider;
|
||||
const sanitizedText = sanitizeForTTS(text);
|
||||
const textToSpeak = shouldUseRaw ? text : sanitizedText;
|
||||
const sanitizedText = sanitizeForTTS(sourceText);
|
||||
const textToSpeak = shouldUseRaw ? sourceText : sanitizedText;
|
||||
|
||||
if (isServerProvider && isServerTTSAvailable) {
|
||||
const voice = voiceProvider === 'openai-compatible' ? openaiCompatibleVoice : openaiVoice;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { useDirectoryStore, useSession, useSessionStatus } from '@/sync/sync-context';
|
||||
import { getSessionAssist, type SessionAssistPayload } from '@/lib/sessionAssistMetadata';
|
||||
|
||||
// How long the chat must sit untouched before the recap becomes visible.
|
||||
// The suggestion has no such delay — it shows as soon as it arrives.
|
||||
export const RECAP_VISIBILITY_DELAY_MS = 5 * 60 * 1000;
|
||||
|
||||
interface LastMessageSnapshot {
|
||||
id: string;
|
||||
role: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Narrow subscription to the last message of a session (id/role/time only). */
|
||||
function useLastMessageSnapshot(sessionId: string): LastMessageSnapshot | null {
|
||||
const store = useDirectoryStore();
|
||||
const cacheRef = React.useRef<LastMessageSnapshot | null>(null);
|
||||
|
||||
const getSnapshot = React.useCallback((): LastMessageSnapshot | null => {
|
||||
if (!sessionId) return null;
|
||||
const messages = store.getState().message[sessionId];
|
||||
const last = messages && messages.length > 0 ? messages[messages.length - 1] : null;
|
||||
const info = last as { id?: string; role?: string; time?: { completed?: number; created?: number } } | null;
|
||||
if (!info?.id) {
|
||||
cacheRef.current = null;
|
||||
return null;
|
||||
}
|
||||
const next: LastMessageSnapshot = {
|
||||
id: info.id,
|
||||
role: typeof info.role === 'string' ? info.role : '',
|
||||
timestamp: info.time?.completed ?? info.time?.created ?? 0,
|
||||
};
|
||||
const cached = cacheRef.current;
|
||||
if (cached && cached.id === next.id && cached.role === next.role && cached.timestamp === next.timestamp) {
|
||||
return cached;
|
||||
}
|
||||
cacheRef.current = next;
|
||||
return next;
|
||||
}, [sessionId, store]);
|
||||
|
||||
const subscribe = React.useCallback((notify: () => void) => {
|
||||
if (!sessionId) return () => undefined;
|
||||
return store.subscribe(notify);
|
||||
}, [sessionId, store]);
|
||||
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
export interface SessionAssistState {
|
||||
/** Valid (fresh) assist payload, or null. */
|
||||
assist: SessionAssistPayload | null;
|
||||
/** Recap text, only when the 5-minute quiet window has elapsed. */
|
||||
visibleRecap: string | null;
|
||||
/** Suggestion text — fresh payload, session idle; caller still gates on input emptiness. */
|
||||
suggestion: string | null;
|
||||
}
|
||||
|
||||
export function useSessionAssistState(sessionId: string): SessionAssistState {
|
||||
const session = useSession(sessionId);
|
||||
const status = useSessionStatus(sessionId);
|
||||
const lastMessage = useLastMessageSnapshot(sessionId);
|
||||
|
||||
const isIdle = !status || status.type === 'idle';
|
||||
const payload = getSessionAssist(session);
|
||||
|
||||
// Fresh = the payload's target message is still the session's last message.
|
||||
const assist = payload
|
||||
&& lastMessage
|
||||
&& lastMessage.role === 'assistant'
|
||||
&& lastMessage.id === payload.forMessageID
|
||||
&& isIdle
|
||||
? payload
|
||||
: null;
|
||||
|
||||
// Recap waits out the quiet window; re-render once when the boundary passes.
|
||||
const lastTimestamp = lastMessage?.timestamp ?? 0;
|
||||
const [, forceTick] = React.useReducer((tick: number) => tick + 1, 0);
|
||||
const quietElapsed = assist ? Date.now() - lastTimestamp >= RECAP_VISIBILITY_DELAY_MS : false;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!assist || quietElapsed || !lastTimestamp) return undefined;
|
||||
const remaining = RECAP_VISIBILITY_DELAY_MS - (Date.now() - lastTimestamp);
|
||||
if (remaining <= 0) return undefined;
|
||||
const timer = setTimeout(forceTick, remaining + 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [assist, quietElapsed, lastTimestamp]);
|
||||
|
||||
return {
|
||||
assist,
|
||||
visibleRecap: assist && assist.recap && quietElapsed ? assist.recap : null,
|
||||
suggestion: assist && assist.suggestion ? assist.suggestion : null,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
|
||||
type AppearanceSlice = {
|
||||
showReasoningTraces: boolean;
|
||||
sessionAssistEnabled: boolean;
|
||||
collapsibleThinkingBlocks: boolean;
|
||||
showDeletionDialog: boolean;
|
||||
nativeNotificationsEnabled: boolean;
|
||||
@@ -50,6 +51,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
|
||||
let previous: AppearanceSlice = {
|
||||
showReasoningTraces: useUIStore.getState().showReasoningTraces,
|
||||
sessionAssistEnabled: useUIStore.getState().sessionAssistEnabled,
|
||||
collapsibleThinkingBlocks: useUIStore.getState().collapsibleThinkingBlocks,
|
||||
showDeletionDialog: useUIStore.getState().showDeletionDialog,
|
||||
nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled,
|
||||
@@ -101,6 +103,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
useUIStore.subscribe((state) => {
|
||||
const current: AppearanceSlice = {
|
||||
showReasoningTraces: state.showReasoningTraces,
|
||||
sessionAssistEnabled: state.sessionAssistEnabled,
|
||||
collapsibleThinkingBlocks: state.collapsibleThinkingBlocks,
|
||||
showDeletionDialog: state.showDeletionDialog,
|
||||
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
||||
@@ -134,6 +137,9 @@ export const startAppearanceAutoSave = (): void => {
|
||||
if (current.showReasoningTraces !== previous.showReasoningTraces) {
|
||||
diff.showReasoningTraces = current.showReasoningTraces;
|
||||
}
|
||||
if (current.sessionAssistEnabled !== previous.sessionAssistEnabled) {
|
||||
diff.sessionAssistEnabled = current.sessionAssistEnabled;
|
||||
}
|
||||
if (current.collapsibleThinkingBlocks !== previous.collapsibleThinkingBlocks) {
|
||||
diff.collapsibleThinkingBlocks = current.collapsibleThinkingBlocks;
|
||||
}
|
||||
|
||||
@@ -113,6 +113,9 @@ export type DesktopSettings = {
|
||||
defaultModel?: string; // format: "provider/model"
|
||||
defaultVariant?: string;
|
||||
defaultAgent?: string;
|
||||
smallModelUseDefault?: boolean;
|
||||
sessionAssistEnabled?: boolean;
|
||||
smallModelOverride?: string; // format: "provider/model"
|
||||
defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id
|
||||
openInAppId?: string;
|
||||
autoCreateWorktree?: boolean;
|
||||
|
||||
+158
-41
@@ -2,6 +2,7 @@
|
||||
import * as gitHttp from './gitApiHttp';
|
||||
import { opencodeClient } from './opencode/client';
|
||||
import { renderMagicPrompt } from './magicPrompts';
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
import { materializeOpenDraftSession, useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
@@ -210,6 +211,71 @@ export async function deleteRemoteBranch(directory: string, payload: import('./a
|
||||
return gitHttp.deleteRemoteBranch(directory, payload);
|
||||
}
|
||||
|
||||
const COMMIT_DIFF_FILE_LIMIT = 30;
|
||||
const COMMIT_DIFF_TOTAL_CHAR_LIMIT = 120_000;
|
||||
|
||||
const collectSelectedFileDiffs = async (directory: string, files: string[]): Promise<string> => {
|
||||
const limited = files.slice(0, COMMIT_DIFF_FILE_LIMIT);
|
||||
const chunks = await Promise.all(limited.map(async (path) => {
|
||||
try {
|
||||
const [staged, unstaged] = await Promise.all([
|
||||
gitHttp.getGitDiff(directory, { path, staged: true }).catch(() => null),
|
||||
gitHttp.getGitDiff(directory, { path, staged: false }).catch(() => null),
|
||||
]);
|
||||
const text = [staged?.diff, unstaged?.diff]
|
||||
.filter((diff): diff is string => typeof diff === 'string' && diff.trim().length > 0)
|
||||
.join('\n');
|
||||
return text ? text : `--- ${path} (no textual diff available)`;
|
||||
} catch {
|
||||
return `--- ${path} (diff unavailable)`;
|
||||
}
|
||||
}));
|
||||
|
||||
let total = '';
|
||||
for (const chunk of chunks) {
|
||||
if (total.length + chunk.length > COMMIT_DIFF_TOTAL_CHAR_LIMIT) {
|
||||
total += '\n[remaining diffs truncated]';
|
||||
break;
|
||||
}
|
||||
total += (total ? '\n\n' : '') + chunk;
|
||||
}
|
||||
if (files.length > limited.length) {
|
||||
total += `\n[${files.length - limited.length} more selected files omitted]`;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
const parseCommitStructured = (structured: Record<string, unknown> | null): { subject: string; highlights: string[] } => {
|
||||
const subject = typeof structured?.subject === 'string' ? structured.subject.trim() : '';
|
||||
const highlights = Array.isArray(structured?.highlights)
|
||||
? structured.highlights.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean).slice(0, 3)
|
||||
: [];
|
||||
if (!subject) {
|
||||
throw new Error('Structured output missing subject');
|
||||
}
|
||||
return { subject, highlights };
|
||||
};
|
||||
|
||||
// Legacy transport: run the structured generation inside the active chat
|
||||
// session. Kept as the fallback for setups with no direct provider login
|
||||
// (vanilla installs on OpenCode's free models), where the small-model
|
||||
// endpoint has nothing to call but the session itself still works.
|
||||
async function generateCommitMessageViaSession(
|
||||
directory: string,
|
||||
visiblePrompt: string,
|
||||
hiddenPrompt: string,
|
||||
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
|
||||
const generationSession = await resolveGenerationSessionContext();
|
||||
const structured = await runStructuredGenerationInActiveSession({
|
||||
directory,
|
||||
visiblePrompt,
|
||||
hiddenPrompt,
|
||||
generationSession,
|
||||
kind: 'commit',
|
||||
});
|
||||
return { message: parseCommitStructured(structured) };
|
||||
}
|
||||
|
||||
export async function generateCommitMessage(
|
||||
directory: string,
|
||||
files: string[],
|
||||
@@ -217,17 +283,12 @@ export async function generateCommitMessage(
|
||||
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
|
||||
const startedAt = Date.now();
|
||||
void options;
|
||||
const generationSession = await resolveGenerationSessionContext();
|
||||
|
||||
console.info('[git-generation][browser] request', {
|
||||
transport: 'session',
|
||||
transport: 'small-model',
|
||||
kind: 'commit',
|
||||
directory,
|
||||
selectedFiles: files.length,
|
||||
sessionId: generationSession.sessionId,
|
||||
providerId: generationSession.providerID,
|
||||
modelId: generationSession.modelID,
|
||||
agent: generationSession.agent,
|
||||
});
|
||||
|
||||
const visiblePrompt = await renderMagicPrompt('git.commit.generate.visible');
|
||||
@@ -236,26 +297,44 @@ export async function generateCommitMessage(
|
||||
});
|
||||
|
||||
try {
|
||||
const structured = await runStructuredGenerationInActiveSession({
|
||||
directory,
|
||||
visiblePrompt,
|
||||
hiddenPrompt,
|
||||
generationSession,
|
||||
kind: 'commit',
|
||||
const diffs = await collectSelectedFileDiffs(directory, files);
|
||||
const { currentProviderId, currentModelId } = useConfigStore.getState();
|
||||
const response = await runtimeFetch('/api/small-model/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
system: visiblePrompt,
|
||||
prompt: `${hiddenPrompt}\n\nDiffs of the selected files:\n${diffs}`,
|
||||
directory,
|
||||
...(currentProviderId ? { preferredProviderID: currentProviderId } : {}),
|
||||
...(currentModelId ? { preferredModelID: currentModelId } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const subject = typeof structured.subject === 'string' ? structured.subject.trim() : '';
|
||||
const highlights = Array.isArray(structured.highlights)
|
||||
? structured.highlights.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean).slice(0, 3)
|
||||
: [];
|
||||
|
||||
if (!subject) {
|
||||
throw new Error('Structured output missing subject');
|
||||
if (response.status === 404) {
|
||||
// No authenticated provider has a small model — fall back to the
|
||||
// session transport so free-model-only setups keep a working button.
|
||||
console.info('[git-generation][browser] small model unavailable, falling back to session transport');
|
||||
const result = await generateCommitMessageViaSession(directory, visiblePrompt, hiddenPrompt);
|
||||
console.info('[git-generation][browser] success', {
|
||||
transport: 'session-fallback',
|
||||
kind: 'commit',
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
subjectLength: result.message.subject.length,
|
||||
highlightsCount: result.message.highlights.length,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = { message: { subject, highlights } };
|
||||
const payload = await response.json().catch(() => null) as { text?: unknown; error?: unknown } | null;
|
||||
if (!response.ok || typeof payload?.text !== 'string') {
|
||||
const message = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const result = { message: parseCommitStructured(extractJsonObject(payload.text)) };
|
||||
console.info('[git-generation][browser] success', {
|
||||
transport: 'session',
|
||||
transport: 'small-model',
|
||||
kind: 'commit',
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
subjectLength: result.message.subject.length,
|
||||
@@ -264,7 +343,7 @@ export async function generateCommitMessage(
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[git-generation][browser] failed', {
|
||||
transport: 'session',
|
||||
transport: 'small-model',
|
||||
kind: 'commit',
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
@@ -279,18 +358,19 @@ export async function generatePullRequestDescription(
|
||||
payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
|
||||
): Promise<import('./api/types').GeneratedPullRequestDescription> {
|
||||
const startedAt = Date.now();
|
||||
const generationSession = await resolveGenerationSessionContext();
|
||||
|
||||
const commitLog = await getGitLog(directory, {
|
||||
from: payload.base,
|
||||
to: payload.head,
|
||||
maxCount: 50,
|
||||
});
|
||||
const COMMIT_BODY_CHAR_LIMIT = 2_000;
|
||||
const commits = (Array.isArray(commitLog?.all) ? commitLog.all : [])
|
||||
.filter((entry) => typeof entry?.hash === 'string' && entry.hash.length > 0)
|
||||
.map((entry) => ({
|
||||
hash: entry.hash,
|
||||
subject: typeof entry.message === 'string' ? entry.message.trim() : '',
|
||||
body: typeof entry.body === 'string' ? entry.body.trim().slice(0, COMMIT_BODY_CHAR_LIMIT) : '',
|
||||
}));
|
||||
|
||||
if (commits.length === 0) {
|
||||
@@ -317,13 +397,9 @@ export async function generatePullRequestDescription(
|
||||
const changedFiles = Array.from(filesSet).sort().slice(0, 300);
|
||||
|
||||
console.info('[git-generation][browser] request', {
|
||||
transport: 'session',
|
||||
transport: 'small-model',
|
||||
kind: 'pr',
|
||||
directory,
|
||||
sessionId: generationSession.sessionId,
|
||||
providerId: generationSession.providerID,
|
||||
modelId: generationSession.modelID,
|
||||
agent: generationSession.agent,
|
||||
base: payload.base,
|
||||
head: payload.head,
|
||||
commits: commits.length,
|
||||
@@ -334,26 +410,67 @@ export async function generatePullRequestDescription(
|
||||
const hiddenPrompt = await renderMagicPrompt('git.pr.generate.instructions', {
|
||||
base_branch: payload.base,
|
||||
head_branch: payload.head,
|
||||
commits: commits.map((commit) => `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`).join('\n'),
|
||||
commits: commits.map((commit) => {
|
||||
const line = `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`;
|
||||
if (!commit.body) return line;
|
||||
const indentedBody = commit.body.split('\n').map((bodyLine) => ` ${bodyLine}`).join('\n');
|
||||
return `${line}\n${indentedBody}`;
|
||||
}).join('\n'),
|
||||
changed_files: changedFiles.length > 0 ? changedFiles.map((file) => `- ${file}`).join('\n') : '- none detected',
|
||||
additional_context_block: payload.context?.trim() ? `\nAdditional context:\n${payload.context.trim()}` : '',
|
||||
});
|
||||
|
||||
const parsePrStructured = (structured: Record<string, unknown> | null) => ({
|
||||
title: typeof structured?.title === 'string' ? structured.title.trim() : '',
|
||||
body: typeof structured?.body === 'string' ? structured.body.trim() : '',
|
||||
});
|
||||
|
||||
try {
|
||||
const structured = await runStructuredGenerationInActiveSession({
|
||||
directory,
|
||||
visiblePrompt,
|
||||
hiddenPrompt,
|
||||
generationSession,
|
||||
kind: 'pr',
|
||||
const { currentProviderId, currentModelId } = useConfigStore.getState();
|
||||
const response = await runtimeFetch('/api/small-model/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
system: visiblePrompt,
|
||||
prompt: hiddenPrompt,
|
||||
directory,
|
||||
...(currentProviderId ? { preferredProviderID: currentProviderId } : {}),
|
||||
...(currentModelId ? { preferredModelID: currentModelId } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = {
|
||||
title: typeof structured.title === 'string' ? structured.title.trim() : '',
|
||||
body: typeof structured.body === 'string' ? structured.body.trim() : '',
|
||||
};
|
||||
if (response.status === 404) {
|
||||
// No authenticated provider has a small model — fall back to the
|
||||
// session transport so free-model-only setups keep working.
|
||||
console.info('[git-generation][browser] small model unavailable, falling back to session transport');
|
||||
const generationSession = await resolveGenerationSessionContext();
|
||||
const structured = await runStructuredGenerationInActiveSession({
|
||||
directory,
|
||||
visiblePrompt,
|
||||
hiddenPrompt,
|
||||
generationSession,
|
||||
kind: 'pr',
|
||||
});
|
||||
const result = parsePrStructured(structured);
|
||||
console.info('[git-generation][browser] success', {
|
||||
transport: 'session-fallback',
|
||||
kind: 'pr',
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
titleLength: result.title.length,
|
||||
bodyLength: result.body.length,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null) as { text?: unknown; error?: unknown } | null;
|
||||
if (!response.ok || typeof payload?.text !== 'string') {
|
||||
const message = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const result = parsePrStructured(extractJsonObject(payload.text));
|
||||
console.info('[git-generation][browser] success', {
|
||||
transport: 'session',
|
||||
transport: 'small-model',
|
||||
kind: 'pr',
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
titleLength: result.title.length,
|
||||
@@ -362,7 +479,7 @@ export async function generatePullRequestDescription(
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[git-generation][browser] failed', {
|
||||
transport: 'session',
|
||||
transport: 'small-model',
|
||||
kind: 'pr',
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -1397,6 +1397,11 @@ export const settingsDict = {
|
||||
'settings.openchamber.defaults.field.defaultAgent': 'Default Agent',
|
||||
'settings.openchamber.defaults.field.showDeletionDialogAria': 'Show deletion dialog',
|
||||
'settings.openchamber.defaults.field.showDeletionDialog': 'Show Deletion Dialog',
|
||||
'settings.openchamber.defaults.smallModel.title': 'Small Model',
|
||||
'settings.openchamber.defaults.smallModel.description': 'A cheap model for quick utility tasks like short recaps and summaries.',
|
||||
'settings.openchamber.defaults.smallModel.useDefault': 'Use default small model',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': 'Use default small model',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': 'Override model',
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': 'Open files in preview mode',
|
||||
'settings.openchamber.defaults.field.openFilesPreview': 'Open files in preview mode',
|
||||
'settings.openchamber.defaults.option.default': 'Default',
|
||||
@@ -1601,6 +1606,7 @@ export const settingsDict = {
|
||||
'settings.voice.page.field.ttsInputMode': 'TTS Input Mode',
|
||||
'settings.voice.page.field.ttsInputModeSanitized': 'Sanitized',
|
||||
'settings.voice.page.field.ttsInputModeRaw': 'Raw Markdown',
|
||||
'settings.voice.page.field.ttsInputModeSummarized': 'summarized',
|
||||
'settings.openchamber.visual.section.colorMode': 'Color Mode',
|
||||
'settings.openchamber.visual.section.mobileLayout': 'Mobile Layout',
|
||||
'settings.openchamber.visual.option.mobileLayout.default': 'Old',
|
||||
@@ -1684,6 +1690,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.userMessageRenderingAria': 'User message rendering: {option}',
|
||||
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid rendering: {option}',
|
||||
'settings.openchamber.visual.field.diffLayoutAria': 'Diff layout: {option}',
|
||||
'settings.openchamber.visual.field.sessionAssist': 'Generate Session Recap & Suggestion',
|
||||
'settings.openchamber.visual.field.sessionAssistAria': 'Generate a recap and a suggested reply after the agent finishes',
|
||||
'settings.openchamber.visual.field.showReasoningTracesAria': 'Show reasoning traces',
|
||||
'settings.openchamber.visual.field.showReasoningTraces': 'Show Reasoning Traces',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Enable collapsible reasoning blocks',
|
||||
|
||||
@@ -1387,6 +1387,10 @@ export const dict = {
|
||||
'header.actions.toggleChangesPanelAria': 'Toggle changes panel',
|
||||
'header.actions.planWithShortcut': 'Plan ({shortcut})',
|
||||
'header.actions.terminalPanelWithShortcut': 'Terminal panel ({shortcut})',
|
||||
'chat.recap.aria': 'Session recap',
|
||||
'chat.recap.label': 'Recap:',
|
||||
'chat.suggestion.applyAria': 'Use suggested message',
|
||||
'chat.suggestion.dismissAria': 'Dismiss suggestion',
|
||||
'header.actions.toggleTerminalPanelAria': 'Toggle terminal panel',
|
||||
'terminalView.stream.processExitedMessage': '\r\n[Process exited{exitCodeSegment}{signalSegment}]\r\n',
|
||||
'terminalView.stream.processExitedWithCode': ' with code {exitCode}',
|
||||
|
||||
@@ -1363,6 +1363,11 @@ export const settingsDict = {
|
||||
"settings.openchamber.defaults.field.thinkingPlaceholder": "Pensando",
|
||||
"settings.openchamber.defaults.field.defaultAgent": "Agente por defecto",
|
||||
"settings.openchamber.defaults.field.showDeletionDialogAria": "Mostrar diálogo de eliminación",
|
||||
"settings.openchamber.defaults.smallModel.title": "Modelo pequeño",
|
||||
"settings.openchamber.defaults.smallModel.description": "Un modelo económico para tareas utilitarias rápidas, como recapitulaciones y resúmenes breves.",
|
||||
"settings.openchamber.defaults.smallModel.useDefault": "Usar el modelo pequeño predeterminado",
|
||||
"settings.openchamber.defaults.smallModel.useDefaultAria": "Usar el modelo pequeño predeterminado",
|
||||
"settings.openchamber.defaults.smallModel.overrideModel": "Modelo de anulación",
|
||||
"settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación",
|
||||
"settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir archivos en modo vista previa",
|
||||
"settings.openchamber.defaults.field.openFilesPreview": "Abrir archivos en modo vista previa",
|
||||
@@ -1568,6 +1573,7 @@ export const settingsDict = {
|
||||
"settings.voice.page.field.ttsInputMode": "Modo de entrada TTS",
|
||||
"settings.voice.page.field.ttsInputModeSanitized": "Texto limpio",
|
||||
"settings.voice.page.field.ttsInputModeRaw": "Markdown sin procesar",
|
||||
"settings.voice.page.field.ttsInputModeSummarized": "resumido",
|
||||
"settings.openchamber.visual.section.colorMode": "Modo de color",
|
||||
"settings.openchamber.visual.section.mobileLayout": "Diseño móvil",
|
||||
"settings.openchamber.visual.option.mobileLayout.default": "Anterior",
|
||||
@@ -1651,6 +1657,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.field.userMessageRenderingAria": "Renderizado de mensajes del usuario: {option}",
|
||||
"settings.openchamber.visual.field.mermaidRenderingAria": "Renderizado de Mermaid: {option}",
|
||||
"settings.openchamber.visual.field.diffLayoutAria": "Diseño de comparación: {option}",
|
||||
"settings.openchamber.visual.field.sessionAssist": "Generar resumen y sugerencia de sesión",
|
||||
"settings.openchamber.visual.field.sessionAssistAria": "Generar un resumen y una respuesta sugerida cuando el agente termina",
|
||||
"settings.openchamber.visual.field.showReasoningTracesAria": "Mostrar rastros de razonamiento",
|
||||
"settings.openchamber.visual.field.showReasoningTraces": "Mostrar trazas de razonamiento",
|
||||
"settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Habilitar bloques de razonamiento colapsables",
|
||||
|
||||
@@ -1365,6 +1365,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"header.actions.toggleChangesPanelAria": "Alternar panel de cambios",
|
||||
"header.actions.planWithShortcut": "Plan ({shortcut})",
|
||||
"header.actions.terminalPanelWithShortcut": "Panel de terminal ({shortcut})",
|
||||
"chat.recap.aria": "Resumen de la sesión",
|
||||
"chat.recap.label": "Resumen:",
|
||||
"chat.suggestion.applyAria": "Usar mensaje sugerido",
|
||||
"chat.suggestion.dismissAria": "Descartar sugerencia",
|
||||
"header.actions.toggleTerminalPanelAria": "Mostrar u ocultar panel de terminal",
|
||||
"terminalView.stream.processExitedMessage": "\r\n[Proceso terminado{exitCodeSegment}{signalSegment}]\r\n",
|
||||
"terminalView.stream.processExitedWithCode": " con código {exitCode}",
|
||||
|
||||
@@ -1346,6 +1346,11 @@ export const settingsDict = {
|
||||
'settings.openchamber.defaults.field.thinkingPlaceholder': 'Pensée',
|
||||
'settings.openchamber.defaults.field.defaultAgent': 'Agent par défaut',
|
||||
'settings.openchamber.defaults.field.showDeletionDialogAria': 'Afficher la boîte de dialogue de suppression',
|
||||
'settings.openchamber.defaults.smallModel.title': 'Petit modèle',
|
||||
'settings.openchamber.defaults.smallModel.description': 'Un modèle économique pour les tâches utilitaires rapides, comme les récapitulatifs et résumés courts.',
|
||||
'settings.openchamber.defaults.smallModel.useDefault': 'Utiliser le petit modèle par défaut',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': 'Utiliser le petit modèle par défaut',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': 'Modèle de remplacement',
|
||||
'settings.openchamber.defaults.field.showDeletionDialog': 'Afficher la boîte de dialogue de suppression',
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': 'Ouvrir les fichiers en mode aperçu',
|
||||
'settings.openchamber.defaults.field.openFilesPreview': 'Ouvrir les fichiers en mode aperçu',
|
||||
@@ -1619,6 +1624,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.userMessageRenderingAria': 'Rendu du message utilisateur : {option}',
|
||||
'settings.openchamber.visual.field.mermaidRenderingAria': 'Rendu Mermaid : {option}',
|
||||
'settings.openchamber.visual.field.diffLayoutAria': 'Disposition des différences : {option}',
|
||||
'settings.openchamber.visual.field.sessionAssist': 'Générer le récapitulatif et la suggestion de session',
|
||||
'settings.openchamber.visual.field.sessionAssistAria': "Générer un récapitulatif et une réponse suggérée quand l'agent termine",
|
||||
'settings.openchamber.visual.field.showReasoningTracesAria': 'Afficher les traces de raisonnement',
|
||||
'settings.openchamber.visual.field.showReasoningTraces': 'Afficher les traces de raisonnement',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Activer les blocs de raisonnement pliables',
|
||||
@@ -1785,6 +1792,7 @@ export const settingsDict = {
|
||||
'settings.voice.page.field.ttsInputMode': 'Mode d’entrée TTS',
|
||||
'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé',
|
||||
'settings.voice.page.field.ttsInputModeRaw': 'Markdown brut',
|
||||
'settings.voice.page.field.ttsInputModeSummarized': 'résumé',
|
||||
'settings.openchamber.visual.section.mobileLayout': 'Mise en page mobile',
|
||||
'settings.openchamber.visual.option.mobileLayout.default': 'Ancienne',
|
||||
'settings.openchamber.visual.option.mobileLayout.new': 'Nouvelle',
|
||||
|
||||
@@ -1214,6 +1214,10 @@ export const dict = {
|
||||
"header.actions.toggleChangesPanelAria": "Basculer le panneau des changements",
|
||||
'header.actions.planWithShortcut': 'Forfait ({shortcut})',
|
||||
'header.actions.terminalPanelWithShortcut': 'Panneau à bornes ({shortcut})',
|
||||
'chat.recap.aria': 'Récapitulatif de la session',
|
||||
'chat.recap.label': 'Récap :',
|
||||
'chat.suggestion.applyAria': 'Utiliser le message suggéré',
|
||||
'chat.suggestion.dismissAria': 'Ignorer la suggestion',
|
||||
'header.actions.toggleTerminalPanelAria': 'Basculer le panneau à bornes',
|
||||
'terminalView.stream.processExitedMessage': '[Processus terminé{exitCodeSegment}{signalSegment}]',
|
||||
'terminalView.stream.processExitedWithCode': 'avec le code {exitCode}',
|
||||
|
||||
@@ -1396,6 +1396,11 @@ export const settingsDict = {
|
||||
'settings.openchamber.defaults.field.thinkingPlaceholder': '思考',
|
||||
'settings.openchamber.defaults.field.defaultAgent': 'デフォルト Agent',
|
||||
'settings.openchamber.defaults.field.showDeletionDialogAria': '削除ダイアログを表示',
|
||||
'settings.openchamber.defaults.smallModel.title': '小型モデル',
|
||||
'settings.openchamber.defaults.smallModel.description': '短い要約やまとめなどの軽いユーティリティタスク用の低コストモデルです。',
|
||||
'settings.openchamber.defaults.smallModel.useDefault': 'デフォルトの小型モデルを使用',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': 'デフォルトの小型モデルを使用',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': '上書きモデル',
|
||||
'settings.openchamber.defaults.field.showDeletionDialog': '削除ダイアログを表示',
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': 'ファイルをプレビューモードで開く',
|
||||
'settings.openchamber.defaults.field.openFilesPreview': 'ファイルをプレビューモードで開く',
|
||||
@@ -1601,6 +1606,7 @@ export const settingsDict = {
|
||||
'settings.voice.page.field.ttsInputMode': 'TTS 入力モード',
|
||||
'settings.voice.page.field.ttsInputModeSanitized': 'サニタイズ',
|
||||
'settings.voice.page.field.ttsInputModeRaw': '生 Markdown',
|
||||
'settings.voice.page.field.ttsInputModeSummarized': '要約',
|
||||
'settings.openchamber.visual.section.colorMode': 'カラーモード',
|
||||
'settings.openchamber.visual.section.mobileLayout': 'モバイルレイアウト',
|
||||
'settings.openchamber.visual.option.mobileLayout.default': '旧',
|
||||
@@ -1684,6 +1690,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.userMessageRenderingAria': 'ユーザーメッセージ表示: {option}',
|
||||
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 表示: {option}',
|
||||
'settings.openchamber.visual.field.diffLayoutAria': 'Diff レイアウト: {option}',
|
||||
'settings.openchamber.visual.field.sessionAssist': 'セッションの要約と提案を生成',
|
||||
'settings.openchamber.visual.field.sessionAssistAria': 'エージェントの完了後に要約と返信の提案を生成します',
|
||||
'settings.openchamber.visual.field.showReasoningTracesAria': '推論トレースを表示',
|
||||
'settings.openchamber.visual.field.showReasoningTraces': '推論トレースを表示',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '折りたたみ可能な推論ブロックを有効化',
|
||||
|
||||
@@ -1383,6 +1383,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'header.actions.toggleChangesPanelAria': '変更パネルの切り替え',
|
||||
'header.actions.planWithShortcut': '計画({shortcut})',
|
||||
'header.actions.terminalPanelWithShortcut': 'ターミナルパネル({shortcut})',
|
||||
'chat.recap.aria': 'セッションの要約',
|
||||
'chat.recap.label': '要約:',
|
||||
'chat.suggestion.applyAria': '提案されたメッセージを使用',
|
||||
'chat.suggestion.dismissAria': '提案を閉じる',
|
||||
'header.actions.toggleTerminalPanelAria': 'ターミナルパネルの切り替え',
|
||||
'terminalView.stream.processExitedMessage': '\r\n[プロセスが終了しました{exitCodeSegment}{signalSegment}]\r\n',
|
||||
'terminalView.stream.processExitedWithCode': ' コード {exitCode}',
|
||||
|
||||
@@ -1363,6 +1363,11 @@ export const settingsDict = {
|
||||
'settings.openchamber.defaults.field.thinkingPlaceholder': 'Thinking',
|
||||
'settings.openchamber.defaults.field.defaultAgent': '기본 에이전트',
|
||||
'settings.openchamber.defaults.field.showDeletionDialogAria': '삭제 확인 대화상자 표시',
|
||||
'settings.openchamber.defaults.smallModel.title': '소형 모델',
|
||||
'settings.openchamber.defaults.smallModel.description': '짧은 요약 등 가벼운 유틸리티 작업을 위한 저렴한 모델입니다.',
|
||||
'settings.openchamber.defaults.smallModel.useDefault': '기본 소형 모델 사용',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': '기본 소형 모델 사용',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': '재정의 모델',
|
||||
'settings.openchamber.defaults.field.showDeletionDialog': '삭제 확인 대화상자 표시',
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': '파일을 미리보기 모드로 열기',
|
||||
'settings.openchamber.defaults.field.openFilesPreview': '파일을 미리보기 모드로 열기',
|
||||
@@ -1568,6 +1573,7 @@ export const settingsDict = {
|
||||
'settings.voice.page.field.ttsInputMode': 'TTS 입력 모드',
|
||||
'settings.voice.page.field.ttsInputModeSanitized': '정제된 텍스트',
|
||||
'settings.voice.page.field.ttsInputModeRaw': '원본 Markdown',
|
||||
'settings.voice.page.field.ttsInputModeSummarized': '요약',
|
||||
'settings.openchamber.visual.section.colorMode': '색상 모드',
|
||||
'settings.openchamber.visual.section.mobileLayout': '모바일 레이아웃',
|
||||
'settings.openchamber.visual.option.mobileLayout.default': '이전',
|
||||
@@ -1651,6 +1657,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.userMessageRenderingAria': '사용자 메시지 렌더링: {option}',
|
||||
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 렌더링: {option}',
|
||||
'settings.openchamber.visual.field.diffLayoutAria': 'Diff 레이아웃: {option}',
|
||||
'settings.openchamber.visual.field.sessionAssist': '세션 요약 및 제안 생성',
|
||||
'settings.openchamber.visual.field.sessionAssistAria': '에이전트가 완료되면 요약과 제안 답장을 생성합니다',
|
||||
'settings.openchamber.visual.field.showReasoningTracesAria': 'Reasoning trace 표시',
|
||||
'settings.openchamber.visual.field.showReasoningTraces': 'Reasoning Trace 표시',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '접을 수 있는 추론 블록 활성화',
|
||||
|
||||
@@ -1389,6 +1389,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"header.actions.toggleChangesPanelAria": "변경 패널 전환",
|
||||
'header.actions.planWithShortcut': '플랜 ({shortcut})',
|
||||
'header.actions.terminalPanelWithShortcut': '터미널 패널 ({shortcut})',
|
||||
'chat.recap.aria': '세션 요약',
|
||||
'chat.recap.label': '요약:',
|
||||
'chat.suggestion.applyAria': '제안된 메시지 사용',
|
||||
'chat.suggestion.dismissAria': '제안 닫기',
|
||||
'header.actions.toggleTerminalPanelAria': '토글 터미널 패널',
|
||||
'terminalView.stream.processExitedMessage': '\r\n[Process exited{exitCodeSegment}{signalSegment}]\r\n',
|
||||
'terminalView.stream.processExitedWithCode': ', 종료 코드 {exitCode}',
|
||||
|
||||
@@ -678,6 +678,11 @@ export const settingsDict = {
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': 'Otwieraj pliki w trybie podglądu',
|
||||
'settings.openchamber.defaults.field.showDeletionDialog': 'Pokaż dialog usuwania',
|
||||
'settings.openchamber.defaults.field.showDeletionDialogAria': 'Pokaż dialog usuwania',
|
||||
'settings.openchamber.defaults.smallModel.title': 'Mały model',
|
||||
'settings.openchamber.defaults.smallModel.description': 'Tani model do szybkich zadań pomocniczych, takich jak krótkie podsumowania.',
|
||||
'settings.openchamber.defaults.smallModel.useDefault': 'Używaj domyślnego małego modelu',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': 'Używaj domyślnego małego modelu',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': 'Model zastępczy',
|
||||
'settings.openchamber.defaults.field.thinkingPlaceholder': 'Myślenie',
|
||||
'settings.openchamber.defaults.option.default': 'Domyślne',
|
||||
'settings.openchamber.defaults.option.defaultLowercase': 'domyślne',
|
||||
@@ -969,6 +974,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.showDotfilesAria': 'Pokaż pliki ukryte',
|
||||
'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Pokaż rozwinięte narzędzia bash',
|
||||
'settings.openchamber.visual.field.showExpandedEditToolsAria': 'Pokaż rozwinięte narzędzia edycji',
|
||||
'settings.openchamber.visual.field.sessionAssist': 'Generuj podsumowanie i sugestię sesji',
|
||||
'settings.openchamber.visual.field.sessionAssistAria': 'Generuj podsumowanie i sugerowaną odpowiedź po zakończeniu pracy agenta',
|
||||
'settings.openchamber.visual.field.showReasoningTraces': 'Pokaż ślady rozumowania',
|
||||
'settings.openchamber.visual.field.showReasoningTracesAria': 'Pokaż ślady rozumowania',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocks': 'Włącz zwijalne bloki rozumowania',
|
||||
@@ -1819,6 +1826,7 @@ export const settingsDict = {
|
||||
'settings.voice.page.field.ttsInputMode': 'Tryb wejścia TTS',
|
||||
'settings.voice.page.field.ttsInputModeSanitized': 'Oczyszczony tekst',
|
||||
'settings.voice.page.field.ttsInputModeRaw': 'Surowy Markdown',
|
||||
'settings.voice.page.field.ttsInputModeSummarized': 'streszczony',
|
||||
'settings.window.description': 'Okno ustawień OpenChamber.',
|
||||
'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior',
|
||||
'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior',
|
||||
|
||||
@@ -2061,6 +2061,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'header.actions.planWithShortcut': 'Plan ({shortcut})',
|
||||
'header.actions.rightSidebarWithShortcut': 'Prawy panel boczny ({shortcut})',
|
||||
'header.actions.terminalPanelWithShortcut': 'Panel terminala ({shortcut})',
|
||||
'chat.recap.aria': 'Podsumowanie sesji',
|
||||
'chat.recap.label': 'Podsumowanie:',
|
||||
'chat.suggestion.applyAria': 'Użyj sugerowanej wiadomości',
|
||||
'chat.suggestion.dismissAria': 'Odrzuć sugestię',
|
||||
'header.actions.toggleRightSidebarAria': 'Przełącz prawy panel boczny',
|
||||
'header.actions.toggleTerminalPanelAria': 'Przełącz panel terminala',
|
||||
'header.changes.availableAria': 'Dostępne zmiany',
|
||||
|
||||
@@ -1363,6 +1363,11 @@ export const settingsDict = {
|
||||
"settings.openchamber.defaults.field.thinkingPlaceholder": "Pensando",
|
||||
"settings.openchamber.defaults.field.defaultAgent": "Agente por padrão",
|
||||
"settings.openchamber.defaults.field.showDeletionDialogAria": "Mostrar diálogo de eliminación",
|
||||
"settings.openchamber.defaults.smallModel.title": "Modelo pequeno",
|
||||
"settings.openchamber.defaults.smallModel.description": "Um modelo barato para tarefas utilitárias rápidas, como recapitulações e resumos curtos.",
|
||||
"settings.openchamber.defaults.smallModel.useDefault": "Usar o modelo pequeno padrão",
|
||||
"settings.openchamber.defaults.smallModel.useDefaultAria": "Usar o modelo pequeno padrão",
|
||||
"settings.openchamber.defaults.smallModel.overrideModel": "Modelo de substituição",
|
||||
"settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación",
|
||||
"settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir arquivos em modo prévia",
|
||||
"settings.openchamber.defaults.field.openFilesPreview": "Abrir arquivos em modo prévia",
|
||||
@@ -1568,6 +1573,7 @@ export const settingsDict = {
|
||||
"settings.voice.page.field.ttsInputMode": "Modo de entrada TTS",
|
||||
"settings.voice.page.field.ttsInputModeSanitized": "Texto limpo",
|
||||
"settings.voice.page.field.ttsInputModeRaw": "Markdown bruto",
|
||||
"settings.voice.page.field.ttsInputModeSummarized": "resumido",
|
||||
"settings.openchamber.visual.section.colorMode": "Modo de cor",
|
||||
"settings.openchamber.visual.section.mobileLayout": "Layout móvel",
|
||||
"settings.openchamber.visual.option.mobileLayout.default": "Anterior",
|
||||
@@ -1651,6 +1657,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.field.userMessageRenderingAria": "Renderizado de mensagens do usuário: {option}",
|
||||
"settings.openchamber.visual.field.mermaidRenderingAria": "Renderizado de Mermaid: {option}",
|
||||
"settings.openchamber.visual.field.diffLayoutAria": "Layout de comparação: {option}",
|
||||
"settings.openchamber.visual.field.sessionAssist": "Gerar resumo e sugestão da sessão",
|
||||
"settings.openchamber.visual.field.sessionAssistAria": "Gerar um resumo e uma resposta sugerida quando o agente termina",
|
||||
"settings.openchamber.visual.field.showReasoningTracesAria": "Mostrar rastros de raciocínio",
|
||||
"settings.openchamber.visual.field.showReasoningTraces": "Mostrar rastros de raciocínio",
|
||||
"settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Habilitar blocos de raciocínio recolhíveis",
|
||||
|
||||
@@ -1365,6 +1365,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"header.actions.toggleChangesPanelAria": "Alternar painel de alterações",
|
||||
"header.actions.planWithShortcut": "Plano ({shortcut})",
|
||||
"header.actions.terminalPanelWithShortcut": "Painel de terminal ({shortcut})",
|
||||
"chat.recap.aria": "Resumo da sessão",
|
||||
"chat.recap.label": "Resumo:",
|
||||
"chat.suggestion.applyAria": "Usar mensagem sugerida",
|
||||
"chat.suggestion.dismissAria": "Dispensar sugestão",
|
||||
"header.actions.toggleTerminalPanelAria": "Mostrar ou ocultar painel de terminal",
|
||||
"terminalView.stream.processExitedMessage": "\r\n[Processo encerrado{exitCodeSegment}{signalSegment}]\r\n",
|
||||
"terminalView.stream.processExitedWithCode": " com código {exitCode}",
|
||||
|
||||
@@ -1363,6 +1363,11 @@ export const settingsDict = {
|
||||
"settings.openchamber.defaults.field.thinkingPlaceholder": "Мислення",
|
||||
"settings.openchamber.defaults.field.defaultAgent": "Агент за замовчуванням",
|
||||
"settings.openchamber.defaults.field.showDeletionDialogAria": "Показати діалогове вікно видалення",
|
||||
"settings.openchamber.defaults.smallModel.title": "Мала модель",
|
||||
"settings.openchamber.defaults.smallModel.description": "Дешева модель для швидких службових задач — коротких підсумків і резюме.",
|
||||
"settings.openchamber.defaults.smallModel.useDefault": "Використовувати типову малу модель",
|
||||
"settings.openchamber.defaults.smallModel.useDefaultAria": "Використовувати типову малу модель",
|
||||
"settings.openchamber.defaults.smallModel.overrideModel": "Модель заміни",
|
||||
"settings.openchamber.defaults.field.showDeletionDialog": "Показати діалогове вікно видалення",
|
||||
"settings.openchamber.defaults.field.openFilesPreviewAria": "Відкривати файли в режимі попереднього перегляду",
|
||||
"settings.openchamber.defaults.field.openFilesPreview": "Відкривати файли в режимі попереднього перегляду",
|
||||
@@ -1568,6 +1573,7 @@ export const settingsDict = {
|
||||
"settings.voice.page.field.ttsInputMode": "Режим вводу TTS",
|
||||
"settings.voice.page.field.ttsInputModeSanitized": "Очищений текст",
|
||||
"settings.voice.page.field.ttsInputModeRaw": "Сирий Markdown",
|
||||
"settings.voice.page.field.ttsInputModeSummarized": "скорочений",
|
||||
"settings.openchamber.visual.section.colorMode": "Режим теми",
|
||||
"settings.openchamber.visual.section.mobileLayout": "Мобільний макет",
|
||||
"settings.openchamber.visual.option.mobileLayout.default": "Попередній",
|
||||
@@ -1651,6 +1657,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.field.userMessageRenderingAria": "Відображення повідомлень користувача: {option}",
|
||||
"settings.openchamber.visual.field.mermaidRenderingAria": "Візуалізація Mermaid: {option}",
|
||||
"settings.openchamber.visual.field.diffLayoutAria": "Компонування diff: {option}",
|
||||
"settings.openchamber.visual.field.sessionAssist": "Генерувати підсумок і пропозицію для сесії",
|
||||
"settings.openchamber.visual.field.sessionAssistAria": "Генерувати підсумок і запропоновану відповідь після завершення роботи агента",
|
||||
"settings.openchamber.visual.field.showReasoningTracesAria": "Показати сліди міркувань",
|
||||
"settings.openchamber.visual.field.showReasoningTraces": "Показати сліди міркувань",
|
||||
"settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Увімкнути згортальні блоки міркувань",
|
||||
|
||||
@@ -1365,6 +1365,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"header.actions.toggleChangesPanelAria": "Перемкнути панель змін",
|
||||
"header.actions.planWithShortcut": "План ({shortcut})",
|
||||
"header.actions.terminalPanelWithShortcut": "Термінальна панель ({shortcut})",
|
||||
"chat.recap.aria": "Підсумок сесії",
|
||||
"chat.recap.label": "Підсумок:",
|
||||
"chat.suggestion.applyAria": "Використати запропоноване повідомлення",
|
||||
"chat.suggestion.dismissAria": "Прибрати пропозицію",
|
||||
"header.actions.toggleTerminalPanelAria": "Перемкнути панель терміналу",
|
||||
"terminalView.stream.processExitedMessage": "\r\n[Process exited{exitCodeSegment}{signalSegment}]\r\n",
|
||||
"terminalView.stream.processExitedWithCode": " з кодом {exitCode}",
|
||||
|
||||
@@ -1363,6 +1363,11 @@ export const settingsDict = {
|
||||
'settings.openchamber.defaults.field.thinkingPlaceholder': '思考模式',
|
||||
'settings.openchamber.defaults.field.defaultAgent': '默认智能体',
|
||||
'settings.openchamber.defaults.field.showDeletionDialogAria': '显示删除对话框',
|
||||
'settings.openchamber.defaults.smallModel.title': '小模型',
|
||||
'settings.openchamber.defaults.smallModel.description': '用于快速实用任务(如简短回顾和摘要)的廉价模型。',
|
||||
'settings.openchamber.defaults.smallModel.useDefault': '使用默认小模型',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': '使用默认小模型',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': '覆盖模型',
|
||||
'settings.openchamber.defaults.field.showDeletionDialog': '显示删除对话框',
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': '以预览模式打开文件',
|
||||
'settings.openchamber.defaults.field.openFilesPreview': '以预览模式打开文件',
|
||||
@@ -1568,6 +1573,7 @@ export const settingsDict = {
|
||||
'settings.voice.page.field.ttsInputMode': 'TTS 输入模式',
|
||||
'settings.voice.page.field.ttsInputModeSanitized': '清理后文本',
|
||||
'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown',
|
||||
'settings.voice.page.field.ttsInputModeSummarized': '摘要',
|
||||
'settings.openchamber.visual.section.colorMode': '颜色模式',
|
||||
'settings.openchamber.visual.section.mobileLayout': '移动端布局',
|
||||
'settings.openchamber.visual.option.mobileLayout.default': '旧版',
|
||||
@@ -1651,6 +1657,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.userMessageRenderingAria': '用户消息渲染:{option}',
|
||||
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 渲染:{option}',
|
||||
'settings.openchamber.visual.field.diffLayoutAria': '差异布局:{option}',
|
||||
'settings.openchamber.visual.field.sessionAssist': '生成会话回顾与建议',
|
||||
'settings.openchamber.visual.field.sessionAssistAria': '代理完成后生成回顾和建议回复',
|
||||
'settings.openchamber.visual.field.showReasoningTracesAria': '显示推理轨迹',
|
||||
'settings.openchamber.visual.field.showReasoningTraces': '显示推理轨迹',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '启用可折叠推理块',
|
||||
|
||||
@@ -1353,6 +1353,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"header.actions.toggleChangesPanelAria": "切换更改面板",
|
||||
'header.actions.planWithShortcut': '计划({shortcut})',
|
||||
'header.actions.terminalPanelWithShortcut': '终端面板({shortcut})',
|
||||
'chat.recap.aria': '会话回顾',
|
||||
'chat.recap.label': '回顾:',
|
||||
'chat.suggestion.applyAria': '使用建议的消息',
|
||||
'chat.suggestion.dismissAria': '关闭建议',
|
||||
'header.actions.toggleTerminalPanelAria': '切换终端面板',
|
||||
'terminalView.stream.processExitedMessage': '\r\n[进程已退出{exitCodeSegment}{signalSegment}]\r\n',
|
||||
'terminalView.stream.processExitedWithCode': ',退出码 {exitCode}',
|
||||
|
||||
@@ -1279,6 +1279,11 @@
|
||||
'settings.openchamber.defaults.field.thinkingPlaceholder': '思考模式',
|
||||
'settings.openchamber.defaults.field.defaultAgent': '預設 Agent',
|
||||
'settings.openchamber.defaults.field.showDeletionDialogAria': '顯示刪除對話方塊',
|
||||
'settings.openchamber.defaults.smallModel.title': '小模型',
|
||||
'settings.openchamber.defaults.smallModel.description': '用於快速實用任務(如簡短回顧與摘要)的廉價模型。',
|
||||
'settings.openchamber.defaults.smallModel.useDefault': '使用預設小模型',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': '使用預設小模型',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': '覆寫模型',
|
||||
'settings.openchamber.defaults.field.showDeletionDialog': '顯示刪除對話方塊',
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': '以預覽模式開啟檔案',
|
||||
'settings.openchamber.defaults.field.openFilesPreview': '以預覽模式開啟檔案',
|
||||
@@ -1484,6 +1489,7 @@
|
||||
'settings.voice.page.field.ttsInputMode': 'TTS 輸入模式',
|
||||
'settings.voice.page.field.ttsInputModeSanitized': '清理後文字',
|
||||
'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown',
|
||||
'settings.voice.page.field.ttsInputModeSummarized': '摘要',
|
||||
'settings.openchamber.visual.section.colorMode': '顏色模式',
|
||||
'settings.openchamber.visual.section.localization': '在地化',
|
||||
'settings.openchamber.visual.section.spacingAndLayout': '間距與佈局',
|
||||
@@ -1567,6 +1573,8 @@
|
||||
'settings.openchamber.visual.field.userMessageRenderingAria': '使用者訊息渲染:{option}',
|
||||
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 渲染:{option}',
|
||||
'settings.openchamber.visual.field.diffLayoutAria': '差異佈局:{option}',
|
||||
'settings.openchamber.visual.field.sessionAssist': '產生工作階段回顧與建議',
|
||||
'settings.openchamber.visual.field.sessionAssistAria': '代理完成後產生回顧與建議回覆',
|
||||
'settings.openchamber.visual.field.showReasoningTracesAria': '顯示推理軌跡',
|
||||
'settings.openchamber.visual.field.showReasoningTraces': '顯示推理軌跡',
|
||||
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '啟用可摺疊推理區塊',
|
||||
|
||||
@@ -1357,6 +1357,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"header.actions.toggleChangesPanelAria": "切換變更面板",
|
||||
'header.actions.planWithShortcut': '計畫({shortcut})',
|
||||
'header.actions.terminalPanelWithShortcut': '終端機面板({shortcut})',
|
||||
'chat.recap.aria': '工作階段回顧',
|
||||
'chat.recap.label': '回顧:',
|
||||
'chat.suggestion.applyAria': '使用建議的訊息',
|
||||
'chat.suggestion.dismissAria': '關閉建議',
|
||||
'header.actions.toggleTerminalPanelAria': '切換終端機面板',
|
||||
'terminalView.stream.processExitedMessage': '\r\n[處理程序已結束{exitCodeSegment}{signalSegment}]\r\n',
|
||||
'terminalView.stream.processExitedWithCode': ',結束代碼 {exitCode}',
|
||||
|
||||
@@ -70,7 +70,7 @@ const MAGIC_PROMPT_DEFINITIONS: readonly MagicPromptDefinition[] = [
|
||||
title: 'Commit Generation Visible Prompt',
|
||||
group: 'Git',
|
||||
description: 'Visible user message for commit message generation.',
|
||||
template: 'You are generating a Conventional Commits subject line using session context and selected file paths.',
|
||||
template: 'You are generating a Conventional Commits subject line from the diffs of the selected files.',
|
||||
},
|
||||
{
|
||||
id: 'git.commit.generate.instructions',
|
||||
|
||||
@@ -423,6 +423,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
|
||||
store.setShowReasoningTraces(settings.showReasoningTraces);
|
||||
}
|
||||
if (typeof settings.sessionAssistEnabled === 'boolean' && settings.sessionAssistEnabled !== store.sessionAssistEnabled) {
|
||||
store.setSessionAssistEnabled(settings.sessionAssistEnabled);
|
||||
}
|
||||
if (typeof settings.collapsibleThinkingBlocks === 'boolean' && settings.collapsibleThinkingBlocks !== store.collapsibleThinkingBlocks) {
|
||||
store.setCollapsibleThinkingBlocks(settings.collapsibleThinkingBlocks);
|
||||
}
|
||||
@@ -765,6 +768,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.showReasoningTraces === 'boolean') {
|
||||
result.showReasoningTraces = candidate.showReasoningTraces;
|
||||
}
|
||||
if (typeof candidate.sessionAssistEnabled === 'boolean') {
|
||||
result.sessionAssistEnabled = candidate.sessionAssistEnabled;
|
||||
}
|
||||
if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
|
||||
result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
|
||||
}
|
||||
@@ -832,6 +838,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
|
||||
result.defaultAgent = candidate.defaultAgent;
|
||||
}
|
||||
if (typeof candidate.smallModelUseDefault === 'boolean') {
|
||||
result.smallModelUseDefault = candidate.smallModelUseDefault;
|
||||
}
|
||||
if (typeof candidate.smallModelOverride === 'string' && candidate.smallModelOverride.length > 0) {
|
||||
result.smallModelOverride = candidate.smallModelOverride;
|
||||
}
|
||||
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
||||
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
// Recap + suggested follow-up generated by the server's session-assist
|
||||
// watcher, stored under session.metadata.openchamber.assist. Freshness is
|
||||
// encoded in forMessageID: the payload is only valid while that message is
|
||||
// still the session's last assistant message.
|
||||
export interface SessionAssistPayload {
|
||||
recap: string;
|
||||
suggestion: string;
|
||||
forMessageID: string;
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
export function getSessionAssist(session: Session | null | undefined): SessionAssistPayload | null {
|
||||
const metadata = (session as { metadata?: unknown } | null | undefined)?.metadata;
|
||||
if (!isRecord(metadata)) return null;
|
||||
const namespace = metadata.openchamber;
|
||||
if (!isRecord(namespace)) return null;
|
||||
const assist = namespace.assist;
|
||||
if (!isRecord(assist)) return null;
|
||||
|
||||
const recap = typeof assist.recap === 'string' ? assist.recap.trim() : '';
|
||||
const suggestion = typeof assist.suggestion === 'string' ? assist.suggestion.trim() : '';
|
||||
const forMessageID = typeof assist.forMessageID === 'string' ? assist.forMessageID : '';
|
||||
if (!forMessageID || (!recap && !suggestion)) return null;
|
||||
|
||||
return {
|
||||
recap,
|
||||
suggestion,
|
||||
forMessageID,
|
||||
generatedAt: typeof assist.generatedAt === 'number' ? assist.generatedAt : 0,
|
||||
};
|
||||
}
|
||||
@@ -166,6 +166,12 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
titleKey: 'settings.openchamber.visual.section.messageStreamTransport',
|
||||
keywords: ['streaming', 'sse', 'websocket'],
|
||||
},
|
||||
{
|
||||
id: 'chat.session-assist',
|
||||
page: 'chat',
|
||||
titleKey: 'settings.openchamber.visual.field.sessionAssist',
|
||||
keywords: ['recap', 'suggestion', 'assist', 'small model', 'summary'],
|
||||
},
|
||||
{
|
||||
id: 'chat.reasoning-traces',
|
||||
page: 'chat',
|
||||
@@ -260,6 +266,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
titleKey: 'settings.openchamber.defaults.field.showDeletionDialog',
|
||||
keywords: ['delete', 'confirmation'],
|
||||
},
|
||||
{
|
||||
id: 'sessions.small-model',
|
||||
page: 'sessions',
|
||||
titleKey: 'settings.openchamber.defaults.smallModel.title',
|
||||
descriptionKey: 'settings.openchamber.defaults.smallModel.description',
|
||||
keywords: ['small model', 'utility', 'summary', 'recap', 'cheap', 'override'],
|
||||
},
|
||||
{
|
||||
id: 'sessions.auto-cleanup',
|
||||
page: 'sessions',
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { getSessionLastAssistantModel } from '@/sync/session-actions';
|
||||
|
||||
// Selections shorter than this are already note-sized — summarizing them
|
||||
// would only add latency and risk losing the exact wording.
|
||||
const NOTES_SUMMARIZE_MIN_CHARS = 280;
|
||||
|
||||
const NOTES_SYSTEM_PROMPT = [
|
||||
'You distill a text selection from a coding-agent conversation into a project note.',
|
||||
'Return ONLY the note text — no preamble, no surrounding quotes, no headers.',
|
||||
'Write 1-3 tight sentences that capture the essence worth remembering later: facts, decisions, constraints, root causes, gotchas, next steps.',
|
||||
'Preserve exact identifiers verbatim — file paths, function names, commands, flags, versions — in backticks.',
|
||||
'Drop filler, hedging, greetings, and step-by-step narration.',
|
||||
'Write the note in the same language as the selection. Ignore any other language preferences or personalization — only the selection text decides the language.',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Distills a chat selection into a compact note via the small model. Falls
|
||||
* back to the original text on any failure or when no small model is
|
||||
* available within the session's provider (explicit settings/config picks
|
||||
* are still honored server-side).
|
||||
*/
|
||||
export async function summarizeSelectionForNotes(text: string, sessionId?: string | null): Promise<string> {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length < NOTES_SUMMARIZE_MIN_CHARS) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
// The selection's session provider is authoritative — the text came from
|
||||
// that conversation. The composer picker only serves as a fallback.
|
||||
const sessionModel = sessionId ? getSessionLastAssistantModel(sessionId) : null;
|
||||
const { currentProviderId, currentModelId } = useConfigStore.getState();
|
||||
const preferredProviderID = sessionModel?.providerID || currentProviderId || '';
|
||||
const preferredModelID = sessionModel?.modelID || currentModelId || '';
|
||||
const response = await runtimeFetch('/api/small-model/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: trimmed,
|
||||
system: NOTES_SYSTEM_PROMPT,
|
||||
restrictToPreferredProvider: true,
|
||||
...(preferredProviderID ? { preferredProviderID } : {}),
|
||||
...(preferredModelID ? { preferredModelID } : {}),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return trimmed;
|
||||
}
|
||||
const payload = await response.json().catch(() => null) as { text?: unknown } | null;
|
||||
const summary = typeof payload?.text === 'string' ? payload.text.trim() : '';
|
||||
return summary || trimmed;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -1004,7 +1004,7 @@ interface ConfigStore {
|
||||
sttLocalModel: string;
|
||||
sttLanguage: string;
|
||||
showMessageTTSButtons: boolean;
|
||||
ttsInputMode: 'sanitized' | 'raw';
|
||||
ttsInputMode: 'sanitized' | 'raw' | 'summarized';
|
||||
// Summarization settings
|
||||
summarizeMessageTTS: boolean;
|
||||
summarizeVoiceConversation: boolean;
|
||||
@@ -1030,7 +1030,7 @@ interface ConfigStore {
|
||||
setSttLocalModel: (model: string) => void;
|
||||
setSttLanguage: (lang: string) => void;
|
||||
setShowMessageTTSButtons: (show: boolean) => void;
|
||||
setTtsInputMode: (mode: 'sanitized' | 'raw') => void;
|
||||
setTtsInputMode: (mode: 'sanitized' | 'raw' | 'summarized') => void;
|
||||
setSummarizeMessageTTS: (enabled: boolean) => void;
|
||||
setSummarizeVoiceConversation: (enabled: boolean) => void;
|
||||
setSummarizeCharacterThreshold: (threshold: number) => void;
|
||||
@@ -1299,6 +1299,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('ttsInputMode');
|
||||
if (saved === 'raw') return 'raw' as const;
|
||||
if (saved === 'summarized') return 'summarized' as const;
|
||||
}
|
||||
return 'sanitized' as const;
|
||||
})(),
|
||||
@@ -2925,7 +2926,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
setTtsInputMode: (mode: 'sanitized' | 'raw') => {
|
||||
setTtsInputMode: (mode: 'sanitized' | 'raw' | 'summarized') => {
|
||||
set({ ttsInputMode: mode });
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('ttsInputMode', mode);
|
||||
|
||||
@@ -560,6 +560,7 @@ interface UIStore {
|
||||
eventStreamStatus: EventStreamStatus;
|
||||
eventStreamHint: string | null;
|
||||
showReasoningTraces: boolean;
|
||||
sessionAssistEnabled: boolean;
|
||||
collapsibleThinkingBlocks: boolean;
|
||||
groupReasoningBlocks: boolean;
|
||||
chatRenderMode: ChatRenderMode;
|
||||
@@ -708,6 +709,7 @@ interface UIStore {
|
||||
setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void;
|
||||
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
|
||||
setShowReasoningTraces: (value: boolean) => void;
|
||||
setSessionAssistEnabled: (value: boolean) => void;
|
||||
setCollapsibleThinkingBlocks: (value: boolean) => void;
|
||||
setChatRenderMode: (value: ChatRenderMode) => void;
|
||||
setActivityRenderMode: (value: ActivityRenderMode) => void;
|
||||
@@ -851,6 +853,7 @@ export const useUIStore = create<UIStore>()(
|
||||
eventStreamStatus: 'idle',
|
||||
eventStreamHint: null,
|
||||
showReasoningTraces: true,
|
||||
sessionAssistEnabled: true,
|
||||
collapsibleThinkingBlocks: true,
|
||||
groupReasoningBlocks: true,
|
||||
chatRenderMode: 'live',
|
||||
@@ -1543,6 +1546,10 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ showReasoningTraces: value });
|
||||
},
|
||||
|
||||
setSessionAssistEnabled: (value) => {
|
||||
set({ sessionAssistEnabled: value });
|
||||
},
|
||||
|
||||
setCollapsibleThinkingBlocks: (value) => {
|
||||
set({ collapsibleThinkingBlocks: value });
|
||||
},
|
||||
@@ -2227,6 +2234,7 @@ export const useUIStore = create<UIStore>()(
|
||||
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
|
||||
// Note: isSettingsDialogOpen intentionally NOT persisted
|
||||
showReasoningTraces: state.showReasoningTraces,
|
||||
sessionAssistEnabled: state.sessionAssistEnabled,
|
||||
collapsibleThinkingBlocks: state.collapsibleThinkingBlocks,
|
||||
chatRenderMode: state.chatRenderMode,
|
||||
activityRenderMode: state.activityRenderMode,
|
||||
|
||||
@@ -131,6 +131,29 @@ function dirStoreForSession(sessionId: string): { store: DirectoryStoreApi; dire
|
||||
return { store: dirStore(), directory: dir() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider/model of the session's last assistant message — the authoritative
|
||||
* "session provider" for utility calls (notes distillation etc.), independent
|
||||
* of what the composer picker currently points at.
|
||||
*/
|
||||
export function getSessionLastAssistantModel(sessionId: string): { providerID: string; modelID: string } | null {
|
||||
try {
|
||||
const { store } = dirStoreForSession(sessionId)
|
||||
const messages = store.getState().message[sessionId]
|
||||
if (!messages) return null
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const info = messages[i] as { role?: string; providerID?: string; modelID?: string }
|
||||
if (info?.role === "assistant" && typeof info.providerID === "string" && info.providerID
|
||||
&& typeof info.modelID === "string" && info.modelID) {
|
||||
return { providerID: info.providerID, modelID: info.modelID }
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function updateLiveSession(session: Session, directory?: string): void {
|
||||
const stores = _childStores
|
||||
if (!stores) return
|
||||
|
||||
Reference in New Issue
Block a user