实现 AI 图片理解能力测试

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
parent 31df97237d
commit ce4b00bcd9
14 changed files with 696 additions and 17 deletions
+193
View File
@@ -3429,6 +3429,199 @@ body {
}
}
.ai-vision-test {
display: grid;
gap: 16px;
}
.ai-vision-test > header,
.ai-vision-test > footer,
.ai-vision-model {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.ai-vision-test h2,
.ai-vision-test h3,
.ai-vision-test p {
margin: 0;
}
.ai-vision-test h2 {
color: #202724;
font-size: 15px;
}
.ai-vision-test header h3 {
margin-top: 12px;
color: #35403b;
font-size: 13px;
}
.ai-vision-test header p,
.ai-vision-model,
.ai-vision-upload small {
margin-top: 4px;
color: #66706b;
font-size: 12px;
}
.ai-vision-capability {
border-radius: 999px;
padding: 5px 9px;
background: #f1f3f2;
color: #66706b;
white-space: nowrap;
font-size: 12px;
}
.ai-vision-capability.supported {
background: #eaf5f1;
color: #2e8b68;
}
.ai-vision-model {
justify-content: flex-start;
margin: 0;
}
.ai-vision-upload {
display: flex;
min-height: 112px;
align-items: center;
justify-content: center;
gap: 14px;
border: 1px dashed #bfc9c4;
border-radius: 10px;
padding: 14px;
background: #f7f9f8;
color: #35403b;
text-align: center;
cursor: pointer;
}
.ai-vision-upload:hover {
border-color: #247a63;
background: #f2f8f5;
}
.ai-vision-upload input {
display: none;
}
.ai-vision-upload.has-image {
justify-content: flex-start;
text-align: left;
}
.ai-vision-upload img {
width: 112px;
height: 82px;
flex: 0 0 auto;
border-radius: 8px;
object-fit: cover;
}
.ai-vision-upload strong,
.ai-vision-upload small {
display: block;
}
.ai-vision-prompt {
display: grid;
gap: 7px;
color: #46514c;
font-size: 12px;
font-weight: 600;
}
.ai-vision-prompt textarea {
width: 100%;
border: 1px solid #d5ddda;
border-radius: 8px;
outline: none;
padding: 10px;
resize: vertical;
background: #f4f7f5;
color: #202724;
font: 13px/1.6 var(--wxex-font);
}
.ai-vision-prompt textarea:focus {
border-color: #247a63;
box-shadow: 0 0 0 2px rgba(36, 122, 99, 0.08);
}
.ai-vision-privacy {
color: #66706b;
font-size: 12px;
line-height: 1.6;
}
.ai-vision-error {
border-left: 3px solid #c85a5a;
padding: 9px 11px;
background: #fff2f2;
color: #a84444;
font-size: 12px;
}
.ai-vision-result {
display: grid;
gap: 12px;
border: 1px solid #dce7e2;
border-radius: 9px;
padding: 14px;
background: #f7faf9;
}
.ai-vision-result dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 0;
}
.ai-vision-result dt {
color: #929a96;
font-size: 11px;
}
.ai-vision-result dd {
margin: 4px 0 0;
color: #35403b;
font-size: 12px;
font-weight: 600;
}
.ai-vision-result > p {
white-space: pre-wrap;
color: #3d4742;
font-size: 13px;
line-height: 1.7;
}
.ai-vision-test > footer {
justify-content: flex-end;
}
.ai-vision-test > footer button {
min-height: 34px;
border: 1px solid #d5ddda;
border-radius: 7px;
padding: 0 13px;
background: #fff;
color: #46514c;
cursor: pointer;
}
.ai-vision-test > footer button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.report-density-options button:disabled:hover {
border-color: var(--wxex-border);
background: var(--wxex-bg-elevated);
@@ -0,0 +1,121 @@
import type { AIProviderSummary, AIRuntimeModelConfig } from '../../../../../shared/ai-provider'
import type { AIVisionTestState } from './types'
export function AIImageUnderstandingTest({
runtime,
provider,
state,
onSelectImage,
onPromptChange,
onTest,
onClear
}: {
runtime: AIRuntimeModelConfig | null
provider?: AIProviderSummary
state: AIVisionTestState
onSelectImage: (file: File) => void
onPromptChange: (prompt: string) => void
onTest: () => void
onClear: () => void
}): React.ReactElement {
const model = provider?.models.find((item) => item.id === runtime?.model)
const testing = state.status === 'testing'
const result = state.result
return (
<section className="settings-card ai-vision-test">
<header>
<div>
<h2>AI </h2>
<h3></h3>
<p></p>
</div>
<span className={`ai-vision-capability ${model?.capabilities.vision ? 'supported' : ''}`}>
{model?.capabilities.vision ? '✓' : '待验证'}
</span>
</header>
<div className="ai-vision-model">
<span>{runtime?.providerName || '尚未配置'}</span>
<span>{runtime?.modelName || '尚未选择'}</span>
</div>
<label className={`ai-vision-upload ${state.image ? 'has-image' : ''}`}>
<input
type="file"
accept=".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp"
onChange={(event) => {
const file = event.currentTarget.files?.[0]
if (file) onSelectImage(file)
event.currentTarget.value = ''
}}
/>
{state.image ? (
<>
<img src={state.image.dataUrl} alt="图片理解测试预览" />
<div>
<strong>{state.image.fileName}</strong>
<small>{formatFileSize(state.image.size)} · </small>
</div>
</>
) : (
<div>
<strong>{state.status === 'reading' ? '正在读取图片…' : '选择本地图片'}</strong>
<small> PNGJPGJPEGWebP 10 MB</small>
</div>
)}
</label>
<label className="ai-vision-prompt">
<textarea
value={state.prompt}
onChange={(event) => onPromptChange(event.target.value)}
rows={3}
/>
</label>
<p className="ai-vision-privacy">
AI WechatExplorer
</p>
{state.error ? <p className="ai-vision-error">{state.error}</p> : null}
{result?.success ? (
<div className="ai-vision-result">
<h3></h3>
<dl>
<div>
<dt></dt>
<dd>{result.modelName || result.modelId}</dd>
</div>
<div>
<dt></dt>
<dd>{result.latencyMs ?? 0} ms</dd>
</div>
<div>
<dt>Token</dt>
<dd>{result.usage?.total ?? 'API 未返回'}</dd>
</div>
</dl>
<p>{result.answer}</p>
</div>
) : null}
<footer>
{state.image ? <button onClick={onClear}></button> : null}
<button
className="database-key-primary"
disabled={!runtime?.configured || !state.image || testing || !state.prompt.trim()}
onClick={onTest}
>
{testing ? '识别中…' : '开始识别'}
</button>
</footer>
</section>
)
}
function formatFileSize(bytes: number): string {
return bytes < 1024 * 1024
? `${Math.max(1, Math.round(bytes / 1024))} KB`
: `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
@@ -1,4 +1,5 @@
import type { AIModelSettingsAction, AIModelSettingsState } from './types'
import { DEFAULT_VISION_PROMPT } from './types'
export const initialAIModelSettingsState: AIModelSettingsState = {
loading: true,
@@ -6,7 +7,8 @@ export const initialAIModelSettingsState: AIModelSettingsState = {
providers: [],
runtime: null,
editor: null,
presetId: 'deepseek'
presetId: 'deepseek',
visionTest: { status: 'idle', prompt: DEFAULT_VISION_PROMPT }
}
export function aiModelSettingsReducer(
@@ -42,6 +44,49 @@ export function aiModelSettingsReducer(
return { ...state, saving: true, error: undefined }
case 'TEST_START':
return { ...state, testingId: action.providerId, error: undefined }
case 'VISION_READING':
return {
...state,
visionTest: { ...state.visionTest, status: 'reading', result: undefined, error: undefined }
}
case 'VISION_READY':
return {
...state,
visionTest: {
...state.visionTest,
status: 'ready',
image: action.image,
result: undefined,
error: undefined
}
}
case 'VISION_PROMPT':
return { ...state, visionTest: { ...state.visionTest, prompt: action.prompt } }
case 'VISION_TEST_START':
return {
...state,
visionTest: { ...state.visionTest, status: 'testing', result: undefined, error: undefined }
}
case 'VISION_RESULT':
return {
...state,
visionTest: {
...state.visionTest,
status: action.result.success ? 'success' : 'error',
result: action.result,
error: action.result.error
}
}
case 'VISION_ERROR':
return {
...state,
visionTest: { ...state.visionTest, status: 'error', result: undefined, error: action.error }
}
case 'VISION_CLEAR':
return {
...state,
visionTest: { status: 'idle', prompt: state.visionTest.prompt }
}
default:
return state
}
@@ -1,9 +1,26 @@
import type {
AIProviderConfig,
AIProviderSummary,
AIRuntimeModelConfig
AIRuntimeModelConfig,
AIVisionTestResult
} from '../../../../../shared/ai-provider'
export const DEFAULT_VISION_PROMPT =
'请描述这张图片中的主要内容,包括物体、场景、文字信息以及你能观察到的细节。'
export interface AIVisionTestState {
status: 'idle' | 'reading' | 'ready' | 'testing' | 'success' | 'error'
prompt: string
image?: {
dataUrl: string
fileName: string
mimeType: string
size: number
}
result?: AIVisionTestResult
error?: string
}
export interface AIModelSettingsState {
loading: boolean
saving: boolean
@@ -13,6 +30,7 @@ export interface AIModelSettingsState {
originalProviderId?: string
presetId: string
testingId?: string
visionTest: AIVisionTestState
error?: string
}
@@ -29,6 +47,16 @@ export type AIModelSettingsAction =
| { type: 'EDIT'; editor: AIProviderConfig }
| { type: 'SAVE_START' }
| { type: 'TEST_START'; providerId: string }
| { type: 'VISION_READING' }
| {
type: 'VISION_READY'
image: NonNullable<AIVisionTestState['image']>
}
| { type: 'VISION_PROMPT'; prompt: string }
| { type: 'VISION_TEST_START' }
| { type: 'VISION_RESULT'; result: AIVisionTestResult }
| { type: 'VISION_ERROR'; error: string }
| { type: 'VISION_CLEAR' }
export interface AIModelSettingsController {
state: AIModelSettingsState
@@ -41,4 +69,8 @@ export interface AIModelSettingsController {
remove: (providerId: string) => Promise<void>
setDefault: (providerId: string) => Promise<void>
test: (providerId: string) => Promise<void>
selectVisionImage: (file: File) => Promise<void>
setVisionPrompt: (prompt: string) => void
runVisionTest: () => Promise<void>
clearVisionImage: () => void
}
@@ -108,6 +108,71 @@ export function useAIModelSettingsController({
[onNotice, refresh]
)
const selectVisionImage = useCallback(async (file: File): Promise<void> => {
const extension = file.name.split('.').pop()?.toLowerCase()
const inferredType =
extension === 'png'
? 'image/png'
: extension === 'webp'
? 'image/webp'
: extension === 'jpg' || extension === 'jpeg'
? 'image/jpeg'
: ''
const mimeType = file.type === 'image/jpg' ? 'image/jpeg' : file.type || inferredType
const supportedTypes = new Set(['image/png', 'image/jpeg', 'image/webp'])
if (!supportedTypes.has(mimeType)) {
return dispatch({ type: 'VISION_ERROR', error: '请选择 PNG、JPG、JPEG 或 WebP 图片' })
}
if (!file.size || file.size > 10 * 1024 * 1024) {
return dispatch({ type: 'VISION_ERROR', error: '图片大小必须在 10 MB 以内' })
}
dispatch({ type: 'VISION_READING' })
try {
const rawDataUrl = await readFileAsDataUrl(file)
const dataUrl = rawDataUrl.replace(/^data:[^;]*;/, `data:${mimeType};`)
dispatch({
type: 'VISION_READY',
image: { dataUrl, fileName: file.name, mimeType, size: file.size }
})
} catch {
dispatch({ type: 'VISION_ERROR', error: '图片无法读取,请重新选择' })
}
}, [])
const setVisionPrompt = useCallback(
(prompt: string) => dispatch({ type: 'VISION_PROMPT', prompt }),
[]
)
const runVisionTest = useCallback(async (): Promise<void> => {
const { runtime, visionTest } = state
if (!runtime?.configured || !runtime.providerId || !runtime.model) {
return dispatch({ type: 'VISION_ERROR', error: '请先配置可用的默认 AI 模型' })
}
if (!visionTest.image) return dispatch({ type: 'VISION_ERROR', error: '请先选择测试图片' })
if (!visionTest.prompt.trim()) {
return dispatch({ type: 'VISION_ERROR', error: '请填写图片识别提示词' })
}
dispatch({ type: 'VISION_TEST_START' })
try {
const result = await window.api.testAIVision({
providerId: runtime.providerId,
modelId: runtime.model,
prompt: visionTest.prompt,
imageDataUrl: visionTest.image.dataUrl
})
dispatch({ type: 'VISION_RESULT', result })
if (result.success) {
await refresh()
onNotice('图片理解测试成功,已更新模型能力')
}
} catch {
dispatch({ type: 'VISION_ERROR', error: '图片理解测试调用失败,请稍后重试' })
}
}, [onNotice, refresh, state])
const clearVisionImage = useCallback(() => dispatch({ type: 'VISION_CLEAR' }), [])
return {
state,
openNew,
@@ -118,6 +183,23 @@ export function useAIModelSettingsController({
save,
remove,
setDefault,
test
test,
selectVisionImage,
setVisionPrompt,
runVisionTest,
clearVisionImage
}
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () =>
typeof reader.result === 'string'
? resolve(reader.result)
: reject(new Error('invalid image'))
)
reader.addEventListener('error', () => reject(reader.error || new Error('read failed')))
reader.readAsDataURL(file)
})
}
@@ -1,6 +1,7 @@
import type { AIRuntimeModelConfig } from '../../../../../shared/ai-provider'
import { AIProviderCard } from '../ai-model/AIProviderCard'
import { AIProviderEditor } from '../ai-model/AIProviderEditor'
import { AIImageUnderstandingTest } from '../ai-model/AIImageUnderstandingTest'
import { useAIModelSettingsController } from '../ai-model/useAIModelSettingsController'
export function AIModelPage({
@@ -12,6 +13,9 @@ export function AIModelPage({
}): React.ReactElement {
const controller = useAIModelSettingsController({ onRuntimeChange, onNotice })
const runtime = controller.state.runtime
const defaultProvider = controller.state.providers.find(
(provider) => provider.id === runtime?.providerId
)
return (
<div className="settings-page ai-model-page">
<header className="settings-page-header">
@@ -35,6 +39,15 @@ export function AIModelPage({
{runtime?.configured ? '可用' : '未配置'}
</span>
</section>
<AIImageUnderstandingTest
runtime={runtime}
provider={defaultProvider}
state={controller.state.visionTest}
onSelectImage={(file) => void controller.selectVisionImage(file)}
onPromptChange={controller.setVisionPrompt}
onTest={() => void controller.runVisionTest()}
onClear={controller.clearVisionImage}
/>
{controller.state.error ? (
<p className="ai-model-page-error">{controller.state.error}</p>
) : null}
+1 -1
View File
@@ -282,7 +282,7 @@ export const buildGroupReportInput = (
activeUsers: speakerCounts.size,
timeSpan,
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
recordNote: `基于当前已加载的 ${rows.length} 条记录`,
recordNote: `基于 WechatExplorer 加载的 ${rows.length} 条记录`,
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容仅按类型统计。',
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
avatars