feat: small-model utility calls on existing OpenCode providers (#2049)
Adds a server-side "small model" capability: direct, cheap LLM calls that reuse the user's existing OpenCode provider logins — the mechanism OpenCode uses internally for titles and summaries but does not expose through the SDK or plugins. Zero new dependencies; plain fetch with per-provider wire formats, credentials never leave the server. Core (packages/web/server/lib/small-model): - Resolution mirrors OpenCode's session scoping: explicit settings override → small_model from the OpenCode config → family scan within the session's provider → the session's own model. The global provider scan only serves callers without a session context, and background callers forbid it entirely (restrictToPreferredProvider), so conversation content never reaches a provider the user didn't pick — explicit choices excepted. - Per-provider auth replicating OpenCode's plugin loaders: GitHub Copilot (device token as bearer, no exchange), ChatGPT plan via the codex Responses API (single-flight OAuth refresh written back to auth.json), Anthropic messages, Google generateContent, generic OpenAI-compatible. - OpenCode's free models (opencode/big-pickle, *-free) are never called directly; unauthenticated providers are skipped by design. - Prompt clamping to the model's catalog context limit; thinking disabled where a wire switch exists (Z.AI/GLM, MiniMax-M3, Gemini Flash); robust content parsing with a clear error when a thinking model spends its whol budget on reasoning. - Settings → Sessions gains a Small Model group: use-default checkbox plus an override picker limited to authenticated providers, persisted with web/desktop/VS Code sanitization parity. Consumers: - Session assist: a server-side watcher on the global SSE hub generates a short recap and one suggested follow-up after a session idles quietly fo a minute, stored on session metadata (openchamber.assist). Freshness is keyed to the last assistant message id, so new activity invalidates the payload everywhere with no extra writes. The chat shows the recap under the last message after five quiet minutes and the suggestion as a dismissible chip above the composer (tap fills the input, never sends). Gated by a new Chat setting (default on) that is a hard generation switch. Language is anchored to the conversation itself, with a script-mismatch guard against model/backend language hallucination. - TTS: a third input mode, summarized — long replies are condensed to spoken prose before playback on any TTS engine. - Git: commit-message and PR generation moved off the active chat session onto the small model fed with real diffs and the commit list (bodies included), with a session-transport fallback for free-model-only setups. - Notes: Add to notes distills long selections into 1-3 dense sentences preserving exact identifiers, with verbatim fallback on failure. Fixes along the way: - The global event watcher now starts unconditionally; it was gated behind the desktop-notify env, leaving the server-side event hub dead in packaged apps. - OpenCode re-emits message.updated for old user messages after idle; the watcher no longer mistakes those for new activity. - Session metadata merges from a fresh read right before the PATCH, so writes made during the generation window (suggestion dismissals, review links) are preserved; the assist runtime stops during graceful shutdown.
This commit is contained in:
committed by
GitHub
parent
e5b03493da
commit
28f0736d69
@@ -14,6 +14,7 @@ import MessageList, { type MessageListHandle } from './MessageList';
|
||||
import { PermissionCard } from './PermissionCard';
|
||||
import { QuestionCard } from './QuestionCard';
|
||||
import { StatusRowContainer } from './StatusRowContainer';
|
||||
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
@@ -266,6 +267,8 @@ const ChatViewport = React.memo(({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionRecapNote sessionId={currentSessionId} isMobile={isMobile} />
|
||||
|
||||
<div className="mb-3">
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
findAttachmentCitationRanges,
|
||||
} from './attachmentCitations';
|
||||
import { getFileMentionAutocompleteQuery, type FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState';
|
||||
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
|
||||
import type { Part } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
@@ -4053,6 +4054,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
textareaRef.current?.focus({ preventScroll: isCapacitorApp() });
|
||||
}, []);
|
||||
|
||||
const applyAssistSuggestion = React.useCallback((text: string) => {
|
||||
setMessage(text);
|
||||
if (isMobile && !mobileComposerExpanded) {
|
||||
expandMobileComposer('focus');
|
||||
} else {
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}
|
||||
}, [expandMobileComposer, isMobile, mobileComposerExpanded]);
|
||||
|
||||
|
||||
const handleMobileNewSession = React.useCallback(() => {
|
||||
if (newSessionDraftOpen) return;
|
||||
openNewSessionDraft(currentDirectory ? { directoryOverride: currentDirectory } : undefined);
|
||||
@@ -4744,6 +4755,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
)}
|
||||
>
|
||||
{isMobile && !mobileComposerExpanded ? (
|
||||
<div className="flex flex-col">
|
||||
<SessionSuggestionChip
|
||||
sessionId={currentSessionId}
|
||||
hidden={hasContent || newSessionDraftOpen}
|
||||
onApply={applyAssistSuggestion}
|
||||
className="mb-1.5"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1"
|
||||
@@ -4818,7 +4836,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SessionSuggestionChip
|
||||
sessionId={currentSessionId}
|
||||
hidden={hasContent || newSessionDraftOpen}
|
||||
onApply={applyAssistSuggestion}
|
||||
className="mb-1.5"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col relative overflow-visible",
|
||||
@@ -5202,6 +5228,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Wrapper-level dictation engine + overlay: stays mounted across
|
||||
the pill ↔ composer swap so a recording started from the pill
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { useSessionAssistState } from '@/hooks/useSessionAssist';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SessionRecapNoteProps {
|
||||
sessionId: string;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
// Quiet one-paragraph recap of the agent's last reply, rendered right under
|
||||
// the last message (above the reserved bottom gap). Appears only after the
|
||||
// 5-minute quiet window, so the layout shift happens off-screen in practice.
|
||||
export const SessionRecapNote: React.FC<SessionRecapNoteProps> = React.memo(({ sessionId, isMobile }) => {
|
||||
const { visibleRecap } = useSessionAssistState(sessionId);
|
||||
const { t } = useI18n();
|
||||
|
||||
if (!visibleRecap) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-message-column">
|
||||
{/* The last assistant turn carries pb-8 — pull the recap up into that gap. */}
|
||||
<div className="-mt-6" aria-label={t('chat.recap.aria')}>
|
||||
<span className={`typography-meta text-muted-foreground/70 ${isMobile ? 'line-clamp-4' : 'line-clamp-2'}`}>
|
||||
<span className="italic text-muted-foreground/50">{t('chat.recap.label')} </span>
|
||||
{visibleRecap}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SessionRecapNote.displayName = 'SessionRecapNote';
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useSessionAssistState } from '@/hooks/useSessionAssist';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { patchSessionMetadata } from '@/sync/session-actions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SessionSuggestionChipProps {
|
||||
sessionId: string | null;
|
||||
/** The composer already has content — the suggestion must stay out of the way. */
|
||||
hidden: boolean;
|
||||
onApply: (text: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
// One small-model-suggested follow-up message, styled like the draft starter
|
||||
// chips. Tapping it fills the composer (no auto-send); the X patches the
|
||||
// suggestion out of the session metadata so it stays dismissed everywhere.
|
||||
export const SessionSuggestionChip: React.FC<SessionSuggestionChipProps> = React.memo(({ sessionId, hidden, onApply, className }) => {
|
||||
const { suggestion } = useSessionAssistState(sessionId ?? '');
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const [dismissing, setDismissing] = React.useState(false);
|
||||
|
||||
const handleDismiss = React.useCallback(async (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!sessionId || dismissing) return;
|
||||
setDismissing(true);
|
||||
try {
|
||||
await patchSessionMetadata(sessionId, undefined, (metadata) => {
|
||||
const namespace = isRecord(metadata.openchamber) ? metadata.openchamber : {};
|
||||
const assist = isRecord(namespace.assist) ? namespace.assist : {};
|
||||
const nextAssist = { ...assist };
|
||||
delete nextAssist.suggestion;
|
||||
return { ...metadata, openchamber: { ...namespace, assist: nextAssist } };
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to dismiss suggestion:', error);
|
||||
} finally {
|
||||
setDismissing(false);
|
||||
}
|
||||
}, [sessionId, dismissing]);
|
||||
|
||||
if (!suggestion || hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chipStyle: React.CSSProperties = {
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex w-full min-w-0 justify-center ${className ?? ''}`}>
|
||||
<div className="relative w-full min-w-0 max-w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply(suggestion)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
aria-label={t('chat.suggestion.applyAria')}
|
||||
className="group flex w-full min-w-0 select-none items-center gap-1.5 rounded-full border py-1.5 pl-3 pr-8 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
|
||||
style={chipStyle}
|
||||
>
|
||||
<Icon name="pencil-ai-2" className="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
|
||||
<span className="truncate">{suggestion}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm whitespace-pre-wrap">
|
||||
{suggestion}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => void handleDismiss(event)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
aria-label={t('chat.suggestion.dismissAria')}
|
||||
title={t('chat.suggestion.dismissAria')}
|
||||
className="absolute right-1.5 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SessionSuggestionChip.displayName = 'SessionSuggestionChip';
|
||||
@@ -10,6 +10,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
|
||||
import { summarizeSelectionForNotes } from '@/lib/smallModel';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
@@ -518,7 +519,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
try {
|
||||
setIsAddingToNotes(true);
|
||||
const noteText = selectedTextMarkdown || selectedText;
|
||||
// Long selections are distilled into a compact note by the small model;
|
||||
// short ones (and any generation failure) go in verbatim.
|
||||
const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId);
|
||||
const projectData = await getProjectNotesAndTodos(currentProjectRef);
|
||||
const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText);
|
||||
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
|
||||
|
||||
Reference in New Issue
Block a user