mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
实现 AI 图片理解能力测试
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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 返回错误' }
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -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: (
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user