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:
Bohdan Triapitsyn
2026-07-05 23:19:10 +03:00
committed by GitHub
parent e5b03493da
commit 28f0736d69
61 changed files with 2455 additions and 103 deletions
@@ -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, {
+1 -3
View File
@@ -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>
+47 -2
View File
@@ -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;
+94
View File
@@ -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;
}
+3
View File
@@ -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
View File
@@ -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',
+4
View File
@@ -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",
+4
View File
@@ -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 dentré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',
+4
View File
@@ -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': '折りたたみ可能な推論ブロックを有効化',
+4
View File
@@ -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': '접을 수 있는 추론 블록 활성화',
+4
View File
@@ -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',
+4
View File
@@ -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": "Увімкнути згортальні блоки міркувань",
+4
View File
@@ -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}',
+1 -1
View File
@@ -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',
+12
View File
@@ -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,
};
}
+13
View File
@@ -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',
+57
View File
@@ -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;
}
}
+4 -3
View File
@@ -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);
+8
View File
@@ -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,
+23
View File
@@ -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
@@ -291,7 +291,7 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
const keysToClear = new Set<string>();
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) {
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary', 'smallModelOverride']) {
const value = restChanges[key];
if (typeof value === 'string' && value.trim().length === 0) {
keysToClear.add(key);
@@ -299,6 +299,14 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
}
}
if ('smallModelUseDefault' in restChanges && typeof restChanges.smallModelUseDefault !== 'boolean') {
delete restChanges.smallModelUseDefault;
}
if ('sessionAssistEnabled' in restChanges && typeof restChanges.sessionAssistEnabled !== 'boolean') {
delete restChanges.sessionAssistEnabled;
}
if (typeof restChanges.usageAutoRefresh !== 'boolean') {
delete restChanges.usageAutoRefresh;
}
+27 -5
View File
@@ -72,6 +72,7 @@ import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolut
import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js';
import { createSessionRuntime } from './lib/opencode/session-runtime.js';
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
import { createSessionAssistRuntime } from './lib/session-assist/runtime.js';
import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js';
import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js';
import { createTunnelWiringRuntime } from './lib/opencode/tunnel-wiring-runtime.js';
@@ -713,6 +714,12 @@ const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSen
const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args);
clearPendingPushBadge = () => notificationTriggerRuntime.clearPendingPushBadge();
const sessionAssistRuntime = createSessionAssistRuntime({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
getSmallModelService: async () => import('./lib/small-model/index.js'),
});
const globalMessageStreamHub = createGlobalMessageStreamHub({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
@@ -732,6 +739,19 @@ const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
},
});
// Session-assist subscribes to the hub directly: it needs the envelope's
// directory to route its own OpenCode calls to the right instance.
console.log('[session-assist] listening for session events');
globalMessageStreamHub.subscribeEvent((event) => {
const raw = event?.payload;
const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw;
if (!payload || typeof payload !== 'object') return;
const directory = typeof event?.directory === 'string' && event.directory && event.directory !== 'global'
? event.directory
: '';
sessionAssistRuntime.processPayload(payload, directory);
});
const processForwardedEventPayload = (payload, emitSyntheticEvent) => {
if (!payload || typeof payload !== 'object' || typeof emitSyntheticEvent !== 'function') {
return;
@@ -1014,11 +1034,12 @@ const bootstrapOpenCodeAtStartup = async (...args) => {
if (openCodeLifecycleState.openCodeProcess && !openCodeLifecycleState.isExternalOpenCode) {
startHealthMonitoring();
}
if (ENV_DESKTOP_NOTIFY) {
void ensureGlobalWatcherStarted().catch((error) => {
console.warn(`Global event watcher startup failed: ${error?.message || error}`);
});
}
// The global watcher used to start only for desktop notifications; the
// session-assist runtime also rides its event hub, so it now starts
// unconditionally once OpenCode is up.
void ensureGlobalWatcherStarted().catch((error) => {
console.warn(`Global event watcher startup failed: ${error?.message || error}`);
});
};
const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args);
const waitForPortRelease = (...args) => openCodeLifecycleRuntime.waitForPortRelease(...args);
@@ -1037,6 +1058,7 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({
},
syncToHmrState,
openCodeWatcherRuntime,
sessionAssistRuntime,
sessionRuntime,
getHealthCheckInterval: () => healthCheckInterval,
clearHealthCheckInterval: (value) => clearInterval(value),
@@ -759,6 +759,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
req.path.startsWith('/api/push') ||
req.path.startsWith('/api/notifications') ||
req.path.startsWith('/api/session-folders') ||
req.path.startsWith('/api/small-model') ||
req.path.startsWith('/api/text') ||
req.path.startsWith('/api/voice') ||
req.path.startsWith('/api/tts') ||
@@ -1,5 +1,6 @@
import { registerFsRoutes } from '../fs/routes.js';
import { registerQuotaRoutes } from '../quota/routes.js';
import { registerSmallModelRoutes } from '../small-model/routes.js';
import { registerGitHubRoutes } from '../github/routes.js';
import { registerGitRoutes } from '../git/routes.js';
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
@@ -54,6 +55,14 @@ export const createFeatureRoutesRuntime = (dependencies) => {
return quotaProviders;
};
let smallModelService = null;
const getSmallModelService = async () => {
if (!smallModelService) {
smallModelService = await import('../small-model/index.js');
}
return smallModelService;
};
const registerRoutes = async (app, routeDependencies) => {
const {
crypto,
@@ -226,6 +235,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
});
registerQuotaRoutes(app, { getQuotaProviders });
registerSmallModelRoutes(app, { getSmallModelService });
registerGitHubRoutes(app);
registerGitRoutes(app);
registerMagicPromptRoutes(app, {
@@ -0,0 +1,61 @@
const MODELS_DEV_API_URL = 'https://models.dev/api.json';
const DEFAULT_TTL_MS = 10 * 60 * 1000;
const DEFAULT_TIMEOUT_MS = 8000;
// Shared in-process cache of the models.dev catalog. Used by the
// /api/openchamber/models-metadata route and the small-model resolver so the
// server fetches the catalog once, not per consumer.
let cachedMetadata = null;
let cachedAt = 0;
let inflight = null;
const fetchCatalog = async (url, timeoutMs) => {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
throw new Error(`models.dev responded with status ${response.status}`);
}
const metadata = await response.json();
if (!metadata || typeof metadata !== 'object') {
throw new Error('models.dev returned an unexpected payload');
}
return metadata;
};
/**
* Returns the models.dev catalog, serving the in-memory copy while fresh.
* On fetch failure a stale cached copy is returned when available; otherwise
* the error propagates.
*/
export async function getModelsMetadata({
url = MODELS_DEV_API_URL,
ttlMs = DEFAULT_TTL_MS,
timeoutMs = DEFAULT_TIMEOUT_MS,
} = {}) {
const now = Date.now();
if (cachedMetadata && now - cachedAt < ttlMs) {
return { metadata: cachedMetadata, fromCache: true };
}
if (!inflight) {
inflight = fetchCatalog(url, timeoutMs).finally(() => {
inflight = null;
});
}
try {
const metadata = await inflight;
cachedMetadata = metadata;
cachedAt = Date.now();
return { metadata, fromCache: false };
} catch (error) {
if (cachedMetadata) {
return { metadata: cachedMetadata, fromCache: true, stale: true };
}
throw error;
}
}
export { MODELS_DEV_API_URL };
@@ -13,9 +13,6 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
getCachedZenModels,
} = dependencies;
let cachedModelsMetadata = null;
let cachedModelsMetadataTimestamp = 0;
app.get('/api/openchamber/update-check', async (req, res) => {
try {
const { checkForUpdates } = await import('../package-manager.js');
@@ -254,48 +251,18 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
});
app.get('/api/openchamber/models-metadata', async (_req, res) => {
const now = Date.now();
if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < modelsMetadataCacheTtl) {
res.setHeader('Cache-Control', 'public, max-age=60');
return res.json(cachedModelsMetadata);
}
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
try {
const response = await fetch(modelsDevApiUrl, {
signal: controller?.signal,
headers: {
Accept: 'application/json'
}
const { getModelsMetadata } = await import('./models-metadata.js');
const { metadata, fromCache, stale } = await getModelsMetadata({
url: modelsDevApiUrl,
ttlMs: modelsMetadataCacheTtl,
});
if (!response.ok) {
throw new Error(`models.dev responded with status ${response.status}`);
}
const metadata = await response.json();
cachedModelsMetadata = metadata;
cachedModelsMetadataTimestamp = Date.now();
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('Cache-Control', fromCache && !stale ? 'public, max-age=60' : 'public, max-age=300');
res.json(metadata);
} catch (error) {
console.warn('Failed to fetch models.dev metadata via server:', error);
if (cachedModelsMetadata) {
res.setHeader('Cache-Control', 'public, max-age=60');
res.json(cachedModelsMetadata);
} else {
const statusCode = error?.name === 'AbortError' ? 504 : 502;
res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
}
} finally {
if (timeout) {
clearTimeout(timeout);
}
const statusCode = error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 504 : 502;
res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
}
});
@@ -245,6 +245,9 @@ export const createSettingsHelpers = (dependencies) => {
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;
}
@@ -374,6 +377,13 @@ export const createSettingsHelpers = (dependencies) => {
const trimmed = candidate.defaultAgent.trim();
result.defaultAgent = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.smallModelUseDefault === 'boolean') {
result.smallModelUseDefault = candidate.smallModelUseDefault;
}
if (typeof candidate.smallModelOverride === 'string') {
const trimmed = candidate.smallModelOverride.trim();
result.smallModelOverride = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.defaultGitIdentityId === 'string') {
const trimmed = candidate.defaultGitIdentityId.trim();
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
@@ -8,6 +8,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
syncToHmrState,
openCodeWatcherRuntime,
sessionRuntime,
sessionAssistRuntime,
scheduledTasksRuntime,
getHealthCheckInterval,
clearHealthCheckInterval,
@@ -41,6 +42,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
openCodeWatcherRuntime.stop();
sessionRuntime.dispose();
sessionAssistRuntime?.stop?.();
scheduledTasksRuntime?.stop?.();
const healthCheckInterval = getHealthCheckInterval();
@@ -0,0 +1,64 @@
# Session Assist
Server-side watcher that generates a short recap of the agent's last reply
and one suggested user follow-up with the small model
(`lib/small-model`), storing both on the session's metadata under
`metadata.openchamber.assist`.
## Flow
1. `createSessionAssistRuntime` is a consumer of the server's global SSE
fan-out (`index.js``onPayload`), riding the same upstream connection as
notifications. Purely event-driven — dormant sessions never generate
anything, there is no backfill and no session scanning.
2. `session.status: idle` arms a 60-second per-session timer; any `busy`/
`retry` status or a user `message.updated` clears it (the "1 minute of
quiet" rule).
3. On fire: fetch the session (skip sub-agent sessions with `parentID`),
take the LAST exchange only — the final assistant reply plus the user
message it answered (assistant `parentID` → user id) — and call
`generateSmallModelText` with the
session's own provider/model taken from the last assistant message — so
the utility call spends the same subscription as the conversation.
`restrictToPreferredProvider` forbids the resolver's global fallback:
conversation content never goes to a provider the user didn't pick for
the session, unless the small model was chosen explicitly (settings
override or opencode config). A resolver 404 is silently skipped.
4. The `{recap, suggestion}` JSON is clamped and PATCHed onto the session
metadata together with `forMessageID` (the last assistant message id) and
`generatedAt`. Before writing, the session tail is re-checked (a stale
result is dropped) and the metadata is merged from a fresh session read so
concurrent metadata writes made during generation are preserved.
## Settings gate
`sessionAssistEnabled` in OpenChamber settings (Settings → Chat, default on)
is a hard generation switch checked at fire time: when off, no small-model
calls run and nothing is written. Existing payloads keep rendering and can
still be dismissed — the switch is about generation, not visibility.
## Freshness contract (no clearing writes)
Clients do not need the payload to be deleted: they render it only while
`assist.forMessageID` still equals the session's last assistant message id
(and the session is idle). Any new message invalidates the payload
everywhere instantly and offline; the next idle cycle overwrites it.
## UI consumers (packages/ui)
- `lib/sessionAssistMetadata.ts` — payload parsing.
- `hooks/useSessionAssist.ts` — freshness gating + the 5-minute quiet window
for the recap (single timeout to the boundary, no polling).
- `components/chat/SessionRecapSpacer.tsx` — renders the recap inside the
fixed-height reserved gap under the last message (height never changes).
- `components/chat/SessionSuggestionChip.tsx` — one tappable suggestion chip
near the composer (desktop chips row + above the mobile pill); hidden as
soon as the composer has any content. Tap fills the input, never sends.
## Limitations
- The watcher lives in the web server, so VS Code (extension-only, no web
server) does not generate assists; it still renders payloads produced by a
web/desktop instance of the same OpenCode server via `session.updated`.
- Metadata payloads ride every `session.updated` event — keep the clamps
(`RECAP_CHAR_LIMIT`, `SUGGESTION_CHAR_LIMIT`) small.
@@ -0,0 +1,349 @@
// Session assist: after a session goes idle and stays quiet, generate a short
// recap of the agent's last reply plus one suggested user follow-up with the
// small model, and store both on the session's metadata
// (metadata.openchamber.assist). Clients decide visibility from
// assist.forMessageID — a new message makes the payload stale everywhere
// without any extra writes.
//
// Purely event-driven: only sessions that transition busy→idle while the
// server is running ever generate anything. No backfill, no session scans.
import fs from 'fs';
import os from 'os';
import path from 'path';
const OPENCHAMBER_SETTINGS_FILE = path.join(
process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber'),
'settings.json',
);
// The Chat setting is a hard generation switch (default on): when off, no
// small-model calls and no metadata writes happen at all. Existing payloads
// stay untouched — clients keep showing them and dismissal still works.
const isSessionAssistEnabled = () => {
try {
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
const settings = JSON.parse(raw);
return settings?.sessionAssistEnabled !== false;
} catch {
return true;
}
};
const IDLE_QUIET_MS = 60_000;
const TRANSCRIPT_MESSAGE_LIMIT = 12;
const TRANSCRIPT_PART_CHAR_LIMIT = 6_000;
const RECAP_CHAR_LIMIT = 320;
const SUGGESTION_CHAR_LIMIT = 500;
const FETCH_TIMEOUT_MS = 5_000;
const ASSIST_SYSTEM_PROMPT = [
'You assist a user who chats with a coding agent. Based on the conversation transcript, return exactly one JSON object and nothing else — no prose, no markdown, no code fences.',
'Shape: {"recap": string, "suggestion": string}',
'recap: at most 20 words. State the substance directly — the facts, result, or conclusion, plus the next move if there is one. NEVER narrate ("The assistant explained…", "The agent did…") — write the content itself, like a note the user jotted down.',
'suggestion: the next message to send in this conversation, addressed TO the agent — a concise instruction or question that moves the work forward, e.g. "Run the tests and fix failures" / "Commit this". Imperative or question form. Never explain, never offer help, never say "you can".',
'Both values MUST be written in the same language as the conversation text itself. Ignore any other language preferences or personalization you may have — only the conversation text decides the language.',
'Use double quotes for JSON strings, no trailing commas.',
].join('\n');
const extractJsonObject = (value) => {
const text = String(value ?? '').trim();
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = (fenced?.[1] ?? text).trim();
const start = candidate.indexOf('{');
if (start < 0) return null;
for (let end = candidate.length; end > start; end -= 1) {
if (candidate[end - 1] !== '}') continue;
try {
const parsed = JSON.parse(candidate.slice(start, end));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
} catch {
// keep scanning — models wrap JSON in prose sometimes
}
}
return null;
};
const extractSessionStatus = (payload) => {
if (!payload || payload.type !== 'session.status') return null;
const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
const status = properties.status && typeof properties.status === 'object' ? properties.status : {};
const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : '';
const type = typeof status.type === 'string'
? status.type.trim()
: (typeof info.type === 'string' ? info.type.trim() : '');
if (!sessionId || !type) return null;
const directory = typeof properties.directory === 'string' && properties.directory
? properties.directory
: (typeof info.directory === 'string' ? info.directory : '');
return { sessionId, type, directory };
};
const extractUserMessage = (payload) => {
if (!payload || payload.type !== 'message.updated') return null;
const info = payload.properties?.info;
if (!info || typeof info !== 'object' || info.role !== 'user') return null;
if (typeof info.sessionID !== 'string' || !info.sessionID) return null;
return {
sessionId: info.sessionID,
createdAt: typeof info.time?.created === 'number' ? info.time.created : 0,
};
};
const messagePartsToText = (message) => {
const parts = Array.isArray(message?.parts) ? message.parts : [];
return parts
.map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
.filter(Boolean)
.join('\n')
.slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
};
export const createSessionAssistRuntime = ({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
getSmallModelService,
quietMs = IDLE_QUIET_MS,
}) => {
const timers = new Map();
const inflight = new Set();
let stopped = false;
const clearTimer = (sessionId) => {
const existing = timers.get(sessionId);
if (existing) {
clearTimeout(existing.timer);
timers.delete(sessionId);
}
};
const openCodeFetch = async (path, { directory, method = 'GET', body } = {}) => {
const base = buildOpenCodeUrl(path, '');
const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
const response = await fetch(url, {
method,
headers: {
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...getOpenCodeAuthHeaders(),
},
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`OpenCode ${method} ${path} failed with ${response.status}`);
}
return response.json().catch(() => null);
};
const fetchRecentMessages = async (sessionId, directory) => {
const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
const params = new URLSearchParams({ limit: String(TRANSCRIPT_MESSAGE_LIMIT) });
if (directory) params.set('directory', directory);
const response = await fetch(`${base}?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return null;
const messages = await response.json().catch(() => null);
return Array.isArray(messages) ? messages : null;
};
const generateAssist = async (sessionId, directory) => {
if (!isSessionAssistEnabled()) return;
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
.catch((error) => {
console.warn(`[session-assist] session fetch failed: ${error?.message || error}`);
return null;
});
if (!session || typeof session !== 'object') return;
// Sub-agent/task sessions never surface in chat — skip them.
if (typeof session.parentID === 'string' && session.parentID) return;
const messages = await fetchRecentMessages(sessionId, directory);
if (!messages || messages.length === 0) {
console.warn('[session-assist] no messages fetched');
return;
}
let lastAssistant = null;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const info = messages[i]?.info;
if (info?.role === 'assistant') {
lastAssistant = messages[i];
break;
}
}
const lastAssistantInfo = lastAssistant?.info;
if (!lastAssistantInfo?.id) return;
// Only the last exchange: the assistant reply plus the user message it
// answered (assistant info.parentID → user info.id). Everything else is
// token waste for a one-line recap and a single suggestion.
const parentUserMessage = typeof lastAssistantInfo.parentID === 'string' && lastAssistantInfo.parentID
? messages.find((message) => message?.info?.id === lastAssistantInfo.parentID && message?.info?.role === 'user')
: null;
const userText = parentUserMessage ? messagePartsToText(parentUserMessage) : '';
const assistantText = messagePartsToText(lastAssistant);
const transcript = [
userText ? `User:\n${userText}` : '',
assistantText ? `Assistant:\n${assistantText}` : '',
].filter(Boolean).join('\n\n');
if (!transcript) return;
const { generateSmallModelText } = await getSmallModelService();
// Instruct the language by example, not by description — account-side
// personalization (e.g. the ChatGPT backend knowing the user's locale)
// otherwise leaks a different language into the output.
const languageSample = (userText || assistantText).slice(0, 200).replace(/\s+/g, ' ').trim();
let generated;
try {
generated = await generateSmallModelText({
// Background feature: conversation content must never leave the
// session's own provider unless the user explicitly picked a small
// model (settings override / opencode config).
restrictToPreferredProvider: true,
prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite recap and suggestion in the SAME language as this sample from the conversation: "${languageSample}"`,
system: ASSIST_SYSTEM_PROMPT,
directory,
preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
});
} catch (error) {
// No authenticated provider (404) or a transient model failure — this is
// background sugar, never retry loops or logs spam.
if (Number(error?.statusCode) !== 404) {
console.warn('[session-assist] generation failed:', error?.message || error);
}
return;
}
const structured = extractJsonObject(generated?.text);
let recap = typeof structured?.recap === 'string' ? structured.recap.trim().slice(0, RECAP_CHAR_LIMIT) : '';
let suggestion = typeof structured?.suggestion === 'string' ? structured.suggestion.trim().slice(0, SUGGESTION_CHAR_LIMIT) : '';
// Hard guard against language hallucination: if the conversation contains
// no Cyrillic/CJK at all, the output must not either (and drop per-field,
// so one hallucinated field doesn't kill the other).
const hasCyrillic = (text) => /[\u0400-\u04FF]/.test(text);
const hasCjk = (text) => /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/.test(text);
const inputText = `${userText}\n${assistantText}`;
const scriptMismatch = (text) => (hasCyrillic(text) && !hasCyrillic(inputText))
|| (hasCjk(text) && !hasCjk(inputText));
if (recap && scriptMismatch(recap)) {
console.warn('[session-assist] dropped recap: language mismatch with conversation');
recap = '';
}
if (suggestion && scriptMismatch(suggestion)) {
console.warn('[session-assist] dropped suggestion: language mismatch with conversation');
suggestion = '';
}
if (!recap && !suggestion) return;
// The session may have moved on while we generated — a stale patch would
// flash outdated content, so re-check the tail before writing.
const latest = await fetchRecentMessages(sessionId, directory);
const latestAssistantId = (() => {
if (!latest) return null;
for (let i = latest.length - 1; i >= 0; i -= 1) {
const info = latest[i]?.info;
if (info?.role === 'assistant') return info.id;
if (info?.role === 'user') return null;
}
return null;
})();
if (latestAssistantId !== lastAssistantInfo.id) {
console.log('[session-assist] tail moved on, dropping result');
return;
}
// Merge from a FRESH read: generation takes tens of seconds, and merging
// from the session snapshot fetched before it would clobber any metadata
// written meanwhile (suggestion dismissals, review links, …).
const freshSession = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
.catch(() => null);
const currentMetadata = freshSession?.metadata && typeof freshSession.metadata === 'object'
? freshSession.metadata
: (session.metadata && typeof session.metadata === 'object' ? session.metadata : {});
const currentNamespace = currentMetadata.openchamber && typeof currentMetadata.openchamber === 'object'
? currentMetadata.openchamber
: {};
console.log(`[session-assist] generated for ${sessionId} via ${generated.providerID}/${generated.modelID}`);
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
directory,
method: 'PATCH',
body: {
metadata: {
...currentMetadata,
openchamber: {
...currentNamespace,
assist: {
recap,
suggestion,
forMessageID: lastAssistantInfo.id,
generatedAt: Date.now(),
},
},
},
},
});
};
const armTimer = (sessionId, directory) => {
clearTimer(sessionId);
const timer = setTimeout(() => {
timers.delete(sessionId);
if (stopped || inflight.has(sessionId)) return;
inflight.add(sessionId);
generateAssist(sessionId, directory)
.catch((error) => {
console.warn('[session-assist] failed:', error?.message || error);
})
.finally(() => {
inflight.delete(sessionId);
});
}, quietMs);
if (typeof timer?.unref === 'function') timer.unref();
timers.set(sessionId, { timer, armedAt: Date.now() });
};
const processPayload = (payload, directoryHint = '') => {
if (stopped) return;
const status = extractSessionStatus(payload);
if (status) {
if (status.type === 'idle') {
armTimer(status.sessionId, status.directory || directoryHint);
} else {
clearTimer(status.sessionId);
}
return;
}
const userMessage = extractUserMessage(payload);
if (userMessage) {
// OpenCode re-emits message.updated for OLD user messages after the
// session settles (post-completion metadata patches). Only a message
// created after the timer was armed means the user actually moved on.
const armed = timers.get(userMessage.sessionId);
if (armed && userMessage.createdAt >= armed.armedAt) {
clearTimer(userMessage.sessionId);
}
}
};
const stop = () => {
stopped = true;
for (const { timer } of timers.values()) {
clearTimeout(timer);
}
timers.clear();
};
return { processPayload, stop };
};
@@ -0,0 +1,78 @@
# Small Model
Server-side direct LLM calls that reuse the user's existing OpenCode provider
logins (`~/.local/share/opencode/auth.json`). OpenCode uses a "small model"
internally (titles, summaries) but does not expose it through the SDK or
plugins — this module replicates that mechanism as an OpenChamber runtime API.
## Security boundary
Credentials never leave the server process. The client sends only a prompt;
auth resolution, OAuth refresh, and provider dispatch all happen server-side.
Routes live under `/api/*` and are gated by the ui-auth middleware like every
other runtime API.
## Files
- `index.js` — orchestration: `generateSmallModelText()` / `describeSmallModel()`.
- `resolve.js` — model selection, mirroring OpenCode's `getSmallModel` chain:
0. OpenChamber's own settings override (Settings → Sessions → Small Model):
when `smallModelUseDefault` is `false`, `smallModelOverride`
(`provider/model`) outranks everything below. Sanitized in
`settings-helpers.js` (server), `persistence.ts` (client), and
`bridge-settings-runtime.ts` (VS Code).
1. `small_model` from the merged OpenCode config layers (`provider/model`).
2. Family-priority scan (`gemini-flash``gpt-nano``claude-haiku`)
**within the session's provider first** (`preferredProviderID`, like
OpenCode resolves within the current provider), then over the other
providers with a usable auth entry, newest `release_date` first.
3. GitHub Copilot hidden utility models (`gpt-*-nano/mini`) — these never
appear in the catalog, so they participate as the `gpt-nano` family entry
and as a final utility fallback.
4. Last resort: the session's own model (`preferredModelID`) when no small
model resolves anywhere — costlier, but always valid.
- Input clamp: the prompt is truncated to the resolved model's catalog
`limit.context` (minus an output reserve, ~4 chars/token estimate;
conservative default when the model is not in the catalog). Truncation is
reported as `inputTruncated: true` in the response.
- `call.js` — wire formats and per-provider auth, replicating OpenCode's
plugin auth loaders:
- **GitHub Copilot**: OpenAI-compatible `/chat/completions` on
`https://api.githubcopilot.com` (or `copilot-api.<enterprise>`) with the
stored device-OAuth token as the bearer — no token exchange, no expiry.
- **OpenAI OAuth (ChatGPT plan)**: streaming Responses API on
`https://chatgpt.com/backend-api/codex/responses` with
`ChatGPT-Account-Id`; expired tokens are refreshed against
`auth.openai.com` (single-flight) and written back to `auth.json`.
- **Anthropic** (`type: api`): `/v1/messages` with `x-api-key`.
- **Google** (`type: api`): `generateContent` with `x-goog-api-key`.
- Everything else: OpenAI-compatible `/chat/completions` against the
provider's models.dev base URL with `Authorization: Bearer <key>`.
- `catalog.js` — models.dev catalog via the shared in-process cache
(`../opencode/models-metadata.js`, also serving
`/api/openchamber/models-metadata`).
- `routes.js``GET /api/small-model` (resolution preview) and
`POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?,
model?, directory? }` → `{ text, providerID, modelID, source }`).
## Registration
Mounted lazily from `feature-routes-runtime.js` (same pattern as quota): the
module is imported on first request, not at server startup.
## Known limitations
- OpenCode's free models (`opencode/big-pickle`, `*-free`) work without a
token only through OpenCode's own server — direct calls are rejected, and
piggybacking on their subsidized infra is out of bounds by design. Every
resolution step therefore requires a usable auth entry for the provider:
a session on an unauthenticated `opencode` provider falls through to the
global scan (or a clean 404 on a vanilla setup with no logins).
- Anthropic OAuth (Claude Pro/Max) entries are not supported — OpenCode itself
keeps those outside `auth.json` in this generation; only `type: api` keys
work for Anthropic.
- Amazon Bedrock, GitLab, Azure and other credential-chain providers are out
of scope; they need more than a key/token (regions, resource names).
- Responses from the codex backend are collected from the SSE stream; the
endpoint itself is non-streaming by design (small utility calls).
+380
View File
@@ -0,0 +1,380 @@
import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
import { getCatalogProvider } from './catalog.js';
import { getAuthEntryForProvider } from './resolve.js';
// Direct, non-streaming text generation against the provider APIs, replicating
// how OpenCode authenticates each of them (see the plugin auth loaders in the
// opencode repo). auth.json credentials never leave this process.
const REQUEST_TIMEOUT_MS = 60_000;
// Generous default: thinking models that can't be switched off (DeepSeek,
// Qwen, …) spend part of this budget on reasoning before the actual answer.
const DEFAULT_MAX_OUTPUT_TOKENS = 4_000;
const USER_AGENT = 'opencode/1.0 openchamber';
const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
const httpError = async (response, provider) => {
const body = await response.text().catch(() => '');
const snippet = body ? `: ${body.slice(0, 300)}` : '';
return new Error(`${provider} request failed with ${response.status}${snippet}`);
};
// ---------------------------------------------------------------------------
// OpenAI OAuth (ChatGPT plan / codex) token refresh — single-flight, with the
// refreshed token written back to auth.json exactly like OpenCode does.
// ---------------------------------------------------------------------------
let openaiRefreshPromise = null;
const decodeJwtClaims = (token) => {
try {
const payload = token.split('.')[1];
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
} catch {
return null;
}
};
const extractChatgptAccountId = (accessToken) => {
const claims = decodeJwtClaims(accessToken);
const auth = claims?.['https://api.openai.com/auth'];
const value = auth?.chatgpt_account_id;
return typeof value === 'string' && value ? value : null;
};
const refreshOpenaiOauth = async (entry) => {
if (!openaiRefreshPromise) {
openaiRefreshPromise = (async () => {
const response = await fetch(CODEX_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: entry.refresh,
client_id: CODEX_CLIENT_ID,
}),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw await httpError(response, 'OpenAI token refresh');
}
const payload = await response.json();
const access = typeof payload?.access_token === 'string' ? payload.access_token : '';
if (!access) {
throw new Error('OpenAI token refresh returned no access token');
}
const refreshed = {
...entry,
type: 'oauth',
access,
refresh: typeof payload?.refresh_token === 'string' && payload.refresh_token
? payload.refresh_token
: entry.refresh,
expires: Date.now() + (Number(payload?.expires_in) > 0 ? Number(payload.expires_in) : 3600) * 1000,
};
const auth = readAuthFile();
auth.openai = refreshed;
writeAuthFile(auth);
return refreshed;
})().finally(() => {
openaiRefreshPromise = null;
});
}
return openaiRefreshPromise;
};
const ensureFreshOpenaiOauth = async (entry) => {
if (entry.access && Number(entry.expires) > Date.now()) {
return entry;
}
if (!entry.refresh) {
throw new Error('OpenAI OAuth entry has no refresh token');
}
return refreshOpenaiOauth(entry);
};
// ---------------------------------------------------------------------------
// Wire formats
// ---------------------------------------------------------------------------
const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, extraBody }) => {
const trimmedBase = baseURL.replace(/\/+$/, '');
const response = await fetch(`${trimmedBase}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...headers,
},
body: JSON.stringify({
model: modelID,
messages: [
...(system ? [{ role: 'system', content: system }] : []),
{ role: 'user', content: prompt },
],
max_tokens: maxOutputTokens,
stream: false,
...(extraBody || {}),
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw await httpError(response, providerLabel);
}
const payload = await response.json();
const message = payload?.choices?.[0]?.message;
// Providers disagree on the content shape: plain string, an array of
// typed parts, or (thinking models) an empty content with the budget spent
// on reasoning_content.
let text = '';
if (typeof message?.content === 'string') {
text = message.content;
} else if (Array.isArray(message?.content)) {
text = message.content
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
.join('');
}
if (!text.trim() && typeof message?.reasoning_content === 'string' && message.reasoning_content.trim()) {
const finishReason = payload?.choices?.[0]?.finish_reason;
throw new Error(
`${providerLabel} spent the output budget on reasoning and returned no answer`
+ (finishReason ? ` (finish_reason: ${finishReason})` : ''),
);
}
if (!text.trim()) {
throw new Error(`${providerLabel} returned no message content`);
}
return text;
};
const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: modelID,
max_tokens: maxOutputTokens,
...(system ? { system } : {}),
messages: [{ role: 'user', content: prompt }],
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw await httpError(response, 'Anthropic');
}
const payload = await response.json();
const text = (payload?.content || [])
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
.map((part) => part.text)
.join('');
if (!text) {
throw new Error('Anthropic returned no text content');
}
return text;
};
const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelID)}:generateContent`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
// thinkingBudget 0 switches Gemini Flash thinking off; Flash is the only
// family the small-model resolver picks for Google.
generationConfig: { maxOutputTokens, thinkingConfig: { thinkingBudget: 0 } },
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw await httpError(response, 'Google');
}
const payload = await response.json();
const text = (payload?.candidates?.[0]?.content?.parts || [])
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
.join('');
if (!text) {
throw new Error('Google returned no text content');
}
return text;
};
// ChatGPT-plan traffic goes to the codex backend, which only speaks the
// streaming Responses API — collect the output_text deltas from the SSE body.
const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, system }) => {
const response = await fetch(CODEX_RESPONSES_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: `Bearer ${accessToken}`,
...(accountId ? { 'ChatGPT-Account-Id': accountId } : {}),
originator: 'opencode',
'User-Agent': USER_AGENT,
},
body: JSON.stringify({
model: modelID,
...(system ? { instructions: system } : {}),
input: [
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: prompt }],
},
],
// The codex backend rejects max_output_tokens (OpenCode forces it to
// undefined for this provider too).
stream: true,
store: false,
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw await httpError(response, 'OpenAI (ChatGPT plan)');
}
const raw = await response.text();
let text = '';
let completedText = '';
for (const line of raw.split('\n')) {
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (!data || data === '[DONE]') continue;
let event;
try {
event = JSON.parse(data);
} catch {
continue;
}
if (event?.type === 'response.output_text.delta' && typeof event.delta === 'string') {
text += event.delta;
}
if (event?.type === 'response.output_text.done' && typeof event.text === 'string') {
completedText = event.text;
}
if (event?.type === 'response.failed' || event?.type === 'error') {
const message = event?.response?.error?.message || event?.message || 'response failed';
throw new Error(`OpenAI (ChatGPT plan) stream error: ${message}`);
}
}
const result = completedText || text;
if (!result) {
throw new Error('OpenAI (ChatGPT plan) returned no text output');
}
return result;
};
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
export async function callSmallModel({ auth, catalog, providerID, modelID, prompt, system, maxOutputTokens }) {
const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
const entry = getAuthEntryForProvider(auth, providerID);
if (!entry) {
throw new Error(`No OpenCode login found for provider "${providerID}"`);
}
if (providerID === 'github-copilot') {
// OpenCode uses the stored device-OAuth token directly as the bearer —
// access === refresh, no exchange, no expiry.
const token = entry.refresh || entry.access || entry.key;
if (!token) {
throw new Error('GitHub Copilot login has no token');
}
const baseURL = entry.enterpriseUrl
? `https://copilot-api.${String(entry.enterpriseUrl).replace(/^https?:\/\//, '').replace(/\/+$/, '')}`
: 'https://api.githubcopilot.com';
return callOpenaiCompatible({
baseURL,
headers: {
Authorization: `Bearer ${token}`,
'User-Agent': USER_AGENT,
'Openai-Intent': 'conversation-edits',
'x-initiator': 'agent',
'X-GitHub-Api-Version': '2026-06-01',
},
modelID,
prompt,
system,
maxOutputTokens: tokens,
providerLabel: 'GitHub Copilot',
});
}
if (providerID === 'openai' && entry.type === 'oauth') {
const fresh = await ensureFreshOpenaiOauth(entry);
return callCodexResponses({
accessToken: fresh.access,
accountId: fresh.accountId || extractChatgptAccountId(fresh.access),
modelID,
prompt,
system,
});
}
const apiKey = entry.type === 'api' ? entry.key
: entry.type === 'wellknown' ? entry.token
: entry.access;
if (!apiKey) {
throw new Error(`OpenCode login for "${providerID}" has no usable credential`);
}
if (providerID === 'anthropic') {
return callAnthropic({ apiKey, modelID, prompt, system, maxOutputTokens: tokens });
}
if (providerID === 'google') {
return callGoogle({ apiKey, modelID, prompt, system, maxOutputTokens: tokens });
}
// Everything else: OpenAI-compatible chat completions against the catalog's
// base URL for that provider (openai itself included).
const provider = getCatalogProvider(catalog, providerID);
const baseURL = providerID === 'openai'
? 'https://api.openai.com/v1'
: typeof provider?.api === 'string' && provider.api
? provider.api
: null;
if (!baseURL) {
throw new Error(`Provider "${providerID}" has no known API base URL`);
}
// Thinking models burn the output budget on reasoning and leave content
// empty — disable thinking where a wire-format switch exists (mirrors
// OpenCode's smallOptions/variants special cases). There is NO universal
// parameter: unknown body fields 400 on some providers, so this stays an
// explicit allowlist. Models without a switch (DeepSeek, Qwen, Kimi, …)
// just get the generous output budget.
const lowerModel = modelID.toLowerCase();
const supportsThinkingToggle = providerID.includes('zai')
|| providerID.includes('zhipu')
|| lowerModel.includes('glm')
|| lowerModel.includes('minimax-m3');
const extraBody = supportsThinkingToggle ? { thinking: { type: 'disabled' } } : undefined;
return callOpenaiCompatible({
baseURL,
headers: { Authorization: `Bearer ${apiKey}` },
modelID,
prompt,
system,
maxOutputTokens: tokens,
providerLabel: provider?.name || providerID,
extraBody,
});
}
@@ -0,0 +1,13 @@
import { getModelsMetadata } from '../opencode/models-metadata.js';
// The models.dev catalog is shared with the /api/openchamber/models-metadata
// route through one in-process cache — no extra fetches, no cache files.
export async function getModelCatalog() {
const { metadata } = await getModelsMetadata();
return metadata;
}
export function getCatalogProvider(catalog, providerID) {
const entry = catalog?.[providerID];
return entry && typeof entry === 'object' ? entry : null;
}
@@ -0,0 +1,167 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { readAuthFile } from '../opencode/auth.js';
import { readConfigLayers } from '../opencode/shared.js';
import { getModelCatalog } from './catalog.js';
import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
import { callSmallModel } from './call.js';
const OPENCHAMBER_SETTINGS_FILE = path.join(
process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber'),
'settings.json',
);
// OpenChamber's own settings: when the user unchecks "use default small model"
// their explicit override outranks every other resolution step.
const readSmallModelSettingsOverride = () => {
try {
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
const settings = JSON.parse(raw);
if (!settings || typeof settings !== 'object') return null;
if (settings.smallModelUseDefault !== false) return null;
const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
return override || null;
} catch {
return null;
}
};
// Rough safety clamp so a huge input never blows the model's context window.
// Token estimate is ~4 chars/token; when the catalog has no limit for the
// model (Copilot/codex utility models are not listed) a conservative default
// applies.
const DEFAULT_CONTEXT_TOKENS = 64_000;
const OUTPUT_RESERVE_TOKENS = 4_000;
const clampPromptToModelLimit = ({ prompt, catalog, providerID, modelID }) => {
const limit = catalog?.[providerID]?.models?.[modelID]?.limit;
const contextTokens = Number(limit?.context) > 0 ? Number(limit.context) : DEFAULT_CONTEXT_TOKENS;
const inputBudgetTokens = Math.max(1_000, contextTokens - OUTPUT_RESERVE_TOKENS);
const maxChars = inputBudgetTokens * 4;
if (prompt.length <= maxChars) {
return { prompt, truncated: false };
}
return { prompt: `${prompt.slice(0, maxChars)}`, truncated: true };
};
const readConfiguredSmallModel = (workingDirectory) => {
try {
const { mergedConfig } = readConfigLayers(workingDirectory);
const value = mergedConfig?.small_model;
return typeof value === 'string' ? value : null;
} catch {
return null;
}
};
/**
* Generates text with the user's small model, resolved and authenticated
* entirely server-side from the OpenCode config and auth store.
*/
export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false }) {
if (typeof prompt !== 'string' || !prompt.trim()) {
throw Object.assign(new Error('prompt is required'), { statusCode: 400 });
}
const auth = readAuthFile();
const catalog = await getModelCatalog().catch(() => ({}));
const explicit = parseModelRef(model);
const resolved = explicit
? { ...explicit, source: 'request' }
: resolveSmallModel({
auth,
catalog,
settingsSmallModel: readSmallModelSettingsOverride(),
configSmallModel: readConfiguredSmallModel(directory),
preferredProviderID,
preferredModelID,
});
if (!resolved) {
throw Object.assign(
new Error('No small model available — no authenticated provider has a suitable model'),
{ statusCode: 404 },
);
}
// Callers with a session context can forbid silently switching providers:
// an explicit user choice (settings override, opencode config, request
// model) is always allowed, anything else must stay on the session's
// provider.
if (restrictToPreferredProvider
&& !['settings', 'config', 'request'].includes(resolved.source)
&& resolved.providerID !== preferredProviderID) {
throw Object.assign(
new Error('No small model available within the session provider'),
{ statusCode: 404 },
);
}
const clamped = clampPromptToModelLimit({
prompt: prompt.trim(),
catalog,
providerID: resolved.providerID,
modelID: resolved.modelID,
});
const text = await callSmallModel({
auth,
catalog,
providerID: resolved.providerID,
modelID: resolved.modelID,
prompt: clamped.prompt,
system: typeof system === 'string' && system.trim() ? system.trim() : undefined,
maxOutputTokens,
});
return {
text: text.trim(),
providerID: resolved.providerID,
modelID: resolved.modelID,
source: resolved.source,
...(clamped.truncated ? { inputTruncated: true } : {}),
};
}
/**
* Provider ids with a usable OpenCode login the set the small model can
* actually call. Used by the settings override picker to hide providers that
* would only ever fail (e.g. opencode free models without a token).
*/
export function listAuthenticatedProviders() {
try {
const auth = readAuthFile();
const ids = new Set(
Object.keys(auth || {}).filter((providerID) => isUsableAuthEntry(auth[providerID])),
);
// The catalog id is github-copilot while legacy auth entries may sit
// under the copilot alias.
if (isUsableAuthEntry(getAuthEntryForProvider(auth, 'github-copilot'))) {
ids.add('github-copilot');
}
return Array.from(ids);
} catch {
return [];
}
}
/**
* Reports which model would be used, without calling it.
*/
export async function describeSmallModel({ directory, preferredProviderID, preferredModelID } = {}) {
const auth = readAuthFile();
const catalog = await getModelCatalog().catch(() => ({}));
const resolved = resolveSmallModel({
auth,
catalog,
settingsSmallModel: readSmallModelSettingsOverride(),
configSmallModel: readConfiguredSmallModel(directory),
preferredProviderID,
preferredModelID,
});
return resolved;
}
@@ -0,0 +1,131 @@
import { getCatalogProvider } from './catalog.js';
// Mirrors OpenCode's getSmallModel fallback chain:
// 1. `small_model` from the merged config layers ("provider/model").
// 2. GitHub Copilot's hidden utility models when Copilot is logged in.
// 3. Family-priority scan of the authenticated providers' catalog models.
const FAMILY_PRIORITY = ['gemini-flash', 'gpt-nano', 'claude-haiku'];
const COPILOT_UTILITY_MODELS = ['gpt-5.4-nano', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini'];
// The ChatGPT-plan codex backend only accepts a small allowlist of models
// (nano/API-key models are rejected with 400) — this is its cheapest one.
const OPENAI_OAUTH_SMALL_MODEL = 'gpt-5.4-mini';
const AUTH_PROVIDER_ALIASES = {
'github-copilot': ['github-copilot', 'copilot'],
};
export function getAuthEntryForProvider(auth, providerID) {
const aliases = AUTH_PROVIDER_ALIASES[providerID] || [providerID];
for (const alias of aliases) {
const entry = auth?.[alias];
if (entry && typeof entry === 'object') {
return entry;
}
}
return null;
}
export function isUsableAuthEntry(entry) {
if (!entry || typeof entry !== 'object') return false;
if (entry.type === 'api') return typeof entry.key === 'string' && entry.key.length > 0;
if (entry.type === 'oauth') {
return (typeof entry.access === 'string' && entry.access.length > 0)
|| (typeof entry.refresh === 'string' && entry.refresh.length > 0);
}
if (entry.type === 'wellknown') return typeof entry.token === 'string' && entry.token.length > 0;
return false;
}
export function parseModelRef(value) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
const slash = trimmed.indexOf('/');
if (slash <= 0 || slash === trimmed.length - 1) return null;
return {
providerID: trimmed.slice(0, slash),
modelID: trimmed.slice(slash + 1),
};
}
const pickByFamily = (models, family) => {
const matches = Object.values(models)
.filter((model) => model && typeof model === 'object' && model.family === family);
if (matches.length === 0) return null;
matches.sort((a, b) => String(b.release_date || '').localeCompare(String(a.release_date || '')));
return matches[0];
};
// Small-model candidates within ONE provider, by family priority. Copilot and
// ChatGPT-plan OpenAI have fixed small models that never appear in the
// catalog; everyone else is scanned through the catalog families.
const pickWithinProvider = (providerID, auth, catalog, family) => {
if (providerID === 'openai' && auth.openai?.type === 'oauth') {
return family === 'gpt-nano'
? { providerID, modelID: OPENAI_OAUTH_SMALL_MODEL, source: 'codex-small' }
: null;
}
if (providerID === 'github-copilot') {
return family === 'gpt-nano'
? { providerID, modelID: COPILOT_UTILITY_MODELS[0], source: 'copilot-utility' }
: null;
}
const provider = getCatalogProvider(catalog, providerID);
if (!provider || !provider.models || typeof provider.models !== 'object') return null;
const model = pickByFamily(provider.models, family);
return model?.id ? { providerID, modelID: model.id, source: 'family-scan' } : null;
};
export function resolveSmallModel({ auth, catalog, settingsSmallModel, configSmallModel, preferredProviderID, preferredModelID }) {
// OpenChamber's own setting (Settings → Sessions → Small Model override)
// outranks everything, including the OpenCode config.
const fromSettings = parseModelRef(settingsSmallModel);
if (fromSettings) {
return { ...fromSettings, source: 'settings' };
}
const explicit = parseModelRef(configSmallModel);
if (explicit) {
return { ...explicit, source: 'config' };
}
// Like OpenCode: when the caller has a session context, the utility call
// stays on the session's provider. Scan its families for a small model,
// otherwise run on the session's own model — never silently switch to a
// different provider's subscription.
const preferred = typeof preferredProviderID === 'string' && preferredProviderID
? preferredProviderID
: null;
if (preferred && isUsableAuthEntry(getAuthEntryForProvider(auth, preferred))) {
for (const family of FAMILY_PRIORITY) {
const match = pickWithinProvider(preferred, auth, catalog, family);
if (match) return match;
}
if (typeof preferredModelID === 'string' && preferredModelID) {
return { providerID: preferred, modelID: preferredModelID, source: 'session-model' };
}
}
// No session context (or its provider has no usable login): scan all
// authenticated providers by family priority.
const authedProviders = Object.keys(auth || {}).filter((providerID) =>
providerID !== preferred && isUsableAuthEntry(auth[providerID]));
for (const family of FAMILY_PRIORITY) {
for (const providerID of authedProviders) {
const match = pickWithinProvider(providerID, auth, catalog, family);
if (match) return match;
}
}
// Copilot's utility fallback for legacy auth aliases the loop above missed.
const copilotEntry = getAuthEntryForProvider(auth, 'github-copilot');
if (isUsableAuthEntry(copilotEntry)) {
return {
providerID: 'github-copilot',
modelID: COPILOT_UTILITY_MODELS[0],
source: 'copilot-utility',
};
}
return null;
}
@@ -0,0 +1,197 @@
import { describe, it, expect } from 'bun:test';
import { resolveSmallModel, parseModelRef, isUsableAuthEntry } from './resolve.js';
const catalog = {
google: {
id: 'google',
models: {
'gemini-2.5-flash': { id: 'gemini-2.5-flash', family: 'gemini-flash', release_date: '2025-06-01' },
'gemini-2.0-flash': { id: 'gemini-2.0-flash', family: 'gemini-flash', release_date: '2024-12-01' },
'gemini-2.5-pro': { id: 'gemini-2.5-pro', family: 'gemini-pro', release_date: '2025-06-01' },
},
},
anthropic: {
id: 'anthropic',
models: {
'claude-haiku-4-5': { id: 'claude-haiku-4-5', family: 'claude-haiku', release_date: '2025-10-01' },
'claude-sonnet-4-5': { id: 'claude-sonnet-4-5', family: 'claude-sonnet', release_date: '2025-09-01' },
},
},
};
describe('parseModelRef', () => {
it('splits provider/model on the first slash', () => {
expect(parseModelRef('anthropic/claude-haiku-4-5')).toEqual({
providerID: 'anthropic',
modelID: 'claude-haiku-4-5',
});
});
it('keeps slashes inside the model id', () => {
expect(parseModelRef('openrouter/google/gemini-2.5-flash')).toEqual({
providerID: 'openrouter',
modelID: 'google/gemini-2.5-flash',
});
});
it('rejects values without a provider or model part', () => {
expect(parseModelRef('anthropic/')).toBeNull();
expect(parseModelRef('/model')).toBeNull();
expect(parseModelRef('plain')).toBeNull();
expect(parseModelRef(undefined)).toBeNull();
});
});
describe('isUsableAuthEntry', () => {
it('accepts api keys, oauth tokens, and wellknown tokens', () => {
expect(isUsableAuthEntry({ type: 'api', key: 'sk-x' })).toBe(true);
expect(isUsableAuthEntry({ type: 'oauth', access: 'a', refresh: 'r', expires: 0 })).toBe(true);
expect(isUsableAuthEntry({ type: 'wellknown', key: 'k', token: 't' })).toBe(true);
});
it('rejects empty or malformed entries', () => {
expect(isUsableAuthEntry({ type: 'api', key: '' })).toBe(false);
expect(isUsableAuthEntry({ type: 'oauth' })).toBe(false);
expect(isUsableAuthEntry(null)).toBe(false);
});
});
describe('resolveSmallModel', () => {
it('gives the OpenChamber settings override top priority', () => {
const result = resolveSmallModel({
auth: { anthropic: { type: 'api', key: 'sk-x' } },
catalog,
settingsSmallModel: 'anthropic/claude-haiku-4-5',
configSmallModel: 'openai/gpt-4o-mini',
preferredProviderID: 'anthropic',
});
expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'settings' });
});
it('prefers the configured small_model', () => {
const result = resolveSmallModel({
auth: { anthropic: { type: 'api', key: 'sk-x' } },
catalog,
configSmallModel: 'openai/gpt-4o-mini',
});
expect(result).toEqual({ providerID: 'openai', modelID: 'gpt-4o-mini', source: 'config' });
});
it('scans authenticated providers by family priority, newest first', () => {
const result = resolveSmallModel({
auth: {
google: { type: 'api', key: 'g-key' },
anthropic: { type: 'api', key: 'sk-x' },
},
catalog,
configSmallModel: null,
});
expect(result).toEqual({ providerID: 'google', modelID: 'gemini-2.5-flash', source: 'family-scan' });
});
it('skips providers without a usable credential', () => {
const result = resolveSmallModel({
auth: {
google: { type: 'api', key: '' },
anthropic: { type: 'api', key: 'sk-x' },
},
catalog,
configSmallModel: null,
});
expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'family-scan' });
});
it('falls back to Copilot utility models when only Copilot is logged in', () => {
const result = resolveSmallModel({
auth: { 'github-copilot': { type: 'oauth', access: 't', refresh: 't', expires: 0 } },
catalog,
configSmallModel: null,
});
expect(result?.providerID).toBe('github-copilot');
expect(result?.source).toBe('copilot-utility');
});
it('returns null when nothing is authenticated', () => {
expect(resolveSmallModel({ auth: {}, catalog, configSmallModel: null })).toBeNull();
});
it('prefers the session provider over other authenticated providers', () => {
const result = resolveSmallModel({
auth: {
google: { type: 'api', key: 'g-key' },
anthropic: { type: 'api', key: 'sk-x' },
},
catalog,
configSmallModel: null,
preferredProviderID: 'anthropic',
});
expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'family-scan' });
});
it('ignores a preferred provider without a usable login', () => {
const result = resolveSmallModel({
auth: { google: { type: 'api', key: 'g-key' } },
catalog,
configSmallModel: null,
preferredProviderID: 'anthropic',
});
expect(result).toEqual({ providerID: 'google', modelID: 'gemini-2.5-flash', source: 'family-scan' });
});
it('never uses a session provider without a login (opencode free models)', () => {
// Vanilla setups default the picker to opencode/big-pickle with no
// opencode token — those free models only work through OpenCode itself
// and must never be called directly, so the session context is ignored.
const result = resolveSmallModel({
auth: { openai: { type: 'oauth', access: 'a', refresh: 'r', expires: Date.now() + 60_000 } },
catalog,
configSmallModel: null,
preferredProviderID: 'opencode',
preferredModelID: 'big-pickle',
});
expect(result).toEqual({ providerID: 'openai', modelID: 'gpt-5.4-mini', source: 'codex-small' });
});
it('resolves nothing on a vanilla setup with no logins at all', () => {
const result = resolveSmallModel({
auth: {},
catalog,
configSmallModel: null,
preferredProviderID: 'opencode',
preferredModelID: 'big-pickle',
});
expect(result).toBeNull();
});
it('falls back to the session model instead of scanning other providers', () => {
const result = resolveSmallModel({
auth: {
'opencode-go': { type: 'api', key: 'oc-key' },
openai: { type: 'oauth', access: 'a', refresh: 'r', expires: Date.now() + 60_000 },
},
catalog: {
'opencode-go': {
id: 'opencode-go',
models: {
'deepseek-v4-flash': { id: 'deepseek-v4-flash', family: 'deepseek-flash', release_date: '2026-01-01' },
},
},
},
configSmallModel: null,
preferredProviderID: 'opencode-go',
preferredModelID: 'deepseek-v4-flash',
});
expect(result).toEqual({ providerID: 'opencode-go', modelID: 'deepseek-v4-flash', source: 'session-model' });
});
it('falls back to the session model itself when nothing resolves', () => {
const result = resolveSmallModel({
auth: { mistral: { type: 'api', key: 'm-key' } },
catalog,
configSmallModel: null,
preferredProviderID: 'mistral',
preferredModelID: 'mistral-large-latest',
});
expect(result).toEqual({ providerID: 'mistral', modelID: 'mistral-large-latest', source: 'session-model' });
});
});
@@ -0,0 +1,44 @@
export function registerSmallModelRoutes(app, { getSmallModelService }) {
app.get('/api/small-model', async (req, res) => {
try {
const { describeSmallModel, listAuthenticatedProviders } = await getSmallModelService();
const resolved = await describeSmallModel({
directory: typeof req.query.directory === 'string' ? req.query.directory : undefined,
preferredProviderID: typeof req.query.providerID === 'string' ? req.query.providerID : undefined,
preferredModelID: typeof req.query.modelID === 'string' ? req.query.modelID : undefined,
});
res.json({
available: Boolean(resolved),
model: resolved,
authenticatedProviders: listAuthenticatedProviders(),
});
} catch (error) {
console.error('Failed to resolve small model:', error);
res.status(500).json({ error: error.message || 'Failed to resolve small model' });
}
});
app.post('/api/small-model/generate', async (req, res) => {
try {
const { generateSmallModelText } = await getSmallModelService();
const { prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {};
const result = await generateSmallModelText({
prompt,
system,
maxOutputTokens,
model,
directory,
preferredProviderID,
preferredModelID,
restrictToPreferredProvider: restrictToPreferredProvider === true,
});
res.json(result);
} catch (error) {
const statusCode = Number(error?.statusCode) || 500;
if (statusCode >= 500) {
console.error('Small model generation failed:', error);
}
res.status(statusCode).json({ error: error.message || 'Small model generation failed' });
}
});
}
+13 -1
View File
@@ -173,6 +173,18 @@ function step(label, fn) {
return result;
}
function printReleaseNextSteps(version) {
log.success(`Release v${version} prepared locally`);
log.info('Next steps:');
console.log(` git add -A`);
console.log(` git commit -m "release v${version}"`);
console.log(` git tag v${version}`);
console.log(` git push origin main --tags`);
console.log('');
console.log('This will trigger the GitHub Actions release workflow.');
console.log(`Make sure CHANGELOG.md contains a section like "## [${version}] - YYYY-MM-DD" before pushing.`);
}
function normalizeAction(action = '') {
const normalized = action.toLowerCase();
const aliases = {
@@ -575,7 +587,7 @@ async function createRelease(options) {
if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) throw new Error('Invalid version format. Use semver, e.g. 1.4.7 or 1.4.7-beta.1');
step('Validating codebase', () => run('bun', ['run', 'release:prepare']));
step(`Bumping version to ${version}`, () => run('node', ['scripts/bump-version.mjs', version]));
log.success(`Release v${version} prepared locally`);
printReleaseNextSteps(version);
}
async function chooseAction(config) {