fix: avoid opencode structured output session breakage
Replace server-enforced structured output with local JSON parsing for Git generation Render generated commit and PR JSON responses as compact chat cards Tighten generation prompts while preserving active session context
This commit is contained in:
@@ -6,6 +6,8 @@ import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
import { GeneratedJsonResultCard } from './GeneratedJsonResultCard';
|
||||
import { parseGeneratedJsonResult } from './generatedJsonResult';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } };
|
||||
|
||||
@@ -71,6 +73,18 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const generatedResult = !isStreaming && isFinalized ? parseGeneratedJsonResult(displayTextContent) : null;
|
||||
if (generatedResult) {
|
||||
return (
|
||||
<div
|
||||
className={`group/assistant-text relative break-words ${chatRenderMode === 'live' ? 'my-1' : ''}`}
|
||||
key={part.id || `${messageId}-text`}
|
||||
>
|
||||
<GeneratedJsonResultCard result={generatedResult} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group/assistant-text relative break-words ${chatRenderMode === 'live' ? 'my-1' : ''}`}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { GeneratedResult } from './generatedJsonResult';
|
||||
|
||||
export const GeneratedJsonResultCard: React.FC<{ result: GeneratedResult }> = ({ result }) => {
|
||||
const { t } = useI18n();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const copyText = React.useMemo(() => {
|
||||
if (result.kind === 'commit') {
|
||||
return [result.subject, ...result.highlights.map((highlight) => `- ${highlight}`)].join('\n');
|
||||
}
|
||||
return [result.title, result.body].filter(Boolean).join('\n\n');
|
||||
}, [result]);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
const copyResult = await copyTextToClipboard(copyText || result.raw);
|
||||
if (!copyResult.ok) return;
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
}, [copyText, result.raw]);
|
||||
|
||||
return (
|
||||
<div data-component="generated-json-result" className="my-4 group overflow-hidden rounded-2xl border border-border/80 bg-[var(--surface-elevated)]">
|
||||
<div className="flex items-center justify-between border-b border-border/70 px-3 py-1.5">
|
||||
<span className="font-mono text-[13px] text-muted-foreground">
|
||||
{result.kind === 'commit'
|
||||
? t('chat.generatedResult.commit.title')
|
||||
: t('chat.generatedResult.pullRequest.title')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => { void handleCopy(); }}
|
||||
title={copied ? t('chat.generatedResult.actions.copied') : t('chat.generatedResult.actions.copy')}
|
||||
aria-label={copied ? t('chat.generatedResult.actions.copied') : t('chat.generatedResult.actions.copy')}
|
||||
className="text-muted-foreground hover:text-foreground md:opacity-0 md:group-hover:opacity-100"
|
||||
>
|
||||
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 px-3 py-3">
|
||||
{result.kind === 'commit' ? (
|
||||
<>
|
||||
<div className="typography-ui-label text-foreground">{result.subject}</div>
|
||||
{result.highlights.length > 0 ? (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
{result.highlights.map((highlight, index) => (
|
||||
<li key={`${index}-${highlight}`} className="flex gap-2">
|
||||
<span className="text-muted-foreground/70">-</span>
|
||||
<span>{highlight}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{result.title ? (
|
||||
<div>
|
||||
<div className="typography-micro uppercase tracking-[0.12em] text-muted-foreground">{t('chat.generatedResult.pullRequest.titleLabel')}</div>
|
||||
<div className="mt-1 typography-ui-label text-foreground">{result.title}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{result.body ? (
|
||||
<div>
|
||||
<div className="typography-micro uppercase tracking-[0.12em] text-muted-foreground">{t('chat.generatedResult.pullRequest.bodyLabel')}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words text-sm leading-relaxed text-muted-foreground font-sans">{result.body}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
export type GeneratedCommitResult = {
|
||||
kind: 'commit';
|
||||
subject: string;
|
||||
highlights: string[];
|
||||
raw: string;
|
||||
};
|
||||
|
||||
export type GeneratedPrResult = {
|
||||
kind: 'pr';
|
||||
title: string;
|
||||
body: string;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
export type GeneratedResult = GeneratedCommitResult | GeneratedPrResult;
|
||||
|
||||
const parseJsonObjects = (value: string): Record<string, unknown>[] => {
|
||||
const text = value.trim();
|
||||
const candidates = new Set<string>();
|
||||
|
||||
const fencedMatches = text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi);
|
||||
for (const match of fencedMatches) {
|
||||
if (match[1]) candidates.add(match[1].trim());
|
||||
}
|
||||
|
||||
const firstObjectStart = text.indexOf('{');
|
||||
if (firstObjectStart >= 0) {
|
||||
for (let end = text.length; end > firstObjectStart; end -= 1) {
|
||||
if (text[end - 1] === '}') {
|
||||
candidates.add(text.slice(firstObjectStart, end));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parsed: Record<string, unknown>[] = [];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const item = JSON.parse(candidate) as unknown;
|
||||
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
parsed.push(item as Record<string, unknown>);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON prose; the model may include markdown before the object.
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const parseGeneratedJsonResult = (value: string): GeneratedResult | null => {
|
||||
for (const item of parseJsonObjects(value)) {
|
||||
const subject = typeof item.subject === 'string' ? item.subject.trim() : '';
|
||||
const highlights = Array.isArray(item.highlights)
|
||||
? item.highlights.filter((entry) => typeof entry === 'string').map((entry) => entry.trim()).filter(Boolean).slice(0, 3)
|
||||
: [];
|
||||
if (subject) {
|
||||
return { kind: 'commit', subject, highlights, raw: JSON.stringify({ subject, highlights }, null, 2) };
|
||||
}
|
||||
|
||||
const title = typeof item.title === 'string' ? item.title.trim() : '';
|
||||
const body = typeof item.body === 'string' ? item.body.trim() : '';
|
||||
if (title || body) {
|
||||
return { kind: 'pr', title, body, raw: JSON.stringify({ title, body }, null, 2) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user