mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-21 21:47:00 +08:00
feat: 新增实验性微信卡片分享与自动部署能力
补充 Cloudflare Worker、R2 存储、微信 JS-SDK 签名与上传鉴权 增加自动部署 Skill 和配置引导文档 优化报告工具栏、微信卡片弹窗及窄屏响应式布局 补充 Worker 鉴权、卡片生成、过期清理与安全转义测试
This commit is contained in:
@@ -135,6 +135,12 @@ import { KnowledgeSearchService } from './knowledge/knowledge-search-service'
|
||||
import { AiSearchPipelineService } from './services/ai-search-pipeline-service'
|
||||
import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
|
||||
import { runFirstLaunchMigration } from './app-data-migration'
|
||||
import { WechatShareConfigStore } from './wechat-share-config-store'
|
||||
import { WechatShareCardService } from './wechat-share-card-service'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
WechatShareServiceConfig
|
||||
} from '../shared/wechat-share-card'
|
||||
|
||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||
@@ -154,6 +160,8 @@ const imageKeyConfigService = new ImageKeyConfigService()
|
||||
const aiProviderService = new AIProviderService()
|
||||
const keyServiceMac = new KeyServiceMac()
|
||||
const keyServiceWin = new KeyServiceWin()
|
||||
const wechatShareConfigStore = new WechatShareConfigStore()
|
||||
const wechatShareCardService = new WechatShareCardService(wechatShareConfigStore)
|
||||
let tray: Tray | null = null
|
||||
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||
let recallProtectionGeneration = 0
|
||||
@@ -1201,6 +1209,14 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('wechat-share:getConfig', async () => wechatShareConfigStore.status())
|
||||
ipcMain.handle('wechat-share:saveConfig', async (_, config: WechatShareServiceConfig) =>
|
||||
wechatShareConfigStore.save(config)
|
||||
)
|
||||
ipcMain.handle('wechat-share:publish', async (_, request: PublishWechatShareCardRequest) =>
|
||||
wechatShareCardService.publish(request)
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getVoiceData',
|
||||
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
|
||||
|
||||
@@ -81,6 +81,7 @@ const normalizeRecord = async (
|
||||
const pngStatus = await fileStatus(record.pngPath)
|
||||
return {
|
||||
...record,
|
||||
textModelName: record.textModelName || record.modelName,
|
||||
jsonPath,
|
||||
htmlStatus,
|
||||
pngStatus,
|
||||
@@ -181,6 +182,8 @@ export async function saveGeneratedReport(
|
||||
pngStatus: savedPngPath ? 'ready' : 'missing',
|
||||
imageSize: await readPngSize(savedPngPath),
|
||||
duration: request.duration,
|
||||
textModelName: request.textModelName || request.modelName,
|
||||
imageModelName: request.imageModelName,
|
||||
modelName: request.modelName,
|
||||
tokenUsage: request.tokenUsage,
|
||||
fileSize: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AIProviderSummary,
|
||||
AiSearchProviderStatus,
|
||||
AIRuntimeModelConfig,
|
||||
AIVisionRuntimeConfig,
|
||||
AIVisionTestRequest,
|
||||
AIVisionTestResult,
|
||||
LegacyAIConfig
|
||||
@@ -74,6 +75,60 @@ export class AIProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
getVisionRuntimeConfig(): AIVisionRuntimeConfig {
|
||||
const result = this.list()
|
||||
const defaultProvider = result.providers.find((item) => item.id === result.defaultProviderId)
|
||||
const defaultModel = defaultProvider?.models.find(
|
||||
(item) => item.id === defaultProvider.defaultModel
|
||||
)
|
||||
if (
|
||||
defaultProvider &&
|
||||
defaultModel &&
|
||||
(defaultProvider.hasApiKey || !needsApiKey(defaultProvider)) &&
|
||||
(defaultModel.capabilities.vision || defaultModel.capabilities.ocr)
|
||||
) {
|
||||
return {
|
||||
providerId: defaultProvider.id,
|
||||
providerName: defaultProvider.name,
|
||||
model: defaultModel.id,
|
||||
modelName: defaultModel.name || defaultModel.id,
|
||||
configured: true,
|
||||
status: defaultProvider.status,
|
||||
timeoutMs: defaultProvider.advanced.timeoutMs,
|
||||
source: 'default-model'
|
||||
}
|
||||
}
|
||||
|
||||
for (const provider of result.providers) {
|
||||
if (!provider.hasApiKey && needsApiKey(provider)) continue
|
||||
const model =
|
||||
provider.models.find(
|
||||
(item) =>
|
||||
item.id === provider.defaultModel && (item.capabilities.vision || item.capabilities.ocr)
|
||||
) || provider.models.find((item) => item.capabilities.vision || item.capabilities.ocr)
|
||||
if (!model) continue
|
||||
return {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
model: model.id,
|
||||
modelName: model.name || model.id,
|
||||
configured: true,
|
||||
status: provider.status,
|
||||
timeoutMs: provider.advanced.timeoutMs,
|
||||
source: 'vision-capability'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
providerName: '尚未配置',
|
||||
model: '',
|
||||
modelName: '尚未验证图片理解模型',
|
||||
configured: false,
|
||||
status: 'untested',
|
||||
source: 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
getAiSearchProviderStatus(providerId?: string): AiSearchProviderStatus {
|
||||
const result = this.list()
|
||||
const provider =
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
|
||||
// 2. 同图(imageHash)走缓存,绝不重复调 AI
|
||||
// 2. 同图(imageHash)在 10 分钟内走缓存,过期后重新调 AI
|
||||
// 3. 失败不抛,日志记录 + 返回原状(不阻塞日报)
|
||||
// 4. 日报最多识别 3 张达到热点门槛的图片;缓存命中即返回,未命中并发调 AI
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
} from '../../shared/image-insight'
|
||||
import {
|
||||
calculateImageHeatScore,
|
||||
isFreshImageInsight,
|
||||
isHotImageCandidate
|
||||
} from '../../shared/image-insight'
|
||||
|
||||
@@ -46,6 +47,13 @@ export interface ImageCandidateInput {
|
||||
|
||||
interface ProviderServiceLike {
|
||||
list(): ProviderSummaryLike
|
||||
getVisionRuntimeConfig(): {
|
||||
providerId?: string
|
||||
providerName: string
|
||||
model: string
|
||||
modelName: string
|
||||
configured: boolean
|
||||
}
|
||||
analyzeImage(
|
||||
messages: Array<{
|
||||
role: string
|
||||
@@ -81,7 +89,7 @@ interface ProviderSummaryLike {
|
||||
class ImageInsightService {
|
||||
private providerService: ProviderServiceLike | null = null
|
||||
private decryptService: DecryptServiceLike | null = null
|
||||
/** 最近一次实际使用的默认 AI provider/model,仅用于写入分析元数据 */
|
||||
/** 最近一次实际使用的视觉 provider/model,仅用于写入分析元数据 */
|
||||
private runtimeProviderId: string | undefined = undefined
|
||||
private runtimeModelId: string | undefined = undefined
|
||||
|
||||
@@ -97,13 +105,11 @@ class ImageInsightService {
|
||||
this.runtimeProviderId,
|
||||
this.runtimeModelId
|
||||
)
|
||||
// 读取默认 provider/model(后续 analyze 时使用)
|
||||
// 读取当前视觉 provider/model(后续 analyze 时仍会刷新,避免配置变化后继续用旧模型)
|
||||
try {
|
||||
const list = deps.providerService.list()
|
||||
const provider =
|
||||
list.providers.find((p) => p.id === list.defaultProviderId) || list.providers[0]
|
||||
this.runtimeProviderId = provider?.id
|
||||
this.runtimeModelId = provider?.defaultModel
|
||||
const runtime = deps.providerService.getVisionRuntimeConfig()
|
||||
this.runtimeProviderId = runtime.providerId
|
||||
this.runtimeModelId = runtime.model || undefined
|
||||
console.log(
|
||||
'[ImageInsightService] bind loaded default provider=%s model=%s',
|
||||
this.runtimeProviderId,
|
||||
@@ -145,7 +151,7 @@ class ImageInsightService {
|
||||
|
||||
/**
|
||||
* 主入口:分析一张图片。
|
||||
* 1. 通过 imageHash 查缓存,命中即返回
|
||||
* 1. 通过 imageHash 查 10 分钟缓存,新鲜则返回
|
||||
* 2. 未命中:解密图片 → 调 AI → 解析响应 → 落库 → 返回
|
||||
* 3. 任意步骤失败:记录日志,返回 success=false,**不抛**
|
||||
*/
|
||||
@@ -153,8 +159,15 @@ class ImageInsightService {
|
||||
try {
|
||||
if (!request.force) {
|
||||
const cached = imageInsightsStore.getByHash(request.imageHash)
|
||||
if (isFreshImageInsight(cached)) {
|
||||
return { success: true, insight: cached || undefined, fromCache: true }
|
||||
}
|
||||
if (cached) {
|
||||
return { success: true, insight: cached, fromCache: true }
|
||||
console.log(
|
||||
'[ImageInsightService] cache expired hash=%s ageMs=%d',
|
||||
request.imageHash,
|
||||
Date.now() - Number(cached.updatedAt || 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,11 +197,24 @@ class ImageInsightService {
|
||||
}
|
||||
]
|
||||
|
||||
const list = this.providerService.list()
|
||||
const provider =
|
||||
list.providers.find((item) => item.id === list.defaultProviderId) || list.providers[0]
|
||||
this.runtimeProviderId = provider?.id
|
||||
this.runtimeModelId = provider?.defaultModel
|
||||
const runtime =
|
||||
request.providerId && request.modelId
|
||||
? {
|
||||
providerId: request.providerId,
|
||||
model: request.modelId,
|
||||
configured: true
|
||||
}
|
||||
: this.providerService.getVisionRuntimeConfig()
|
||||
if (!runtime.configured || !runtime.providerId || !runtime.model) {
|
||||
return { success: false, error: '尚未配置或验证支持图片理解的 AI 模型' }
|
||||
}
|
||||
this.runtimeProviderId = runtime.providerId
|
||||
this.runtimeModelId = runtime.model
|
||||
console.log(
|
||||
'[ImageInsightService] analyze using vision provider=%s model=%s',
|
||||
this.runtimeProviderId,
|
||||
this.runtimeModelId
|
||||
)
|
||||
const result = await this.providerService.analyzeImage(messages, {
|
||||
providerId: this.runtimeProviderId,
|
||||
modelId: this.runtimeModelId
|
||||
@@ -273,7 +299,7 @@ class ImageInsightService {
|
||||
heatScore
|
||||
}
|
||||
const cached = imageInsightsStore.getByHash(hash)
|
||||
if (cached) candidate.insight = cached
|
||||
if (isFreshImageInsight(cached) && cached) candidate.insight = cached
|
||||
candidates.push(candidate)
|
||||
}
|
||||
candidates.sort((a, b) => b.heatScore - a.heatScore)
|
||||
@@ -286,7 +312,6 @@ class ImageInsightService {
|
||||
listBySession(sessionId: string, limit?: number): ImageInsight[] {
|
||||
return imageInsightsStore.listBySession(sessionId, limit)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const imageInsightService = new ImageInsightService()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { nativeImage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import QRCode from 'qrcode'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
PublishWechatShareCardResult
|
||||
} from '../shared/wechat-share-card'
|
||||
import { WechatShareConfigStore } from './wechat-share-config-store'
|
||||
|
||||
const MAX_REPORT_BYTES = 25 * 1024 * 1024
|
||||
|
||||
export class WechatShareCardService {
|
||||
constructor(private readonly configStore: WechatShareConfigStore) {}
|
||||
|
||||
async publish(request: PublishWechatShareCardRequest): Promise<PublishWechatShareCardResult> {
|
||||
try {
|
||||
const config = await this.configStore.loadRaw()
|
||||
if (!config) return { success: false, error: '请先配置微信卡片服务' }
|
||||
const png = await fs.readFile(request.pngPath)
|
||||
if (!png.length) return { success: false, error: '日报图片为空' }
|
||||
if (png.length > MAX_REPORT_BYTES) return { success: false, error: '日报图片不能超过 25 MB' }
|
||||
|
||||
const source = nativeImage.createFromBuffer(png)
|
||||
if (source.isEmpty()) return { success: false, error: '无法读取日报图片' }
|
||||
const size = source.getSize()
|
||||
const squareSize = Math.min(size.width, size.height)
|
||||
const thumbnail = source
|
||||
.crop({ x: 0, y: 0, width: squareSize, height: squareSize })
|
||||
.resize({ width: 360, height: 360, quality: 'best' })
|
||||
.toJPEG(84)
|
||||
|
||||
const response = await fetch(`${config.serviceUrl}/api/cards`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.uploadToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: request.title.trim().slice(0, 64),
|
||||
description: request.description.trim().slice(0, 120),
|
||||
expiresInDays: Math.max(1, Math.min(30, request.expiresInDays || 7)),
|
||||
imageBase64: png.toString('base64'),
|
||||
thumbnailBase64: thumbnail.toString('base64')
|
||||
}),
|
||||
signal: AbortSignal.timeout(90_000)
|
||||
})
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
cardId?: string
|
||||
shareUrl?: string
|
||||
viewUrl?: string
|
||||
expiresAt?: string
|
||||
error?: string
|
||||
}
|
||||
if (!response.ok || !payload.shareUrl) {
|
||||
return { success: false, error: payload.error || `卡片服务返回 HTTP ${response.status}` }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
cardId: payload.cardId,
|
||||
shareUrl: payload.shareUrl,
|
||||
viewUrl: payload.viewUrl,
|
||||
expiresAt: payload.expiresAt,
|
||||
qrCodeDataUrl: await QRCode.toDataURL(payload.shareUrl, {
|
||||
width: 360,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type {
|
||||
WechatShareServiceConfig,
|
||||
WechatShareServiceConfigResult
|
||||
} from '../shared/wechat-share-card'
|
||||
|
||||
const normalizeUrl = (value: string): string => value.trim().replace(/\/+$/, '')
|
||||
|
||||
const validate = (config: WechatShareServiceConfig): string | null => {
|
||||
try {
|
||||
const url = new URL(normalizeUrl(config.serviceUrl))
|
||||
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) {
|
||||
return '卡片服务必须使用 HTTPS'
|
||||
}
|
||||
} catch {
|
||||
return '卡片服务地址无效'
|
||||
}
|
||||
if (config.uploadToken.trim().length < 24) return '上传密钥至少需要 24 个字符'
|
||||
return null
|
||||
}
|
||||
|
||||
export class WechatShareConfigStore {
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'wechat-share-service.bin')
|
||||
}
|
||||
|
||||
async loadRaw(): Promise<WechatShareServiceConfig | null> {
|
||||
if (!(await fs.pathExists(this.filePath))) return null
|
||||
if (!safeStorage.isEncryptionAvailable()) throw new Error('系统安全存储不可用')
|
||||
const encrypted = await fs.readFile(this.filePath)
|
||||
const parsed = JSON.parse(safeStorage.decryptString(encrypted)) as WechatShareServiceConfig
|
||||
const error = validate(parsed)
|
||||
if (error) throw new Error(error)
|
||||
return { serviceUrl: normalizeUrl(parsed.serviceUrl), uploadToken: parsed.uploadToken.trim() }
|
||||
}
|
||||
|
||||
async status(): Promise<WechatShareServiceConfigResult> {
|
||||
try {
|
||||
const config = await this.loadRaw()
|
||||
return {
|
||||
success: true,
|
||||
configured: Boolean(config),
|
||||
serviceUrl: config?.serviceUrl
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async save(config: WechatShareServiceConfig): Promise<WechatShareServiceConfigResult> {
|
||||
const normalized = {
|
||||
serviceUrl: normalizeUrl(config.serviceUrl),
|
||||
uploadToken: config.uploadToken.trim()
|
||||
}
|
||||
const error = validate(normalized)
|
||||
if (error) return { success: false, error }
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
await fs.ensureDir(path.dirname(this.filePath))
|
||||
await fs.writeFile(this.filePath, safeStorage.encryptString(JSON.stringify(normalized)), {
|
||||
mode: 0o600
|
||||
})
|
||||
await fs.chmod(this.filePath, 0o600)
|
||||
return { success: true, configured: true, serviceUrl: normalized.serviceUrl }
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -79,6 +79,12 @@ import type {
|
||||
KnowledgeSearchIpcRequest,
|
||||
KnowledgeSearchIpcResult
|
||||
} from '../shared/knowledge'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
PublishWechatShareCardResult,
|
||||
WechatShareServiceConfig,
|
||||
WechatShareServiceConfigResult
|
||||
} from '../shared/wechat-share-card'
|
||||
|
||||
export type ParsedContent =
|
||||
| { type: 'text'; content: string }
|
||||
@@ -327,6 +333,13 @@ declare global {
|
||||
) => Promise<UpdateGeneratedReportTemplateResult>
|
||||
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||
getWechatShareConfig: () => Promise<WechatShareServiceConfigResult>
|
||||
saveWechatShareConfig: (
|
||||
config: WechatShareServiceConfig
|
||||
) => Promise<WechatShareServiceConfigResult>
|
||||
publishWechatShareCard: (
|
||||
request: PublishWechatShareCardRequest
|
||||
) => Promise<PublishWechatShareCardResult>
|
||||
getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
|
||||
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
|
||||
readDatabaseKeyClipboard: () => Promise<{
|
||||
|
||||
@@ -54,6 +54,10 @@ import type {
|
||||
KnowledgeSearchIpcRequest,
|
||||
KnowledgeSearchIpcResult
|
||||
} from '../shared/knowledge'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
WechatShareServiceConfig
|
||||
} from '../shared/wechat-share-card'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
@@ -224,6 +228,11 @@ const api = {
|
||||
deleteGeneratedReport: (reportId: string) =>
|
||||
ipcRenderer.invoke('report:deleteGenerated', reportId),
|
||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
||||
getWechatShareConfig: () => ipcRenderer.invoke('wechat-share:getConfig'),
|
||||
saveWechatShareConfig: (config: WechatShareServiceConfig) =>
|
||||
ipcRenderer.invoke('wechat-share:saveConfig', config),
|
||||
publishWechatShareCard: (request: PublishWechatShareCardRequest) =>
|
||||
ipcRenderer.invoke('wechat-share:publish', request),
|
||||
getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot),
|
||||
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
|
||||
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
|
||||
|
||||
+132
-8
@@ -6,7 +6,11 @@ import { ApiWorkspace } from './features/api-center/ApiWorkspace'
|
||||
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
|
||||
import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace'
|
||||
import type { SettingsCategoryId } from './features/settings/model/types'
|
||||
import type { AIRuntimeModelConfig } from '../../shared/ai-provider'
|
||||
import type {
|
||||
AIProviderSummary,
|
||||
AIRuntimeModelConfig,
|
||||
ReportModelChoice
|
||||
} from '../../shared/ai-provider'
|
||||
import { AppPage } from './components/layout/navigation'
|
||||
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
|
||||
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
|
||||
@@ -73,6 +77,51 @@ const INITIAL_MESSAGE_COUNT = 20
|
||||
const MESSAGE_PAGE_SIZE = 100
|
||||
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
|
||||
const EXPORT_PREVIEW_LIMIT = 20
|
||||
const REPORT_TEXT_MODEL_STORAGE_KEY = 'group_report_text_model'
|
||||
const REPORT_VISION_MODEL_STORAGE_KEY = 'group_report_vision_model'
|
||||
|
||||
const reportModelKey = (model: { providerId?: string; model: string }): string =>
|
||||
model.providerId && model.model ? `${model.providerId}::${model.model}` : ''
|
||||
|
||||
const reportProviderConfigured = (provider: AIProviderSummary): boolean =>
|
||||
Boolean(provider.hasApiKey || provider.type === 'ollama' || provider.auth.type === 'none')
|
||||
|
||||
const reportModelChoices = (
|
||||
providers: AIProviderSummary[],
|
||||
capability: 'chat' | 'vision'
|
||||
): ReportModelChoice[] =>
|
||||
providers.flatMap((provider) => {
|
||||
if (!reportProviderConfigured(provider)) return []
|
||||
return provider.models
|
||||
.filter((model) =>
|
||||
capability === 'chat'
|
||||
? model.capabilities.chat
|
||||
: model.capabilities.vision || model.capabilities.ocr
|
||||
)
|
||||
.map((model) => ({
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
model: model.id,
|
||||
modelName: model.name || model.id,
|
||||
configured: true as const,
|
||||
status: provider.status,
|
||||
timeoutMs: provider.advanced.timeoutMs
|
||||
}))
|
||||
})
|
||||
|
||||
const selectReportModel = (
|
||||
choices: ReportModelChoice[],
|
||||
storageKey: string,
|
||||
fallback: { providerId?: string; model: string }
|
||||
): ReportModelChoice | undefined => {
|
||||
const savedKey = localStorage.getItem(storageKey) || ''
|
||||
const fallbackKey = reportModelKey(fallback)
|
||||
return (
|
||||
choices.find((choice) => reportModelKey(choice) === savedKey) ||
|
||||
choices.find((choice) => reportModelKey(choice) === fallbackKey) ||
|
||||
choices[0]
|
||||
)
|
||||
}
|
||||
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
@@ -209,6 +258,16 @@ function App(): React.ReactElement {
|
||||
configured: false,
|
||||
status: 'untested'
|
||||
})
|
||||
const [reportTextModelConfig, setReportTextModelConfig] = useState<AiModelConfig>({
|
||||
providerName: '尚未配置',
|
||||
model: '',
|
||||
modelName: '尚未选择模型',
|
||||
configured: false,
|
||||
status: 'untested'
|
||||
})
|
||||
const [aiVisionModelConfig, setAiVisionModelConfig] = useState<ReportModelChoice>()
|
||||
const [reportTextModelOptions, setReportTextModelOptions] = useState<ReportModelChoice[]>([])
|
||||
const [reportVisionModelOptions, setReportVisionModelOptions] = useState<ReportModelChoice[]>([])
|
||||
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
||||
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
||||
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
|
||||
@@ -336,7 +395,25 @@ function App(): React.ReactElement {
|
||||
localStorage.removeItem('ai_model')
|
||||
}
|
||||
}
|
||||
setAiModelConfig(await window.api.getAIRuntimeConfig())
|
||||
const [runtime, visionRuntime, providerList] = await Promise.all([
|
||||
window.api.getAIRuntimeConfig(),
|
||||
window.api.getAIVisionRuntimeConfig(),
|
||||
window.api.listAIProviders()
|
||||
])
|
||||
const providers = providerList.success ? providerList.providers : []
|
||||
const textChoices = reportModelChoices(providers, 'chat')
|
||||
const visionChoices = reportModelChoices(providers, 'vision')
|
||||
const selectedText = selectReportModel(textChoices, REPORT_TEXT_MODEL_STORAGE_KEY, runtime)
|
||||
const selectedVision = selectReportModel(
|
||||
visionChoices,
|
||||
REPORT_VISION_MODEL_STORAGE_KEY,
|
||||
visionRuntime
|
||||
)
|
||||
setReportTextModelOptions(textChoices)
|
||||
setReportVisionModelOptions(visionChoices)
|
||||
setAiModelConfig(runtime)
|
||||
setReportTextModelConfig(selectedText || runtime)
|
||||
setAiVisionModelConfig(selectedVision)
|
||||
} catch (error) {
|
||||
console.warn('[AI Provider] 配置加载失败:', error)
|
||||
}
|
||||
@@ -349,7 +426,8 @@ function App(): React.ReactElement {
|
||||
sourceContact: reportSourceContact,
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
modelConfig: aiModelConfig
|
||||
modelConfig: reportTextModelConfig,
|
||||
visionModelConfig: aiVisionModelConfig
|
||||
})
|
||||
const lastCapturedReportKeyRef = React.useRef('')
|
||||
|
||||
@@ -1496,7 +1574,10 @@ function App(): React.ReactElement {
|
||||
htmlPath: reportGeneration.reportPaths?.htmlPath,
|
||||
pngPath: reportGeneration.reportPaths?.pngPath,
|
||||
duration: reportGeneration.generationMetadata.durationMs,
|
||||
modelName: reportGeneration.generationMetadata.modelName || aiModelConfig.model,
|
||||
textModelName:
|
||||
reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName,
|
||||
imageModelName: aiVisionModelConfig?.modelName || aiVisionModelConfig?.model,
|
||||
modelName: reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName,
|
||||
tokenUsage: reportGeneration.generationMetadata.tokenUsage,
|
||||
generationLogs: reportGeneration.generationMetadata.generationLogs,
|
||||
reportSnapshot,
|
||||
@@ -1521,7 +1602,10 @@ function App(): React.ReactElement {
|
||||
|
||||
void saveReport()
|
||||
}, [
|
||||
aiModelConfig.model,
|
||||
aiVisionModelConfig?.model,
|
||||
aiVisionModelConfig?.modelName,
|
||||
reportTextModelConfig.model,
|
||||
reportTextModelConfig.modelName,
|
||||
reportGeneration.generatedImage,
|
||||
reportGeneration.generationMetadata,
|
||||
reportGeneration.phase,
|
||||
@@ -1689,7 +1773,10 @@ function App(): React.ReactElement {
|
||||
sourceContact={reportSourceContact}
|
||||
summaryDateRange={summaryDateRange}
|
||||
summaryMessageTypes={summaryMessageTypes}
|
||||
modelConfig={aiModelConfig}
|
||||
modelConfig={reportTextModelConfig}
|
||||
visionModelConfig={aiVisionModelConfig}
|
||||
textModelOptions={reportTextModelOptions}
|
||||
visionModelOptions={reportVisionModelOptions}
|
||||
rangeMessageCount={reportGeneration.rangeMessages.length}
|
||||
reportMessageCount={reportGeneration.reportMessages.length}
|
||||
messageTypeCounts={reportGeneration.messageTypeCounts}
|
||||
@@ -1702,6 +1789,14 @@ function App(): React.ReactElement {
|
||||
onSummaryDateRangeChange={setSummaryDateRange}
|
||||
onSummaryMessageTypesChange={setSummaryMessageTypes}
|
||||
onOpenModelSettings={openModelSettings}
|
||||
onTextModelChange={(model) => {
|
||||
localStorage.setItem(REPORT_TEXT_MODEL_STORAGE_KEY, reportModelKey(model))
|
||||
setReportTextModelConfig(model)
|
||||
}}
|
||||
onVisionModelChange={(model) => {
|
||||
localStorage.setItem(REPORT_VISION_MODEL_STORAGE_KEY, reportModelKey(model))
|
||||
setAiVisionModelConfig(model)
|
||||
}}
|
||||
onGenerate={() => {
|
||||
reportGeneration.resetGenerationStatus()
|
||||
void reportGeneration.generate()
|
||||
@@ -1726,7 +1821,7 @@ function App(): React.ReactElement {
|
||||
preparationProgress={reportGeneration.preparationProgress}
|
||||
imageInsightSummary={reportGeneration.imageInsightSummary}
|
||||
canRetryModelStep={reportGeneration.canRetryModelStep}
|
||||
currentModel={aiModelConfig}
|
||||
currentModel={reportTextModelConfig}
|
||||
onRetry={(model) => void reportGeneration.retry(model)}
|
||||
onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()}
|
||||
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
|
||||
@@ -1765,7 +1860,36 @@ function App(): React.ReactElement {
|
||||
onContactsChange={setContacts}
|
||||
onFilteredContactsChange={setFilteredContacts}
|
||||
onReturnToLogin={handleReturnToLogin}
|
||||
onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)}
|
||||
onAIRuntimeChange={(config: AIRuntimeModelConfig) => {
|
||||
setAiModelConfig(config)
|
||||
void Promise.all([
|
||||
window.api.getAIVisionRuntimeConfig(),
|
||||
window.api.listAIProviders()
|
||||
])
|
||||
.then(([visionRuntime, providerList]) => {
|
||||
const providers = providerList.success ? providerList.providers : []
|
||||
const textChoices = reportModelChoices(providers, 'chat')
|
||||
const visionChoices = reportModelChoices(providers, 'vision')
|
||||
setReportTextModelOptions(textChoices)
|
||||
setReportVisionModelOptions(visionChoices)
|
||||
setReportTextModelConfig(
|
||||
(current) =>
|
||||
selectReportModel(
|
||||
textChoices,
|
||||
REPORT_TEXT_MODEL_STORAGE_KEY,
|
||||
current.configured ? current : config
|
||||
) || config
|
||||
)
|
||||
setAiVisionModelConfig((current) =>
|
||||
selectReportModel(
|
||||
visionChoices,
|
||||
REPORT_VISION_MODEL_STORAGE_KEY,
|
||||
current || visionRuntime
|
||||
)
|
||||
)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}}
|
||||
onNotice={setReportNotice}
|
||||
onOpenSettings={openSettings}
|
||||
onAppearanceChange={handleAppearanceChange}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
import type { ReportModelChoice } from '../../../../shared/ai-provider'
|
||||
import {
|
||||
AiModelConfig,
|
||||
RangeMessageState,
|
||||
@@ -22,6 +23,9 @@ interface AiReportWorkspaceProps {
|
||||
summaryDateRange: SummaryDateRange
|
||||
summaryMessageTypes: SummaryMessageType[]
|
||||
modelConfig: AiModelConfig
|
||||
visionModelConfig?: ReportModelChoice
|
||||
textModelOptions: ReportModelChoice[]
|
||||
visionModelOptions: ReportModelChoice[]
|
||||
rangeMessageCount: number
|
||||
reportMessageCount: number
|
||||
messageTypeCounts: Record<SummaryMessageType, number>
|
||||
@@ -34,6 +38,8 @@ interface AiReportWorkspaceProps {
|
||||
onSummaryDateRangeChange: (value: SummaryDateRange) => void
|
||||
onSummaryMessageTypesChange: (value: SummaryMessageType[]) => void
|
||||
onOpenModelSettings: () => void
|
||||
onTextModelChange: (model: ReportModelChoice) => void
|
||||
onVisionModelChange: (model: ReportModelChoice) => void
|
||||
onGenerate: () => void
|
||||
onCloseResult: () => void
|
||||
onCopyImage: () => Promise<{ success: boolean; error?: string }>
|
||||
@@ -69,6 +75,9 @@ export function AiReportWorkspace({
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
modelConfig,
|
||||
visionModelConfig,
|
||||
textModelOptions,
|
||||
visionModelOptions,
|
||||
rangeMessageCount,
|
||||
reportMessageCount,
|
||||
messageTypeCounts,
|
||||
@@ -81,6 +90,8 @@ export function AiReportWorkspace({
|
||||
onSummaryDateRangeChange,
|
||||
onSummaryMessageTypesChange,
|
||||
onOpenModelSettings,
|
||||
onTextModelChange,
|
||||
onVisionModelChange,
|
||||
onGenerate,
|
||||
onCloseResult,
|
||||
onCopyImage,
|
||||
@@ -171,7 +182,16 @@ export function AiReportWorkspace({
|
||||
disabled={configDisabled}
|
||||
/>
|
||||
<ReportGroupMemberSelector sourceContact={sourceContact} disabled={configDisabled} />
|
||||
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
|
||||
<ModelSummary
|
||||
config={modelConfig}
|
||||
visionConfig={visionModelConfig}
|
||||
textModels={textModelOptions}
|
||||
visionModels={visionModelOptions}
|
||||
disabled={configDisabled}
|
||||
onTextModelChange={onTextModelChange}
|
||||
onVisionModelChange={onVisionModelChange}
|
||||
onOpenSettings={onOpenModelSettings}
|
||||
/>
|
||||
<section className="report-config-section report-timeout-section">
|
||||
<div>
|
||||
<h3>日报生成超时</h3>
|
||||
|
||||
@@ -1,24 +1,87 @@
|
||||
import React from 'react'
|
||||
import type { ReportModelChoice } from '../../../../shared/ai-provider'
|
||||
import { AiModelConfig } from '../../hooks/useGroupReportGeneration'
|
||||
|
||||
interface ModelSummaryProps {
|
||||
config: AiModelConfig
|
||||
visionConfig?: ReportModelChoice
|
||||
textModels: ReportModelChoice[]
|
||||
visionModels: ReportModelChoice[]
|
||||
disabled?: boolean
|
||||
onTextModelChange: (model: ReportModelChoice) => void
|
||||
onVisionModelChange: (model: ReportModelChoice) => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function ModelSummary({ config, onOpenSettings }: ModelSummaryProps): React.ReactElement {
|
||||
const statusText = config.configured ? '配置正常' : '尚未配置'
|
||||
const modelKey = (model: { providerId?: string; model: string } | undefined): string =>
|
||||
model?.providerId && model.model ? `${model.providerId}::${model.model}` : ''
|
||||
|
||||
const optionLabel = (model: ReportModelChoice): string =>
|
||||
`${model.providerName} · ${model.modelName || model.model}`
|
||||
|
||||
export function ModelSummary({
|
||||
config,
|
||||
visionConfig,
|
||||
textModels,
|
||||
visionModels,
|
||||
disabled = false,
|
||||
onTextModelChange,
|
||||
onVisionModelChange,
|
||||
onOpenSettings
|
||||
}: ModelSummaryProps): React.ReactElement {
|
||||
const changeModel = (
|
||||
key: string,
|
||||
models: ReportModelChoice[],
|
||||
onChange: (model: ReportModelChoice) => void
|
||||
): void => {
|
||||
const selected = models.find((model) => modelKey(model) === key)
|
||||
if (selected) onChange(selected)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="report-config-section">
|
||||
<div className="report-model-summary">
|
||||
<div>
|
||||
<div className="report-model-summary-content">
|
||||
<h3>模型配置</h3>
|
||||
<p>
|
||||
{config.modelName || config.model || '未选择模型'} · {statusText}
|
||||
</p>
|
||||
<div className="report-model-selects">
|
||||
<label>
|
||||
<span>文字总结模型</span>
|
||||
<select
|
||||
aria-label="文字总结模型"
|
||||
value={modelKey(config)}
|
||||
disabled={disabled || !textModels.length}
|
||||
onChange={(event) => changeModel(event.target.value, textModels, onTextModelChange)}
|
||||
>
|
||||
{!textModels.length && <option value="">没有已配置的文字模型</option>}
|
||||
{textModels.map((model) => (
|
||||
<option key={modelKey(model)} value={modelKey(model)}>
|
||||
{optionLabel(model)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>图片理解模型</span>
|
||||
<select
|
||||
aria-label="图片理解模型"
|
||||
value={modelKey(visionConfig)}
|
||||
disabled={disabled || !visionModels.length}
|
||||
onChange={(event) =>
|
||||
changeModel(event.target.value, visionModels, onVisionModelChange)
|
||||
}
|
||||
>
|
||||
{!visionModels.length && <option value="">没有已验证的图片理解模型</option>}
|
||||
{visionModels.map((model) => (
|
||||
<option key={modelKey(model)} value={modelKey(model)}>
|
||||
{optionLabel(model)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<small>图片识别缓存 10 分钟;识图完成后仍由文字总结模型生成日报。</small>
|
||||
</div>
|
||||
<button type="button" onClick={onOpenSettings}>
|
||||
<button type="button" onClick={onOpenSettings} disabled={disabled}>
|
||||
更改模型
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -108,8 +108,12 @@ export function ReportInfoPanel({ report, onReveal }: ReportInfoPanelProps): Rea
|
||||
<b>{formatDuration(report.duration)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>模型</span>
|
||||
<b>{modelLabel(report.modelName)}</b>
|
||||
<span>文字模型</span>
|
||||
<b>{modelLabel(report.textModelName || report.modelName)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>图片模型</span>
|
||||
<b>{modelLabel(report.imageModelName)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Token 来源</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
interface ReportToolbarProps {
|
||||
canCopyImage: boolean
|
||||
canReveal: boolean
|
||||
canShare: boolean
|
||||
canSwitchTemplate: boolean
|
||||
currentTemplateId?: SelectableReportTemplateId
|
||||
isSwitchingTemplate: boolean
|
||||
@@ -14,18 +15,21 @@ interface ReportToolbarProps {
|
||||
onRegenerate: () => void
|
||||
onCopyImage: () => void
|
||||
onReveal: () => void
|
||||
onShare: () => void
|
||||
}
|
||||
|
||||
export function ReportToolbar({
|
||||
canCopyImage,
|
||||
canReveal,
|
||||
canShare,
|
||||
canSwitchTemplate,
|
||||
currentTemplateId,
|
||||
isSwitchingTemplate,
|
||||
onSwitchTemplate,
|
||||
onRegenerate,
|
||||
onCopyImage,
|
||||
onReveal
|
||||
onReveal,
|
||||
onShare
|
||||
}: ReportToolbarProps): React.ReactElement {
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [templateOpen, setTemplateOpen] = useState(false)
|
||||
@@ -88,6 +92,9 @@ export function ReportToolbar({
|
||||
<button type="button" className="primary" disabled={!canReveal} onClick={onReveal}>
|
||||
打开报告
|
||||
</button>
|
||||
<button type="button" disabled={!canShare} onClick={onShare}>
|
||||
生成微信卡片
|
||||
</button>
|
||||
<div className="report-more-menu" ref={menuRef}>
|
||||
<button type="button" onClick={() => setMoreOpen((open) => !open)}>
|
||||
更多
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ReportEmptyState } from './ReportEmptyState'
|
||||
import { ReportToolbar } from './ReportToolbar'
|
||||
import { ReportZoomBar } from './ReportZoomBar'
|
||||
import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
|
||||
import { WechatShareCardDialog } from './WechatShareCardDialog'
|
||||
|
||||
interface ReportViewerProps {
|
||||
report: GeneratedReportRecord | null
|
||||
@@ -49,6 +50,7 @@ export function ReportViewer({
|
||||
const [status, setStatus] = useState('')
|
||||
const [imageError, setImageError] = useState('')
|
||||
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false)
|
||||
const [shareDialogOpen, setShareDialogOpen] = useState(false)
|
||||
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -57,6 +59,7 @@ export function ReportViewer({
|
||||
setStatus('')
|
||||
setImageError('')
|
||||
setIsSwitchingTemplate(false)
|
||||
setShareDialogOpen(false)
|
||||
setZoom(1)
|
||||
setFitZoom(1)
|
||||
setNaturalSize(null)
|
||||
@@ -165,6 +168,7 @@ export function ReportViewer({
|
||||
<ReportToolbar
|
||||
canCopyImage={Boolean(report.generatedImage)}
|
||||
canReveal={Boolean(report.pngPath || report.htmlPath)}
|
||||
canShare={Boolean(report.pngPath)}
|
||||
canSwitchTemplate={Boolean(
|
||||
(report.reportSnapshot && report.reportMetadata) ||
|
||||
report.reportRenderSnapshot ||
|
||||
@@ -176,6 +180,7 @@ export function ReportViewer({
|
||||
onRegenerate={onRegenerate}
|
||||
onCopyImage={() => void handleCopy()}
|
||||
onReveal={() => void handleReveal()}
|
||||
onShare={() => setShareDialogOpen(true)}
|
||||
/>
|
||||
</header>
|
||||
{status && <div className="report-viewer-status">{status}</div>}
|
||||
@@ -233,6 +238,14 @@ export function ReportViewer({
|
||||
onFitPage={fitPage}
|
||||
onActualSize={showActualSize}
|
||||
/>
|
||||
{shareDialogOpen && report.pngPath && (
|
||||
<WechatShareCardDialog
|
||||
pngPath={report.pngPath}
|
||||
initialTitle={`${report.contactName}日报 · ${report.reportDate}`}
|
||||
initialDescription={`基于 ${report.messageCount} 条群聊消息生成的 AI 日报`}
|
||||
onClose={() => setShareDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { PublishWechatShareCardResult } from '../../../../shared/wechat-share-card'
|
||||
|
||||
interface WechatShareCardDialogProps {
|
||||
pngPath: string
|
||||
initialTitle: string
|
||||
initialDescription: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function WechatShareCardDialog({
|
||||
pngPath,
|
||||
initialTitle,
|
||||
initialDescription,
|
||||
onClose
|
||||
}: WechatShareCardDialogProps): React.ReactElement {
|
||||
const [title, setTitle] = useState(initialTitle)
|
||||
const [description, setDescription] = useState(initialDescription)
|
||||
const [serviceUrl, setServiceUrl] = useState('https://share.example.com')
|
||||
const [uploadToken, setUploadToken] = useState('')
|
||||
const [configured, setConfigured] = useState<boolean | null>(null)
|
||||
const [editingConfig, setEditingConfig] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [result, setResult] = useState<PublishWechatShareCardResult | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void window.api.getWechatShareConfig().then((response) => {
|
||||
setConfigured(Boolean(response.success && response.configured))
|
||||
if (response.serviceUrl) setServiceUrl(response.serviceUrl)
|
||||
if (!response.success) setError(response.error || '读取卡片服务配置失败')
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
const closeOnEscape = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape' && !busy) onClose()
|
||||
}
|
||||
window.addEventListener('keydown', closeOnEscape)
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
window.removeEventListener('keydown', closeOnEscape)
|
||||
}
|
||||
}, [busy, onClose])
|
||||
|
||||
const publish = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
if (!configured || editingConfig) {
|
||||
const saved = await window.api.saveWechatShareConfig({ serviceUrl, uploadToken })
|
||||
if (!saved.success) {
|
||||
setError(saved.error || '保存卡片服务配置失败')
|
||||
return
|
||||
}
|
||||
setConfigured(true)
|
||||
setEditingConfig(false)
|
||||
}
|
||||
const published = await window.api.publishWechatShareCard({
|
||||
pngPath,
|
||||
title,
|
||||
description,
|
||||
expiresInDays: 7
|
||||
})
|
||||
if (!published.success) {
|
||||
setError(published.error || '微信卡片生成失败')
|
||||
return
|
||||
}
|
||||
setResult(published)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyLink = async (): Promise<void> => {
|
||||
if (!result?.shareUrl) return
|
||||
await navigator.clipboard.writeText(result.shareUrl)
|
||||
setCopied(true)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="wechat-share-dialog-backdrop" role="presentation" onMouseDown={onClose}>
|
||||
<section
|
||||
className="wechat-share-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="wechat-share-title"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2 id="wechat-share-title">生成微信分享卡片</h2>
|
||||
<p>卡片和日报将在 7 天后自动失效。</p>
|
||||
</div>
|
||||
<button type="button" className="wechat-share-dialog-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{result?.qrCodeDataUrl ? (
|
||||
<div className="wechat-share-success">
|
||||
<img src={result.qrCodeDataUrl} alt="微信分享二维码" />
|
||||
<h3>使用微信扫码</h3>
|
||||
<p>打开页面后点击右上角 ···,发送给好友或群聊。</p>
|
||||
{result.expiresAt && (
|
||||
<small>有效期至 {new Date(result.expiresAt).toLocaleString('zh-CN')}</small>
|
||||
)}
|
||||
<div>
|
||||
<button type="button" onClick={() => void copyLink()}>
|
||||
{copied ? '链接已复制' : '复制分享链接'}
|
||||
</button>
|
||||
<button type="button" className="primary" onClick={onClose}>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="wechat-share-form">
|
||||
<label>
|
||||
<span>卡片标题</span>
|
||||
<input
|
||||
maxLength={64}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>卡片描述</span>
|
||||
<textarea
|
||||
maxLength={120}
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{configured === true && !editingConfig && (
|
||||
<div className="wechat-share-service-summary">
|
||||
<div>
|
||||
<span>卡片服务</span>
|
||||
<b>{serviceUrl}</b>
|
||||
</div>
|
||||
<button type="button" onClick={() => setEditingConfig(true)}>
|
||||
更改
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(configured === false || editingConfig) && (
|
||||
<div className="wechat-share-service-config">
|
||||
<h3>首次配置卡片服务</h3>
|
||||
<label>
|
||||
<span>服务地址</span>
|
||||
<input
|
||||
value={serviceUrl}
|
||||
onChange={(event) => setServiceUrl(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>上传密钥</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={uploadToken}
|
||||
onChange={(event) => setUploadToken(event.target.value)}
|
||||
placeholder="Cloudflare Worker 的 UPLOAD_TOKEN"
|
||||
/>
|
||||
</label>
|
||||
<p>上传密钥仅加密保存在本机,不是公众号 AppSecret。</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="wechat-share-privacy">
|
||||
生成后会将当前日报长图和缩略图上传到你的私有 R2 存储。
|
||||
</div>
|
||||
{error && <p className="report-inline-error">{error}</p>}
|
||||
<footer>
|
||||
<button type="button" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
disabled={
|
||||
busy ||
|
||||
configured === null ||
|
||||
!title.trim() ||
|
||||
((!configured || editingConfig) && uploadToken.trim().length < 24)
|
||||
}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
{busy ? '正在生成卡片…' : '生成二维码'}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../utils/voice-message-reference'
|
||||
import type { VoiceModelStatus } from '../../../shared/voice-recognition'
|
||||
import { resolveMemberName } from '../../../shared/member-names'
|
||||
import type { ReportModelChoice } from '../../../shared/ai-provider'
|
||||
|
||||
export type { VoiceTranscriptionProgress } from '../utils/voice-message-reference'
|
||||
|
||||
@@ -86,6 +87,7 @@ interface UseGroupReportGenerationArgs {
|
||||
summaryDateRange: SummaryDateRange
|
||||
summaryMessageTypes: SummaryMessageType[]
|
||||
modelConfig: AiModelConfig
|
||||
visionModelConfig?: ReportModelChoice
|
||||
}
|
||||
|
||||
interface PreparedReportContext {
|
||||
@@ -260,7 +262,8 @@ export function useGroupReportGeneration({
|
||||
sourceContact,
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
modelConfig
|
||||
modelConfig,
|
||||
visionModelConfig
|
||||
}: UseGroupReportGenerationArgs): {
|
||||
phase: ReportGenerationPhase
|
||||
error: string
|
||||
@@ -464,7 +467,7 @@ export function useGroupReportGeneration({
|
||||
const pushLog = (log: ReportGenerationLog): void => {
|
||||
context.logs.push(log)
|
||||
setGenerationMetadata({
|
||||
modelName: selectedModel.model,
|
||||
modelName: selectedModel.modelName || selectedModel.model,
|
||||
generationLogs: [...context.logs]
|
||||
})
|
||||
}
|
||||
@@ -482,7 +485,7 @@ export function useGroupReportGeneration({
|
||||
setPhase('requestingModel')
|
||||
setPreparationProgress({ stage: 'summarizingInput', label: '整理总结中' })
|
||||
setGenerationMetadata({
|
||||
modelName: selectedModel.model,
|
||||
modelName: selectedModel.modelName || selectedModel.model,
|
||||
generationLogs: [...context.logs]
|
||||
})
|
||||
writeReportLog('info', '调用模型生成日报内容', {
|
||||
@@ -619,7 +622,7 @@ export function useGroupReportGeneration({
|
||||
Number.isFinite(exportFinishTime) && exportFinishTime > context.startedAt
|
||||
? exportFinishTime - context.startedAt
|
||||
: Date.now() - context.startedAt,
|
||||
modelName: selectedModel.model,
|
||||
modelName: selectedModel.modelName || selectedModel.model,
|
||||
tokenUsage,
|
||||
generationLogs: [...context.logs]
|
||||
})
|
||||
@@ -681,7 +684,10 @@ export function useGroupReportGeneration({
|
||||
const logs: ReportGenerationLog[] = []
|
||||
const pushLog = (log: ReportGenerationLog): void => {
|
||||
logs.push(log)
|
||||
setGenerationMetadata({ modelName: modelConfig.model, generationLogs: [...logs] })
|
||||
setGenerationMetadata({
|
||||
modelName: modelConfig.modelName || modelConfig.model,
|
||||
generationLogs: [...logs]
|
||||
})
|
||||
}
|
||||
const trackStep = async <T>(label: string, task: () => Promise<T>): Promise<T> => {
|
||||
const startedAt = new Date()
|
||||
@@ -702,7 +708,10 @@ export function useGroupReportGeneration({
|
||||
setVoiceTranscriptionProgress(null)
|
||||
setPreparationProgress(null)
|
||||
setImageInsightSummary(EMPTY_IMAGE_INSIGHT_SUMMARY)
|
||||
setGenerationMetadata({ modelName: modelConfig.model, generationLogs: [] })
|
||||
setGenerationMetadata({
|
||||
modelName: modelConfig.modelName || modelConfig.model,
|
||||
generationLogs: []
|
||||
})
|
||||
writeReportLog('info', '开始生成群聊日报', {
|
||||
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
|
||||
dateRange: summaryDateRange,
|
||||
@@ -736,7 +745,8 @@ export function useGroupReportGeneration({
|
||||
memberNamePreference
|
||||
)
|
||||
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full', {
|
||||
onProgress: setPreparationProgress
|
||||
onProgress: setPreparationProgress,
|
||||
visionModel: visionModelConfig
|
||||
})
|
||||
})
|
||||
|
||||
@@ -782,7 +792,8 @@ export function useGroupReportGeneration({
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
templateId,
|
||||
transcribeSelectedVoiceMessages
|
||||
transcribeSelectedVoiceMessages,
|
||||
visionModelConfig
|
||||
])
|
||||
|
||||
const retry = useCallback(
|
||||
|
||||
@@ -467,12 +467,57 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.report-model-summary-content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.report-model-selects {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.report-model-selects label {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 5px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font: 12px/17px var(--wxex-font);
|
||||
}
|
||||
|
||||
.report-model-selects select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 30px 0 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-primary);
|
||||
font: 13px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
.report-model-selects select:disabled {
|
||||
color: var(--wxex-text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.report-model-summary p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font: 13px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
.report-model-summary small {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--wxex-text-muted);
|
||||
font: 11px/17px var(--wxex-font);
|
||||
}
|
||||
|
||||
.report-model-summary button,
|
||||
.report-result-actions button,
|
||||
.report-task-error button {
|
||||
|
||||
@@ -245,19 +245,27 @@
|
||||
flex: 0 0 auto;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 18px 22px 12px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
background: var(--wxex-bg-main);
|
||||
}
|
||||
|
||||
.report-viewer-header > div:first-child {
|
||||
flex: 1 1 220px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.report-viewer-header h1 {
|
||||
font: 700 20px/27px var(--wxex-font);
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.report-viewer-toolbar {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
@@ -592,6 +600,15 @@
|
||||
.report-viewer-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.report-viewer-header > div:first-child {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.report-viewer-toolbar {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
@@ -1169,3 +1186,198 @@
|
||||
color: var(--wxex-text-muted);
|
||||
font: 11px/17px var(--wxex-font);
|
||||
}
|
||||
.wechat-share-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(20, 31, 27, 0.46);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.wechat-share-dialog {
|
||||
width: min(540px, calc(100vw - 48px));
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(95, 129, 116, 0.2);
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
box-shadow: 0 28px 90px rgba(14, 32, 25, 0.24);
|
||||
color: #17241f;
|
||||
font-family: var(--wxex-font);
|
||||
}
|
||||
.wechat-share-dialog,
|
||||
.wechat-share-dialog * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wechat-share-dialog > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 24px 26px 18px;
|
||||
border-bottom: 1px solid #e8eeeb;
|
||||
}
|
||||
.wechat-share-dialog h2,
|
||||
.wechat-share-dialog h3,
|
||||
.wechat-share-dialog p {
|
||||
margin: 0;
|
||||
}
|
||||
.wechat-share-dialog header h2 {
|
||||
color: #17241f;
|
||||
font-size: 20px;
|
||||
}
|
||||
.wechat-share-dialog header p {
|
||||
margin-top: 6px;
|
||||
color: #718078;
|
||||
font-size: 13px;
|
||||
}
|
||||
.wechat-share-dialog-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #f1f5f3;
|
||||
color: #5f6d67;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
.wechat-share-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 24px 26px 26px;
|
||||
}
|
||||
.wechat-share-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.wechat-share-form label > span {
|
||||
color: #33463e;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.wechat-share-form input,
|
||||
.wechat-share-form textarea {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
border: 1px solid #cfdbd6;
|
||||
border-radius: 11px;
|
||||
background: #fbfdfc;
|
||||
padding: 11px 13px;
|
||||
color: #1f2c27;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.wechat-share-form input:focus,
|
||||
.wechat-share-form textarea:focus {
|
||||
border-color: #32a978;
|
||||
box-shadow: 0 0 0 3px rgba(50, 169, 120, 0.12);
|
||||
}
|
||||
.wechat-share-service-config {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid #d6e8df;
|
||||
border-radius: 14px;
|
||||
background: #f2faf6;
|
||||
}
|
||||
.wechat-share-service-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
padding: 13px 15px;
|
||||
border: 1px solid #dce6e1;
|
||||
border-radius: 12px;
|
||||
background: #f7faf8;
|
||||
}
|
||||
.wechat-share-service-summary > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
.wechat-share-service-summary span {
|
||||
color: #728078;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wechat-share-service-summary b {
|
||||
overflow: hidden;
|
||||
color: #2c4138;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wechat-share-service-summary button {
|
||||
flex: 0 0 auto;
|
||||
min-height: 30px;
|
||||
padding: 0 11px;
|
||||
}
|
||||
.wechat-share-service-config h3 {
|
||||
color: #23523f;
|
||||
font-size: 14px;
|
||||
}
|
||||
.wechat-share-service-config p,
|
||||
.wechat-share-privacy {
|
||||
color: #68776f;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wechat-share-privacy {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #f5f7f6;
|
||||
}
|
||||
.wechat-share-form > footer,
|
||||
.wechat-share-success > div {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.wechat-share-form button,
|
||||
.wechat-share-success button {
|
||||
min-height: 38px;
|
||||
padding: 0 17px;
|
||||
border: 1px solid #ced9d4;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #34463f;
|
||||
}
|
||||
.wechat-share-form button.primary,
|
||||
.wechat-share-success button.primary {
|
||||
border-color: #15945e;
|
||||
background: #15945e;
|
||||
color: #fff;
|
||||
}
|
||||
.wechat-share-form button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.wechat-share-success {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 11px;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.wechat-share-success > img {
|
||||
width: 260px;
|
||||
max-width: 80%;
|
||||
border: 10px solid #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 35px rgba(27, 59, 46, 0.12);
|
||||
}
|
||||
.wechat-share-success h3 {
|
||||
color: #203029;
|
||||
font-size: 18px;
|
||||
}
|
||||
.wechat-share-success p,
|
||||
.wechat-share-success small {
|
||||
color: #6c7b74;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wechat-share-success > div {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ImageCandidateQuery
|
||||
} from '../../../shared/image-insight'
|
||||
import { calculateImageHeatScore, isHotImageCandidate } from '../../../shared/image-insight'
|
||||
import type { ReportModelChoice } from '../../../shared/ai-provider'
|
||||
|
||||
interface ReportImageReadResult {
|
||||
success: boolean
|
||||
@@ -93,6 +94,7 @@ export interface ReportPreparationProgress {
|
||||
|
||||
export interface BuildGroupReportFactsOptions {
|
||||
onProgress?: (progress: ReportPreparationProgress) => void
|
||||
visionModel?: ReportModelChoice
|
||||
}
|
||||
|
||||
function friendlyImageNotice(warnings: string[]): string {
|
||||
@@ -422,6 +424,8 @@ const buildMediaSection = async (
|
||||
sender: candidate.sender,
|
||||
sentAt: candidate.sentAt,
|
||||
sessionId: candidate.sessionId,
|
||||
providerId: options.visionModel?.providerId,
|
||||
modelId: options.visionModel?.model,
|
||||
force: false
|
||||
})
|
||||
if (!analyzeResp.success || !analyzeResp.insight) {
|
||||
|
||||
@@ -74,6 +74,17 @@ export interface AIRuntimeModelConfig {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/** 日报工作区内可选择的模型,不会修改全局默认 Provider。 */
|
||||
export interface ReportModelChoice extends AIRuntimeModelConfig {
|
||||
providerId: string
|
||||
configured: true
|
||||
}
|
||||
|
||||
export interface AIVisionRuntimeConfig extends AIRuntimeModelConfig {
|
||||
/** 图片理解模型独立于文字总结模型,由已验证的 vision/ocr capability 自动选择。 */
|
||||
source: 'default-model' | 'vision-capability' | 'unavailable'
|
||||
}
|
||||
|
||||
export interface AiSearchProviderStatus {
|
||||
configured: boolean
|
||||
requiresConsent: boolean
|
||||
|
||||
@@ -12,6 +12,21 @@ export type ImageCategory =
|
||||
|
||||
export type ImageImportance = 'low' | 'medium' | 'high'
|
||||
|
||||
/** 日报图片理解结果最多复用 10 分钟;旧记录保留在磁盘,仅不再作为缓存命中。 */
|
||||
export const IMAGE_INSIGHT_CACHE_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
export const isFreshImageInsight = (
|
||||
insight: Pick<ImageInsight, 'updatedAt'> | null | undefined,
|
||||
now = Date.now()
|
||||
): boolean =>
|
||||
Boolean(
|
||||
insight &&
|
||||
Number.isFinite(insight.updatedAt) &&
|
||||
insight.updatedAt > 0 &&
|
||||
now >= insight.updatedAt &&
|
||||
now - insight.updatedAt < IMAGE_INSIGHT_CACHE_TTL_MS
|
||||
)
|
||||
|
||||
/**
|
||||
* 单张微信图片的 AI 理解结果(持久化到 image-insights.json)
|
||||
*
|
||||
@@ -57,6 +72,9 @@ export interface ImageAnalysisRequest {
|
||||
sender: string
|
||||
sentAt: number
|
||||
sessionId: string
|
||||
/** 日报局部选择的图片理解模型;未传时仍使用自动视觉路由。 */
|
||||
providerId?: string
|
||||
modelId?: string
|
||||
/** 强制重新分析(忽略缓存) */
|
||||
force?: boolean
|
||||
}
|
||||
@@ -118,8 +136,7 @@ export interface ImageCandidateQuery {
|
||||
export const isHotImageCandidate = (input: {
|
||||
responseCount: number
|
||||
interactionCount: number
|
||||
}): boolean =>
|
||||
input.responseCount >= 2 || (input.responseCount >= 1 && input.interactionCount >= 1)
|
||||
}): boolean => input.responseCount >= 2 || (input.responseCount >= 1 && input.interactionCount >= 1)
|
||||
|
||||
export const calculateImageHeatScore = (input: {
|
||||
responseCount: number
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface GeneratedReportRecord {
|
||||
height: number
|
||||
}
|
||||
duration?: number
|
||||
/** 文字总结模型;modelName 保留为旧记录兼容字段。 */
|
||||
textModelName?: string
|
||||
/** 图片理解模型。 */
|
||||
imageModelName?: string
|
||||
modelName?: string
|
||||
tokenUsage?: {
|
||||
input?: number
|
||||
@@ -62,6 +66,8 @@ export interface SaveGeneratedReportRequest {
|
||||
htmlPath?: string
|
||||
pngPath?: string
|
||||
duration?: number
|
||||
textModelName?: string
|
||||
imageModelName?: string
|
||||
modelName?: string
|
||||
tokenUsage?: {
|
||||
input?: number
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface WechatShareServiceConfig {
|
||||
serviceUrl: string
|
||||
uploadToken: string
|
||||
}
|
||||
|
||||
export interface WechatShareServiceConfigResult {
|
||||
success: boolean
|
||||
configured?: boolean
|
||||
serviceUrl?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface PublishWechatShareCardRequest {
|
||||
pngPath: string
|
||||
title: string
|
||||
description: string
|
||||
expiresInDays?: number
|
||||
}
|
||||
|
||||
export interface PublishWechatShareCardResult {
|
||||
success: boolean
|
||||
cardId?: string
|
||||
shareUrl?: string
|
||||
viewUrl?: string
|
||||
qrCodeDataUrl?: string
|
||||
expiresAt?: string
|
||||
error?: string
|
||||
}
|
||||
Reference in New Issue
Block a user