Skip to content

Commit 403a818

Browse files
committed
feat: Add follow-up question functionality and conversation history management
1 parent ed497c0 commit 403a818

File tree

6 files changed

+271
-33
lines changed

6 files changed

+271
-33
lines changed

src/main/ai.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readFileSync } from 'node:fs'
22
import { join } from 'node:path'
3-
import { streamText } from 'ai'
3+
import { streamText, type ModelMessage } from 'ai'
44
import { createOpenAI } from '@ai-sdk/openai'
55
import { settings } from './settings'
66

@@ -35,3 +35,37 @@ export function getSolutionStream(base64Image: string, abortSignal?: AbortSignal
3535
})
3636
return textStream
3737
}
38+
39+
export function getFollowUpStream(
40+
messages: ModelMessage[],
41+
userQuestion: string,
42+
abortSignal?: AbortSignal
43+
) {
44+
const openai = createOpenAI({
45+
baseURL: settings.apiBaseURL,
46+
apiKey: settings.apiKey
47+
})
48+
49+
// Add the user's follow-up question to the conversation
50+
const updatedMessages: ModelMessage[] = [
51+
...messages,
52+
{
53+
role: 'user',
54+
content: [
55+
{
56+
type: 'text',
57+
text: userQuestion
58+
}
59+
]
60+
}
61+
]
62+
63+
const { textStream } = streamText({
64+
model: openai(settings.model || 'gpt-4o-mini'),
65+
system:
66+
settings.customPrompt || PROMPT_SYSTEM + `\n使用编程语言:${settings.codeLanguage} 解答。`,
67+
messages: updatedMessages,
68+
abortSignal
69+
})
70+
return textStream
71+
}

src/main/shortcuts.ts

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { globalShortcut, ipcMain } from 'electron'
2+
import type { ModelMessage } from 'ai'
23
import { takeScreenshot } from './take-screenshot'
3-
import { getSolutionStream } from './ai'
4+
import { getSolutionStream, getFollowUpStream } from './ai'
45
import { state } from './state'
56
import { settings } from './settings'
67

