Skip to content

Commit 87a98e6

Browse files
authored
Merge pull request #6 from GwIhViEte/feature/新增停止生成按钮
Feature/新增停止生成按钮
2 parents 6731462 + 2b83879 commit 87a98e6

11 files changed

Lines changed: 242 additions & 27 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/ai.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createOpenAI } from '@ai-sdk/openai'
33
import { PROMPT_SYSTEM } from './prompts'
44
import { settings } from './settings'
55

6-
export function getSolutionStream(base64Image: string) {
6+
export function getSolutionStream(base64Image: string, abortSignal?: AbortSignal) {
77
const openai = createOpenAI({
88
baseURL: settings.apiBaseURL,
99
apiKey: settings.apiKey
@@ -18,15 +18,16 @@ export function getSolutionStream(base64Image: string) {
1818
content: [
1919
{
2020
type: 'text',
21-
text: `The screenshot is as follows, and the programming language is ${settings.codeLanguage}.`
21+
text: `以下是一张截图。编程语言:${settings.codeLanguage || '未指定'}。请严格将所有代码放在 Markdown 代码块中(使用三反引号,若可请标注语言),解释文字放在代码块之外。`
2222
},
2323
{
2424
type: 'image',
2525
image: base64Image
2626
}
2727
]
2828
}
29-
]
29+
],
30+
abortSignal
3031
})
3132
return textStream
3233
}

src/main/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
import 'dotenv/config'
22
import { app, BrowserWindow, globalShortcut } from 'electron'
3+
4+
// Swallow AbortError from user-initiated stream cancellations to keep console clean
5+
function isAbortError(error: unknown): boolean {
6+
const err = error as any
7+
return (
8+
!!err &&
9+
(err.name === 'AbortError' || err.code === 'ABORT_ERR' || /aborted/i.test(String(err.message)))
10+
)
11+
}
12+
13+
process.on('unhandledRejection', (error) => {
14+
if (isAbortError(error)) return
15+
console.error(error)
16+
})
17+
18+
process.on('uncaughtException', (error) => {
19+
if (isAbortError(error)) return
20+
console.error(error)
21+
})
322
import { electronApp, optimizer } from '@electron-toolkit/utils'
423
import './shortcuts'
524
import { createWindow } from './main-window'

src/main/prompts.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
export const PROMPT_SYSTEM = `
2-
你是一个经验丰富的程序员,擅长解决编程问题。
3-
你将根据用户提供的截图,给出解决问题的思路和代码。
4-
请使用中文回答。
2+
你是一名经验丰富的程序员,负责根据截图快速分析问题并给出可直接运行的解决方案。
3+
请使用简洁中文回答,并遵循以下要求:
54
6-
* 先分析截图,提取出问题描述
7-
* 根据问题描述,给出「解决问题的思路」,优先选择简单的解法
8-
* 根据解题思路给出「代码」,代码需要有详尽的「注释」
9-
* 提供测试用例,并给出测试结果
10-
* 最后给出「总结」,总结解题思路和代码实现,以及测试结果
5+
- 先简要概括截图中题目/需求与关键点;
6+
- 给出清晰的解题思路与步骤,优先选择实现难度更低、鲁棒性更好的方案;
7+
- 依据思路输出完整代码,包含必要的注释;
8+
- 如有多种实现或注意事项,列出权衡与扩展建议;
119
12-
如果分析截图没哟发现编程问题,则进行以下尝试
13-
* 尝试查找其他编程相关问题,如果有,则给出答案,并跟进情况给出适当的解析和提示。
14-
* 尝试查找其他问题,如果有,则给出答案,并跟进情况给出适当的解析和提示。
15-
* 如果以上尝试都没有发现问题,则对截图内容进行分析总结。
10+
格式与代码输出规范(重要):
11+
- 所有代码必须使用 Markdown 代码块包裹,使用三反引号 \`\`\` 开始与结束;
12+
- 若已知编程语言,请在开头三反引号后标注语言(例如 \`\`\`ts、\`\`\`python);
13+
- 解释性文字必须放在代码块之外,禁止把解释写进代码块;
14+
- 如有多段代码,分别使用独立的代码块;
15+
- 若需要补充命令行或配置,也使用独立代码块并标注对应语言(如 \`\`\`bash、\`\`\`json)。
16+
17+
当截图无法明确题目时:
18+
- 可合理推断题目并给出自洽的示例与实现;
19+
- 或指出缺失信息并给出可执行的最小示例与后续补全建议。
1620
`
21+

