feat: add screenshot OCR extraction

This commit is contained in:
2026-07-08 00:42:49 +08:00
parent 18a9f53062
commit 273af6a429
14 changed files with 1407 additions and 46 deletions
+16 -1
View File
@@ -23,6 +23,7 @@ import {
SaveRegionImage,
SaveRegionToRemote,
SummarizeRegion,
ExtractTextRegion,
CancelRegion,
GetConfig,
} from '../wailsjs/go/main/App'
@@ -58,7 +59,7 @@ async function loadThemePreference() {
// `SettingsTab` is hoisted to the App shell so the sidebar (which lives
// here) and the inner SettingsView (which renders the matching card) can
// share a single source of truth without an event bus.
type SettingsTab = 'general' | 's3' | 'ssh' | 'llm'
type SettingsTab = 'general' | 's3' | 'ssh' | 'llm' | 'ocr'
const activeTab = ref<SettingsTab>('general')
// Sidebar entries are declarative so adding a destination type later is
@@ -68,6 +69,7 @@ const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
{ id: 's3', label: '对象存储' },
{ id: 'ssh', label: '远程主机' },
{ id: 'llm', label: '智能识图' },
{ id: 'ocr', label: '文字提取' },
]
interface OverlayPayload {
@@ -192,6 +194,16 @@ async function onOverlaySummarize(rect: OverlayResult) {
}
}
async function onOverlayOCR(rect: OverlayResult) {
mode.value = 'settings'
overlayPayload.value = null
try {
await ExtractTextRegion(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlayCancel() {
mode.value = 'settings'
overlayPayload.value = null
@@ -223,6 +235,8 @@ onMounted(() => {
'success',
url === 'summary copied to clipboard'
? 'Summary copied to clipboard'
: url === 'ocr text copied to clipboard'
? 'OCR text copied to clipboard'
: `Copied: ${url}`
)
})
@@ -262,6 +276,7 @@ onUnmounted(() => {
@save="onOverlaySave"
@save-remote="onOverlaySaveRemote"
@summarize="onOverlaySummarize"
@ocr="onOverlayOCR"
@cancel="onOverlayCancel"
/>
+27 -3
View File
@@ -58,6 +58,10 @@ const emit = defineEmits<{
e: 'summarize',
payload: { rect: Rect; annotations: Annotation[] }
): void
(
e: 'ocr',
payload: { rect: Rect; annotations: Annotation[] }
): void
(e: 'cancel'): void
}>()
@@ -138,7 +142,7 @@ const sizeLabel = computed(() => {
})
const MARK_TOOLBAR_W = 178
const ACTION_TOOLBAR_W = 216
const ACTION_TOOLBAR_W = 252
const TOOLBAR_GROUP_GAP = 8
const toolOrder: Tool[] = ['pen', 'rect', 'ellipse', 'text']
@@ -571,7 +575,11 @@ function onSummarize() {
emitAction('summarize')
}
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summarize') {
function onOCR() {
emitAction('ocr')
}
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summarize' | 'ocr') {
if (!rect.value) return
commitTextDraft()
const payload = {
@@ -583,6 +591,7 @@ function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summa
if (action === 'save') emit('save', payload)
if (action === 'save-remote') emit('save-remote', payload)
if (action === 'summarize') emit('summarize', payload)
if (action === 'ocr') emit('ocr', payload)
}
function onCancel() {
@@ -991,6 +1000,21 @@ onUnmounted(() => {
@click="onSaveRemote"
v-html="saveRemoteIcon"
/>
<button
class="action-btn"
data-tip="提取文字"
aria-label="提取文字"
@click="onOCR"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 8V5h3" />
<path d="M16 5h3v3" />
<path d="M19 16v3h-3" />
<path d="M8 19H5v-3" />
<path d="M9 15l3-7 3 7" />
<path d="M10 13h4" />
</svg>
</button>
<button
class="action-btn"
data-tip="复制总结"
@@ -1124,7 +1148,7 @@ onUnmounted(() => {
width: 178px;
}
.action-toolbar {
width: 216px;
width: 252px;
}
.icon-btn,
+163 -1
View File
@@ -9,6 +9,7 @@
* • "s3" — S3-compatible object-storage credentials.
* • "ssh" — SSH/SCP destination for the "save remote" button.
* • "llm" — multimodal screenshot summary provider settings.
* • "ocr" — cloud OCR provider settings for text extraction.
*
* State flow: load() pulls config from Go on mount, save() pushes back.
*/
@@ -23,7 +24,7 @@ import {
// Tab discriminator shared with the App shell. Kept as a string union so
// the parent can pass the value without importing a type from this view.
type TabId = 'general' | 's3' | 'ssh' | 'llm'
type TabId = 'general' | 's3' | 'ssh' | 'llm' | 'ocr'
type ThemeMode = 'auto' | 'light' | 'dark'
const props = defineProps<{
@@ -74,12 +75,30 @@ interface LLMConfig {
providers: Record<string, LLMProviderConfig>
}
interface OCRProviderConfig {
label: string
endpoint: string
accessKeyId: string
accessKeySecret: string
region: string
service: string
action: string
version: string
timeoutSecs: number
}
interface OCRConfig {
activeProvider: string
providers: Record<string, OCRProviderConfig>
}
interface AppConfig {
hotkey: string
theme: ThemeMode
s3: S3Config
ssh: SSHConfig
llm: LLMConfig
ocr: OCRConfig
}
const llmProviderOptions = [
@@ -88,6 +107,11 @@ const llmProviderOptions = [
{ id: 'openai', label: 'ChatGPT / OpenAI-compatible' },
]
const ocrProviderOptions = [
{ id: 'aliyun', label: '阿里云文字识别' },
{ id: 'volcengine', label: '火山引擎文字识别' },
]
const themeOptions: Array<{ id: ThemeMode; label: string }> = [
{ id: 'light', label: '浅色' },
{ id: 'dark', label: '深色' },
@@ -165,6 +189,33 @@ function defaultConfig(): AppConfig {
},
},
},
ocr: {
activeProvider: 'aliyun',
providers: {
aliyun: {
label: '阿里云文字识别',
endpoint: 'https://ocr-api.cn-hangzhou.aliyuncs.com',
accessKeyId: '',
accessKeySecret: '',
region: '',
service: '',
action: 'RecognizeGeneral',
version: '2021-07-07',
timeoutSecs: 30,
},
volcengine: {
label: '火山引擎文字识别',
endpoint: 'https://visual.volcengineapi.com',
accessKeyId: '',
accessKeySecret: '',
region: 'cn-north-1',
service: 'cv',
action: 'OCRNormal',
version: '2020-08-26',
timeoutSecs: 30,
},
},
},
}
}
@@ -198,6 +249,15 @@ const activeLLMProvider = computed(() => {
return config.value.llm.providers[id]
})
const activeOCRProvider = computed(() => {
const id = config.value.ocr.activeProvider || 'aliyun'
if (!config.value.ocr.providers[id]) {
config.value.ocr.activeProvider = 'aliyun'
return config.value.ocr.providers.aliyun
}
return config.value.ocr.providers[id]
})
function normalizeTheme(theme: unknown): ThemeMode {
return theme === 'light' || theme === 'dark' || theme === 'auto'
? theme
@@ -225,6 +285,11 @@ async function load() {
...defaultConfig().llm.providers,
...((cfg as any).llm?.providers ?? {}),
}
merged.ocr = { ...merged.ocr, ...(cfg as any).ocr }
merged.ocr.providers = {
...defaultConfig().ocr.providers,
...((cfg as any).ocr?.providers ?? {}),
}
merged.theme = normalizeTheme((cfg as any).theme)
config.value = merged
emit('theme-change', config.value.theme)
@@ -586,6 +651,103 @@ onMounted(load)
</div>
</section>
<!-- OCR tab cloud OCR provider settings for screenshot text extraction. -->
<section v-if="props.tab === 'ocr'" class="card">
<h2>文字提取</h2>
<div class="grid">
<label class="field full">
<span>Provider</span>
<select v-model="config.ocr.activeProvider">
<option
v-for="provider in ocrProviderOptions"
:key="provider.id"
:value="provider.id"
>
{{ provider.label }}
</option>
</select>
</label>
<label class="field full">
<span>Endpoint *</span>
<input
v-model="activeOCRProvider.endpoint"
placeholder="https://ocr-api.cn-hangzhou.aliyuncs.com"
spellcheck="false"
/>
</label>
<label class="field">
<span>AccessKey ID *</span>
<input v-model="activeOCRProvider.accessKeyId" spellcheck="false" />
</label>
<label class="field">
<span>AccessKey Secret *</span>
<input v-model="activeOCRProvider.accessKeySecret" type="password" />
</label>
<label
v-if="config.ocr.activeProvider === 'volcengine'"
class="field"
>
<span>Region *</span>
<input
v-model="activeOCRProvider.region"
placeholder="cn-north-1"
spellcheck="false"
/>
</label>
<label
v-if="config.ocr.activeProvider === 'volcengine'"
class="field"
>
<span>Service *</span>
<input
v-model="activeOCRProvider.service"
placeholder="cv"
spellcheck="false"
/>
</label>
<label class="field">
<span>Action *</span>
<input
v-model="activeOCRProvider.action"
placeholder="RecognizeGeneral / OCRNormal"
spellcheck="false"
/>
</label>
<label class="field">
<span>Version *</span>
<input
v-model="activeOCRProvider.version"
placeholder="2021-07-07 / 2020-08-26"
spellcheck="false"
/>
</label>
<label class="field">
<span>Timeout (sec)</span>
<input
v-model.number="activeOCRProvider.timeoutSecs"
type="number"
min="5"
max="120"
placeholder="30"
/>
</label>
</div>
<p v-if="config.ocr.activeProvider === 'aliyun'" class="hint">
阿里云默认使用 OCR API <code>RecognizeGeneral</code>请求体为截图
PNG 原始数据并使用 ACS3-HMAC-SHA256 签名
</p>
<p v-else class="hint">
火山引擎默认使用视觉智能的 <code>OCRNormal</code>截图以
<code>image_base64</code> 发送并使用 Region/Service 参与签名
</p>
<div class="actions">
<button class="btn primary" :disabled="saving" @click="save">
{{ saving ? 'Saving' : 'Save' }}
</button>
</div>
</section>
<transition name="fade">
<div
v-if="message"