@@ -29,6 +30,9 @@ interface StreamContext {
2930

3031
let currentStreamContext: StreamContext | null = null
3132

33+
// Conversation history tracking
34+
let conversationMessages: ModelMessage[] = []
35+
3236
function abortCurrentStream(reason: AbortReason) {
3337
if (!currentStreamContext) return
3438
currentStreamContext.reason = reason
@@ -53,6 +57,22 @@ const callbacks: Record<string, () => void> = {
5357
abortCurrentStream('new-request')
5458
const screenshotData = await takeScreenshot()
5559
if (screenshotData && mainWindow && !mainWindow.isDestroyed()) {
60+
conversationMessages = [
61+
{
62+
role: 'user',
63+
content: [
64+
{
65+
type: 'text',
66+
text: `这是屏幕截图`
67+
},
68+
{
69+
type: 'image',
70+
image: screenshotData
71+
}
72+
]
73+
}
74+
]
75+
5676
const streamContext: StreamContext = {
5777
controller: new AbortController(),
5878
reason: null
@@ -61,6 +81,7 @@ const callbacks: Record<string, () => void> = {
6181
mainWindow.webContents.send('screenshot-taken', screenshotData)
6282
let endedNaturally = true
6383
let streamStarted = false
84+
let assistantResponse = ''
6485
try {
6586
const solutionStream = getSolutionStream(screenshotData, streamContext.controller.signal)
6687
streamStarted = true
@@ -70,6 +91,7 @@ const callbacks: Record<string, () => void> = {
7091
endedNaturally = false
7192
break
7293
}
94+
assistantResponse += chunk
7395
mainWindow.webContents.send('solution-chunk', chunk)
7496
}
7597
} catch (error) {
@@ -88,6 +110,13 @@ const callbacks: Record<string, () => void> = {
88110
mainWindow.webContents.send('solution-stopped')
89111
}
90112
} else if (endedNaturally) {
113+
// Add assistant response to conversation history
114+
if (assistantResponse) {
115+
conversationMessages.push({
116+
role: 'assistant',
117+
content: assistantResponse
118+
})
119+
}
91120
mainWindow.webContents.send('solution-complete')
92121
}
93122
} catch (error) {
@@ -202,3 +231,102 @@ ipcMain.handle('stopSolutionStream', () => {
202231
abortCurrentStream('user')
203232
return true
204233
})
234+
235+
ipcMain.handle('sendFollowUpQuestion', async (_event, question: string) => {
236+
const mainWindow = global.mainWindow
237+
if (!mainWindow || mainWindow.isDestroyed() || !state.inCoderPage || !settings.apiKey) {
238+
return { success: false, error: 'Invalid state' }
239+
}
240+
241+
// Validate that there's an active conversation
242+
if (conversationMessages.length === 0) {
243+
return { success: false, error: 'No active conversation' }
244+
}
245+
246+
abortCurrentStream('new-request')
247+
const streamContext: StreamContext = {
248+
controller: new AbortController(),
249+
reason: null
250+
}
251+
currentStreamContext = streamContext
252+
253+
// Add a separator before the follow-up response
254+
mainWindow.webContents.send('solution-chunk', '\n\n---\n\n')
255+
256+
let endedNaturally = true
257+
let streamStarted = false
258+
let assistantResponse = ''
259+
260+
try {
261+
const followUpStream = getFollowUpStream(
262+
conversationMessages,
263+
question,
264+
streamContext.controller.signal
265+
)
266+
streamStarted = true
267+
268+
try {
269+
for await (const chunk of followUpStream) {
270+
if (streamContext.controller.signal.aborted) {
271+
endedNaturally = false
272+
break
273+
}
274+
assistantResponse += chunk
275+
mainWindow.webContents.send('solution-chunk', chunk)
276+
}
277+
} catch (error) {
278+
if (!streamContext.controller.signal.aborted) {
279+
endedNaturally = false
280+
console.error('Error streaming follow-up solution:', error)
281+
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
282+
mainWindow.webContents.send('solution-error', errorMessage)
283+
} else {
284+
endedNaturally = false
285+
}
286+
}
287+
288+
if (streamContext.controller.signal.aborted) {
289+
if (streamContext.reason === 'user') {
290+
mainWindow.webContents.send('solution-stopped')
291+
}
292+
} else if (endedNaturally) {
293+
// Update conversation history with user question and assistant response
294+
conversationMessages.push({
295+
role: 'user',
296+
content: [
297+
{
298+
type: 'text',
299+
text: question
300+
}
301+
]
302+
})
303+
if (assistantResponse) {
304+
conversationMessages.push({
305+
role: 'assistant',
306+
content: assistantResponse
307+
})
308+
}
309+
mainWindow.webContents.send('solution-complete')
310+
}
311+
} catch (error) {
312+
if (streamContext.controller.signal.aborted) {
313+
if (streamContext.reason === 'user') {
314+
mainWindow.webContents.send('solution-stopped')
315+
}
316+
} else {
317+
endedNaturally = false
318+
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
319+
console.error('Error streaming follow-up solution:', error)
320+
mainWindow.webContents.send('solution-error', errorMessage)
321+
}
322+
} finally {
323+
if (currentStreamContext === streamContext) {
324+
currentStreamContext = null
325+
}
326+
if (!streamStarted && streamContext.reason === 'user') {
327+
mainWindow.webContents.send('solution-stopped')
328+
}
329+
}
330+
331+
return { success: true }
332+
})

src/preload/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ const api = {
5858
// Stop solution stream
5959
stopSolutionStream: () => ipcRenderer.invoke('stopSolutionStream'),
6060

61+
// Send follow-up question
62+
sendFollowUpQuestion: (question: string) => ipcRenderer.invoke('sendFollowUpQuestion', question),
63+
6164
// Listen for solution completion
6265
onSolutionComplete: (callback: () => void) => {
6366
ipcRenderer.on('solution-complete', callback)

src/renderer/src/coder/AppContent.tsx

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ const SCROLL_OFFSET = 120
88

99
export function AppContent() {
1010
const {
11-
isLoading,
1211
screenshotData,
1312
solutionChunks,
1413
setScreenshotData,
@@ -17,15 +16,6 @@ export function AppContent() {
1716
clearSolution
1817
} = useSolutionStore()
1918

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-
2919
useEffect(() => {
3020
// Listen for screenshot events
3121
window.api.onScreenshotTaken((data: string) => {
@@ -65,25 +55,6 @@ export function AppContent() {
6555
}
6656
}, [setIsLoading, addSolutionChunk])
6757

68-
// Mark solution as complete when chunks stop coming
69-
useEffect(() => {
70-
if (isLoading && solutionChunks.length > 0) {
71-
const timer = setTimeout(() => {
72-
setIsLoading(false)
73-
}, 1000) // Wait 1 second after last chunk to mark as complete
74-
75-
return () => clearTimeout(timer)
76-
}
77-
return undefined
78-
}, [solutionChunks, isLoading, setIsLoading])
79-
80-
// When run ends (isLoading goes false), ensure code fence is closed
81-
useEffect(() => {
82-
if (!isLoading) {
83-
ensureClosedFence()
84-
}
85-
}, [isLoading])
86-
8758
useEffect(() => {
8859
window.api.onScrollPageUp(() => {
8960
const container = document.getElementById('app-content')

src/renderer/src/coder/AppStatusBar.tsx

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,58 @@
1-
import { Pointer, PointerOff, OctagonX } from 'lucide-react'
1+
import { useState } from 'react'
2+
import { Pointer, PointerOff, OctagonX, MessageCircle } from 'lucide-react'
23
import { useSolutionStore } from '@/lib/store/solution'
34
import { useShortcutsStore } from '@/lib/store/shortcuts'
45
import { useAppStore } from '@/lib/store/app'
56
import ShortcutRenderer from '@/components/ShortcutRenderer'
67
import { Button } from '@/components/ui/button'
8+
import { Dialog, DialogTitle, DialogContent, DialogFooter } from '@/components/ui/dialog'
9+
import { Textarea } from '@/components/ui/textarea'
710

811
export function AppStatusBar() {
9-
const { isLoading: isReceivingSolution, setIsLoading } = useSolutionStore()
12+
const {
13+
isLoading: isReceivingSolution,
14+
setIsLoading,
15+
screenshotData,
16+
solutionChunks
17+
} = useSolutionStore()
1018
const { ignoreMouse } = useAppStore()
1119
const { shortcuts } = useShortcutsStore()
20+
const [isDialogOpen, setIsDialogOpen] = useState(false)
21+
const [questionInput, setQuestionInput] = useState('')
1222

1323
const handleStop = () => {
1424
setIsLoading(false)
1525
void window.api.stopSolutionStream()
1626
}
1727

28+
const handleFollowUpClick = () => {
29+
setIsDialogOpen(true)
30+
}
31+
32+
const handleDialogClose = () => {
33+
setIsDialogOpen(false)
34+
setQuestionInput('')
35+
}
36+
37+
const handleSubmitQuestion = async () => {
38+
if (!questionInput.trim()) return
39+
40+
setIsLoading(true)
41+
setIsDialogOpen(false)
42+
const question = questionInput.trim()
43+
setQuestionInput('')
44+
45+
try {
46+
await window.api.sendFollowUpQuestion(question)
47+
} catch (error) {
48+
console.error('Error sending follow-up question:', error)
49+
setIsLoading(false)
50+
}
51+
}
52+
53+
// Check if there's an active conversation
54+
const hasActiveConversation = screenshotData && solutionChunks.length > 0 && !isReceivingSolution
55+
1856
return (
1957
<div className="absolute bottom-0 flex items-center justify-between w-full text-blue-100 bg-gray-600/10 px-4 pb-1">
2058
<div>
@@ -40,6 +78,19 @@ export function AppStatusBar() {
4078
)}
4179
</div>
4280
<div className="flex items-center space-x-4 select-none">
81+
{/* Follow-up Question Button */}
82+
{hasActiveConversation && (
83+
<Button
84+
variant="ghost"
85+
size="sm"
86+
onClick={handleFollowUpClick}
87+
className="h-7 px-3 text-xs"
88+
disabled={isReceivingSolution}
89+
>
90+
<MessageCircle className="w-4 h-4 mr-1" />
91+
追问问题
92+
</Button>
93+
)}
4394
{/* Mouse Status Indicator */}
4495
<div className="flex items-center">
4596
{ignoreMouse ? (
@@ -58,6 +109,36 @@ export function AppStatusBar() {
58109
)}
59110
</div>
60111
</div>
112+
113+
{/* Follow-up Question Dialog */}
114+
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
115+
<DialogTitle className="sr-only">追问问题</DialogTitle>
116+
<DialogContent>
117+
<div className="py-4">
118+
<Textarea
119+
placeholder="请输入追问内容,按 Ctrl+Enter 提交..."
120+
value={questionInput}
121+
className="min-h-24"
122+
onChange={(e) => setQuestionInput(e.target.value)}
123+
autoFocus
124+
onKeyDown={(e) => {
125+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
126+
e.preventDefault()
127+
handleSubmitQuestion()
128+
}
129+
}}
130+
/>
131+
</div>
132+
<DialogFooter>
133+
<Button variant="outline" onClick={handleDialogClose}>
134+
取消
135+
</Button>
136+
<Button onClick={handleSubmitQuestion} disabled={!questionInput.trim()}>
137+
提交
138+
</Button>
139+
</DialogFooter>
140+
</DialogContent>
141+
</Dialog>
61142
</div>
62143
)
63144
}

0 commit comments

Comments
 (0)