Files
SayIt/client/src/services/personalization/promptRouter.ts
T
crosswk 00b04c8a3f feat: 新增上下文感知写作、服务器模式识别纠错与短语音跳过整理
上下文感知读取光标附近的文本交给 AI,选中文字时改走单独的编辑指令 Prompt;
识别纠错在历史记录里回放并修正原文,提交前需用户确认、30 天内可撤回。
另有 AI 整理的托盘开关与快捷键、应用规则拖动排序、麦克风设备提示,
以及录音保留期与麦克风静音误报两处修复。
2026-08-19 15:11:41 +08:00

125 lines
5.5 KiB
TypeScript

import type { ActiveAppContext } from '@/types/appContext'
import { BUILTIN_PRESETS } from '@/services/store'
import type { AppPromptRule, PromptResolution, PromptRoutingInput } from './types'
import { buildDynamicIdentityPrompt, summarizeDomainScenes } from './userStats'
function normalizeText(value?: string) {
return String(value || '').trim().toLowerCase()
}
function normalizeProcessName(context: ActiveAppContext | null) {
const processName = normalizeText(context?.processName)
if (processName) return processName
const exePath = String(context?.exePath || '')
const segments = exePath.split(/[\\/]/)
return normalizeText(segments[segments.length - 1])
}
function includesAny(value: string, patterns?: string[]) {
if (!value || !patterns?.length) return false
return patterns.some((pattern) => value.includes(normalizeText(pattern)))
}
/**
* 判断一条应用规则是否命中当前窗口。
*
* 进程名是硬证据,窗口标题只是软线索 —— 所以**只要规则写了进程名、且这次确实拿到了
* 进程名,就只按进程名判定**,标题不再独立触发。
*
* 原来两者是「或」的关系,而标题是「包含即命中」,一个标题能同时命中多条规则:
* 在 Outlook 里写一封标题为「Teams 会议纪要 - Outlook」的邮件时,Teams 规则也会命中,
* 又因为 Teams 优先级更高,最终按"即时聊天短消息"来整理这封正式邮件。
*
* 保留标题/类名/控件 ID 的能力,是给"进程名区分不了"的场景兜底:
* · 规则本身没写进程名(如网页版应用,进程都是浏览器,只能靠标题区分);
* · 少数窗口拿不到进程名(此时不能因为进程名为空就直接判不命中)。
*/
export function matchesAppPromptRule(rule: AppPromptRule, context: ActiveAppContext | null) {
if (!context) return false
const processName = normalizeProcessName(context)
if (rule.matcher.processNames.length > 0 && processName) {
return rule.matcher.processNames.some((candidate) => processName === normalizeText(candidate))
}
const windowTitle = normalizeText(context.windowTitle)
const windowClass = normalizeText(context.windowClass)
const automationId = normalizeText(context.automationId)
if (includesAny(windowTitle, rule.matcher.windowTitleIncludes)) return true
if (includesAny(windowClass, rule.matcher.windowClasses)) return true
if (includesAny(automationId, rule.matcher.automationIds)) return true
return false
}
function pickPreset(presetId: string | undefined, presets: PromptRoutingInput['presets'], activePresetId: string) {
const fallback = presets.find((preset) => preset.id === activePresetId)
|| BUILTIN_PRESETS.find((preset) => preset.id === activePresetId)
|| presets[0]
|| BUILTIN_PRESETS[0]
if (!presetId) return fallback
return presets.find((preset) => preset.id === presetId)
|| BUILTIN_PRESETS.find((preset) => preset.id === presetId)
|| fallback
}
function summarizeResolution(parts: string[]) {
return parts.filter(Boolean).join(' | ')
}
/** 构造"热词注入 AI 提示词"的段落;无热词时返回 null。供实时与重新识别两条路径共用。 */
export function buildHotwordInjectionPart(hotwords: string[] | undefined): string | null {
if (!hotwords || hotwords.length === 0) return null
const terms = Array.from(new Set(hotwords.map((w) => w.trim()).filter(Boolean)))
if (terms.length === 0) return null
return `用户术语表:以下是用户常用的专有名词/术语,整理时若遇到读音相近或明显误识的词,请优先纠正为下列正确写法(保持其大小写),并原样保留:\n${terms.join('、')}` // i18n-allow: 中文口述整理 Prompt
}
export function resolvePromptRouting(input: PromptRoutingInput): PromptResolution {
// 顺序即优先级:取列表里第一条「已启用且命中」的规则,也就是界面上自上而下的第一条。
// 顺序完全由用户在界面上排(启用一条会自动置顶),这里不要再重排。
const matchedRule = input.appRules
.find((rule) => rule.enabled && matchesAppPromptRule(rule, input.appContext))
const preset = pickPreset(matchedRule?.presetId, input.presets, input.activePresetId)
const systemPromptParts = [preset.systemPrompt.trim()]
const dominantScene = summarizeDomainScenes(input.userStats, 1)[0]
if (matchedRule?.promptAppend) {
systemPromptParts.push(`应用场景补充:\n${matchedRule.promptAppend.trim()}`) // i18n-allow: 中文口述整理 Prompt
}
const dynamicIdentityPrompt = buildDynamicIdentityPrompt(input.userStats)
if (dynamicIdentityPrompt) {
systemPromptParts.push(`用户画像补充:\n${dynamicIdentityPrompt}`) // i18n-allow: 中文口述整理 Prompt
}
// 可选:把热词表注入系统提示词,帮助 AI 在整理时纠正/保留专有名词(默认关闭)。
let hotwordInjected = false
if (input.injectHotwords) {
const part = buildHotwordInjectionPart(input.hotwords)
if (part) {
systemPromptParts.push(part)
hotwordInjected = true
}
}
const summaryParts = [
`Base preset id: ${preset.id}`,
matchedRule ? `App rule id: ${matchedRule.id}` : 'App rule: no match',
dynamicIdentityPrompt && dominantScene ? `User profile: ${dominantScene.label}` : '',
hotwordInjected ? 'Hotwords injected: yes' : '',
]
return {
appId: matchedRule?.appId,
appName: matchedRule?.name,
preset,
matchedRule,
systemPrompt: systemPromptParts.join('\n\n'),
summary: summarizeResolution(summaryParts),
}
}