src/main/shortcuts.ts

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ enum ShortcutStatus {
1919

2020
const MOVE_STEP = 200
2121
const shortcuts: Record<string, Shortcut> = {}
22+
23+
type AbortReason = 'user' | 'new-request'
24+
25+
interface StreamContext {
26+
controller: AbortController
27+
reason: AbortReason | null
28+
}
29+
30+
let currentStreamContext: StreamContext | null = null
31+
32+
function abortCurrentStream(reason: AbortReason) {
33+
if (!currentStreamContext) return
34+
currentStreamContext.reason = reason
35+
currentStreamContext.controller.abort()
36+
}
37+
2238
const callbacks: Record<string, () => void> = {
2339
hideOrShowMainWindow: async () => {
2440
const mainWindow = global.mainWindow
@@ -33,16 +49,74 @@ const callbacks: Record<string, () => void> = {
3349
takeScreenshot: async () => {
3450
const mainWindow = global.mainWindow
3551
if (!mainWindow || mainWindow.isDestroyed() || !state.inCoderPage || !settings.apiKey) return
52+
53+
abortCurrentStream('new-request')
3654
const screenshotData = await takeScreenshot()
3755
if (screenshotData && mainWindow && !mainWindow.isDestroyed()) {
56+
const streamContext: StreamContext = {
57+
controller: new AbortController(),
58+
reason: null
59+
}
60+
currentStreamContext = streamContext
3861
mainWindow.webContents.send('screenshot-taken', screenshotData)
39-
const solutionStream = getSolutionStream(screenshotData)
40-
for await (const chunk of solutionStream) {
41-
mainWindow.webContents.send('solution-chunk', chunk)
62+
let endedNaturally = true
63+
let streamStarted = false
64+
try {
65+
const solutionStream = getSolutionStream(screenshotData, streamContext.controller.signal)
66+
streamStarted = true
67+
try {
68+
for await (const chunk of solutionStream) {
69+
if (streamContext.controller.signal.aborted) {
70+
endedNaturally = false
71+
break
72+
}
73+
mainWindow.webContents.send('solution-chunk', chunk)
74+
}
75+
} catch (error) {
76+
if (!streamContext.controller.signal.aborted) {
77+
endedNaturally = false
78+
console.error('Error streaming solution:', error)
79+
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
80+
mainWindow.webContents.send('solution-error', errorMessage)
81+
} else {
82+
endedNaturally = false
83+
}
84+
}
85+
86+
if (streamContext.controller.signal.aborted) {
87+
if (streamContext.reason === 'user') {
88+
mainWindow.webContents.send('solution-stopped')
89+
}
90+
} else if (endedNaturally) {
91+
mainWindow.webContents.send('solution-complete')
92+
}
93+
} catch (error) {
94+
if (streamContext.controller.signal.aborted) {
95+
if (streamContext.reason === 'user') {
96+
mainWindow.webContents.send('solution-stopped')
97+
}
98+
} else {
99+
endedNaturally = false
100+
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
101+
console.error('Error streaming solution:', error)
102+
mainWindow.webContents.send('solution-error', errorMessage)
103+
}
104+
} finally {
105+
if (currentStreamContext === streamContext) {
106+
currentStreamContext = null
107+
}
108+
if (!streamStarted && streamContext.reason === 'user') {
109+
mainWindow.webContents.send('solution-stopped')
110+
}
42111
}
43112
}
44113
},
45114

115+
// Stop current AI solution stream
116+
stopSolutionStream: () => {
117+
abortCurrentStream('user')
118+
},
119+
46120
ignoreOrEnableMouse: () => {
47121
const mainWindow = global.mainWindow
48122
if (!mainWindow || mainWindow.isDestroyed() || !state.inCoderPage) return
@@ -122,3 +196,9 @@ ipcMain.handle('updateShortcuts', (_event, _shortcuts: { action: string; key: st
122196
}
123197
})
124198
})
199+
200+
ipcMain.handle('stopSolutionStream', () => {
201+
if (!currentStreamContext) return false
202+
abortCurrentStream('user')
203+
return true
204+
})

src/preload/index.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ declare global {
1919
removeScreenshotListener: () => void
2020
onSolutionChunk: (callback: (chunk: string) => void) => void
2121
removeSolutionChunkListener: () => void
22+
stopSolutionStream: () => Promise<boolean>
23+
onSolutionComplete: (callback: () => void) => void
24+
removeSolutionCompleteListener: () => void
25+
onSolutionStopped: (callback: () => void) => void
26+
removeSolutionStoppedListener: () => void
27+
onSolutionError: (callback: (message: string) => void) => void
28+
removeSolutionErrorListener: () => void
2229
onScrollPageUp: (callback: () => void) => void
2330
removeScrollPageUpListener: () => void
2431
onScrollPageDown: (callback: () => void) => void

src/preload/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,33 @@ const api = {
5555
ipcRenderer.removeAllListeners('solution-chunk')
5656
},
5757

58+
// Stop solution stream
59+
stopSolutionStream: () => ipcRenderer.invoke('stopSolutionStream'),
60+
61+
// Listen for solution completion
62+
onSolutionComplete: (callback: () => void) => {
63+
ipcRenderer.on('solution-complete', callback)
64+
},
65+
removeSolutionCompleteListener: () => {
66+
ipcRenderer.removeAllListeners('solution-complete')
67+
},
68+
69+
onSolutionStopped: (callback: () => void) => {
70+
ipcRenderer.on('solution-stopped', callback)
71+
},
72+
removeSolutionStoppedListener: () => {
73+
ipcRenderer.removeAllListeners('solution-stopped')
74+
},
75+
76+
onSolutionError: (callback: (message: string) => void) => {
77+
ipcRenderer.on('solution-error', (_event, message) => {
78+
callback(message)
79+
})
80+
},
81+
removeSolutionErrorListener: () => {
82+
ipcRenderer.removeAllListeners('solution-error')
83+
},
84+
5885
// Listen for scroll page up
5986
onScrollPageUp: (callback: () => void) => {
6087
ipcRenderer.on('scroll-page-up', callback)

src/renderer/src/coder/AppContent.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ export function AppContent() {
1717
clearSolution
1818
} = useSolutionStore()
1919

20+
// Ensure any unfinished fenced code block is closed when a run ends
21+
const ensureClosedFence = () => {
22+
const text = useSolutionStore.getState().solutionChunks.join('')
23+
const fenceCount = (text.match(/```/g) || []).length
24+
if (fenceCount % 2 === 1) {
25+
addSolutionChunk('\n```')
26+
}
27+
}
28+
2029
useEffect(() => {
2130
// Listen for screenshot events
2231
window.api.onScreenshotTaken((data: string) => {
@@ -38,6 +47,24 @@ export function AppContent() {
3847
}
3948
}, [setScreenshotData, clearSolution, setIsLoading, addSolutionChunk])
4049

50+
useEffect(() => {
51+
window.api.onSolutionComplete(() => {
52+
setIsLoading(false)
53+
})
54+
window.api.onSolutionStopped(() => {
55+
setIsLoading(false)
56+
})
57+
window.api.onSolutionError((message: string) => {
58+
setIsLoading(false)
59+
addSolutionChunk(`\n\n> 生成失败:${message}`)
60+
})
61+
return () => {
62+
window.api.removeSolutionCompleteListener()
63+
window.api.removeSolutionStoppedListener()
64+
window.api.removeSolutionErrorListener()
65+
}
66+
}, [setIsLoading, addSolutionChunk])
67+
4168
// Mark solution as complete when chunks stop coming
4269
useEffect(() => {
4370
if (isLoading && solutionChunks.length > 0) {
@@ -50,6 +77,13 @@ export function AppContent() {
5077
return undefined
5178
}, [solutionChunks, isLoading, setIsLoading])
5279

80+
// When run ends (isLoading goes false), ensure code fence is closed
81+
useEffect(() => {
82+
if (!isLoading) {
83+
ensureClosedFence()
84+
}
85+
}, [isLoading])
86+
5387
useEffect(() => {
5488
window.api.onScrollPageUp(() => {
5589
const container = document.getElementById('app-content')

src/renderer/src/coder/AppStatusBar.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
1-
import { Pointer, PointerOff } from 'lucide-react'
1+
import { Pointer, PointerOff, Square } from 'lucide-react'
22
import { useSolutionStore } from '@/lib/store/solution'
33
import { useShortcutsStore } from '@/lib/store/shortcuts'
44
import { useAppStore } from '@/lib/store/app'
55
import ShortcutRenderer from '@/components/ShortcutRenderer'
6+
import { Button } from '@/components/ui/button'
67

78
export function AppStatusBar() {
8-
const { isLoading: isReceivingSolution } = useSolutionStore()
9+
const { isLoading: isReceivingSolution, setIsLoading } = useSolutionStore()
910
const { ignoreMouse } = useAppStore()
1011
const { shortcuts } = useShortcutsStore()
1112

13+
const handleStop = () => {
14+
setIsLoading(false)
15+
void window.api.stopSolutionStream()
16+
}
17+
1218
return (
1319
<div className="absolute bottom-0 flex items-center justify-between w-full text-blue-100 bg-gray-600/10 px-4 pb-1">
1420
<div>
1521
{isReceivingSolution && (
16-
<div className="flex items-center">
17-
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-r-2 border-[currentColor] mr-2"></div>
18-
<span className="text-sm">分析中...</span>
22+
<div className="flex items-center space-x-2">
23+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-r-2 border-[currentColor]"></div>
24+
<span className="text-sm">正在生成...</span>
25+
<Button
26+
variant="secondary"
27+
size="sm"
28+
className="h-6 px-2 text-xs"
29+
onClick={handleStop}
30+
>
31+
<Square className="w-3 h-3 mr-1" />
32+
停止生成
33+
</Button>
1934
</div>
2035
)}
2136
</div>
@@ -26,7 +41,7 @@ export function AppStatusBar() {
2641
<>
2742
<PointerOff className="w-4 h-4 mr-2" />
2843
<span className="text-xs">
29-
取消鼠标穿透
44+
取消鼠标透传
3045
<ShortcutRenderer
3146
shortcut={shortcuts.ignoreOrEnableMouse.key}
3247
className="inline-block scale-75 text-xs border border-current bg-transparent py-0 px-1"

0 commit comments

Comments
 (0)