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"
+4
View File
@@ -17,6 +17,10 @@ export function CopyNativeRegionImage(arg1:main.CaptureResult):Promise<void>;
export function CopyRegionImage(arg1:main.CaptureResult):Promise<void>;
export function ExtractTextNativeRegion(arg1:main.CaptureResult):Promise<void>;
export function ExtractTextRegion(arg1:main.CaptureResult):Promise<void>;
export function GetConfig():Promise<domain.AppConfig>;
export function QuitApp():Promise<void>;
+8
View File
@@ -30,6 +30,14 @@ export function CopyRegionImage(arg1) {
return window['go']['main']['App']['CopyRegionImage'](arg1);
}
export function ExtractTextNativeRegion(arg1) {
return window['go']['main']['App']['ExtractTextNativeRegion'](arg1);
}
export function ExtractTextRegion(arg1) {
return window['go']['main']['App']['ExtractTextRegion'](arg1);
}
export function GetConfig() {
return window['go']['main']['App']['GetConfig']();
}
+93 -30
View File
@@ -1,13 +1,13 @@
export namespace application {
export class Point {
x: number;
y: number;
static createFrom(source: any = {}) {
return new Point(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.x = source["x"];
@@ -25,7 +25,7 @@ export namespace application {
static createFrom(source: any = {}) {
return new Annotation(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.tool = source["tool"];
@@ -35,7 +35,7 @@ export namespace application {
this.strokeWidth = source["strokeWidth"];
this.fontSize = source["fontSize"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -58,7 +58,67 @@ export namespace application {
}
export namespace domain {
export class OCRProviderConfig {
label: string;
endpoint: string;
accessKeyId: string;
accessKeySecret: string;
region: string;
service: string;
action: string;
version: string;
timeoutSecs: number;
static createFrom(source: any = {}) {
return new OCRProviderConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.endpoint = source["endpoint"];
this.accessKeyId = source["accessKeyId"];
this.accessKeySecret = source["accessKeySecret"];
this.region = source["region"];
this.service = source["service"];
this.action = source["action"];
this.version = source["version"];
this.timeoutSecs = source["timeoutSecs"];
}
}
export class OCRConfig {
activeProvider: string;
providers: Record<string, OCRProviderConfig>;
static createFrom(source: any = {}) {
return new OCRConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.activeProvider = source["activeProvider"];
this.providers = this.convertValues(source["providers"], OCRProviderConfig, true);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class LLMProviderConfig {
label: string;
baseUrl: string;
@@ -68,11 +128,11 @@ export namespace domain {
temperature: number;
timeoutSecs: number;
maxInlineBytes: number;
static createFrom(source: any = {}) {
return new LLMProviderConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
@@ -89,18 +149,18 @@ export namespace domain {
activeProvider: string;
prompt: string;
providers: Record<string, LLMProviderConfig>;
static createFrom(source: any = {}) {
return new LLMConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.activeProvider = source["activeProvider"];
this.prompt = source["prompt"];
this.providers = this.convertValues(source["providers"], LLMProviderConfig, true);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -129,11 +189,11 @@ export namespace domain {
strictHostKey: boolean;
knownHostsPath: string;
connectTimeoutSecs: number;
static createFrom(source: any = {}) {
return new SSHConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.host = source["host"];
@@ -156,11 +216,11 @@ export namespace domain {
pathPrefix: string;
publicUrlBase: string;
usePathStyle: boolean;
static createFrom(source: any = {}) {
return new S3Config(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.endpoint = source["endpoint"];
@@ -179,11 +239,12 @@ export namespace domain {
s3: S3Config;
ssh: SSHConfig;
llm: LLMConfig;
ocr: OCRConfig;
static createFrom(source: any = {}) {
return new AppConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.hotkey = source["hotkey"];
@@ -191,8 +252,9 @@ export namespace domain {
this.s3 = this.convertValues(source["s3"], S3Config);
this.ssh = this.convertValues(source["ssh"], SSHConfig);
this.llm = this.convertValues(source["llm"], LLMConfig);
this.ocr = this.convertValues(source["ocr"], OCRConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -211,22 +273,24 @@ export namespace domain {
return a;
}
}
}
export namespace main {
export class CaptureActionResult {
completed: boolean;
path?: string;
static createFrom(source: any = {}) {
return new CaptureActionResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.completed = source["completed"];
@@ -238,11 +302,11 @@ export namespace main {
y: number;
w: number;
h: number;
static createFrom(source: any = {}) {
return new RegionRect(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.x = source["x"];
@@ -254,17 +318,17 @@ export namespace main {
export class CaptureResult {
rect: RegionRect;
annotations: application.Annotation[];
static createFrom(source: any = {}) {
return new CaptureResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.rect = this.convertValues(source["rect"], RegionRect);
this.annotations = this.convertValues(source["annotations"], application.Annotation);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -285,4 +349,3 @@ export namespace main {
}
}