feat: you can summary screen shot directly
This commit is contained in:
+132
-5
@@ -3,9 +3,7 @@
|
||||
* App shell — switches between two distinct UI modes that share the same
|
||||
* Wails window:
|
||||
*
|
||||
* • "settings" : full settings UI (self-drawn title bar + sidebar + form).
|
||||
* Self-drawn because the window is now Frameless to make
|
||||
* the overlay paint edge-to-edge.
|
||||
* • "settings" : full settings UI (native title bar + sidebar + form).
|
||||
* • "overlay" : Snipaste-style region picker that fills the whole
|
||||
* primary display.
|
||||
*
|
||||
@@ -24,6 +22,7 @@ import {
|
||||
CopyRegionImage,
|
||||
SaveRegionImage,
|
||||
SaveRegionToRemote,
|
||||
SummarizeRegion,
|
||||
CancelRegion,
|
||||
} from '../wailsjs/go/main/App'
|
||||
|
||||
@@ -39,7 +38,7 @@ const mode = ref<Mode>('settings')
|
||||
// `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'
|
||||
type SettingsTab = 'general' | 's3' | 'ssh' | 'llm'
|
||||
const activeTab = ref<SettingsTab>('general')
|
||||
|
||||
// Sidebar entries are declarative so adding a destination type later is
|
||||
@@ -48,6 +47,7 @@ const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 's3', label: 'S3' },
|
||||
{ id: 'ssh', label: 'SSH' },
|
||||
{ id: 'llm', label: 'LLM' },
|
||||
]
|
||||
|
||||
interface OverlayPayload {
|
||||
@@ -62,6 +62,14 @@ const capturing = ref(false)
|
||||
const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null)
|
||||
let toastTimer: number | undefined
|
||||
|
||||
const operationStatus = ref<{
|
||||
operation: string
|
||||
phase: string
|
||||
message: string
|
||||
state: 'running' | 'success' | 'error'
|
||||
} | null>(null)
|
||||
let operationTimer: number | undefined
|
||||
|
||||
function showToast(kind: 'success' | 'error', text: string) {
|
||||
toast.value = { kind, text }
|
||||
window.clearTimeout(toastTimer)
|
||||
@@ -70,6 +78,21 @@ function showToast(kind: 'success' | 'error', text: string) {
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function updateOperationStatus(payload: {
|
||||
operation: string
|
||||
phase: string
|
||||
message: string
|
||||
state: 'running' | 'success' | 'error'
|
||||
}) {
|
||||
operationStatus.value = payload
|
||||
window.clearTimeout(operationTimer)
|
||||
if (payload.state !== 'running') {
|
||||
operationTimer = window.setTimeout(() => {
|
||||
operationStatus.value = null
|
||||
}, payload.state === 'success' ? 1600 : 3200)
|
||||
}
|
||||
}
|
||||
|
||||
async function retryHotkey() {
|
||||
try {
|
||||
await RetryRegisterHotkey()
|
||||
@@ -168,6 +191,28 @@ async function onOverlaySaveRemote(rect: {
|
||||
}
|
||||
}
|
||||
|
||||
async function onOverlaySummarize(rect: {
|
||||
rect: {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
annotations: Array<{
|
||||
tool: string
|
||||
color: string
|
||||
points: Array<{ x: number; y: number }>
|
||||
}>
|
||||
}) {
|
||||
mode.value = 'settings'
|
||||
overlayPayload.value = null
|
||||
try {
|
||||
await SummarizeRegion(rect as any)
|
||||
} catch {
|
||||
/* Surfaced via upload:failure */
|
||||
}
|
||||
}
|
||||
|
||||
async function onOverlayCancel() {
|
||||
mode.value = 'settings'
|
||||
overlayPayload.value = null
|
||||
@@ -194,7 +239,12 @@ onMounted(() => {
|
||||
capturing.value = false
|
||||
})
|
||||
EventsOn('upload:success', (url: string) => {
|
||||
showToast('success', `Copied: ${url}`)
|
||||
showToast(
|
||||
'success',
|
||||
url === 'summary copied to clipboard'
|
||||
? 'Summary copied to clipboard'
|
||||
: `Copied: ${url}`
|
||||
)
|
||||
})
|
||||
EventsOn('upload:failure', (reason: string) => {
|
||||
showToast('error', reason)
|
||||
@@ -205,6 +255,7 @@ onMounted(() => {
|
||||
EventsOn('hotkey:error', (reason: string) => {
|
||||
hotkeyStatus.value = { state: 'error', reason }
|
||||
})
|
||||
EventsOn('operation:status', updateOperationStatus)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -216,6 +267,7 @@ onUnmounted(() => {
|
||||
EventsOff('upload:failure')
|
||||
EventsOff('hotkey:ready')
|
||||
EventsOff('hotkey:error')
|
||||
EventsOff('operation:status')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -229,6 +281,7 @@ onUnmounted(() => {
|
||||
@copy="onOverlayCopy"
|
||||
@save="onOverlaySave"
|
||||
@save-remote="onOverlaySaveRemote"
|
||||
@summarize="onOverlaySummarize"
|
||||
@cancel="onOverlayCancel"
|
||||
/>
|
||||
|
||||
@@ -264,6 +317,20 @@ onUnmounted(() => {
|
||||
</main>
|
||||
|
||||
<Toast v-if="toast" :kind="toast.kind" :text="toast.text" />
|
||||
<div
|
||||
v-if="operationStatus"
|
||||
class="operation-hud"
|
||||
:class="operationStatus.state"
|
||||
>
|
||||
<span v-if="operationStatus.state === 'running'" class="spinner" />
|
||||
<span v-else class="status-mark">
|
||||
{{ operationStatus.state === 'success' ? 'OK' : '!' }}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{{ operationStatus.phase }}</strong>
|
||||
<p>{{ operationStatus.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -371,6 +438,66 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.operation-hud {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
padding: 12px 14px;
|
||||
color: #f9fafb;
|
||||
background: rgba(17, 24, 39, 0.94);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 34px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
.operation-hud strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.operation-hud p {
|
||||
margin: 0;
|
||||
color: #d1d5db;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #60a5fa;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.status-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.operation-hud.success .status-mark {
|
||||
color: #052e16;
|
||||
background: #86efac;
|
||||
}
|
||||
.operation-hud.error .status-mark {
|
||||
color: #450a0a;
|
||||
background: #fca5a5;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.app-root {
|
||||
background: #1c1d22;
|
||||
|
||||
@@ -51,6 +51,10 @@ const emit = defineEmits<{
|
||||
e: 'save-remote',
|
||||
payload: { rect: Rect; annotations: Annotation[] }
|
||||
): void
|
||||
(
|
||||
e: 'summarize',
|
||||
payload: { rect: Rect; annotations: Annotation[] }
|
||||
): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
@@ -112,7 +116,7 @@ const sizeLabel = computed(() => {
|
||||
})
|
||||
|
||||
const MARK_TOOLBAR_W = 190
|
||||
const ACTION_TOOLBAR_W = 180
|
||||
const ACTION_TOOLBAR_W = 216
|
||||
const TOOLBAR_GROUP_GAP = 8
|
||||
|
||||
const rightToolbarPos = computed(() => {
|
||||
@@ -363,7 +367,11 @@ function onSaveRemote() {
|
||||
emitAction('save-remote')
|
||||
}
|
||||
|
||||
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') {
|
||||
function onSummarize() {
|
||||
emitAction('summarize')
|
||||
}
|
||||
|
||||
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summarize') {
|
||||
if (!rect.value) return
|
||||
const payload = {
|
||||
rect: { ...rect.value },
|
||||
@@ -373,6 +381,7 @@ function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') {
|
||||
if (action === 'copy') emit('copy', payload)
|
||||
if (action === 'save') emit('save', payload)
|
||||
if (action === 'save-remote') emit('save-remote', payload)
|
||||
if (action === 'summarize') emit('summarize', payload)
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
@@ -593,8 +602,8 @@ onUnmounted(() => {
|
||||
/>
|
||||
<button
|
||||
class="action-btn"
|
||||
data-tip="复制图标"
|
||||
aria-label="复制图标"
|
||||
data-tip="复制图片"
|
||||
aria-label="复制图片"
|
||||
@click="onCopy"
|
||||
v-html="copyIcon"
|
||||
/>
|
||||
@@ -612,6 +621,19 @@ onUnmounted(() => {
|
||||
@click="onSaveRemote"
|
||||
v-html="saveRemoteIcon"
|
||||
/>
|
||||
<button
|
||||
class="action-btn"
|
||||
data-tip="复制总结"
|
||||
aria-label="复制总结"
|
||||
@click="onSummarize"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 3h8l4 4v14H6z" />
|
||||
<path d="M14 3v5h4" />
|
||||
<path d="M9 12h6" />
|
||||
<path d="M9 16h5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn primary"
|
||||
data-tip="上传云端"
|
||||
@@ -706,7 +728,7 @@ onUnmounted(() => {
|
||||
width: 190px;
|
||||
}
|
||||
.action-toolbar {
|
||||
width: 180px;
|
||||
width: 216px;
|
||||
}
|
||||
|
||||
.icon-btn,
|
||||
@@ -762,6 +784,16 @@ onUnmounted(() => {
|
||||
fill: currentColor;
|
||||
pointer-events: none;
|
||||
}
|
||||
.action-btn > svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
.action-btn::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* • "general" — the global hotkey / capture parameters.
|
||||
* • "s3" — S3-compatible object-storage credentials.
|
||||
* • "ssh" — SSH/SCP destination for the "save remote" button.
|
||||
* • "llm" — multimodal screenshot summary provider settings.
|
||||
*
|
||||
* State flow: load() pulls config from Go on mount, save() pushes back.
|
||||
*/
|
||||
@@ -22,7 +23,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'
|
||||
type TabId = 'general' | 's3' | 'ssh' | 'llm'
|
||||
|
||||
const props = defineProps<{
|
||||
tab: TabId
|
||||
@@ -51,12 +52,44 @@ interface SSHConfig {
|
||||
connectTimeoutSecs: number
|
||||
}
|
||||
|
||||
interface LLMProviderConfig {
|
||||
label: string
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
model: string
|
||||
maxTokens: number
|
||||
temperature: number
|
||||
timeoutSecs: number
|
||||
}
|
||||
|
||||
interface LLMConfig {
|
||||
activeProvider: string
|
||||
prompt: string
|
||||
providers: Record<string, LLMProviderConfig>
|
||||
}
|
||||
|
||||
interface AppConfig {
|
||||
hotkey: string
|
||||
s3: S3Config
|
||||
ssh: SSHConfig
|
||||
llm: LLMConfig
|
||||
}
|
||||
|
||||
const llmProviderOptions = [
|
||||
{ id: 'qwen', label: '阿里千问多模态' },
|
||||
{ id: 'doubao', label: '火山方舟豆包多模态' },
|
||||
{ id: 'openai', label: 'ChatGPT / OpenAI-compatible' },
|
||||
]
|
||||
|
||||
const DEFAULT_SUMMARY_PROMPT = `你是一个截图内容总结助手。请阅读截图,并用中文输出一段适合直接粘贴到聊天、工单、Issue 或 PR 中的说明。
|
||||
|
||||
要求:
|
||||
- 先说明截图中最重要的信息和用户可能想表达的意图。
|
||||
- 如果截图包含报错、异常状态、表格、代码、界面控件或关键数字,请准确提取。
|
||||
- 如果截图内容不足以判断,不要编造;直接说明不确定点。
|
||||
- 输出尽量简洁,默认 3 到 6 条要点。
|
||||
- 不要输出 Markdown 标题,不要寒暄。`
|
||||
|
||||
// `defaultConfig` keeps the initial render in sync with the Go-side
|
||||
// DefaultAppConfig so the UI never flashes empty fields before load()
|
||||
// completes. Centralising the defaults also avoids subtle drift between
|
||||
@@ -85,6 +118,39 @@ function defaultConfig(): AppConfig {
|
||||
knownHostsPath: '',
|
||||
connectTimeoutSecs: 10,
|
||||
},
|
||||
llm: {
|
||||
activeProvider: 'openai',
|
||||
prompt: DEFAULT_SUMMARY_PROMPT,
|
||||
providers: {
|
||||
qwen: {
|
||||
label: '阿里千问多模态',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
apiKey: '',
|
||||
model: 'qwen-vl-plus',
|
||||
maxTokens: 600,
|
||||
temperature: 0.2,
|
||||
timeoutSecs: 60,
|
||||
},
|
||||
doubao: {
|
||||
label: '火山方舟豆包多模态',
|
||||
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
apiKey: '',
|
||||
model: '',
|
||||
maxTokens: 600,
|
||||
temperature: 0.2,
|
||||
timeoutSecs: 60,
|
||||
},
|
||||
openai: {
|
||||
label: 'ChatGPT / OpenAI-compatible',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
apiKey: '',
|
||||
model: 'gpt-5.5',
|
||||
maxTokens: 600,
|
||||
temperature: 0.2,
|
||||
timeoutSecs: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +175,15 @@ const sshPathDisplay = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const activeLLMProvider = computed(() => {
|
||||
const id = config.value.llm.activeProvider || 'openai'
|
||||
if (!config.value.llm.providers[id]) {
|
||||
config.value.llm.activeProvider = 'openai'
|
||||
return config.value.llm.providers.openai
|
||||
}
|
||||
return config.value.llm.providers[id]
|
||||
})
|
||||
|
||||
function flash(kind: 'ok' | 'err', text: string) {
|
||||
message.value = { kind, text }
|
||||
window.setTimeout(() => {
|
||||
@@ -125,6 +200,11 @@ async function load() {
|
||||
Object.assign(merged, cfg)
|
||||
merged.s3 = { ...merged.s3, ...(cfg as any).s3 }
|
||||
merged.ssh = { ...merged.ssh, ...(cfg as any).ssh }
|
||||
merged.llm = { ...merged.llm, ...(cfg as any).llm }
|
||||
merged.llm.providers = {
|
||||
...defaultConfig().llm.providers,
|
||||
...((cfg as any).llm?.providers ?? {}),
|
||||
}
|
||||
config.value = merged
|
||||
}
|
||||
}
|
||||
@@ -368,6 +448,103 @@ onMounted(load)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LLM tab — multimodal screenshot summary provider settings. -->
|
||||
<section v-if="props.tab === 'llm'" class="card">
|
||||
<h2>LLM screenshot summary</h2>
|
||||
<div class="grid">
|
||||
<label class="field full">
|
||||
<span>Provider</span>
|
||||
<select v-model="config.llm.activeProvider">
|
||||
<option
|
||||
v-for="provider in llmProviderOptions"
|
||||
:key="provider.id"
|
||||
:value="provider.id"
|
||||
>
|
||||
{{ provider.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field full">
|
||||
<span>Base URL *</span>
|
||||
<input
|
||||
v-model="activeLLMProvider.baseUrl"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>API Key *</span>
|
||||
<input v-model="activeLLMProvider.apiKey" type="password" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Model *</span>
|
||||
<input
|
||||
v-model="activeLLMProvider.model"
|
||||
placeholder="gpt-5.5 / qwen-vl-plus / Ark endpoint ID"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max tokens</span>
|
||||
<input
|
||||
v-model.number="activeLLMProvider.maxTokens"
|
||||
type="number"
|
||||
min="64"
|
||||
max="4096"
|
||||
placeholder="600"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Temperature</span>
|
||||
<input
|
||||
v-model.number="activeLLMProvider.temperature"
|
||||
type="number"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
placeholder="0.2"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Timeout (sec)</span>
|
||||
<input
|
||||
v-model.number="activeLLMProvider.timeoutSecs"
|
||||
type="number"
|
||||
min="10"
|
||||
max="300"
|
||||
placeholder="60"
|
||||
/>
|
||||
</label>
|
||||
<label class="field full">
|
||||
<span>Prompt</span>
|
||||
<textarea
|
||||
v-model="config.llm.prompt"
|
||||
class="prompt-textarea"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">
|
||||
The "copy summary" screenshot action first uploads the image through the
|
||||
S3 configuration, then sends the public image URL to this multimodal
|
||||
provider. Keep S3 configured before using it.
|
||||
</p>
|
||||
<p v-if="config.llm.activeProvider === 'doubao'" class="hint">
|
||||
Volcengine Ark usually uses the inference endpoint ID as the model
|
||||
value; paste the endpoint/model identifier from your Ark console.
|
||||
</p>
|
||||
<p v-else-if="config.llm.activeProvider === 'qwen'" class="hint">
|
||||
DashScope OpenAI-compatible mode is used here. If your workspace uses a
|
||||
regional endpoint, replace Base URL with that compatible-mode URL.
|
||||
</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"
|
||||
@@ -460,13 +637,13 @@ kbd {
|
||||
.field input[type='text'],
|
||||
.field input[type='number'],
|
||||
.field input:not([type='checkbox']),
|
||||
.field select {
|
||||
.field select,
|
||||
.field textarea {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
height: 36px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
@@ -474,8 +651,20 @@ kbd {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.field input[type='text'],
|
||||
.field input[type='number'],
|
||||
.field input:not([type='checkbox']),
|
||||
.field select {
|
||||
height: 36px;
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 168px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
|
||||
}
|
||||
@@ -598,15 +787,20 @@ kbd {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
.field input {
|
||||
.field input,
|
||||
.field textarea {
|
||||
background: #111827;
|
||||
border-color: #374151;
|
||||
color: #e5e7eb;
|
||||
color: #fff;
|
||||
}
|
||||
.field select {
|
||||
background: #111827;
|
||||
border-color: #374151;
|
||||
color: #e5e7eb;
|
||||
color: #fff;
|
||||
}
|
||||
.field input::placeholder,
|
||||
.field textarea::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.field input:disabled {
|
||||
background: #1f2937;
|
||||
|
||||
Reference in New Issue
Block a user