mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
实现 AI 图片理解能力测试
This commit is contained in:
@@ -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>支持 PNG、JPG、JPEG、WebP,最大 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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user