feat: add guarded image generation controls

This commit is contained in:
john
2026-07-20 07:37:43 +08:00
parent 72ed4dde83
commit 105a72c1f1
19 changed files with 482 additions and 23 deletions
+31 -1
View File
@@ -1,10 +1,15 @@
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react';
import { BrainCircuit, Database } from 'lucide-react';
import { BrainCircuit, Database, Image, ImageOff, ImagePlus } from 'lucide-react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
import { getMessageSaveActions } from '../utils/messageSave';
import { getDisplayText } from '../utils/message';
import {
IMAGE_GENERATION_MODE_LABELS,
nextImageGenerationMode,
type ImageGenerationMode,
} from '../utils/imageGeneration';
import {
downloadChatMessageDocx,
} from '../api/client';
@@ -186,6 +191,7 @@ export function ChatPanel({
messageId?: string;
forceDeepReasoning?: boolean;
pgRequired?: boolean;
imageGenerationMode?: ImageGenerationMode;
selectedChatSkill?: string;
fileAttachments?: ChatFileAttachment[];
},
@@ -219,6 +225,7 @@ export function ChatPanel({
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const [forceDeepReasoning, setForceDeepReasoning] = useState(false);
const [pgRequired, setPgRequired] = useState(false);
const [imageGenerationMode, setImageGenerationMode] = useState<ImageGenerationMode>('auto');
const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null);
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
const [pendingFiles, setPendingFiles] = useState<PendingChatFile[]>([]);
@@ -745,6 +752,7 @@ export function ChatPanel({
messageId: outgoingMessageId,
forceDeepReasoning,
pgRequired,
imageGenerationMode,
selectedChatSkill,
fileAttachments: fileAttachmentsToSend,
});
@@ -774,6 +782,7 @@ export function ChatPanel({
}, [
forceDeepReasoning,
pgRequired,
imageGenerationMode,
input,
onSubmit,
onUploadFile,
@@ -1229,6 +1238,27 @@ export function ChatPanel({
<span className="chat-control-onboarding-tip" role="status">使</span>
)}
</label>
<button
type="button"
className={`chat-deep-reasoning-toggle chat-image-generation-toggle is-${imageGenerationMode}${imageGenerationMode === 'required' ? ' is-active' : ''}`}
disabled={voiceDisabled}
role="checkbox"
aria-checked={imageGenerationMode === 'auto' ? 'mixed' : imageGenerationMode === 'required'}
aria-label={`AI 配图:${IMAGE_GENERATION_MODE_LABELS[imageGenerationMode]}`}
title={`AI 配图:${IMAGE_GENERATION_MODE_LABELS[imageGenerationMode]}。点击切换为${IMAGE_GENERATION_MODE_LABELS[nextImageGenerationMode(imageGenerationMode)]}`}
onClick={() => setImageGenerationMode((current) => nextImageGenerationMode(current))}
>
{imageGenerationMode === 'required' ? (
<ImagePlus className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
) : imageGenerationMode === 'disabled' ? (
<ImageOff className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
) : (
<Image className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
)}
<span className="chat-image-generation-state" aria-hidden="true">
{imageGenerationMode === 'auto' ? 'A' : imageGenerationMode === 'required' ? '✓' : '×'}
</span>
</button>
<label
className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}${chatControlOnboardingStep === 2 ? ' chat-control-onboarding-active' : ''}`}
title="勾选后,本轮消息使用深度推理完成"
+1
View File
@@ -548,6 +548,7 @@ export function ChatView({
messageId: options?.messageId,
forceDeepReasoning: options?.forceDeepReasoning,
pgRequired: options?.pgRequired,
imageGenerationMode: options?.imageGenerationMode,
selectedChatSkill: options?.selectedChatSkill,
fileAttachments: options?.fileAttachments,
},
+2
View File
@@ -148,6 +148,7 @@ export function SpaceChatPanel({
messageId: options?.messageId,
forceDeepReasoning: options?.forceDeepReasoning,
pgRequired: options?.pgRequired,
imageGenerationMode: options?.imageGenerationMode,
fileAttachments: options?.fileAttachments,
},
imageUrls,
@@ -160,6 +161,7 @@ export function SpaceChatPanel({
messageId: options?.messageId,
forceDeepReasoning: options?.forceDeepReasoning,
pgRequired: options?.pgRequired,
imageGenerationMode: options?.imageGenerationMode,
fileAttachments: options?.fileAttachments,
},
imageUrls,
+11 -1
View File
@@ -15,6 +15,7 @@ import {
} from '../api/client';
import type { AgentRun } from '../api/client';
import type { ChatState, Message, MindSpaceChatContext, PortalUser, Session, SessionEvent } from '../types';
import type { ImageGenerationMode } from '../utils/imageGeneration';
import { buildContextPrefix } from '../utils/mindspaceChatContext';
import { buildUserAddressPrefix } from '../utils/userAddress';
import {
@@ -307,7 +308,7 @@ export function usePageEditSubChat({
const submit = useCallback(
async (
text: string,
context: MindSpaceChatContext & { messageId?: string },
context: MindSpaceChatContext & { messageId?: string; imageGenerationMode?: ImageGenerationMode },
imageUrls?: string[],
previewImageUrls?: string[],
) => {
@@ -335,6 +336,15 @@ export function usePageEditSubChat({
imageUrls: normalizedImageUrls,
previewImageUrls: normalizedPreviewImageUrls,
});
userMessage.metadata = {
...userMessage.metadata,
memindRun: {
...(userMessage.metadata?.memindRun && typeof userMessage.metadata.memindRun === 'object'
? userMessage.metadata.memindRun
: {}),
imageGenerationMode: context.imageGenerationMode ?? 'auto',
},
};
const requestId = crypto.randomUUID();
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
+3
View File
@@ -35,6 +35,7 @@ import {
readStoredSessionId,
writeStoredSessionId,
} from '../utils/sessionStorage';
import type { ImageGenerationMode } from '../utils/imageGeneration';
import type {
CapabilityMap,
ChatFileAttachment,
@@ -1396,6 +1397,7 @@ export function useTKMindChat(
messageId?: string;
forceDeepReasoning?: boolean;
pgRequired?: boolean;
imageGenerationMode?: ImageGenerationMode;
selectedChatSkill?: string;
fileAttachments?: ChatFileAttachment[];
},
@@ -1446,6 +1448,7 @@ export function useTKMindChat(
: {}),
sessionMessageCount: priorMessageCount,
...(options?.pgRequired ? { pgRequired: true } : {}),
imageGenerationMode: options?.imageGenerationMode ?? 'auto',
...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}),
},
};
+31
View File
@@ -3177,6 +3177,37 @@ body,
background: rgba(121, 183, 255, 0.14);
}
.chat-image-generation-toggle {
position: relative;
}
.chat-image-generation-toggle.is-disabled {
color: var(--color-text-tertiary);
border-color: color-mix(in srgb, var(--color-border-input) 72%, transparent);
background: color-mix(in srgb, var(--color-bg-elevated) 82%, transparent);
}
.chat-image-generation-toggle:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.chat-image-generation-state {
position: absolute;
right: 2px;
bottom: 1px;
min-width: 10px;
height: 10px;
padding: 0 1px;
border-radius: 5px;
color: currentColor;
background: var(--color-bg-elevated);
font-size: 8px;
font-weight: 800;
line-height: 10px;
text-align: center;
}
.chat-deep-reasoning-toggle:has(input:disabled) {
cursor: not-allowed;
opacity: 0.45;
+13
View File
@@ -0,0 +1,13 @@
export type ImageGenerationMode = 'auto' | 'required' | 'disabled';
export const IMAGE_GENERATION_MODE_LABELS: Record<ImageGenerationMode, string> = {
auto: '自动识别',
required: '强制开启',
disabled: '关闭',
};
export function nextImageGenerationMode(mode: ImageGenerationMode): ImageGenerationMode {
if (mode === 'auto') return 'required';
if (mode === 'required') return 'disabled';
return 'auto';
}