From 4a8c69e36a0c08541308a74883b6d9d6fc9efe12 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 24 Feb 2026 15:37:35 +0200 Subject: [PATCH] refactor: move git generation to session structured output - Use active session prompts for commit/PR generation and drop backend git generation routes. - Improve tool activity UX with Structured Output rendering, description styling tweaks, and detailed-mode expansion fixes. --- .../ui/src/components/chat/ChatMessage.tsx | 11 +- .../chat/message/parts/ReasoningPart.tsx | 2 +- .../chat/message/parts/ToolPart.tsx | 12 +- packages/ui/src/components/views/GitView.tsx | 30 +- .../views/git/PullRequestSection.tsx | 15 +- packages/ui/src/lib/gitApi.ts | 330 ++++++++++++- packages/ui/src/lib/gitApiHttp.ts | 10 +- packages/ui/src/lib/opencode/client.ts | 79 +++- packages/ui/src/lib/toolHelpers.ts | 14 + packages/ui/src/stores/messageStore.ts | 16 +- packages/web/server/index.js | 447 ------------------ 11 files changed, 455 insertions(+), 511 deletions(-) diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index adf29db0..9c216c35 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -29,7 +29,7 @@ import { copyTextToClipboard } from '@/lib/clipboard'; const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog')); -const DETAILED_DEFAULT_TOOLS = new Set(['task', 'edit', 'multiedit', 'write', 'bash']); +const DETAILED_DEFAULT_TOOLS = new Set(['task', 'edit', 'multiedit', 'write', 'apply_patch', 'bash', 'todowrite']); const isDetailedDefaultTool = (toolName: unknown): boolean => typeof toolName === 'string' && DETAILED_DEFAULT_TOOLS.has(toolName.toLowerCase()); @@ -437,8 +437,13 @@ const ChatMessage: React.FC = ({ } const toolPart = activity.part as unknown as { id?: string; tool?: unknown }; - if (toolPart.id && isDetailedDefaultTool(toolPart.tool)) { - defaultExpandedToolIds.add(toolPart.id); + if (isDetailedDefaultTool(toolPart.tool)) { + if (toolPart.id) { + defaultExpandedToolIds.add(toolPart.id); + } + if (activity.id) { + defaultExpandedToolIds.add(activity.id); + } } } } diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 44f8cf56..71c02482 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -119,7 +119,7 @@ export const ReasoningTimelineBlock: React.FC = ({ {summary && (
- {summary} + {summary}
)} diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index e32d5c93..2975794b 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; -import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; +import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck2, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers'; @@ -88,6 +88,9 @@ export const getToolIcon = (toolName: string) => { if (tool === 'todowrite' || tool === 'todoread') { return ; } + if (tool === 'structuredoutput' || tool === 'structured_output') { + return ; + } if (tool === 'skill') { return ; } @@ -189,6 +192,11 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile: const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; + const tool = part.tool.toLowerCase(); + + if (tool === 'structuredoutput' || tool === 'structured_output') { + return 'Result'; + } if (part.tool === 'apply_patch') { const files = Array.isArray(metadata?.files) ? metadata?.files : []; @@ -1391,7 +1399,7 @@ const ToolPart: React.FC = ({
{justificationText && ( - + {justificationText} )} diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 0be3376d..92fe6be2 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -62,6 +62,7 @@ import type { GitRemote } from '@/lib/gitApi'; import { BranchPickerDialog } from '@/components/session/BranchPickerDialog'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { cn } from '@/lib/utils'; +import { generateCommitMessage as generateSessionCommitMessage } from '@/lib/gitApi'; type SyncAction = 'fetch' | 'pull' | 'push' | null; type CommitAction = 'commit' | 'commitAndPush' | null; @@ -820,25 +821,14 @@ export const GitView: React.FC = ({ mode = 'full' }) => { return; } + console.error('[git-generation][browser] generate button clicked', { + directory: currentDirectory, + selectedFiles: selectedPaths.size, + }); + setIsGeneratingMessage(true); try { - const { getResolvedGitGenerationModel, settingsZenModel } = useConfigStore.getState(); - const resolvedModel = getResolvedGitGenerationModel(); - const options: { zenModel?: string; providerId?: string; modelId?: string } = {}; - if (resolvedModel) { - options.providerId = resolvedModel.providerId; - options.modelId = resolvedModel.modelId; - if (resolvedModel.providerId === 'zen') { - options.zenModel = resolvedModel.modelId; - } - } else if (settingsZenModel) { - options.zenModel = settingsZenModel; - } - const { message } = await git.generateCommitMessage( - currentDirectory, - Array.from(selectedPaths), - Object.keys(options).length > 0 ? options : undefined - ); + const { message } = await generateSessionCommitMessage(currentDirectory, Array.from(selectedPaths)); const subject = message.subject?.trim() ?? ''; const highlights = Array.isArray(message.highlights) ? message.highlights : []; @@ -859,13 +849,17 @@ export const GitView: React.FC = ({ mode = 'full' }) => { scrollActionPanelToBottom(); } catch (error) { + console.error('[git-generation][browser] GitView generate handler failed', { + message: error instanceof Error ? error.message : String(error), + error, + }); const message = error instanceof Error ? error.message : 'Failed to generate commit message'; toast.error(message); } finally { setIsGeneratingMessage(false); } - }, [currentDirectory, selectedPaths, git, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom]); + }, [currentDirectory, selectedPaths, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom]); const handleCreateBranch = async (branchName: string, remote?: GitRemote) => { if (!currentDirectory || !status) return; diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 0e52f450..43d0609b 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -1128,24 +1128,13 @@ export const PullRequestSection: React.FC<{ if (!directory) return; setIsGenerating(true); try { - const { getResolvedGitGenerationModel, settingsZenModel } = useConfigStore.getState(); - const resolvedModel = getResolvedGitGenerationModel(); - const payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } = { + const payload: { base: string; head: string; context?: string; files?: string[] } = { base: targetBaseBranch, head: branch, }; if (additionalContext) { payload.context = additionalContext; } - if (resolvedModel) { - payload.providerId = resolvedModel.providerId; - payload.modelId = resolvedModel.modelId; - if (resolvedModel.providerId === 'zen') { - payload.zenModel = resolvedModel.modelId; - } - } else if (settingsZenModel) { - payload.zenModel = settingsZenModel; - } const generated = await generatePullRequestDescription(directory, payload); if (generated.title?.trim()) { @@ -1161,7 +1150,7 @@ export const PullRequestSection: React.FC<{ } finally { setIsGenerating(false); } - }, [branch, directory, isGenerating, additionalContext, onGeneratedDescription, targetBaseBranch]); + }, [additionalContext, branch, directory, isGenerating, onGeneratedDescription, targetBaseBranch]); const createPr = React.useCallback(async () => { if (!github?.prCreate) { diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index fc7963a8..5cde04b2 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -2,6 +2,10 @@ import type { RuntimeAPIs } from './api/types'; import * as gitHttp from './gitApiHttp'; +import { opencodeClient } from './opencode/client'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useContextStore } from '@/stores/contextStore'; +import { useConfigStore } from '@/stores/useConfigStore'; export type { GitStatus, @@ -107,22 +111,334 @@ export async function generateCommitMessage( files: string[], options?: { zenModel?: string; providerId?: string; modelId?: string } ): Promise<{ message: import('./api/types').GeneratedCommitMessage }> { - const runtime = getRuntimeGit(); - if (runtime) return runtime.generateCommitMessage(directory, files, options); - return gitHttp.generateCommitMessage(directory, files, options); + const startedAt = Date.now(); + void options; + const generationSession = resolveSessionGenerationContext(); + + if (!generationSession) { + throw new Error('Select an active session for generation'); + } + + console.info('[git-generation][browser] request', { + transport: 'session', + kind: 'commit', + directory, + selectedFiles: files.length, + sessionId: generationSession.sessionId, + providerId: generationSession.providerID, + modelId: generationSession.modelID, + agent: generationSession.agent, + variant: generationSession.variant, + }); + + const prompt = `You are generating a Conventional Commits subject line using session context and selected file paths. + +Return JSON with exactly this shape: +{"subject": string, "highlights": string[]} + +Rules: +- subject format: : +- allowed types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert +- no scope in subject +- keep subject concise and user-facing +- highlights: 0-3 concise user-facing points + +Selected files: +${files.map((file) => `- ${file}`).join('\n')}`; + + try { + const structured = await runStructuredGenerationInActiveSession({ + directory, + prompt, + generationSession, + schema: { + type: 'object', + additionalProperties: false, + properties: { + subject: { type: 'string', description: 'Conventional commit subject line.' }, + highlights: { + type: 'array', + items: { type: 'string', description: 'Short user-facing highlight.' }, + maxItems: 3, + description: 'Optional short user-facing highlights.', + }, + }, + required: ['subject', 'highlights'], + }, + kind: 'commit', + }); + + 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'); + } + + const result = { message: { subject, highlights } }; + console.info('[git-generation][browser] success', { + transport: 'session', + kind: 'commit', + elapsedMs: Date.now() - startedAt, + subjectLength: result.message.subject.length, + highlightsCount: result.message.highlights.length, + }); + return result; + } catch (error) { + console.error('[git-generation][browser] failed', { + transport: 'session', + kind: 'commit', + elapsedMs: Date.now() - startedAt, + message: error instanceof Error ? error.message : String(error), + error, + }); + throw error; + } } export async function generatePullRequestDescription( directory: string, payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } ): Promise { - const runtime = getRuntimeGit(); - if (runtime?.generatePullRequestDescription) { - return runtime.generatePullRequestDescription(directory, payload); + const startedAt = Date.now(); + const generationSession = resolveSessionGenerationContext(); + if (!generationSession) { + throw new Error('Select an active session for generation'); + } + + const commitLog = await getGitLog(directory, { + from: payload.base, + to: payload.head, + maxCount: 50, + }); + 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() : '', + })); + + if (commits.length === 0) { + throw new Error(`No commits found in range ${payload.base}...${payload.head}`); + } + + const filesSet = new Set(); + await Promise.all(commits.map(async (commit) => { + try { + const response = await getCommitFiles(directory, commit.hash); + const files = Array.isArray(response?.files) ? response.files : []; + for (const file of files) { + if (typeof file?.path === 'string' && file.path.trim().length > 0) { + filesSet.add(file.path.trim()); + } + } + } catch (error) { + console.warn('[git-generation][browser] failed to collect commit files', { + hash: commit.hash, + error: error instanceof Error ? error.message : String(error), + }); + } + })); + const changedFiles = Array.from(filesSet).sort().slice(0, 300); + + console.info('[git-generation][browser] request', { + transport: 'session', + kind: 'pr', + directory, + sessionId: generationSession.sessionId, + providerId: generationSession.providerID, + modelId: generationSession.modelID, + agent: generationSession.agent, + variant: generationSession.variant, + base: payload.base, + head: payload.head, + commits: commits.length, + changedFiles: changedFiles.length, + }); + + const prompt = `You are drafting GitHub Pull Request title and body using session context, commit list, and changed files. + +Return JSON with exactly this shape: +{"title": string, "body": string} + +Rules: +- title: concise, outcome-first, conventional style +- body: markdown with sections: ## Summary, ## Why, ## Testing +- keep output concrete and user-facing + +Base branch: ${payload.base} +Head branch: ${payload.head} + +Commits in range (base...head): +${commits.map((commit) => `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`).join('\n')} + +Files changed across these commits: +${changedFiles.length > 0 ? changedFiles.map((file) => `- ${file}`).join('\n') : '- none detected'} +${payload.context?.trim() ? `\nAdditional context:\n${payload.context.trim()}` : ''}`; + + try { + const structured = await runStructuredGenerationInActiveSession({ + directory, + prompt, + generationSession, + schema: { + type: 'object', + additionalProperties: false, + properties: { + title: { type: 'string', description: 'Pull request title.' }, + body: { type: 'string', description: 'Pull request markdown description.' }, + }, + required: ['title', 'body'], + }, + kind: 'pr', + }); + + const result = { + title: typeof structured.title === 'string' ? structured.title.trim() : '', + body: typeof structured.body === 'string' ? structured.body.trim() : '', + }; + console.info('[git-generation][browser] success', { + transport: 'session', + kind: 'pr', + elapsedMs: Date.now() - startedAt, + titleLength: result.title.length, + bodyLength: result.body.length, + }); + return result; + } catch (error) { + console.error('[git-generation][browser] failed', { + transport: 'session', + kind: 'pr', + elapsedMs: Date.now() - startedAt, + message: error instanceof Error ? error.message : String(error), + error, + }); + throw error; } - return gitHttp.generatePullRequestDescription(directory, payload); } +type SessionGenerationContext = { + sessionId: string; + providerID: string; + modelID: string; + agent?: string; + variant?: string; +}; + +const resolveSessionGenerationContext = (): SessionGenerationContext | null => { + const sessionId = useSessionStore.getState().currentSessionId; + if (!sessionId) { + return null; + } + + const context = useContextStore.getState(); + const config = useConfigStore.getState(); + + const agent = context.getSessionAgentSelection(sessionId) || config.currentAgentName || undefined; + const sessionModel = context.getSessionModelSelection(sessionId); + const agentModel = agent ? context.getAgentModelForSession(sessionId, agent) : null; + const selectedModel = agentModel || sessionModel || (config.currentProviderId && config.currentModelId + ? { providerId: config.currentProviderId, modelId: config.currentModelId } + : null); + + if (!selectedModel?.providerId || !selectedModel?.modelId) { + return null; + } + + const variant = agent + ? context.getAgentModelVariantForSession(sessionId, agent, selectedModel.providerId, selectedModel.modelId) + : (config.currentVariant || undefined); + + return { + sessionId, + providerID: selectedModel.providerId, + modelID: selectedModel.modelId, + agent, + variant, + }; +}; + +const runStructuredGenerationInActiveSession = async ({ + directory, + prompt, + generationSession, + schema, + kind, +}: { + directory: string; + prompt: string; + generationSession: SessionGenerationContext; + schema: Record; + kind: 'commit' | 'pr'; +}): Promise> => { + const requestStartedAt = Date.now(); + console.info('[git-generation][browser] runStructuredGenerationInActiveSession start', { + kind, + directory, + sessionId: generationSession.sessionId, + providerID: generationSession.providerID, + modelID: generationSession.modelID, + agent: generationSession.agent, + variant: generationSession.variant, + }); + const trimmedDirectory = typeof directory === 'string' ? directory.trim() : ''; + const firstNewlineIndex = prompt.indexOf('\n'); + const visiblePrompt = (firstNewlineIndex === -1 ? prompt : prompt.slice(0, firstNewlineIndex)).trim(); + const hiddenPrompt = (firstNewlineIndex === -1 ? '' : prompt.slice(firstNewlineIndex + 1)).trim(); + const promptParts: Array<{ type: 'text'; text: string; synthetic?: boolean }> = []; + if (visiblePrompt) { + promptParts.push({ type: 'text', text: visiblePrompt, synthetic: false }); + } + if (hiddenPrompt) { + promptParts.push({ type: 'text', text: hiddenPrompt, synthetic: true }); + } + if (promptParts.length === 0) { + promptParts.push({ type: 'text', text: prompt, synthetic: false }); + } + + const response = await opencodeClient.withDirectory(directory, async () => { + return opencodeClient.getApiClient().session.prompt({ + sessionID: generationSession.sessionId, + ...(trimmedDirectory.length > 0 ? { directory: trimmedDirectory } : {}), + model: { + providerID: generationSession.providerID, + modelID: generationSession.modelID, + }, + ...(generationSession.agent ? { agent: generationSession.agent } : {}), + ...(generationSession.variant ? { variant: generationSession.variant } : {}), + format: { + type: 'json_schema', + schema, + retryCount: 2, + }, + parts: promptParts, + }); + }); + + const responseError = response?.error as { message?: string } | undefined; + if (!response?.data) { + throw new Error(responseError?.message || `Failed to generate ${kind} output`); + } + + const info = response.data.info as { finish?: string; structured_output?: unknown; structured?: unknown; error?: unknown }; + const structuredOutput = info?.structured_output || info?.structured; + if (!structuredOutput || typeof structuredOutput !== 'object' || Array.isArray(structuredOutput)) { + console.error('[git-generation][browser] invalid structured output', { + kind, + sessionId: generationSession.sessionId, + elapsedMs: Date.now() - requestStartedAt, + finish: info?.finish, + messageInfo: response.data.info, + messageParts: response.data.parts, + }); + throw new Error('No structured output returned by session'); + } + + return structuredOutput as Record; +}; + export async function listGitWorktrees(directory: string): Promise { const runtime = getRuntimeGit(); if (runtime?.worktree?.list) { diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index c051ef0d..b88f9df1 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -235,7 +235,15 @@ export async function generateCommitMessage( if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); - throw new Error(error.error || 'Failed to generate commit message'); + console.error('[git-generation][browser] http error', { + status: response.status, + statusText: response.statusText, + error, + }); + const traceSuffix = typeof error?.traceId === 'string' && error.traceId + ? ` (traceId: ${error.traceId})` + : ''; + throw new Error(`${error.error || 'Failed to generate commit message'}${traceSuffix}`); } const data = await response.json(); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 4ab73ea9..6bbba01e 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -620,6 +620,11 @@ class OpencodeService { }>; messageId?: string; agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>; + format?: { + type: 'json_schema'; + schema: Record; + retryCount?: number; + }; }): Promise { // Generate a temporary client-side ID for optimistic UI // This ID won't be sent to the server - server will generate its own @@ -693,27 +698,67 @@ class OpencodeService { // for model work (SSE will deliver output/status). // This avoids 504s from proxy timeouts on long-running turns. const base = this.baseUrl.replace(/\/+$/, ''); - const url = new URL(`${base}/session/${encodeURIComponent(params.id)}/prompt_async`); - if (this.currentDirectory) { - url.searchParams.set('directory', this.currentDirectory); + let url: URL; + try { + url = new URL(`${base}/session/${encodeURIComponent(params.id)}/prompt_async`); + if (this.currentDirectory) { + url.searchParams.set('directory', this.currentDirectory); + } + } catch (error) { + console.error('[git-generation][browser] failed to build prompt_async URL', { + baseUrl: this.baseUrl, + normalizedBase: base, + sessionId: params.id, + directory: this.currentDirectory, + message: error instanceof Error ? error.message : String(error), + error, + }); + throw error; } - const response = await fetch(url.toString(), { - method: 'POST', - headers: { - 'content-type': 'application/json', - accept: 'application/json', - }, - body: JSON.stringify({ - model: { - providerID: params.providerID, - modelID: params.modelID, - }, + if (params.format) { + console.info('[git-generation][browser] send structured message', { + sessionId: params.id, + providerID: params.providerID, + modelID: params.modelID, agent: params.agent, variant: params.variant, - parts, - }), - }); + directory: this.currentDirectory, + baseUrl: this.baseUrl, + formatType: params.format.type, + }); + } + + let response: Response; + try { + response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify({ + model: { + providerID: params.providerID, + modelID: params.modelID, + }, + agent: params.agent, + variant: params.variant, + ...(params.format ? { format: params.format } : {}), + parts, + }), + }); + } catch (error) { + console.error('[git-generation][browser] prompt_async request failed before response', { + sessionId: params.id, + url: url.toString(), + directory: this.currentDirectory, + hasFormat: Boolean(params.format), + message: error instanceof Error ? error.message : String(error), + error, + }); + throw error; + } if (!response.ok) { let detail = ''; diff --git a/packages/ui/src/lib/toolHelpers.ts b/packages/ui/src/lib/toolHelpers.ts index 0d7013dc..28342103 100644 --- a/packages/ui/src/lib/toolHelpers.ts +++ b/packages/ui/src/lib/toolHelpers.ts @@ -186,6 +186,20 @@ export const TOOL_METADATA: Record = { category: 'ai', outputLanguage: 'text', inputFields: [] + }, + + StructuredOutput: { + displayName: 'Structured Output', + category: 'ai', + outputLanguage: 'json', + inputFields: [] + }, + + structuredoutput: { + displayName: 'Structured Output', + category: 'ai', + outputLanguage: 'json', + inputFields: [] } }; diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts index dfeeeb44..9cb838e8 100644 --- a/packages/ui/src/stores/messageStore.ts +++ b/packages/ui/src/stores/messageStore.ts @@ -445,7 +445,7 @@ interface MessageState { interface MessageActions { loadMessages: (sessionId: string, limit?: number) => Promise; - sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell') => Promise; + sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell', format?: { type: 'json_schema'; schema: Record; retryCount?: number }) => Promise; abortCurrentOperation: (currentSessionId?: string) => Promise; _addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; @@ -670,7 +670,7 @@ export const useMessageStore = create()( }); }, - sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal') => { + sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal', format?: { type: 'json_schema'; schema: Record; retryCount?: number }) => { if (!currentSessionId) { throw new Error("No session selected"); } @@ -840,6 +840,17 @@ export const useMessageStore = create()( files: filePayloads.length > 0 ? filePayloads : undefined, }); } else { + if (format) { + console.info('[git-generation][browser] dispatch structured sendMessage', { + sessionId, + providerID, + modelID, + agent, + variant, + directory, + formatType: format.type, + }); + } await opencodeClient.sendMessage({ id: sessionId, providerID, @@ -847,6 +858,7 @@ export const useMessageStore = create()( text: content, agent, variant, + ...(format ? { format } : {}), files: filePayloads.length > 0 ? filePayloads : undefined, additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined, agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 2ad94bbc..e6a9bb09 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -604,9 +604,6 @@ let validatedZenFallback = null; let cachedZenModels = null; let cachedZenModelsTimestamp = 0; const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes -let cachedGitModelCatalog = null; -let cachedGitModelCatalogTimestamp = 0; -const GIT_MODEL_CATALOG_CACHE_TTL = 30 * 1000; /** * Fetch free models from the zen API with caching. Returns an array of @@ -663,219 +660,6 @@ const resolveZenModel = async (override) => { return validatedZenFallback || ZEN_DEFAULT_MODEL; }; -const getGitModelCatalog = async () => { - const now = Date.now(); - if (cachedGitModelCatalog && now - cachedGitModelCatalogTimestamp < GIT_MODEL_CATALOG_CACHE_TTL) { - return cachedGitModelCatalog; - } - - const response = await fetch(buildOpenCodeUrl('/model', ''), { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: AbortSignal.timeout(8_000), - }); - - if (!response.ok) { - throw new Error(`Failed to fetch model catalog: ${response.status}`); - } - - const payload = await response.json().catch(() => null); - const modelRefs = new Set(); - if (Array.isArray(payload)) { - for (const item of payload) { - if (!item || typeof item !== 'object') { - continue; - } - const providerID = typeof item.providerID === 'string' ? item.providerID.trim() : ''; - const modelID = typeof item.modelID === 'string' ? item.modelID.trim() : ''; - if (providerID && modelID) { - modelRefs.add(`${providerID}/${modelID}`); - } - } - } - - cachedGitModelCatalog = modelRefs; - cachedGitModelCatalogTimestamp = now; - return modelRefs; -}; - -/** - * Resolve git generation model based on priority: - * 1) request providerId+modelId - * 2) saved settings gitProviderId+gitModelId - * 3) legacy zenModel from request/settings as zen/ - * 4) Zen default (validatedZenFallback || ZEN_DEFAULT_MODEL) - */ -const resolveGitModel = async (requestParams) => { - const { providerId, modelId, zenModel } = requestParams || {}; - const requestProviderId = typeof providerId === 'string' ? providerId.trim() : ''; - const requestModelId = typeof modelId === 'string' ? modelId.trim() : ''; - - let modelCatalog = null; - try { - modelCatalog = await getGitModelCatalog(); - } catch { - modelCatalog = null; - } - - const hasModel = (providerID, modelID) => { - if (!modelCatalog) { - return false; - } - return modelCatalog.has(`${providerID}/${modelID}`); - }; - - if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) { - return { providerID: requestProviderId, modelID: requestModelId }; - } - - try { - const settings = await readSettingsFromDisk(); - const settingsProviderId = typeof settings?.gitProviderId === 'string' ? settings.gitProviderId.trim() : ''; - const settingsModelId = typeof settings?.gitModelId === 'string' ? settings.gitModelId.trim() : ''; - if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) { - return { providerID: settingsProviderId, modelID: settingsModelId }; - } - } catch { - // ignore - } - - const fallbackZenModel = typeof zenModel === 'string' && zenModel.trim().length > 0 - ? zenModel.trim() - : (await resolveZenModel(zenModel)); - - return { providerID: 'zen', modelID: fallbackZenModel }; -}; - -const GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000; -const GIT_GENERATION_POLL_INTERVAL_MS = 500; - -/** - * Generate text using OpenCode session flow: - * - Create short-lived session - * - POST prompt_async with model and text prompt - * - Poll session messages until final assistant response - * - Extract text from parts - * - Best-effort cleanup of temporary session - */ -const generateWithSessionFlow = async ({ prompt, providerID, modelID }) => { - const completionTimeout = createTimeoutSignal(GIT_GENERATION_TIMEOUT_MS); - let sessionId = null; - - try { - const createUrl = buildOpenCodeUrl('/session', ''); - const createResponse = await fetch(createUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - body: JSON.stringify({ - title: 'Git Generation', - }), - signal: completionTimeout.signal, - }); - - if (!createResponse.ok) { - const errorBody = await createResponse.json().catch(() => ({})); - throw new Error(`Failed to create session: ${createResponse.status} ${JSON.stringify(errorBody)}`); - } - - const sessionData = await createResponse.json(); - sessionId = sessionData?.id; - if (!sessionId) { - throw new Error('Session created but no ID returned'); - } - - const promptUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/prompt_async`, ''); - const promptResponse = await fetch(promptUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - body: JSON.stringify({ - model: { providerID, modelID }, - parts: [{ type: 'text', text: prompt }], - }), - signal: completionTimeout.signal, - }); - - if (!promptResponse.ok) { - const errorBody = await promptResponse.json().catch(() => ({})); - throw new Error(`Failed to send prompt: ${promptResponse.status} ${JSON.stringify(errorBody)}`); - } - - const messagesUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, ''); - let lastAssistantText = ''; - let pollingAttempts = 0; - const maxPollingAttempts = Math.ceil(GIT_GENERATION_TIMEOUT_MS / GIT_GENERATION_POLL_INTERVAL_MS); - - while (pollingAttempts < maxPollingAttempts) { - pollingAttempts++; - - await new Promise((resolve) => setTimeout(resolve, GIT_GENERATION_POLL_INTERVAL_MS)); - - const messagesResponse = await fetch(`${messagesUrl}?limit=10`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: completionTimeout.signal, - }); - - if (!messagesResponse.ok) { - console.warn(`Session messages poll failed: ${messagesResponse.status}`); - continue; - } - - const messages = await messagesResponse.json().catch(() => null); - if (!Array.isArray(messages)) { - continue; - } - - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg?.info?.role === 'assistant' && msg?.info?.finish === 'stop') { - if (Array.isArray(msg.parts)) { - const textParts = msg.parts - .filter((p) => p?.type === 'text' && typeof p?.text === 'string') - .map((p) => p.text) - .filter(Boolean); - if (textParts.length > 0) { - return textParts.join('\n').trim(); - } - } - } - } - } - - throw new Error('Timeout waiting for generation to complete'); - } finally { - completionTimeout.cleanup(); - - if (sessionId) { - try { - const deleteUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, ''); - await fetch(deleteUrl, { - method: 'DELETE', - headers: getOpenCodeAuthHeaders(), - signal: AbortSignal.timeout(5000), - }).catch((err) => { - console.warn('Failed to cleanup temporary session:', err?.message || err); - }); - } catch (err) { - console.warn('Failed to cleanup temporary session:', err?.message || err); - } - } - } -}; const summarizeText = async (text, targetLength, zenModel) => { if (!text || typeof text !== 'string' || text.trim().length === 0) return text; @@ -1215,53 +999,6 @@ const buildTemplateVariables = async (payload, sessionId) => { }; }; -const stripJsonMarkdownWrapper = (value) => { - if (typeof value !== 'string') { - return ''; - } - let trimmed = value.trim(); - if (!trimmed) { - return ''; - } - if (trimmed.startsWith('```')) { - trimmed = trimmed.replace(/^```(?:json)?\s*/i, ''); - const closingFenceIndex = trimmed.lastIndexOf('```'); - if (closingFenceIndex !== -1) { - trimmed = trimmed.slice(0, closingFenceIndex); - } - trimmed = trimmed.trim(); - } - if (trimmed.endsWith('```')) { - trimmed = trimmed.slice(0, -3).trim(); - } - return trimmed; -}; - -const extractJsonObject = (value) => { - if (typeof value !== 'string') { - return null; - } - const source = value.trim(); - if (!source) { - return null; - } - let start = source.indexOf('{'); - while (start !== -1) { - let end = source.indexOf('}', start + 1); - while (end !== -1) { - const candidate = source.slice(start, end + 1); - try { - JSON.parse(candidate); - return candidate; - } catch { - end = source.indexOf('}', end + 1); - } - } - start = source.indexOf('{', start + 1); - } - return null; -}; - const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'); @@ -9884,190 +9621,6 @@ async function main(options = {}) { } }); - app.post('/api/git/commit-message', async (req, res) => { - const { collectDiffs } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const files = Array.isArray(req.body?.files) ? req.body.files : []; - if (files.length === 0) { - return res.status(400).json({ error: 'At least one file is required' }); - } - - const diffs = await collectDiffs(directory, files); - if (diffs.length === 0) { - return res.status(400).json({ error: 'No diffs available for selected files' }); - } - - const MAX_DIFF_LENGTH = 4000; - const diffSummaries = diffs - .map(({ path, diff }) => { - const trimmed = diff.length > MAX_DIFF_LENGTH ? `${diff.slice(0, MAX_DIFF_LENGTH)}\n...` : diff; - return `FILE: ${path}\n${trimmed}`; - }) - .join('\n\n'); - - const prompt = `You are generating a Conventional Commits subject line from the provided diff. - -Return EXACTLY one JSON object (no code fences, no extra keys, no extra text): -{"subject": string, "highlights": string[]} - -Non-negotiable: -- Output must be valid JSON (double quotes). -- Only claim what is supported by the diff. If unsure, be more general; do not guess. - -subject: -- Format: : (NO scope; never write type(scope)) -- Allowed types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert -- Choose type (prefer fix when ambiguous): - - fix: any bug/regression/wrong behavior (state, selection, navigation, persistence, crash) - - feat: new user-facing capability or new workflow (not just guardrails/defaults) - - refactor/perf/docs/test/build/ci/style/chore/revert: only when clearly the primary change -- Summary style: - - imperative, present tense, outcome-first - - <= 72 characters, no trailing period - - avoid filenames, internal function names, and implementation details - -highlights: -- 0-3 items; it is OK to return []. -- Each item: one plain sentence, <= 90 chars, starts with an Uppercase verb. -- Must add information not already in the subject. -- Prefer user-observable behaviors (UI flow, navigation, selection, default view, persistence). -- No markdown bullets, no file paths, no helper names. - -Diff summary (may be truncated): -${diffSummaries}`; - - const { providerID, modelID } = await resolveGitModel({ - providerId: req.body?.providerId, - modelId: req.body?.modelId, - zenModel: req.body?.zenModel, - }); - - const raw = await generateWithSessionFlow({ prompt, providerID, modelID }); - - if (!raw) { - return res.status(502).json({ error: 'No commit message returned by generator' }); - } - - const cleanedJson = stripJsonMarkdownWrapper(raw); - const extractedJson = extractJsonObject(cleanedJson) || extractJsonObject(raw); - const candidates = [cleanedJson, extractedJson, raw].filter((candidate, index, array) => { - return candidate && array.indexOf(candidate) === index; - }); - - for (const candidate of candidates) { - if (!(candidate.startsWith('{') || candidate.startsWith('['))) { - continue; - } - try { - const parsed = JSON.parse(candidate); - return res.json({ message: parsed }); - } catch (parseError) { - console.warn('Commit message generation returned non-JSON body:', parseError); - } - } - - res.json({ message: { subject: raw, highlights: [] } }); - } catch (error) { - console.error('Failed to generate commit message:', error); - res.status(500).json({ error: error.message || 'Failed to generate commit message' }); - } - }); - - app.post('/api/git/pr-description', async (req, res) => { - const { getRangeDiff, getRangeFiles } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const base = typeof req.body?.base === 'string' ? req.body.base.trim() : ''; - const head = typeof req.body?.head === 'string' ? req.body.head.trim() : ''; - if (!base || !head) { - return res.status(400).json({ error: 'base and head are required' }); - } - - const filesToDiff = await getRangeFiles(directory, { base, head }); - - const diffs = []; - for (const filePath of filesToDiff) { - const diff = await getRangeDiff(directory, { base, head, path: filePath, contextLines: 3 }).catch(() => ''); - if (diff && diff.trim().length > 0) { - diffs.push({ path: filePath, diff }); - } - } - if (diffs.length === 0) { - return res.status(400).json({ error: 'No diffs available for base...head' }); - } - - const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n'); - const context = typeof req.body?.context === 'string' ? req.body.context.trim() : ''; - - let prompt = `You are drafting a GitHub Pull Request title + description for a squash-merge workflow. -Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules: -- Title format: conventional, outcome-first, <= 90 chars, no trailing punctuation. -- Use: (): . Types: feat, fix, refactor, perf, docs, test, chore. -- Pick the most important user-facing outcome first; include a second major outcome only when needed. -- Body: GitHub-flavored markdown with sections in this exact order: ## Summary, ## Why, ## Testing. -- Summary: 3-6 bullets, concrete product/workflow impact, no vague filler, no internal helper names. -- Why: 1-3 bullets explaining motivation/tradeoff (what problem this solves for users/devs). -- Testing: checkbox list using "- [ ]"; include realistic manual/automated checks inferred from the diff. -- If tests were not run, include "- [ ] Not run locally" as first testing item. -- Keep language crisp and specific; avoid generic boilerplate. - -Context: -- base branch: ${base} -- head branch: ${head}`; - - if (context) { - prompt += `\n\nAdditional context provided by user:\n${context}`; - } - - prompt += `\n\nDiff summary:\n${diffSummaries}`; - - const { providerID, modelID } = await resolveGitModel({ - providerId: req.body?.providerId, - modelId: req.body?.modelId, - zenModel: req.body?.zenModel, - }); - - const raw = await generateWithSessionFlow({ prompt, providerID, modelID }); - if (!raw) { - return res.status(502).json({ error: 'No PR description returned by generator' }); - } - - const cleanedJson = stripJsonMarkdownWrapper(raw); - const extractedJson = extractJsonObject(cleanedJson) || extractJsonObject(raw); - const candidates = [cleanedJson, extractedJson, raw].filter((candidate, index, array) => { - return candidate && array.indexOf(candidate) === index; - }); - - for (const candidate of candidates) { - if (!(candidate.startsWith('{') || candidate.startsWith('['))) { - continue; - } - try { - const parsed = JSON.parse(candidate); - const title = typeof parsed?.title === 'string' ? parsed.title : ''; - const body = typeof parsed?.body === 'string' ? parsed.body : ''; - return res.json({ title, body }); - } catch (parseError) { - console.warn('PR description generation returned non-JSON body:', parseError); - } - } - - return res.json({ title: '', body: raw }); - } catch (error) { - console.error('Failed to generate PR description:', error); - return res.status(500).json({ error: error.message || 'Failed to generate PR description' }); - } - }); - app.post('/api/git/pull', async (req, res) => { const { pull } = await getGitLibraries(); try {