实现 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
+13 -2
View File
@@ -61,6 +61,13 @@
font-size: 13px;
line-height: 1.5;
}
.record-note {
color: #485465;
font-weight: 650;
}
.overview {
margin-top: 2px;
}
.avatar-grid {
width: 58px;
height: 58px;
@@ -422,7 +429,11 @@
<div class="hero-top">
<div>
<h1>{{GROUP_NAME}}日报</h1>
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
<div class="sub">
<div>{{DATE_RANGE}}</div>
<div class="record-note">{{RECORD_NOTE}}</div>
<div class="overview">{{OVERVIEW}}</div>
</div>
</div>
<div class="avatar-grid">{{HERO_AVATARS}}</div>
</div>
@@ -479,7 +490,7 @@
</section>
<footer class="footer">
数据来源:微信群聊记录<br />
数据来源:WechatExplorer · 微信群聊记录<br />
生成时间:{{GENERATED_AT}}<br />
{{FOOTER_NOTE}}
</footer>
+2 -1
View File
@@ -249,7 +249,8 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
GROUP_NAME: escapeHtml(metadata.groupName),
DATE_RANGE: escapeHtml(metadata.dateRange),
RECORD_NOTE: escapeHtml(`${metadata.recordNote} ${report.overview}`.trim()),
RECORD_NOTE: escapeHtml(`基于 WechatExplorer 加载的 ${metadata.messageCount} 条记录`),
OVERVIEW: escapeHtml(report.overview),
HERO_AVATARS: heroAvatars,
MESSAGE_COUNT: String(metadata.messageCount),
ACTIVE_USERS: String(metadata.activeUsers),
+9 -1
View File
@@ -27,7 +27,12 @@ import {
} from './report-history-service'
import type { GroupReportExportRequest } from '../shared/group-report'
import type { SaveGeneratedReportRequest } from '../shared/report-history'
import type { AIChatRequestOptions, AIProviderConfig, LegacyAIConfig } from '../shared/ai-provider'
import type {
AIChatRequestOptions,
AIProviderConfig,
AIVisionTestRequest,
LegacyAIConfig
} from '../shared/ai-provider'
import { DatabaseKeyStore } from './database-key-store'
import { ImageKeyConfigService } from './services/image-key-config-service'
import { AIProviderService } from './services/ai-provider-service'
@@ -372,6 +377,9 @@ app.whenReady().then(async () => {
aiProviderService.setDefault(providerId)
)
ipcMain.handle('ai:testProvider', (_, providerId: string) => aiProviderService.test(providerId))
ipcMain.handle('ai:testVision', (_, request: AIVisionTestRequest) =>
aiProviderService.testVision(request)
)
ipcMain.handle('ai:migrateLegacy', (_, config: LegacyAIConfig) =>
aiProviderService.migrateLegacy(config)
)
+148 -8
View File
@@ -8,6 +8,8 @@ import type {
AIProviderListResult,
AIProviderSummary,
AIRuntimeModelConfig,
AIVisionTestRequest,
AIVisionTestResult,
LegacyAIConfig
} from '../../shared/ai-provider'
import { AIProviderKeyStore } from '../ai-provider-key-store'
@@ -18,7 +20,8 @@ interface AIProviderMetadataFile {
providers: Array<Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'>>
}
type AIMessage = { role: string; content: string }
type AIMessagePart = { type: 'text'; text: string } | { type: 'image'; dataUrl: string }
type AIMessage = { role: string; content: string | AIMessagePart[] }
type AIRequestResult = {
data: string
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
@@ -151,7 +154,7 @@ export class AIProviderService {
}
async chat(
messages: AIMessage[],
messages: Array<{ role: string; content: string }>,
options?: AIChatRequestOptions
): Promise<{
success: boolean
@@ -166,6 +169,42 @@ export class AIProviderService {
}
}
async testVision(request: AIVisionTestRequest): Promise<AIVisionTestResult> {
const startedAt = Date.now()
const imageError = validateVisionImage(request.imageDataUrl)
if (imageError) return { success: false, code: 'INVALID_IMAGE', error: imageError }
if (!request.prompt.trim()) {
return { success: false, code: 'INVALID_IMAGE', error: '请填写图片识别提示词' }
}
try {
const resolved = this.resolveProvider(request)
const result = await requestProvider(resolved.provider, resolved.key, resolved.model, [
{
role: 'user',
content: [
{ type: 'text', text: request.prompt.trim() },
{ type: 'image', dataUrl: request.imageDataUrl }
]
}
])
if (!result.data.trim()) throw new Error('API 未返回识别内容')
this.markVisionCapability(resolved.provider.id, resolved.model)
const model = resolved.provider.models.find((item) => item.id === resolved.model)
return {
success: true,
providerName: resolved.provider.name,
modelId: resolved.model,
modelName: model?.name || resolved.model,
latencyMs: Date.now() - startedAt,
usage: result.usage,
answer: result.data
}
} catch (error) {
const failure = visionFailure(error)
return { success: false, ...failure, latencyMs: Date.now() - startedAt }
}
}
private async request(
messages: AIMessage[],
options?: AIChatRequestOptions,
@@ -175,17 +214,25 @@ export class AIProviderService {
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
}> {
if (options?.apiKey) return this.requestLegacy(messages, options)
const resolved = this.resolveProvider(options)
return requestProvider(resolved.provider, resolved.key, resolved.model, messages, testing)
}
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
provider: AIProviderSummary
model: string
key: string
} {
const list = this.list()
const provider =
list.providers.find((item) => item.id === options?.providerId) ||
list.providers.find((item) => item.id === list.defaultProviderId)
if (!provider) throw new Error('尚未配置 AI Provider')
const model = options?.modelId || provider.defaultModel
if (!provider.models.some((item) => item.id === model)) throw new Error('当前模型不存在')
const key = this.keyStore.get(provider.id).key || ''
if (needsApiKey(provider) && !key) throw new Error('当前供应商尚未配置 API Key')
return provider.type === 'anthropic-messages'
? requestAnthropic(provider, key, model, messages, testing)
: requestOpenAICompatible(provider, key, model, messages, testing)
return { provider, model, key }
}
private async requestLegacy(
@@ -215,6 +262,15 @@ export class AIProviderService {
this.writeMetadata(data)
}
private markVisionCapability(providerId: string, modelId: string): void {
const data = this.readMetadata()
const provider = data.providers.find((item) => item.id === providerId)
const model = provider?.models.find((item) => item.id === modelId)
if (!provider || !model || model.capabilities.vision) return
model.capabilities.vision = true
this.writeMetadata(data)
}
private ensureEnvironmentMigration(): void {
const data = this.readMetadata()
if (data.providers.length) return
@@ -329,6 +385,51 @@ function buildHeaders(provider: AIProviderSummary, apiKey: string): Record<strin
return headers
}
function requestProvider(
provider: AIProviderSummary,
apiKey: string,
model: string,
messages: AIMessage[],
testing = false
): Promise<AIRequestResult> {
return provider.type === 'anthropic-messages'
? requestAnthropic(provider, apiKey, model, messages, testing)
: requestOpenAICompatible(provider, apiKey, model, messages, testing)
}
function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
return messages.map((message) => ({
role: message.role,
content:
typeof message.content === 'string'
? message.content
: message.content.map((part) =>
part.type === 'text'
? { type: 'text', text: part.text }
: { type: 'image_url', image_url: { url: part.dataUrl } }
)
}))
}
function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
return messages
.filter((message) => message.role !== 'system')
.map((message) => ({
role: message.role,
content:
typeof message.content === 'string'
? message.content
: message.content.map((part) => {
if (part.type === 'text') return { type: 'text', text: part.text }
const image = parseVisionImage(part.dataUrl)
return {
type: 'image',
source: { type: 'base64', media_type: image.mimeType, data: image.base64 }
}
})
}))
}
async function requestOpenAICompatible(
provider: AIProviderSummary,
apiKey: string,
@@ -346,7 +447,7 @@ async function requestOpenAICompatible(
headers: buildHeaders(provider, apiKey),
body: JSON.stringify({
model,
messages,
messages: toOpenAIMessages(messages),
temperature: provider.advanced.temperature,
max_tokens: testing ? 8 : provider.advanced.maxTokens
})
@@ -377,9 +478,16 @@ async function requestAnthropic(
): Promise<AIRequestResult> {
const system = messages
.filter((message) => message.role === 'system')
.map((message) => message.content)
.map((message) =>
typeof message.content === 'string'
? message.content
: message.content
.filter((part) => part.type === 'text')
.map((part) => (part.type === 'text' ? part.text : ''))
.join('\n')
)
.join('\n\n')
const anthropicMessages = messages.filter((message) => message.role !== 'system')
const anthropicMessages = toAnthropicMessages(messages)
const headers = buildHeaders(provider, apiKey)
if (!headers['anthropic-version']) headers['anthropic-version'] = '2023-06-01'
const endpoint = provider.baseUrl.endsWith('/messages')
@@ -440,3 +548,35 @@ function safeAIError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
return message.replace(/sk-[a-z0-9_-]+/gi, '***').slice(0, 300)
}
function parseVisionImage(dataUrl: string): { mimeType: string; base64: string; bytes: number } {
const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/=]+)$/i.exec(dataUrl)
if (!match) throw new Error('图片格式不受支持,请选择 PNG、JPG、JPEG 或 WebP')
const bytes = Buffer.byteLength(match[2], 'base64')
return { mimeType: match[1].toLowerCase(), base64: match[2], bytes }
}
function validateVisionImage(dataUrl: string): string | undefined {
try {
const image = parseVisionImage(dataUrl)
if (!image.bytes) return '图片内容为空'
if (image.bytes > 10 * 1024 * 1024) return '图片不能超过 10 MB'
return undefined
} catch (error) {
return error instanceof Error ? error.message : '图片无法读取'
}
}
function visionFailure(error: unknown): {
code: 'VISION_UNSUPPORTED' | 'API_ERROR'
error: string
} {
const message = safeAIError(error)
const unsupported =
/vision|multimodal|image[_ ]url|image input|image.*support|support.*image|图片.*不支持|不支持.*图片/i.test(
message
)
return unsupported
? { code: 'VISION_UNSUPPORTED', error: '当前模型不支持图片理解' }
: { code: 'API_ERROR', error: message || 'API 返回错误' }
}
+3
View File
@@ -26,6 +26,8 @@ import type {
AIProviderConfig,
AIProviderListResult,
AIRuntimeModelConfig,
AIVisionTestRequest,
AIVisionTestResult,
LegacyAIConfig
} from '../shared/ai-provider'
@@ -108,6 +110,7 @@ declare global {
deleteAIProvider: (providerId: string) => Promise<AIProviderListResult>
setDefaultAIProvider: (providerId: string) => Promise<AIProviderListResult>
testAIProvider: (providerId: string) => Promise<AIConnectionTestResult>
testAIVision: (request: AIVisionTestRequest) => Promise<AIVisionTestResult>
migrateLegacyAIConfig: (config: LegacyAIConfig) => Promise<AIProviderListResult>
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
getVoiceData: (
+7 -1
View File
@@ -2,7 +2,12 @@ import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import type { GroupReportExportRequest } from '../shared/group-report'
import type { SaveGeneratedReportRequest } from '../shared/report-history'
import type { AIChatRequestOptions, AIProviderConfig, LegacyAIConfig } from '../shared/ai-provider'
import type {
AIChatRequestOptions,
AIProviderConfig,
AIVisionTestRequest,
LegacyAIConfig
} from '../shared/ai-provider'
// 渲染器的自定义 API
const api = {
@@ -29,6 +34,7 @@ const api = {
setDefaultAIProvider: (providerId: string) =>
ipcRenderer.invoke('ai:setDefaultProvider', providerId),
testAIProvider: (providerId: string) => ipcRenderer.invoke('ai:testProvider', providerId),
testAIVision: (request: AIVisionTestRequest) => ipcRenderer.invoke('ai:testVision', request),
migrateLegacyAIConfig: (config: LegacyAIConfig) => ipcRenderer.invoke('ai:migrateLegacy', config),
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String),
getVoiceData: (sessionId: string, localId: number, createTime: number, svrId?: string | number) =>
+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
+24
View File
@@ -88,3 +88,27 @@ export interface AIConnectionTestResult {
error?: string
latencyMs?: number
}
export interface AIVisionTestRequest {
providerId?: string
modelId?: string
prompt: string
imageDataUrl: string
}
export interface AIVisionTestResult {
success: boolean
providerName?: string
modelId?: string
modelName?: string
latencyMs?: number
usage?: {
input?: number
output?: number
total?: number
estimated?: boolean
}
answer?: string
code?: 'INVALID_IMAGE' | 'VISION_UNSUPPORTED' | 'API_ERROR'
error?: string
}