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.
This commit is contained in:
@@ -29,7 +29,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
|||||||
|
|
||||||
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
|
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 =>
|
const isDetailedDefaultTool = (toolName: unknown): boolean =>
|
||||||
typeof toolName === 'string' && DETAILED_DEFAULT_TOOLS.has(toolName.toLowerCase());
|
typeof toolName === 'string' && DETAILED_DEFAULT_TOOLS.has(toolName.toLowerCase());
|
||||||
@@ -437,8 +437,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toolPart = activity.part as unknown as { id?: string; tool?: unknown };
|
const toolPart = activity.part as unknown as { id?: string; tool?: unknown };
|
||||||
if (toolPart.id && isDetailedDefaultTool(toolPart.tool)) {
|
if (isDetailedDefaultTool(toolPart.tool)) {
|
||||||
defaultExpandedToolIds.add(toolPart.id);
|
if (toolPart.id) {
|
||||||
|
defaultExpandedToolIds.add(toolPart.id);
|
||||||
|
}
|
||||||
|
if (activity.id) {
|
||||||
|
defaultExpandedToolIds.add(activity.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70">
|
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70">
|
||||||
<span className="truncate block">{summary}</span>
|
<span className="truncate block italic">{summary}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
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 { cn } from '@/lib/utils';
|
||||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
|
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
|
||||||
@@ -88,6 +88,9 @@ export const getToolIcon = (toolName: string) => {
|
|||||||
if (tool === 'todowrite' || tool === 'todoread') {
|
if (tool === 'todowrite' || tool === 'todoread') {
|
||||||
return <RiListCheck3 className={iconClass} />;
|
return <RiListCheck3 className={iconClass} />;
|
||||||
}
|
}
|
||||||
|
if (tool === 'structuredoutput' || tool === 'structured_output') {
|
||||||
|
return <RiListCheck2 className={iconClass} />;
|
||||||
|
}
|
||||||
if (tool === 'skill') {
|
if (tool === 'skill') {
|
||||||
return <RiBookLine className={iconClass} />;
|
return <RiBookLine className={iconClass} />;
|
||||||
}
|
}
|
||||||
@@ -189,6 +192,11 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile:
|
|||||||
const stateWithData = state as ToolStateWithMetadata;
|
const stateWithData = state as ToolStateWithMetadata;
|
||||||
const metadata = stateWithData.metadata;
|
const metadata = stateWithData.metadata;
|
||||||
const input = stateWithData.input;
|
const input = stateWithData.input;
|
||||||
|
const tool = part.tool.toLowerCase();
|
||||||
|
|
||||||
|
if (tool === 'structuredoutput' || tool === 'structured_output') {
|
||||||
|
return 'Result';
|
||||||
|
}
|
||||||
|
|
||||||
if (part.tool === 'apply_patch') {
|
if (part.tool === 'apply_patch') {
|
||||||
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
|
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
|
||||||
@@ -1391,7 +1399,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
|
|
||||||
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta" style={{ color: 'var(--tools-description)' }}>
|
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta" style={{ color: 'var(--tools-description)' }}>
|
||||||
{justificationText && (
|
{justificationText && (
|
||||||
<span className={cn("truncate italic", isMobile && "max-w-[120px]")} style={{ color: 'var(--tools-description)', opacity: 0.8 }}>
|
<span className={cn("truncate", isMobile && "max-w-[120px]")} style={{ color: 'var(--tools-description)', opacity: 0.8 }}>
|
||||||
{justificationText}
|
{justificationText}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import type { GitRemote } from '@/lib/gitApi';
|
|||||||
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
|
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
|
||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { generateCommitMessage as generateSessionCommitMessage } from '@/lib/gitApi';
|
||||||
|
|
||||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||||
@@ -820,25 +821,14 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.error('[git-generation][browser] generate button clicked', {
|
||||||
|
directory: currentDirectory,
|
||||||
|
selectedFiles: selectedPaths.size,
|
||||||
|
});
|
||||||
|
|
||||||
setIsGeneratingMessage(true);
|
setIsGeneratingMessage(true);
|
||||||
try {
|
try {
|
||||||
const { getResolvedGitGenerationModel, settingsZenModel } = useConfigStore.getState();
|
const { message } = await generateSessionCommitMessage(currentDirectory, Array.from(selectedPaths));
|
||||||
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 subject = message.subject?.trim() ?? '';
|
const subject = message.subject?.trim() ?? '';
|
||||||
const highlights = Array.isArray(message.highlights) ? message.highlights : [];
|
const highlights = Array.isArray(message.highlights) ? message.highlights : [];
|
||||||
|
|
||||||
@@ -859,13 +849,17 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
|||||||
|
|
||||||
scrollActionPanelToBottom();
|
scrollActionPanelToBottom();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[git-generation][browser] GitView generate handler failed', {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
error,
|
||||||
|
});
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : 'Failed to generate commit message';
|
error instanceof Error ? error.message : 'Failed to generate commit message';
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingMessage(false);
|
setIsGeneratingMessage(false);
|
||||||
}
|
}
|
||||||
}, [currentDirectory, selectedPaths, git, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom]);
|
}, [currentDirectory, selectedPaths, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom]);
|
||||||
|
|
||||||
const handleCreateBranch = async (branchName: string, remote?: GitRemote) => {
|
const handleCreateBranch = async (branchName: string, remote?: GitRemote) => {
|
||||||
if (!currentDirectory || !status) return;
|
if (!currentDirectory || !status) return;
|
||||||
|
|||||||
@@ -1128,24 +1128,13 @@ export const PullRequestSection: React.FC<{
|
|||||||
if (!directory) return;
|
if (!directory) return;
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
try {
|
try {
|
||||||
const { getResolvedGitGenerationModel, settingsZenModel } = useConfigStore.getState();
|
const payload: { base: string; head: string; context?: string; files?: string[] } = {
|
||||||
const resolvedModel = getResolvedGitGenerationModel();
|
|
||||||
const payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } = {
|
|
||||||
base: targetBaseBranch,
|
base: targetBaseBranch,
|
||||||
head: branch,
|
head: branch,
|
||||||
};
|
};
|
||||||
if (additionalContext) {
|
if (additionalContext) {
|
||||||
payload.context = 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);
|
const generated = await generatePullRequestDescription(directory, payload);
|
||||||
|
|
||||||
if (generated.title?.trim()) {
|
if (generated.title?.trim()) {
|
||||||
@@ -1161,7 +1150,7 @@ export const PullRequestSection: React.FC<{
|
|||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
}, [branch, directory, isGenerating, additionalContext, onGeneratedDescription, targetBaseBranch]);
|
}, [additionalContext, branch, directory, isGenerating, onGeneratedDescription, targetBaseBranch]);
|
||||||
|
|
||||||
const createPr = React.useCallback(async () => {
|
const createPr = React.useCallback(async () => {
|
||||||
if (!github?.prCreate) {
|
if (!github?.prCreate) {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import type { RuntimeAPIs } from './api/types';
|
import type { RuntimeAPIs } from './api/types';
|
||||||
import * as gitHttp from './gitApiHttp';
|
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 {
|
export type {
|
||||||
GitStatus,
|
GitStatus,
|
||||||
@@ -107,22 +111,334 @@ export async function generateCommitMessage(
|
|||||||
files: string[],
|
files: string[],
|
||||||
options?: { zenModel?: string; providerId?: string; modelId?: string }
|
options?: { zenModel?: string; providerId?: string; modelId?: string }
|
||||||
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
|
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
|
||||||
const runtime = getRuntimeGit();
|
const startedAt = Date.now();
|
||||||
if (runtime) return runtime.generateCommitMessage(directory, files, options);
|
void options;
|
||||||
return gitHttp.generateCommitMessage(directory, files, 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: <type>: <summary>
|
||||||
|
- 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(
|
export async function generatePullRequestDescription(
|
||||||
directory: string,
|
directory: string,
|
||||||
payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
|
payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
|
||||||
): Promise<import('./api/types').GeneratedPullRequestDescription> {
|
): Promise<import('./api/types').GeneratedPullRequestDescription> {
|
||||||
const runtime = getRuntimeGit();
|
const startedAt = Date.now();
|
||||||
if (runtime?.generatePullRequestDescription) {
|
const generationSession = resolveSessionGenerationContext();
|
||||||
return runtime.generatePullRequestDescription(directory, payload);
|
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<string>();
|
||||||
|
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<string, unknown>;
|
||||||
|
kind: 'commit' | 'pr';
|
||||||
|
}): Promise<Record<string, unknown>> => {
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> {
|
export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> {
|
||||||
const runtime = getRuntimeGit();
|
const runtime = getRuntimeGit();
|
||||||
if (runtime?.worktree?.list) {
|
if (runtime?.worktree?.list) {
|
||||||
|
|||||||
@@ -235,7 +235,15 @@ export async function generateCommitMessage(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
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();
|
const data = await response.json();
|
||||||
|
|||||||
@@ -620,6 +620,11 @@ class OpencodeService {
|
|||||||
}>;
|
}>;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>;
|
agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>;
|
||||||
|
format?: {
|
||||||
|
type: 'json_schema';
|
||||||
|
schema: Record<string, unknown>;
|
||||||
|
retryCount?: number;
|
||||||
|
};
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
// Generate a temporary client-side ID for optimistic UI
|
// Generate a temporary client-side ID for optimistic UI
|
||||||
// This ID won't be sent to the server - server will generate its own
|
// 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).
|
// for model work (SSE will deliver output/status).
|
||||||
// This avoids 504s from proxy timeouts on long-running turns.
|
// This avoids 504s from proxy timeouts on long-running turns.
|
||||||
const base = this.baseUrl.replace(/\/+$/, '');
|
const base = this.baseUrl.replace(/\/+$/, '');
|
||||||
const url = new URL(`${base}/session/${encodeURIComponent(params.id)}/prompt_async`);
|
let url: URL;
|
||||||
if (this.currentDirectory) {
|
try {
|
||||||
url.searchParams.set('directory', this.currentDirectory);
|
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(), {
|
if (params.format) {
|
||||||
method: 'POST',
|
console.info('[git-generation][browser] send structured message', {
|
||||||
headers: {
|
sessionId: params.id,
|
||||||
'content-type': 'application/json',
|
providerID: params.providerID,
|
||||||
accept: 'application/json',
|
modelID: params.modelID,
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: {
|
|
||||||
providerID: params.providerID,
|
|
||||||
modelID: params.modelID,
|
|
||||||
},
|
|
||||||
agent: params.agent,
|
agent: params.agent,
|
||||||
variant: params.variant,
|
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) {
|
if (!response.ok) {
|
||||||
let detail = '';
|
let detail = '';
|
||||||
|
|||||||
@@ -186,6 +186,20 @@ export const TOOL_METADATA: Record<string, ToolMetadata> = {
|
|||||||
category: 'ai',
|
category: 'ai',
|
||||||
outputLanguage: 'text',
|
outputLanguage: 'text',
|
||||||
inputFields: []
|
inputFields: []
|
||||||
|
},
|
||||||
|
|
||||||
|
StructuredOutput: {
|
||||||
|
displayName: 'Structured Output',
|
||||||
|
category: 'ai',
|
||||||
|
outputLanguage: 'json',
|
||||||
|
inputFields: []
|
||||||
|
},
|
||||||
|
|
||||||
|
structuredoutput: {
|
||||||
|
displayName: 'Structured Output',
|
||||||
|
category: 'ai',
|
||||||
|
outputLanguage: 'json',
|
||||||
|
inputFields: []
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ interface MessageState {
|
|||||||
|
|
||||||
interface MessageActions {
|
interface MessageActions {
|
||||||
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
|
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
|
||||||
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<void>;
|
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<string, unknown>; retryCount?: number }) => Promise<void>;
|
||||||
abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
|
abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
|
||||||
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
|
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
|
||||||
addStreamingPart: (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<MessageStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
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<string, unknown>; retryCount?: number }) => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
throw new Error("No session selected");
|
throw new Error("No session selected");
|
||||||
}
|
}
|
||||||
@@ -840,6 +840,17 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
files: filePayloads.length > 0 ? filePayloads : undefined,
|
files: filePayloads.length > 0 ? filePayloads : undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
if (format) {
|
||||||
|
console.info('[git-generation][browser] dispatch structured sendMessage', {
|
||||||
|
sessionId,
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
agent,
|
||||||
|
variant,
|
||||||
|
directory,
|
||||||
|
formatType: format.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
await opencodeClient.sendMessage({
|
await opencodeClient.sendMessage({
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
providerID,
|
providerID,
|
||||||
@@ -847,6 +858,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
text: content,
|
text: content,
|
||||||
agent,
|
agent,
|
||||||
variant,
|
variant,
|
||||||
|
...(format ? { format } : {}),
|
||||||
files: filePayloads.length > 0 ? filePayloads : undefined,
|
files: filePayloads.length > 0 ? filePayloads : undefined,
|
||||||
additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined,
|
additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined,
|
||||||
agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined,
|
agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined,
|
||||||
|
|||||||
@@ -604,9 +604,6 @@ let validatedZenFallback = null;
|
|||||||
let cachedZenModels = null;
|
let cachedZenModels = null;
|
||||||
let cachedZenModelsTimestamp = 0;
|
let cachedZenModelsTimestamp = 0;
|
||||||
const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
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
|
* 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;
|
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/<model>
|
|
||||||
* 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) => {
|
const summarizeText = async (text, targetLength, zenModel) => {
|
||||||
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
|
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
|
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||||
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
||||||
: path.join(os.homedir(), '.config', 'openchamber');
|
: 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: <type>: <summary> (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: <type>(<scope>): <summary>. 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) => {
|
app.post('/api/git/pull', async (req, res) => {
|
||||||
const { pull } = await getGitLibraries();
|
const { pull } = await getGitLibraries();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user