feat: generate and edit conversation images across runtimes
Add model-controlled image invocation and read-only runtime assignments. Route image tools through Main so accepted operations retain their originating conversation when chat runtimes change, with saved references, cancellation, and manual recovery. Keep Node hashing outside browser-shared contracts to restore production builds. Validation includes the production build, typecheck, lint, focused regressions, real Host transport checks, and two successful real image requests. Full UI-driven runtime switching acceptance remains documented as pending. Release note: 开启图片模型的会话调用后,聊天助手可调用图片生成与编辑工具,Runtime 分配自动管理。图片成果保存在原会话,可继续修改,并独立显示生成状态和取消入口。
This commit is contained in:
@@ -483,6 +483,11 @@ const api: DesktopApi = {
|
||||
},
|
||||
},
|
||||
conversations: {
|
||||
imageOperations: {
|
||||
cancel: vi.fn(),
|
||||
regenerate: vi.fn(),
|
||||
onChanged: vi.fn(() => () => undefined),
|
||||
},
|
||||
list: vi.fn(async () => []),
|
||||
replace: vi.fn(async () => {}),
|
||||
saveLocal: vi.fn(async () => {}),
|
||||
@@ -8926,6 +8931,19 @@ describe("App", () => {
|
||||
expect(mode).toHaveAccessibleName("工作模式:Ask · 只读问答");
|
||||
});
|
||||
|
||||
it('opens model settings from no-tool image guidance without losing the request draft', async () => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({ id: 'model', label: 'Legacy chat', available: true, supportsToolExecution: false, capability: 'chat', detail: 'Ready' });
|
||||
render(<App />);
|
||||
expect(await screen.findByText('当前聊天模型无法自动调用图片工具,可使用已有直连图片工作流。')).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Keep my image request' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往图片模型设置' }));
|
||||
expect(await screen.findByRole('tab', { name: '模型连接' })).toHaveAttribute('aria-selected', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
|
||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('Keep my image request');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(api.conversations.imageOperations.regenerate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a direct model to submit Execute with GoodBuddy approvals", async () => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: "model",
|
||||
@@ -10290,6 +10308,93 @@ describe("App", () => {
|
||||
anchorClick.mockRestore();
|
||||
});
|
||||
|
||||
it("updates the original image message after leaving its conversation and queues history editing across Runtime switches", async () => {
|
||||
const conversationId = crypto.randomUUID();
|
||||
const messageId = crypto.randomUUID();
|
||||
const otherId = crypto.randomUUID();
|
||||
const operation: import('../../shared/image-generation-contracts').ImageOperation = {
|
||||
id: crypto.randomUUID(), conversationId, messageId, requestId: crypto.randomUUID(), callId: 'image-call',
|
||||
modelProfileId: crypto.randomUUID(), modelName: 'image-model', modelProfileName: '图片连接',
|
||||
input: { intent: 'create', prompt: 'Blue circle', sourceArtifactIds: [] }, state: 'running', artifactIds: [], createdAt: 1, updatedAt: 1,
|
||||
};
|
||||
const secondOperation = { ...operation, id: crypto.randomUUID(), modelProfileName: 'Second image connection' };
|
||||
let imageListener: ((value: typeof operation) => void) | undefined;
|
||||
vi.mocked(api.conversations.imageOperations.onChanged).mockImplementationOnce(listener => { imageListener = listener; return () => { imageListener = undefined; }; });
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{ id: conversationId, projectId, title: '图片原会话', updatedAt: 2, workMode: 'execute', messages: [
|
||||
{ id: messageId, role: 'assistant', content: '图片稍后保存', createdAt: 1, state: 'complete', imageOperations: [operation, secondOperation] },
|
||||
] },
|
||||
{ id: otherId, projectId, title: '另一会话', updatedAt: 1, messages: [
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '另一会话正文', createdAt: 1, state: 'complete' },
|
||||
] },
|
||||
]);
|
||||
const artifact = { id: crypto.randomUUID(), kind: 'image' as const, title: 'Blue circle', mimeType: 'image/png',
|
||||
content: 'data:image/png;base64,iVBORw0KGgo=', byteSize: 8, createdAt: '2026-09-15T00:00:00Z', updatedAt: '2026-09-15T00:00:00Z' };
|
||||
vi.mocked(api.artifacts.get).mockResolvedValueOnce(artifact);
|
||||
render(<App />);
|
||||
expect(await screen.findByText('图片稍后保存')).toBeVisible();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^另一会话/u }));
|
||||
expect(await screen.findByText('另一会话正文')).toBeVisible();
|
||||
act(() => imageListener?.({ ...operation, state: 'completed', artifactIds: [artifact.id], updatedAt: 3 }));
|
||||
expect(screen.queryByRole('img', { name: 'Blue circle' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^图片原会话/u }));
|
||||
expect(await screen.findByRole('img', { name: 'Blue circle' })).toBeVisible();
|
||||
expect(screen.getByText(/图片已保存/u)).toBeVisible();
|
||||
expect(screen.getAllByRole('region', { name: '图片生成' }).map(card => within(card).getByRole('status').textContent)).toEqual([
|
||||
'图片已保存 · 图片连接', '正在生成图片 · Second image connection',
|
||||
]);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续修改' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /sonnet-5/u }));
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /^OpenCode · 默认模型/u }));
|
||||
await waitFor(() => expect(api.agent.getStatus).toHaveBeenLastCalledWith({ provider: 'opencode' }));
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Turn the circle red' } });
|
||||
fireEvent.click(screen.getByLabelText('发送'));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
expect(run.mock.calls[0]![0]).toMatchObject({ runtimeSelection: { provider: 'opencode' }, imageContextArtifactIds: [artifact.id] });
|
||||
expect(api.conversationQueue.enqueueUser).toHaveBeenCalledWith(expect.objectContaining({ imageContextArtifactIds: [artifact.id] }));
|
||||
expect(api.context.addPastedImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recovers failed image editing through settings and composer attachments without resubmitting the operation', async () => {
|
||||
const conversationId = crypto.randomUUID();
|
||||
const operation: import('../../shared/image-generation-contracts').ImageOperation = {
|
||||
id: crypto.randomUUID(), conversationId, messageId: crypto.randomUUID(), requestId: crypto.randomUUID(), callId: 'failed-edit',
|
||||
modelProfileId: crypto.randomUUID(), modelName: 'original-image-model', modelProfileName: 'Original image connection',
|
||||
input: { intent: 'edit', prompt: 'Change the background', sourceArtifactIds: [crypto.randomUUID()] },
|
||||
state: 'failed', error: 'Original source unavailable', artifactIds: [], createdAt: 1, updatedAt: 1,
|
||||
};
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{ id: conversationId, projectId, title: 'Failed image editing', updatedAt: 2, workMode: 'execute', messages: [
|
||||
{ id: operation.messageId, role: 'assistant', content: '', createdAt: 1, state: 'complete', imageOperations: [operation] },
|
||||
] }]);
|
||||
const source = { id: crypto.randomUUID(), name: 'replacement.png', size: 64, preview: 'Replacement image', kind: 'image' as const, contentUrl: 'data:image/png;base64,iVBORw0KGgo=' };
|
||||
vi.mocked(api.context.selectFiles).mockResolvedValueOnce([source]);
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '重新选择素材' }));
|
||||
const composer = screen.getByLabelText('向 GoodBuddy 提问');
|
||||
expect(composer).toHaveValue('使用图片模型“Original image connection”:Change the background');
|
||||
expect(composer).toHaveFocus();
|
||||
expect(await within(composer.closest<HTMLElement>('.composer')!).findByText('replacement.png')).toBeVisible();
|
||||
expect(api.context.selectFiles).toHaveBeenCalledOnce();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(api.conversations.imageOperations.regenerate).not.toHaveBeenCalled();
|
||||
fireEvent.change(composer, { target: { value: 'My edited image request' } });
|
||||
vi.mocked(api.context.selectFiles).mockResolvedValueOnce([]);
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新选择素材' }));
|
||||
await waitFor(() => expect(api.context.selectFiles).toHaveBeenCalledTimes(2));
|
||||
expect(composer).toHaveValue('My edited image request');
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新选择模型' }));
|
||||
expect(await screen.findByRole('tab', { name: '模型连接' })).toHaveAttribute('aria-selected', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
|
||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('My edited image request');
|
||||
expect(screen.getByText('Original source unavailable')).toBeVisible();
|
||||
expect(api.conversations.imageOperations.regenerate).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByLabelText('发送'));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
expect(run.mock.calls[0]![0]).toMatchObject({ conversationId, prompt: 'My edited image request', contextIds: [source.id] });
|
||||
expect(run.mock.calls[0]![0].imageContextArtifactIds).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can dispatch a request to the parallel expert team", async () => {
|
||||
render(<App />);
|
||||
|
||||
|
||||
+113
-11
@@ -1,3 +1,5 @@
|
||||
import { ImageCapabilityNotice } from "./ImageCapabilityNotice";
|
||||
import type { ImageOperation } from "../../shared/image-generation-contracts";
|
||||
import {
|
||||
ArrowDown,
|
||||
Bot,
|
||||
@@ -803,6 +805,9 @@ type ChatScrollSnapshot = {
|
||||
};
|
||||
|
||||
function ChatHistoryPane({
|
||||
onOpenImageModelSettings,
|
||||
onReselectImageSources,
|
||||
onEditImage,
|
||||
active,
|
||||
artifactById,
|
||||
conversationHtmlRenderingEnabled,
|
||||
@@ -824,6 +829,9 @@ function ChatHistoryPane({
|
||||
taskStrip,
|
||||
visibleMessageCount,
|
||||
}: {
|
||||
onOpenImageModelSettings: () => void;
|
||||
onReselectImageSources: (operation: ImageOperation) => void;
|
||||
onEditImage: (artifact: AssistantArtifact) => void;
|
||||
active: boolean;
|
||||
artifactById: ReadonlyMap<string, AssistantArtifact>;
|
||||
conversationHtmlRenderingEnabled: boolean;
|
||||
@@ -1107,6 +1115,9 @@ function ChatHistoryPane({
|
||||
</div>
|
||||
)}
|
||||
<ChatTimeline
|
||||
onOpenImageModelSettings={onOpenImageModelSettings}
|
||||
onReselectImageSources={onReselectImageSources}
|
||||
onEditImage={onEditImage}
|
||||
artifactById={artifactById}
|
||||
conversationId={conversation.id}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
@@ -1344,6 +1355,8 @@ function toConversationMessage(message: Message): ConversationMessage {
|
||||
sourceReferences: message.sourceReferences,
|
||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||
artifactIds: message.artifactIds,
|
||||
imageOperations: message.imageOperations,
|
||||
imageSourceArtifactIds: message.imageSourceArtifactIds,
|
||||
imageContextNotice: message.imageContextNotice,
|
||||
task: message.task,
|
||||
attachments: message.attachments,
|
||||
@@ -1477,15 +1490,26 @@ function mergePersistedConversations(
|
||||
conversation.messages.map((message) => message.id),
|
||||
);
|
||||
const messages = [
|
||||
...conversation.messages.map((message) => {
|
||||
const localMessage = localMessageById.get(message.id);
|
||||
...conversation.messages.map((persistedMessage) => {
|
||||
const localMessage = localMessageById.get(persistedMessage.id);
|
||||
const operations = new Map(persistedMessage.imageOperations?.map(operation => [operation.id, operation]));
|
||||
for (const operation of localMessage?.imageOperations ?? []) {
|
||||
if (operation.updatedAt > (operations.get(operation.id)?.updatedAt ?? -1)) operations.set(operation.id, operation);
|
||||
}
|
||||
const imageMetadata = {
|
||||
imageOperations: operations.size ? [...operations.values()] : undefined,
|
||||
imageSourceArtifactIds: persistedMessage.imageSourceArtifactIds ?? localMessage?.imageSourceArtifactIds,
|
||||
artifactIds: [...new Set([...(persistedMessage.artifactIds ?? []),
|
||||
...[...operations.values()].flatMap(operation => operation.artifactIds)])].slice(-8),
|
||||
};
|
||||
const message = { ...persistedMessage, ...imageMetadata };
|
||||
if (!conversation.activeRequest && local.activeRequest?.messageId === message.id) {
|
||||
return { ...message, pendingQuestions: undefined };
|
||||
}
|
||||
if (!localIsNewer) {
|
||||
// Pending prompts are live-only and are omitted from persisted messages.
|
||||
if (localMessage?.state === "streaming" && message.state === "streaming") {
|
||||
return { ...message, approval: localMessage.approval, pendingQuestions: localMessage.pendingQuestions };
|
||||
return { ...localMessage, ...imageMetadata };
|
||||
}
|
||||
return message;
|
||||
}
|
||||
@@ -1495,7 +1519,8 @@ function mergePersistedConversations(
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
return localMessage;
|
||||
return { ...localMessage, ...imageMetadata,
|
||||
artifactIds: [...new Set([...(localMessage.artifactIds ?? []), ...(message.artifactIds ?? [])])].slice(-8) };
|
||||
}),
|
||||
...local.messages.filter((message) => !serverMessageIds.has(message.id)),
|
||||
];
|
||||
@@ -2505,6 +2530,8 @@ function App(): React.JSX.Element {
|
||||
Record<string, ContextAttachment[]>
|
||||
>({});
|
||||
const attachments = attachmentsByConversation[activeId] ?? [];
|
||||
const [imageReferencesByConversation, setImageReferencesByConversation] = useState<Record<string, AssistantArtifact[]>>({});
|
||||
const imageReferences = imageReferencesByConversation[activeId] ?? [];
|
||||
const attachmentsRef = useRef(new Map<string, ContextAttachment[]>());
|
||||
const updateAttachments = useCallback(
|
||||
(
|
||||
@@ -2618,6 +2645,7 @@ function App(): React.JSX.Element {
|
||||
);
|
||||
const hydratingArtifactIds = useRef(new Set<string>());
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const attachmentButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [chatScrollSnapshots, setChatScrollSnapshots] = useState<
|
||||
Record<string, ChatScrollSnapshot>
|
||||
>({});
|
||||
@@ -6017,7 +6045,7 @@ function App(): React.JSX.Element {
|
||||
const missingIds = [
|
||||
...new Set(
|
||||
(activeConversation?.messages ?? []).flatMap(
|
||||
(message) => message.artifactIds ?? [],
|
||||
(message) => [...(message.artifactIds ?? []), ...(message.imageOperations?.flatMap(operation => operation.artifactIds) ?? [])],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -6123,11 +6151,25 @@ function App(): React.JSX.Element {
|
||||
);
|
||||
const removeAgentListener =
|
||||
window.goodbuddy.agent.onEvent(handleAgentEvent);
|
||||
const removeImageListener = window.goodbuddy.conversations.imageOperations.onChanged((operation) => {
|
||||
setConversations(current => current.map(conversation => conversation.id !== operation.conversationId ? conversation : {
|
||||
...conversation,
|
||||
updatedAt: Math.max(conversation.updatedAt, operation.updatedAt),
|
||||
messages: conversation.messages.map(message => message.id !== operation.messageId ? message : {
|
||||
...message,
|
||||
imageOperations: message.imageOperations?.some(item => item.id === operation.id)
|
||||
? message.imageOperations.map(item => item.id === operation.id ? operation : item)
|
||||
: [...(message.imageOperations ?? []), operation],
|
||||
artifactIds: [...new Set([...(message.artifactIds ?? []), ...operation.artifactIds])].slice(-8),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
const removeOpenSettingsListener = window.goodbuddy.app.onOpenSettings(() =>
|
||||
setView("settings"),
|
||||
);
|
||||
return () => {
|
||||
removeAgentListener();
|
||||
removeImageListener();
|
||||
removeOpenSettingsListener();
|
||||
};
|
||||
}, [handleAgentEvent, setView]);
|
||||
@@ -7103,6 +7145,7 @@ function App(): React.JSX.Element {
|
||||
includeMemoryContext: !command,
|
||||
prompt,
|
||||
attachments: attachmentSnapshot,
|
||||
imageContextArtifactIds: imageReferences.map(artifact => artifact.id),
|
||||
knowledgeLibraryIds: knowledgeLibraryIdsSnapshot,
|
||||
knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot,
|
||||
};
|
||||
@@ -7119,6 +7162,7 @@ function App(): React.JSX.Element {
|
||||
setRuntimeMenuOpen(false);
|
||||
setInput("");
|
||||
updateAttachments([]);
|
||||
setImageReferencesByConversation(current => ({ ...current, [conversationId]: [] }));
|
||||
if (command) {
|
||||
setSelectedRuntimeCommand("");
|
||||
}
|
||||
@@ -7236,6 +7280,22 @@ function App(): React.JSX.Element {
|
||||
),
|
||||
);
|
||||
try {
|
||||
const saveOrigin = conversationPersistenceQueueRef.current.then(async () => {
|
||||
const origin = {
|
||||
...conversationSnapshot,
|
||||
title: conversationSnapshot.title === "新对话" ? prompt.slice(0, 24) : conversationSnapshot.title,
|
||||
updatedAt: assistantMessage.createdAt,
|
||||
workMode: workModeSnapshot,
|
||||
messages: [...historySnapshot, userMessage, assistantMessage],
|
||||
};
|
||||
await window.goodbuddy.conversations.saveLocal([{
|
||||
header: toLocalConversationHeader(origin),
|
||||
messages: [toConversationMessage(userMessage), toConversationMessage(assistantMessage)],
|
||||
}]);
|
||||
persistedLocalConversationsRef.current.set(conversationId, origin);
|
||||
});
|
||||
conversationPersistenceQueueRef.current = saveOrigin.catch(() => undefined);
|
||||
await saveOrigin;
|
||||
await window.goodbuddy.agent.run({
|
||||
requestId,
|
||||
conversationId,
|
||||
@@ -7254,9 +7314,9 @@ function App(): React.JSX.Element {
|
||||
knowledgeLibraryIds: knowledgeLibraryIdsSnapshot,
|
||||
knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot,
|
||||
contextIds: attachmentSnapshot.map((attachment) => attachment.id),
|
||||
...(runtimeSelectionSnapshot.provider === "model"
|
||||
? {
|
||||
imageContextArtifactIds: [...historySnapshot]
|
||||
imageContextArtifactIds: queuedInput?.imageContextArtifactIds?.length
|
||||
? queuedInput.imageContextArtifactIds
|
||||
: attachmentSnapshot.some(attachment => attachment.kind === 'image') ? undefined : [...historySnapshot]
|
||||
.reverse()
|
||||
.find(
|
||||
(message) =>
|
||||
@@ -7264,8 +7324,6 @@ function App(): React.JSX.Element {
|
||||
message.state === "complete" &&
|
||||
message.artifactIds?.length,
|
||||
)?.artifactIds,
|
||||
}
|
||||
: {}),
|
||||
contextCompressionState: conversationSnapshot.contextCompressionState,
|
||||
history: retainedHistorySnapshot.map((message) => ({
|
||||
role: message.role,
|
||||
@@ -7588,6 +7646,30 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const editImage = useCallback((artifact: AssistantArtifact): void => {
|
||||
const conversationId = activeConversationIdRef.current;
|
||||
setContextError(undefined);
|
||||
setImageReferencesByConversation(current => ({ ...current,
|
||||
[conversationId]: [...(current[conversationId] ?? []).filter(item => item.id !== artifact.id), artifact].slice(-8),
|
||||
}));
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const openImageModelSettings = useCallback((): void => {
|
||||
setSettingsInitialCategory("model");
|
||||
setView("settings");
|
||||
}, [setView]);
|
||||
|
||||
const reselectImageSources = useCallback((operation: ImageOperation): void => {
|
||||
if (operation.conversationId !== activeId) return;
|
||||
setInput(current => current || t("chat.images.recoveryPrompt", {
|
||||
model: operation.modelProfileName ?? operation.modelName,
|
||||
prompt: operation.input.prompt,
|
||||
}));
|
||||
inputRef.current?.focus();
|
||||
attachmentButtonRef.current?.click();
|
||||
}, [activeId, setInput, t]);
|
||||
|
||||
const selectContextFiles = async (paths?: string[]): Promise<void> => {
|
||||
if (selectingContextFilesRef.current) {
|
||||
return;
|
||||
@@ -9229,6 +9311,9 @@ function App(): React.JSX.Element {
|
||||
locale={locale}
|
||||
onCopyMessage={copyMessage}
|
||||
onDownloadImage={downloadImage}
|
||||
onEditImage={editImage}
|
||||
onOpenImageModelSettings={openImageModelSettings}
|
||||
onReselectImageSources={reselectImageSources}
|
||||
onOpenCitationContext={openCitationContext}
|
||||
onOpenCitationSource={openCitationSource}
|
||||
onOpenImage={openImageViewer}
|
||||
@@ -9343,12 +9428,20 @@ function App(): React.JSX.Element {
|
||||
running={conversationExecutionRunning}
|
||||
/>
|
||||
<div className="composer">
|
||||
<ImageCapabilityNotice
|
||||
runtime={runtime}
|
||||
workMode={effectiveWorkMode}
|
||||
hasCallableImageModels={runtimeSettings?.modelProfiles.some(profile =>
|
||||
profile.protocol === "openai-images-generations" && profile.allowConversationInvocation === true
|
||||
) ?? false}
|
||||
onOpenModelSettings={openImageModelSettings}
|
||||
/>
|
||||
{composerOptionSummary && (
|
||||
<div className="composer__option-summary" aria-label={t("composer.settings")}>
|
||||
{composerOptionSummary}
|
||||
</div>
|
||||
)}
|
||||
{(attachments.length > 0 ||
|
||||
{(attachments.length > 0 || imageReferences.length > 0 ||
|
||||
selectingContextFiles) && (
|
||||
<div
|
||||
aria-busy={selectingContextFiles}
|
||||
@@ -9360,6 +9453,14 @@ function App(): React.JSX.Element {
|
||||
aria-invalid={contextError ? true : undefined}
|
||||
className="context-list"
|
||||
>
|
||||
{imageReferences.map(artifact => <div className="context-chip" key={artifact.id}>
|
||||
{artifact.content && <img alt="" className="context-chip__thumbnail" src={artifact.content} />}
|
||||
<span><strong>{artifact.title}</strong><small>{t('chat.images.edit')}</small></span>
|
||||
<button type="button" aria-label={t('composer.removeAttachment', { name: artifact.title })}
|
||||
onClick={() => setImageReferencesByConversation(current => ({ ...current,
|
||||
[activeId]: (current[activeId] ?? []).filter(item => item.id !== artifact.id),
|
||||
}))}>×</button>
|
||||
</div>)}
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
className="context-chip"
|
||||
@@ -9558,6 +9659,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
aria-invalid={contextError ? true : undefined}
|
||||
disabled={selectingContextFiles}
|
||||
ref={attachmentButtonRef}
|
||||
onClick={() => void selectContextFiles()}
|
||||
title={t("composer.addAttachment")}
|
||||
type="button"
|
||||
|
||||
@@ -11,9 +11,11 @@ type CapabilitySettingsTab = 'skills' | 'mcp' | 'tools'
|
||||
|
||||
export function CapabilitiesAndToolsSettingsSection({
|
||||
magicNotesEnabled,
|
||||
onOpenImageModelSettings,
|
||||
onNotify
|
||||
}: {
|
||||
magicNotesEnabled: boolean
|
||||
onOpenImageModelSettings: () => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('settings')
|
||||
@@ -76,7 +78,10 @@ export function CapabilitiesAndToolsSettingsSection({
|
||||
role="tabpanel"
|
||||
>
|
||||
{mcpVisited && (
|
||||
<McpSettingsSection magicNotesEnabled={magicNotesEnabled} />
|
||||
<McpSettingsSection
|
||||
magicNotesEnabled={magicNotesEnabled}
|
||||
onOpenImageModelSettings={onOpenImageModelSettings}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<section
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
UserRound,
|
||||
XCircle
|
||||
} from 'lucide-react'
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ImageOperationStatus } from './ImageOperationStatus'
|
||||
import type { ImageOperation } from '../../shared/image-generation-contracts'
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
AgentEvent,
|
||||
@@ -75,6 +77,8 @@ export type Message = {
|
||||
sourceReferences?: KnowledgeSearchReference[]
|
||||
knowledgeRetrieval?: KnowledgeRetrievalStatus
|
||||
artifactIds?: string[]
|
||||
imageOperations?: ConversationMessage['imageOperations']
|
||||
imageSourceArtifactIds?: string[]
|
||||
imageContextNotice?: ConversationMessage['imageContextNotice']
|
||||
task?: ConversationMessage['task']
|
||||
attachments?: ConversationAttachment[]
|
||||
@@ -488,6 +492,9 @@ function SubagentStatusList({
|
||||
}
|
||||
|
||||
type ChatMessageRowProps = {
|
||||
onOpenImageModelSettings?: () => void
|
||||
onReselectImageSources?: (operation: ImageOperation) => void
|
||||
onEditImage?: (artifact: AssistantArtifact) => void
|
||||
artifactById: ReadonlyMap<string, AssistantArtifact>
|
||||
canRetry: boolean
|
||||
conversationId: string
|
||||
@@ -522,6 +529,9 @@ type ChatMessageRowProps = {
|
||||
}
|
||||
|
||||
function ChatMessageRowView({
|
||||
onOpenImageModelSettings,
|
||||
onReselectImageSources,
|
||||
onEditImage,
|
||||
artifactById,
|
||||
canRetry,
|
||||
conversationId,
|
||||
@@ -541,6 +551,10 @@ function ChatMessageRowView({
|
||||
retryContent
|
||||
}: ChatMessageRowProps): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const imageArtifactIds = useMemo(() => [...new Set([
|
||||
...(message.artifactIds ?? []),
|
||||
...(message.imageOperations?.flatMap(operation => operation.artifactIds) ?? [])
|
||||
])], [message.artifactIds, message.imageOperations])
|
||||
const compressionMarkers =
|
||||
message.contextCompressions ??
|
||||
(message.contextCompression ? [message.contextCompression] : [])
|
||||
@@ -799,7 +813,8 @@ function ChatMessageRowView({
|
||||
subagents={unorderedSubagents}
|
||||
/>
|
||||
)}
|
||||
{message.artifactIds?.map((artifactId) => {
|
||||
{message.imageOperations?.map(operation => <ImageOperationStatus key={operation.id} operation={operation} onOpenImageModelSettings={onOpenImageModelSettings} onReselectImageSources={onReselectImageSources} />)}
|
||||
{imageArtifactIds.map((artifactId) => {
|
||||
const candidate = artifactById.get(artifactId)
|
||||
const artifact =
|
||||
candidate?.kind === 'image' &&
|
||||
@@ -833,6 +848,7 @@ function ChatMessageRowView({
|
||||
</button>
|
||||
<figcaption>{artifact.title}</figcaption>
|
||||
<div className="message-image-actions">
|
||||
{onEditImage && <button type="button" onClick={() => onEditImage(artifact)}>{t('chat.images.edit')}</button>}
|
||||
<button
|
||||
onClick={(event) =>
|
||||
onOpenImage(
|
||||
@@ -1254,6 +1270,9 @@ function ChatMessageRowView({
|
||||
export const ChatMessageRow = memo(ChatMessageRowView)
|
||||
|
||||
type ChatTimelineProps = {
|
||||
onOpenImageModelSettings?: () => void
|
||||
onReselectImageSources?: (operation: ImageOperation) => void
|
||||
onEditImage?: (artifact: AssistantArtifact) => void
|
||||
artifactById: ReadonlyMap<string, AssistantArtifact>
|
||||
conversationId: string
|
||||
hiddenMessageCount: number
|
||||
@@ -1291,6 +1310,9 @@ type ChatTimelineProps = {
|
||||
}
|
||||
|
||||
export const ChatTimeline = memo(function ChatTimeline({
|
||||
onOpenImageModelSettings,
|
||||
onReselectImageSources,
|
||||
onEditImage,
|
||||
artifactById,
|
||||
conversationId,
|
||||
hiddenMessageCount,
|
||||
@@ -1331,6 +1353,9 @@ export const ChatTimeline = memo(function ChatTimeline({
|
||||
const messageIndex = messageStartIndex + visibleMessageIndex
|
||||
return (
|
||||
<ChatMessageRow
|
||||
onOpenImageModelSettings={onOpenImageModelSettings}
|
||||
onReselectImageSources={onReselectImageSources}
|
||||
onEditImage={onEditImage}
|
||||
artifactById={artifactById}
|
||||
canRetry={
|
||||
message.state === 'error' &&
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ImageCapabilityNotice } from './ImageCapabilityNotice'
|
||||
import type { AgentRuntimeStatus } from '../../shared/contracts'
|
||||
|
||||
afterEach(cleanup)
|
||||
const runtime: AgentRuntimeStatus = { id: 'model', label: 'Chat', available: true, capability: 'chat', supportsToolExecution: false, detail: 'Ready' }
|
||||
|
||||
describe('conversation image capability guidance', () => {
|
||||
it('explains the existing direct image workflow and links to configuration without selecting a model', () => {
|
||||
const onOpenModelSettings = vi.fn()
|
||||
const view = render(<ImageCapabilityNotice runtime={runtime} workMode="ask" hasCallableImageModels={false} onOpenModelSettings={onOpenModelSettings} />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('当前聊天模型无法自动调用图片工具,可使用已有直连图片工作流。')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('在 Runtime 选择器中选择图片模型')
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往图片模型设置' }))
|
||||
expect(onOpenModelSettings).toHaveBeenCalledOnce()
|
||||
view.rerender(<ImageCapabilityNotice runtime={{ ...runtime, supportsToolExecution: true }} workMode="ask" hasCallableImageModels onOpenModelSettings={onOpenModelSettings} />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('生成或编辑图片需要 Execute 模式')
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
{ ...runtime, available: false },
|
||||
{ ...runtime, capability: 'image-generation' as const },
|
||||
{ ...runtime, supportsToolExecution: true },
|
||||
])('does not claim missing tool capability for loading, unavailable, image, or tool-capable runtimes: %j', current => {
|
||||
const { container } = render(<ImageCapabilityNotice runtime={current} workMode="execute" hasCallableImageModels onOpenModelSettings={vi.fn()} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AgentRuntimeStatus } from '../../shared/contracts'
|
||||
|
||||
export function ImageCapabilityNotice({ runtime, workMode, hasCallableImageModels, onOpenModelSettings }: {
|
||||
runtime?: AgentRuntimeStatus
|
||||
workMode: 'ask' | 'execute'
|
||||
hasCallableImageModels: boolean
|
||||
onOpenModelSettings: () => void
|
||||
}): React.JSX.Element | null {
|
||||
const { t } = useTranslation('app')
|
||||
if (!runtime?.available || runtime.capability === 'image-generation') return null
|
||||
const noTools = !runtime.supportsToolExecution
|
||||
if (!noTools && (!hasCallableImageModels || workMode !== 'ask')) return null
|
||||
return (
|
||||
<div className="composer__option-summary" role="status">
|
||||
<span>{t(noTools ? 'chat.images.noToolsNotice' : 'chat.images.askNotice')}</span>
|
||||
{noTools && (
|
||||
<>
|
||||
{' '}
|
||||
<span>{t('chat.images.directWorkflowNotice')}</span>
|
||||
<div className="message-image-actions">
|
||||
<button type="button" onClick={onOpenModelSettings}>{t('chat.images.openModelSettings')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ImageOperationStatus } from './ImageOperationStatus'
|
||||
import type { ImageOperation } from '../../shared/image-generation-contracts'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
describe('image operation controls', () => {
|
||||
it('offers explicit failed-edit recovery without changing regeneration inputs', async () => {
|
||||
const regenerate = vi.fn(async () => undefined)
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: { conversations: { imageOperations: { regenerate } } } })
|
||||
const operation: ImageOperation = {
|
||||
id: crypto.randomUUID(), conversationId: crypto.randomUUID(), messageId: crypto.randomUUID(),
|
||||
requestId: crypto.randomUUID(), callId: 'edit-call', modelProfileId: crypto.randomUUID(), modelName: 'original-model',
|
||||
input: { intent: 'edit', prompt: 'Change the background', sourceArtifactIds: [crypto.randomUUID()] },
|
||||
state: 'failed', error: 'Source unavailable', artifactIds: [], createdAt: 1, updatedAt: 1
|
||||
}
|
||||
const onOpenImageModelSettings = vi.fn()
|
||||
const onReselectImageSources = vi.fn()
|
||||
const view = render(<ImageOperationStatus operation={operation} onOpenImageModelSettings={onOpenImageModelSettings} onReselectImageSources={onReselectImageSources} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新选择模型' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新选择素材' }))
|
||||
expect(onOpenImageModelSettings).toHaveBeenCalledOnce()
|
||||
expect(onReselectImageSources).toHaveBeenCalledExactlyOnceWith(operation)
|
||||
expect(regenerate).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新生成' }))
|
||||
await waitFor(() => expect(regenerate).toHaveBeenCalledExactlyOnceWith({ conversationId: operation.conversationId, operationId: operation.id }))
|
||||
view.rerender(<ImageOperationStatus operation={{ ...operation, state: 'running' }} onOpenImageModelSettings={onOpenImageModelSettings} onReselectImageSources={onReselectImageSources} />)
|
||||
expect(screen.queryByRole('button', { name: '重新选择模型' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '重新选择素材' })).not.toBeInTheDocument()
|
||||
view.rerender(<ImageOperationStatus operation={{ ...operation, input: { ...operation.input, intent: 'create', sourceArtifactIds: [] } }} onOpenImageModelSettings={onOpenImageModelSettings} onReselectImageSources={onReselectImageSources} />)
|
||||
expect(screen.getByRole('button', { name: '重新选择模型' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '重新选择素材' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cancels independently and offers an explicit new request with local error recovery', async () => {
|
||||
const cancel = vi.fn(async () => undefined)
|
||||
const regenerate = vi.fn(async () => { throw new Error('Selected image model is unavailable') })
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: { conversations: { imageOperations: { cancel, regenerate } } } })
|
||||
const operation: ImageOperation = { id: crypto.randomUUID(), conversationId: crypto.randomUUID(), messageId: crypto.randomUUID(),
|
||||
requestId: crypto.randomUUID(), callId: 'image-call', modelProfileId: crypto.randomUUID(), modelName: 'image-model',
|
||||
input: { intent: 'create', prompt: 'Blue circle', sourceArtifactIds: [] }, state: 'running', artifactIds: [], createdAt: 1, updatedAt: 1 }
|
||||
const view = render(<ImageOperationStatus operation={operation} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消生成' }))
|
||||
await waitFor(() => expect(cancel).toHaveBeenCalledExactlyOnceWith({ conversationId: operation.conversationId, operationId: operation.id }))
|
||||
view.rerender(<ImageOperationStatus operation={{ ...operation, state: 'unconfirmed' }} />)
|
||||
expect(screen.getByText('将发起新的模型请求。')).toBeInTheDocument()
|
||||
fireEvent.click(await screen.findByRole('button', { name: '重新生成' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Selected image model is unavailable')
|
||||
expect(screen.getByText('Blue circle')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '重新生成' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '重新生成' })).toHaveAccessibleDescription('Selected image model is unavailable')
|
||||
expect(regenerate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('distinguishes pending cancellation, stopped waiting, and a saved late result', () => {
|
||||
const operation: ImageOperation = {
|
||||
id: crypto.randomUUID(), conversationId: crypto.randomUUID(), messageId: crypto.randomUUID(),
|
||||
requestId: crypto.randomUUID(), callId: 'image-call', modelProfileId: crypto.randomUUID(), modelName: 'image-model',
|
||||
input: { intent: 'create', prompt: 'Blue circle', sourceArtifactIds: [] },
|
||||
state: 'cancelling', cancellationRequested: true, artifactIds: [], createdAt: 1, updatedAt: 1
|
||||
}
|
||||
const view = render(<ImageOperationStatus operation={operation} />)
|
||||
expect(screen.getByText('已请求取消。提供商可能继续生成并计费。')).toBeVisible()
|
||||
expect(screen.queryByText(/已停止等待/)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '取消生成' })).toBeDisabled()
|
||||
view.rerender(<ImageOperationStatus operation={{ ...operation, state: 'stopped' }} />)
|
||||
expect(screen.getByText('已停止等待。提供商可能继续生成并计费,晚到的结果会保存在这里。')).toBeVisible()
|
||||
view.rerender(<ImageOperationStatus operation={{ ...operation, state: 'completed', artifactIds: [crypto.randomUUID()] }} />)
|
||||
expect(screen.getByText('取消未生效,已生成并保存图片。')).toBeVisible()
|
||||
expect(screen.queryByText(/已停止等待/)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '重新生成' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ImageOperation } from '../../shared/image-generation-contracts'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
|
||||
export function ImageOperationStatus({ operation, onOpenImageModelSettings, onReselectImageSources }: {
|
||||
operation: ImageOperation
|
||||
onOpenImageModelSettings?: () => void
|
||||
onReselectImageSources?: (operation: ImageOperation) => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const active = ['running', 'saving', 'cancelling'].includes(operation.state)
|
||||
const needsRecovery = !active && (operation.state === 'failed' || Boolean(error))
|
||||
const act = async (): Promise<void> => {
|
||||
setPending(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const api = window.goodbuddy.conversations.imageOperations
|
||||
await api[active ? 'cancel' : 'regenerate']({ conversationId: operation.conversationId, operationId: operation.id })
|
||||
} catch (reason) {
|
||||
setError(displayErrorMessage(reason, t('chat.images.states.failed')))
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
return <section className="message-retrieval-status" aria-label={t('chat.images.operation')}>
|
||||
<div>
|
||||
<strong role="status">{t(`chat.images.states.${operation.state}`)} · {operation.modelProfileName ?? operation.modelName}</strong>
|
||||
{operation.cancellationRequested && <p>{t(
|
||||
operation.state === 'completed'
|
||||
? 'chat.images.cancelTooLateNotice'
|
||||
: active ? 'chat.images.cancellingNotice' : 'chat.images.cancelNotice'
|
||||
)}</p>}
|
||||
{operation.state === 'unconfirmed' && <p>{t('chat.images.unconfirmedNotice')}</p>}
|
||||
{operation.error && <p>{operation.error}</p>}
|
||||
<details><summary>{t('chat.images.details')}</summary><div>{operation.input.prompt}</div>
|
||||
<small>{operation.modelName} · {operation.input.quality ?? 'auto'}</small>
|
||||
{operation.input.sourceArtifactIds.length > 0 && <div>{t('chat.images.sources', { count: operation.input.sourceArtifactIds.length })}</div>}
|
||||
</details>
|
||||
<div className="message-image-actions">
|
||||
<button type="button" aria-describedby={error ? `image-operation-error-${operation.id}` : undefined} disabled={pending || operation.state === 'cancelling'} onClick={() => void act()}>
|
||||
{t(active ? 'chat.images.cancel' : 'chat.images.regenerate')}
|
||||
</button>
|
||||
{!active && <small>{t('chat.images.regenerateNotice')}</small>}
|
||||
{needsRecovery && onOpenImageModelSettings && (
|
||||
<button type="button" disabled={pending} onClick={onOpenImageModelSettings}>
|
||||
{t('chat.images.reselectModel')}
|
||||
</button>
|
||||
)}
|
||||
{needsRecovery && operation.input.intent === 'edit' && onReselectImageSources && (
|
||||
<button type="button" disabled={pending} onClick={() => onReselectImageSources(operation)}>
|
||||
{t('chat.images.reselectSources')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{needsRecovery && <small>{t('chat.images.recoveryNotice')}</small>}
|
||||
{error && <p id={`image-operation-error-${operation.id}`} role="alert">{error}</p>}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -96,8 +96,10 @@ function editorFromServer(server: McpServerSummary): McpEditor {
|
||||
}
|
||||
|
||||
export function McpSettingsSection({
|
||||
onOpenImageModelSettings,
|
||||
magicNotesEnabled = false
|
||||
}: {
|
||||
onOpenImageModelSettings: () => void
|
||||
magicNotesEnabled?: boolean
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('integrations')
|
||||
@@ -574,11 +576,49 @@ export function McpSettingsSection({
|
||||
</div>
|
||||
<small>
|
||||
{t('mcp.profiles.count', {
|
||||
count: builtinMcpServers.length
|
||||
count: builtinMcpServers.length + 1
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
<div className="mcp-server-list">
|
||||
<article
|
||||
className="capability-card builtin-mcp-card"
|
||||
aria-label={t('conversationImages.title')}
|
||||
>
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{t('conversationImages.title')}</strong>
|
||||
<small>{t('conversationImages.managed')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<p id="image-generation-management">
|
||||
{t('conversationImages.description')}
|
||||
</p>
|
||||
<p>{t('conversationImages.runtimeScope')}</p>
|
||||
<div className="runtime-assignments">
|
||||
<small>{t('mcp.builtin.assignedTo')}</small>
|
||||
{configurableMcpTargets.map((target) => (
|
||||
<label key={target}>
|
||||
<input
|
||||
aria-describedby="image-generation-management"
|
||||
checked={
|
||||
snapshot?.imageGeneration?.assignments.includes(target) ?? false
|
||||
}
|
||||
disabled
|
||||
type="checkbox"
|
||||
/>
|
||||
{runtimeLabels[target]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onOpenImageModelSettings}
|
||||
type="button"
|
||||
>
|
||||
{t('conversationImages.openSettings')}
|
||||
</button>
|
||||
</article>
|
||||
{builtinMcpServers.map((server) => {
|
||||
const expansionId = `builtin:${server.id}`
|
||||
const expanded = expandedItemIds.has(expansionId)
|
||||
|
||||
@@ -4790,6 +4790,11 @@ describe('SettingsPanel runtime files', () => {
|
||||
target: { value: 'openai-images-generations' }
|
||||
})
|
||||
const qualitySelect = screen.getByLabelText('图片质量 默认模型')
|
||||
const invocationSwitch = screen.getByRole('switch', { name: '允许 AI 在会话中调用' })
|
||||
expect(invocationSwitch).not.toBeChecked()
|
||||
expect(screen.queryByRole('radio', { name: '设为默认生图模型' })).not.toBeInTheDocument()
|
||||
fireEvent.click(invocationSwitch)
|
||||
fireEvent.click(screen.getByRole('radio', { name: '设为默认生图模型' }))
|
||||
expect(qualitySelect).toHaveValue('auto')
|
||||
fireEvent.change(qualitySelect, {
|
||||
target: { value: 'high' }
|
||||
@@ -4807,10 +4812,12 @@ describe('SettingsPanel runtime files', () => {
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
protocol: 'openai-images-generations',
|
||||
allowConversationInvocation: true,
|
||||
imageGenerationQuality: 'high'
|
||||
})
|
||||
],
|
||||
imageGenerationQuality: 'high'
|
||||
imageGenerationQuality: 'high',
|
||||
defaultImageModelProfileId: modelProfileId
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -5179,6 +5186,77 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(screen.queryByText('智能心跳')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([false, true])('shows immutable image assignments and source navigation when enabled=%s', async (enabled) => {
|
||||
const snapshot = {
|
||||
...capabilitySnapshot,
|
||||
imageGeneration: {
|
||||
modelProfiles: enabled ? [{ id: modelProfileId, name: 'Image' }] : [],
|
||||
assignments: enabled ? ['model', 'opencode', 'continue', 'deepseek-harness'] : []
|
||||
}
|
||||
}
|
||||
getCapabilitySnapshot.mockResolvedValueOnce(snapshot).mockResolvedValueOnce(snapshot)
|
||||
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '能力与工具' }))
|
||||
await screen.findByText('文档写作')
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
|
||||
const card = screen.getByRole('article', { name: '图片生成与编辑' })
|
||||
await waitFor(() => {
|
||||
const checkboxes = within(card).getAllByRole('checkbox')
|
||||
expect(checkboxes).toHaveLength(4)
|
||||
for (const checkbox of checkboxes) {
|
||||
expect(checkbox).toBeDisabled()
|
||||
if (enabled) expect(checkbox).toBeChecked()
|
||||
else expect(checkbox).not.toBeChecked()
|
||||
}
|
||||
})
|
||||
expect(within(card).getByText('内置 · 自动管理')).toBeInTheDocument()
|
||||
expect(within(card).queryByRole('switch')).not.toBeInTheDocument()
|
||||
expect(within(card).queryByText(/暂不支持/)).not.toBeInTheDocument()
|
||||
for (const checkbox of within(card).getAllByRole('checkbox')) {
|
||||
expect(checkbox).toHaveAccessibleDescription('由图片模型的「允许 AI 在会话中调用」设置自动管理。')
|
||||
fireEvent.click(checkbox)
|
||||
fireEvent.click(checkbox.closest('label')!)
|
||||
fireEvent.keyDown(checkbox, { key: ' ' })
|
||||
}
|
||||
expect(setBuiltinMcpServerAssignments).not.toHaveBeenCalled()
|
||||
expect(setBuiltinMcpServerEnabled).not.toHaveBeenCalled()
|
||||
expect(saveMcpServer).not.toHaveBeenCalled()
|
||||
const navigation = within(card).getByRole('button', { name: '前往图片模型设置' })
|
||||
navigation.focus()
|
||||
expect(navigation).toHaveFocus()
|
||||
fireEvent.click(navigation)
|
||||
expect(screen.getByRole('tab', { name: '模型连接' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(await screen.findByRole('button', { name: '添加自定义' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('retains a disabled or deleted image default and keeps drafts on save failure', async () => {
|
||||
const imageId = '00000000-0000-4000-8000-000000000099'
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings,
|
||||
defaultImageModelProfileId: imageId,
|
||||
modelProfiles: [runtimeSettings.modelProfiles[0]!, {
|
||||
...runtimeSettings.modelProfiles[0]!, id: imageId, name: 'Image',
|
||||
protocol: 'openai-images-generations', allowConversationInvocation: true
|
||||
}]
|
||||
})
|
||||
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: '编辑模型连接 Image' }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: '允许 AI 在会话中调用' }))
|
||||
expect(screen.getByText('默认生图模型不可用,请开启该模型或明确选择其他图片模型。')).toBeInTheDocument()
|
||||
updateRuntime.mockRejectedValueOnce(new Error('Image settings save failed'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
await screen.findByText('Image settings save failed')
|
||||
expect(screen.getByRole('switch', { name: '允许 AI 在会话中调用' })).not.toBeChecked()
|
||||
expect(updateRuntime).toHaveBeenLastCalledWith(expect.objectContaining({ defaultImageModelProfileId: imageId }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除模型连接 Image' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
await waitFor(() => expect(updateRuntime).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
defaultImageModelProfileId: imageId,
|
||||
modelProfiles: [expect.objectContaining({ id: modelProfileId })]
|
||||
})))
|
||||
})
|
||||
|
||||
it('groups Skills and MCP under Capabilities and tools', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
|
||||
@@ -279,6 +279,7 @@ function toModelProfileDrafts(
|
||||
({ requestHeaders, requestBody, ...profile }) => ({
|
||||
...profile,
|
||||
supportsImageInput: profile.supportsImageInput ?? false,
|
||||
allowConversationInvocation: profile.allowConversationInvocation ?? false,
|
||||
maximumOutputTokens:
|
||||
profile.maximumOutputTokens ??
|
||||
defaultAnthropicMaximumOutputTokens,
|
||||
@@ -356,6 +357,7 @@ function hydrateRuntimeSettings(
|
||||
modelProfiles: (value: ModelProfileDraft[]) => void
|
||||
selectedModelProfileId: (value: RuntimeDraftSelection) => void
|
||||
defaultModelProfileId: (value: string) => void
|
||||
defaultImageModelProfileId: (value: string | null) => void
|
||||
opencodeModelSource: (value: RuntimeModelSource) => void
|
||||
continueModelSource: (value: RuntimeModelSource) => void
|
||||
deepseekHarnessModelSource: (value: RuntimeModelSource) => void
|
||||
@@ -404,6 +406,7 @@ function hydrateRuntimeSettings(
|
||||
: fallbackProfileId
|
||||
)
|
||||
setters.defaultModelProfileId(value.defaultModelProfileId)
|
||||
setters.defaultImageModelProfileId(value.defaultImageModelProfileId ?? null)
|
||||
setters.opencodeModelSource(configured.opencodeModelSource)
|
||||
setters.continueModelSource(configured.continueModelSource)
|
||||
setters.deepseekHarnessModelSource(
|
||||
@@ -671,6 +674,8 @@ export function SettingsPanel({
|
||||
const [selectedModelProfileId, setSelectedModelProfileId] =
|
||||
useState('')
|
||||
const [defaultModelProfileId, setDefaultModelProfileId] = useState('')
|
||||
const [defaultImageModelProfileId, setDefaultImageModelProfileId] =
|
||||
useState<string | null>(null)
|
||||
const [opencodeModelSource, setOpencodeModelSource] =
|
||||
useState<RuntimeModelSource>({ kind: 'platform' })
|
||||
const [continueModelSource, setContinueModelSource] =
|
||||
@@ -844,6 +849,7 @@ export function SettingsPanel({
|
||||
modelProfiles: setModelProfiles,
|
||||
selectedModelProfileId: setSelectedModelProfileId,
|
||||
defaultModelProfileId: setDefaultModelProfileId,
|
||||
defaultImageModelProfileId: setDefaultImageModelProfileId,
|
||||
opencodeModelSource: setOpencodeModelSource,
|
||||
continueModelSource: setContinueModelSource,
|
||||
deepseekHarnessModelSource: setDeepseekHarnessModelSource,
|
||||
@@ -905,6 +911,7 @@ export function SettingsPanel({
|
||||
provider,
|
||||
modelProfiles,
|
||||
defaultModelProfileId,
|
||||
defaultImageModelProfileId,
|
||||
opencodeModelSource,
|
||||
continueModelSource,
|
||||
deepseekHarnessModelSource,
|
||||
@@ -936,6 +943,7 @@ export function SettingsPanel({
|
||||
provider: settings.provider,
|
||||
modelProfiles: toModelProfileDrafts(settings),
|
||||
defaultModelProfileId: settings.defaultModelProfileId,
|
||||
defaultImageModelProfileId: settings.defaultImageModelProfileId ?? null,
|
||||
opencodeModelSource:
|
||||
savedConfiguredSettings?.opencodeModelSource,
|
||||
continueModelSource:
|
||||
@@ -1420,6 +1428,7 @@ export function SettingsPanel({
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput,
|
||||
allowConversationInvocation: profile.allowConversationInvocation ?? false,
|
||||
contextWindowTokens: profile.contextWindowTokens,
|
||||
maximumOutputTokens: profile.maximumOutputTokens,
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
@@ -1506,6 +1515,7 @@ export function SettingsPanel({
|
||||
apiKey: defaultProfileInput.apiKey,
|
||||
modelProfiles: profileInputs,
|
||||
defaultModelProfileId: defaultProfile.id,
|
||||
defaultImageModelProfileId,
|
||||
opencodeModelSource,
|
||||
continueModelSource,
|
||||
deepseekHarnessModelSource,
|
||||
@@ -1802,6 +1812,7 @@ export function SettingsPanel({
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: defaultRuntimeSettings.modelAuthentication,
|
||||
supportsImageInput: defaultRuntimeSettings.supportsImageInput,
|
||||
allowConversationInvocation: false,
|
||||
maximumOutputTokens: defaultAnthropicMaximumOutputTokens,
|
||||
imageGenerationQuality:
|
||||
defaultRuntimeSettings.imageGenerationQuality,
|
||||
@@ -2929,6 +2940,15 @@ export function SettingsPanel({
|
||||
{t('actions.addCustom')}
|
||||
</button>
|
||||
</div>
|
||||
{defaultImageModelProfileId && !modelProfiles.some((profile) =>
|
||||
profile.id === defaultImageModelProfileId &&
|
||||
profile.protocol === 'openai-images-generations' &&
|
||||
profile.allowConversationInvocation
|
||||
) && (
|
||||
<p className="settings-warning" role="status">
|
||||
{t('conversationImages.unavailable')}
|
||||
</p>
|
||||
)}
|
||||
<div className="model-connection-manager">
|
||||
<aside
|
||||
aria-label={t('model.profile.listAriaLabel')}
|
||||
@@ -2963,6 +2983,12 @@ export function SettingsPanel({
|
||||
{defaultModelProfileId === profile.id && (
|
||||
<span>{t('model.profile.defaultBadge')}</span>
|
||||
)}
|
||||
{profile.protocol === 'openai-images-generations' && profile.allowConversationInvocation && (
|
||||
<span>{t('conversationImages.callable')}</span>
|
||||
)}
|
||||
{defaultImageModelProfileId === profile.id && (
|
||||
<span>{t('conversationImages.defaultBadge')}</span>
|
||||
)}
|
||||
{profile.protocol ===
|
||||
'openai-images-generations' && (
|
||||
<span>{t('model.profile.imageBadge')}</span>
|
||||
@@ -3247,6 +3273,36 @@ export function SettingsPanel({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{profile.protocol === 'openai-images-generations' && (
|
||||
<div className="field">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={profile.allowConversationInvocation ?? false}
|
||||
onChange={(event) => updateModelProfile(profile.id, {
|
||||
allowConversationInvocation: event.target.checked
|
||||
})}
|
||||
aria-describedby={`conversation-images-help-${profile.id}`}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('conversationImages.allow')}</span>
|
||||
</label>
|
||||
<small id={`conversation-images-help-${profile.id}`}>
|
||||
{t('conversationImages.description')}
|
||||
</small>
|
||||
{profile.allowConversationInvocation && (
|
||||
<label className="check-field">
|
||||
<input
|
||||
checked={defaultImageModelProfileId === profile.id}
|
||||
name="default-image-model-profile"
|
||||
onChange={() => setDefaultImageModelProfileId(profile.id)}
|
||||
type="radio"
|
||||
/>
|
||||
<span>{t('conversationImages.setDefault')}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isAgentRuntimeModelProtocol(profile.protocol) && (
|
||||
<>
|
||||
<div className="field">
|
||||
@@ -4081,6 +4137,17 @@ export function SettingsPanel({
|
||||
)}
|
||||
{activeTab === 'capabilities' && (
|
||||
<CapabilitiesAndToolsSettingsSection
|
||||
onOpenImageModelSettings={() => {
|
||||
if (requestTabChange('model')) {
|
||||
setModelType('llm')
|
||||
const imageProfile = modelProfiles.find(
|
||||
(profile) => profile.protocol === 'openai-images-generations'
|
||||
)
|
||||
if (imageProfile) {
|
||||
setSelectedModelProfileId(imageProfile.id)
|
||||
}
|
||||
}
|
||||
}}
|
||||
magicNotesEnabled={magicNotesEnabled}
|
||||
onNotify={onNotify}
|
||||
/>
|
||||
|
||||
@@ -92,6 +92,18 @@ describe('WorkspacePrimitives', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('wraps long model names, prompts, and errors inside shared message status cards', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.message-retrieval-status\s*\{[^}]*overflow-wrap:\s*anywhere;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.message-retrieval-status > div\s*\{[^}]*min-width:\s*0;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.message-image-actions\s*\{[^}]*flex-wrap:\s*wrap;/u
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps shared switch rows aligned without standalone dividers', () => {
|
||||
const toggleRowStyles = stylesheet.match(
|
||||
/\.toggle-row\s*\{(?<rules>[^}]*)\}/u
|
||||
|
||||
@@ -267,6 +267,26 @@ export const app = {
|
||||
},
|
||||
exportSpeaker: '{{speaker}}:\n{{content}}',
|
||||
images: {
|
||||
operation: 'Image generation',
|
||||
edit: 'Continue editing',
|
||||
cancel: 'Cancel generation',
|
||||
regenerate: 'Generate again',
|
||||
regenerateNotice: 'Starts a new model request.',
|
||||
reselectModel: 'Choose another model',
|
||||
reselectSources: 'Choose source images',
|
||||
recoveryNotice: 'Generate again keeps the original model and sources. To change the model, name it explicitly in a new composer request. To change sources, upload attachments or choose Continue editing on a previous image.',
|
||||
recoveryPrompt: 'Use image model "{{model}}": {{prompt}}',
|
||||
noToolsNotice: 'This chat model cannot call image tools automatically. You can use the existing direct image workflow.',
|
||||
directWorkflowNotice: 'Choose an image model in the Runtime selector and send your request. If none is configured, add an image connection in model settings.',
|
||||
askNotice: 'Generating or editing images requires Execute mode. Change it using the work mode selector.',
|
||||
openModelSettings: 'Open image model settings',
|
||||
cancelNotice: 'Waiting was stopped. The provider may still generate and charge for the image; a late result will be saved here.',
|
||||
cancellingNotice: 'Cancellation was requested. The provider may still generate and charge for the image.',
|
||||
cancelTooLateNotice: 'Cancellation did not take effect. The generated image has been saved.',
|
||||
unconfirmedNotice: 'The result could not be confirmed. Check before starting another request.',
|
||||
details: 'Request details',
|
||||
sources: '{{count}} source images',
|
||||
states: { running: 'Generating image', saving: 'Saving image', completed: 'Image saved', cancelling: 'Cancelling generation', stopped: 'Stopped waiting', failed: 'Image generation failed', unconfirmed: 'Result unconfirmed' },
|
||||
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.'
|
||||
|
||||
@@ -2,6 +2,13 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { integrations as chineseIntegrations } from '../zh-CN/integrations'
|
||||
|
||||
export const integrations = {
|
||||
conversationImages: {
|
||||
title: 'Image generation and editing',
|
||||
managed: 'Builtin · Automatically managed',
|
||||
description: 'Automatically managed by the image models’ “Allow AI to invoke in conversations” setting.',
|
||||
runtimeScope: 'Assignments include applicable remote Runtimes through GoodBuddy Agent. Generation requires Execute and a chat model with tool calling; assignments do not indicate runtime readiness.',
|
||||
openSettings: 'Go to image model settings'
|
||||
},
|
||||
channels: {
|
||||
tabs: {
|
||||
weixin: 'WeChat ClawBot',
|
||||
|
||||
@@ -2,6 +2,14 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { settings as chineseSettings } from '../zh-CN/settings'
|
||||
|
||||
export const settings = {
|
||||
conversationImages: {
|
||||
allow: 'Allow AI to invoke in conversations',
|
||||
description: 'When enabled, assistants with tool calling can use this model to generate and edit images. Editing support depends on the image protocol. Results are saved in the originating conversation.',
|
||||
callable: 'Conversation enabled',
|
||||
defaultBadge: 'Default image model',
|
||||
setDefault: 'Set as default image model',
|
||||
unavailable: 'The default image model is unavailable. Enable it or explicitly choose another image model.'
|
||||
},
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
|
||||
@@ -252,6 +252,26 @@ export const app = {
|
||||
},
|
||||
exportSpeaker: '{{speaker}}:\n{{content}}',
|
||||
images: {
|
||||
operation: '图片生成',
|
||||
edit: '继续修改',
|
||||
cancel: '取消生成',
|
||||
regenerate: '重新生成',
|
||||
regenerateNotice: '将发起新的模型请求。',
|
||||
reselectModel: '重新选择模型',
|
||||
reselectSources: '重新选择素材',
|
||||
recoveryNotice: '重新生成仍使用原模型与素材。更换模型后,请在输入区明确指定模型并重新发送;更换素材可上传附件,或在历史图片上点击“继续修改”。',
|
||||
recoveryPrompt: '使用图片模型“{{model}}”:{{prompt}}',
|
||||
noToolsNotice: '当前聊天模型无法自动调用图片工具,可使用已有直连图片工作流。',
|
||||
directWorkflowNotice: '在 Runtime 选择器中选择图片模型后发送请求;尚未配置时,请前往模型设置添加图片连接。',
|
||||
askNotice: '生成或编辑图片需要 Execute 模式,请通过工作模式选择器切换。',
|
||||
openModelSettings: '前往图片模型设置',
|
||||
cancelNotice: '已停止等待。提供商可能继续生成并计费,晚到的结果会保存在这里。',
|
||||
cancellingNotice: '已请求取消。提供商可能继续生成并计费。',
|
||||
cancelTooLateNotice: '取消未生效,已生成并保存图片。',
|
||||
unconfirmedNotice: '暂时无法确认结果,请在重新发起请求前核对。',
|
||||
details: '请求详情',
|
||||
sources: '{{count}} 张来源图片',
|
||||
states: { running: '正在生成图片', saving: '正在保存图片', completed: '图片已保存', cancelling: '正在取消生成', stopped: '已停止等待', failed: '图片生成失败', unconfirmed: '结果未确认' },
|
||||
contextNotice: {
|
||||
'reference-unavailable': '部分历史图片无法读取,本次仅使用可用的图片与文字上下文。',
|
||||
'editing-unavailable': '上游不支持图片编辑,本次按文字要求生成,未使用参考图片。'
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export const integrations = {
|
||||
conversationImages: {
|
||||
title: '图片生成与编辑',
|
||||
managed: '内置 · 自动管理',
|
||||
description: '由图片模型的「允许 AI 在会话中调用」设置自动管理。',
|
||||
runtimeScope: '分配包含 GoodBuddy Agent 远程路径上的适用 Runtime。生成需要 Execute 模式及支持工具调用的聊天模型;勾选不代表 Runtime 已就绪。',
|
||||
openSettings: '前往图片模型设置'
|
||||
},
|
||||
channels: {
|
||||
tabs: {
|
||||
weixin: '微信 ClawBot',
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export const settings = {
|
||||
conversationImages: {
|
||||
allow: '允许 AI 在会话中调用',
|
||||
description: '开启后,支持工具调用的聊天助手可使用此模型生成和编辑图片,编辑能力取决于图片协议。结果保存在发起请求的会话中。',
|
||||
callable: '可在会话中调用',
|
||||
defaultBadge: '默认生图模型',
|
||||
setDefault: '设为默认生图模型',
|
||||
unavailable: '默认生图模型不可用,请开启该模型或明确选择其他图片模型。'
|
||||
},
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
|
||||
@@ -1020,6 +1020,7 @@
|
||||
|
||||
.message-retrieval-status {
|
||||
display: flex;
|
||||
overflow-wrap: anywhere;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
@@ -5749,6 +5750,7 @@ button > svg {
|
||||
|
||||
.message-image-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user