feat: 新增实验性微信卡片分享与自动部署能力

补充 Cloudflare Worker、R2 存储、微信 JS-SDK 签名与上传鉴权
增加自动部署 Skill 和配置引导文档
优化报告工具栏、微信卡片弹窗及窄屏响应式布局
补充 Worker 鉴权、卡片生成、过期清理与安全转义测试
This commit is contained in:
Wxw-Gu
2026-08-13 10:48:07 +08:00
parent d439b4b749
commit 4436d7c8ce
45 changed files with 3832 additions and 69 deletions
+16
View File
@@ -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) => {
+3
View File
@@ -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: {
+55
View File
@@ -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 =
+42 -17
View File
@@ -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()
+73
View File
@@ -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) }
}
}
}
+69
View File
@@ -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 }
}
}
+13
View File
@@ -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<{
+9
View File
@@ -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
View File
@@ -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(
+45
View File
@@ -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 {
+213 -1
View File
@@ -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) {
+11
View File
@@ -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
+19 -2
View File
@@ -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
+6
View File
@@ -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
+28
View File
@@ -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
}