diff --git a/resources/mobile_daily_report.html b/resources/mobile_daily_report.html
index 2197bff..784ff69 100644
--- a/resources/mobile_daily_report.html
+++ b/resources/mobile_daily_report.html
@@ -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 @@
{{GROUP_NAME}}日报
-
{{DATE_RANGE}}
{{RECORD_NOTE}}
+
+
{{DATE_RANGE}}
+
{{RECORD_NOTE}}
+
{{OVERVIEW}}
+
{{HERO_AVATARS}}
@@ -479,7 +490,7 @@
diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts
index db52b4b..f431b9b 100644
--- a/src/main/group-report-service.ts
+++ b/src/main/group-report-service.ts
@@ -249,7 +249,8 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise {
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)
)
diff --git a/src/main/services/ai-provider-service.ts b/src/main/services/ai-provider-service.ts
index 70d86f3..5c93aaf 100644
--- a/src/main/services/ai-provider-service.ts
+++ b/src/main/services/ai-provider-service.ts
@@ -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>
}
-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 {
+ 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 {
+ 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 {
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 返回错误' }
+}
diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts
index 4614298..8301c11 100644
--- a/src/preload/index.d.ts
+++ b/src/preload/index.d.ts
@@ -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
setDefaultAIProvider: (providerId: string) => Promise
testAIProvider: (providerId: string) => Promise
+ testAIVision: (request: AIVisionTestRequest) => Promise
migrateLegacyAIConfig: (config: LegacyAIConfig) => Promise
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
getVoiceData: (
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 8aa1e8a..a4b3a07 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -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) =>
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 1fcc525..aa00fec 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -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);
diff --git a/src/renderer/src/features/settings/ai-model/AIImageUnderstandingTest.tsx b/src/renderer/src/features/settings/ai-model/AIImageUnderstandingTest.tsx
new file mode 100644
index 0000000..79c571d
--- /dev/null
+++ b/src/renderer/src/features/settings/ai-model/AIImageUnderstandingTest.tsx
@@ -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 (
+
+
+
+
AI 能力测试
+
图片理解测试
+
上传图片验证当前模型是否支持视觉理解。
+
+
+ 图片理解 {model?.capabilities.vision ? '✓' : '待验证'}
+
+
+
+
+ 当前供应商:{runtime?.providerName || '尚未配置'}
+ 当前模型:{runtime?.modelName || '尚未选择'}
+
+
+
+
+
+
+
+ 图片只会发送给你配置的 AI 服务,不会上传到 WechatExplorer 的其他服务器,也不会写入本地缓存。
+
+
+ {state.error ? {state.error}
: null}
+ {result?.success ? (
+
+
识别结果
+
+
+
- 模型
+ - {result.modelName || result.modelId}
+
+
+
- 耗时
+ - {result.latencyMs ?? 0} ms
+
+
+
- Token
+ - {result.usage?.total ?? 'API 未返回'}
+
+
+
{result.answer}
+
+ ) : null}
+
+
+
+ )
+}
+
+function formatFileSize(bytes: number): string {
+ return bytes < 1024 * 1024
+ ? `${Math.max(1, Math.round(bytes / 1024))} KB`
+ : `${(bytes / 1024 / 1024).toFixed(1)} MB`
+}
diff --git a/src/renderer/src/features/settings/ai-model/aiModelSettingsReducer.ts b/src/renderer/src/features/settings/ai-model/aiModelSettingsReducer.ts
index a997423..4f826d6 100644
--- a/src/renderer/src/features/settings/ai-model/aiModelSettingsReducer.ts
+++ b/src/renderer/src/features/settings/ai-model/aiModelSettingsReducer.ts
@@ -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
}
diff --git a/src/renderer/src/features/settings/ai-model/types.ts b/src/renderer/src/features/settings/ai-model/types.ts
index ba38517..cb502c2 100644
--- a/src/renderer/src/features/settings/ai-model/types.ts
+++ b/src/renderer/src/features/settings/ai-model/types.ts
@@ -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
+ }
+ | { 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
setDefault: (providerId: string) => Promise
test: (providerId: string) => Promise
+ selectVisionImage: (file: File) => Promise
+ setVisionPrompt: (prompt: string) => void
+ runVisionTest: () => Promise
+ clearVisionImage: () => void
}
diff --git a/src/renderer/src/features/settings/ai-model/useAIModelSettingsController.ts b/src/renderer/src/features/settings/ai-model/useAIModelSettingsController.ts
index 23404bb..2832eba 100644
--- a/src/renderer/src/features/settings/ai-model/useAIModelSettingsController.ts
+++ b/src/renderer/src/features/settings/ai-model/useAIModelSettingsController.ts
@@ -108,6 +108,71 @@ export function useAIModelSettingsController({
[onNotice, refresh]
)
+ const selectVisionImage = useCallback(async (file: File): Promise => {
+ 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 => {
+ 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 {
+ 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)
+ })
+}
diff --git a/src/renderer/src/features/settings/pages/AIModelPage.tsx b/src/renderer/src/features/settings/pages/AIModelPage.tsx
index 1f50c00..913c1d0 100644
--- a/src/renderer/src/features/settings/pages/AIModelPage.tsx
+++ b/src/renderer/src/features/settings/pages/AIModelPage.tsx
@@ -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 (
@@ -35,6 +39,15 @@ export function AIModelPage({
{runtime?.configured ? '可用' : '未配置'}
+ void controller.selectVisionImage(file)}
+ onPromptChange={controller.setVisionPrompt}
+ onTest={() => void controller.runVisionTest()}
+ onClear={controller.clearVisionImage}
+ />
{controller.state.error ? (
{controller.state.error}
) : null}
diff --git a/src/renderer/src/utils/group-report.ts b/src/renderer/src/utils/group-report.ts
index ed3f251..5a28a29 100644
--- a/src/renderer/src/utils/group-report.ts
+++ b/src/renderer/src/utils/group-report.ts
@@ -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
diff --git a/src/shared/ai-provider.ts b/src/shared/ai-provider.ts
index abbdd40..15c18a8 100644
--- a/src/shared/ai-provider.ts
+++ b/src/shared/ai-provider.ts
@@ -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
+}