fix: preserve image context across follow-up requests
Image requests previously sent only the current prompt and attachments, so follow-up edits could lose the previous image and conversation requirements. Reuse the latest successful image from the existing artifact store together with text history, including after reopening the app. Explicit attachments take priority, and configured model IDs remain unchanged. When image editing is explicitly unsupported, continue with text-based image generation and retain a quiet message footer explaining the missing image context. Keep genuine provider errors and cancellation visible. Add focused regressions, persisted footer metadata, and aligned feature documentation. Validated with four real sub-gpt-image-2 image requests and a simulated unsupported-edit response. Image regressions, typecheck and lint passed; the full working-tree run had two unrelated concurrent Runtime-menu failures. Release note: 连续改图会自动带上上一张图片和文字上下文,重开对话后仍可继续修改。上游明确不支持编辑时仍可按文字生图,仅在回复底部轻提示未使用参考图片。
This commit is contained in:
@@ -9536,7 +9536,7 @@ describe("App", () => {
|
||||
.mockImplementation(() => {});
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValueOnce({
|
||||
id: "model",
|
||||
label: "gpt-image-2",
|
||||
label: "sub-gpt-image-2",
|
||||
available: true,
|
||||
supportsToolExecution: false,
|
||||
detail: "OpenAI Images Generations",
|
||||
@@ -9581,6 +9581,7 @@ describe("App", () => {
|
||||
artifactId,
|
||||
kind: "image",
|
||||
title: "生成一只蓝色的猫",
|
||||
imageContextNotice: "editing-unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9615,6 +9616,17 @@ describe("App", () => {
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "生成一只蓝色的猫" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("上游不支持图片编辑,本次按文字要求生成,未使用参考图片。")).toBeVisible();
|
||||
act(() => agentListener?.({ requestId: request.requestId, type: "done" }));
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "把猫改为红色" },
|
||||
});
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledTimes(2));
|
||||
expect(run.mock.calls[1]?.[0].imageContextArtifactIds).toEqual([artifactId]);
|
||||
expect(run.mock.calls[1]?.[0].history).toContainEqual({
|
||||
role: "user", content: "生成一只蓝色的猫",
|
||||
});
|
||||
anchorClick.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
@@ -1341,6 +1341,7 @@ function toConversationMessage(message: Message): ConversationMessage {
|
||||
sourceReferences: message.sourceReferences,
|
||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||
artifactIds: message.artifactIds,
|
||||
imageContextNotice: message.imageContextNotice,
|
||||
task: message.task,
|
||||
attachments: message.attachments,
|
||||
};
|
||||
@@ -4605,6 +4606,7 @@ function App(): React.JSX.Element {
|
||||
artifactIds: [
|
||||
...new Set([...(message.artifactIds ?? []), event.artifactId]),
|
||||
].slice(-8),
|
||||
imageContextNotice: event.imageContextNotice,
|
||||
status: tRef.current("chat.status.savingImage"),
|
||||
}));
|
||||
} else if (event.type === "knowledge-retrieval") {
|
||||
@@ -7122,6 +7124,18 @@ function App(): React.JSX.Element {
|
||||
knowledgeLibraryIds: knowledgeLibraryIdsSnapshot,
|
||||
knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot,
|
||||
contextIds: attachmentSnapshot.map((attachment) => attachment.id),
|
||||
...(runtimeSelectionSnapshot.provider === "model"
|
||||
? {
|
||||
imageContextArtifactIds: [...historySnapshot]
|
||||
.reverse()
|
||||
.find(
|
||||
(message) =>
|
||||
message.role === "assistant" &&
|
||||
message.state === "complete" &&
|
||||
message.artifactIds?.length,
|
||||
)?.artifactIds,
|
||||
}
|
||||
: {}),
|
||||
contextCompressionState: conversationSnapshot.contextCompressionState,
|
||||
history: retainedHistorySnapshot.map((message) => ({
|
||||
role: message.role,
|
||||
|
||||
@@ -61,6 +61,27 @@ function createMessages(): Message[] {
|
||||
}
|
||||
|
||||
describe('ChatTimeline', () => {
|
||||
it('shows an image context limitation as a quiet persistent footer, not an alert', () => {
|
||||
const message: Message = {
|
||||
id: 'image-message', role: 'assistant', content: '', createdAt: Date.now(),
|
||||
state: 'complete', imageContextNotice: 'editing-unavailable'
|
||||
}
|
||||
const props = {
|
||||
artifactById: new Map(), conversationId: 'image-conversation',
|
||||
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
|
||||
messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1
|
||||
}
|
||||
const { rerender } = render(<ChatTimeline {...props} messages={[message]} />)
|
||||
const note = screen.getByText('上游不支持图片编辑,本次按文字要求生成,未使用参考图片。')
|
||||
expect(note).toHaveClass('message__image-context-note')
|
||||
expect(note).not.toHaveAttribute('aria-live')
|
||||
expect(note).not.toHaveAttribute('role')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('重试')).not.toBeInTheDocument()
|
||||
rerender(<ChatTimeline {...props} messages={[{ ...message, imageContextNotice: undefined }]} />)
|
||||
expect(screen.queryByText(/上游不支持图片编辑/u)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -73,6 +73,7 @@ export type Message = {
|
||||
sourceReferences?: KnowledgeSearchReference[]
|
||||
knowledgeRetrieval?: KnowledgeRetrievalStatus
|
||||
artifactIds?: string[]
|
||||
imageContextNotice?: ConversationMessage['imageContextNotice']
|
||||
task?: ConversationMessage['task']
|
||||
attachments?: ConversationAttachment[]
|
||||
}
|
||||
@@ -1032,6 +1033,11 @@ function ChatMessageRowView({
|
||||
value={message.question}
|
||||
/>
|
||||
)}
|
||||
{message.role === 'assistant' && message.imageContextNotice && (
|
||||
<p className="message__image-context-note">
|
||||
{t(`chat.images.contextNotice.${message.imageContextNotice}`)}
|
||||
</p>
|
||||
)}
|
||||
{message.displayCaptureTruncated && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -269,6 +269,10 @@ export const app = {
|
||||
},
|
||||
exportSpeaker: '{{speaker}}:\n{{content}}',
|
||||
images: {
|
||||
contextNotice: {
|
||||
'reference-unavailable': 'Some previous images could not be loaded. Only available images and text context were used.',
|
||||
'editing-unavailable': 'The provider does not support image editing. This image was generated from text without the reference images.'
|
||||
},
|
||||
view: 'View',
|
||||
download: 'Download',
|
||||
viewNamed: 'View image {{title}}',
|
||||
|
||||
@@ -254,6 +254,10 @@ export const app = {
|
||||
},
|
||||
exportSpeaker: '{{speaker}}:\n{{content}}',
|
||||
images: {
|
||||
contextNotice: {
|
||||
'reference-unavailable': '部分历史图片无法读取,本次仅使用可用的图片与文字上下文。',
|
||||
'editing-unavailable': '上游不支持图片编辑,本次按文字要求生成,未使用参考图片。'
|
||||
},
|
||||
view: '查看',
|
||||
download: '下载',
|
||||
viewNamed: '查看图片 {{title}}',
|
||||
|
||||
@@ -6079,6 +6079,11 @@ button > svg {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.message__image-context-note {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
.message__status {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
Reference in New Issue
Block a user