Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Q
qjclaw-dmg
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
AI-甘富林
qjclaw-dmg
Commits
bdb82411
Commit
bdb82411
authored
May 18, 2026
by
edy
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(chat): support cancelling active streams
parent
5abfbb86
Pipeline
#18469
failed
Changes
15
Pipelines
1
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
15 changed files
with
544 additions
and
17 deletions
+544
-17
ipc.ts
apps/desktop/src/main/ipc.ts
+186
-0
index.ts
apps/desktop/src/preload/index.ts
+1
-0
chatCancelIpcSource.test.ts
apps/desktop/test/chatCancelIpcSource.test.ts
+19
-0
App.tsx
apps/ui/src/App.tsx
+7
-4
AppIcons.tsx
apps/ui/src/components/icons/AppIcons.tsx
+8
-0
ChatComposer.tsx
apps/ui/src/features/chat/ChatComposer.tsx
+6
-3
ConversationWorkspaceView.tsx
apps/ui/src/features/chat/ConversationWorkspaceView.tsx
+3
-0
useChatStreamingController.ts
apps/ui/src/features/chat/useChatStreamingController.ts
+137
-3
types.ts
apps/ui/src/features/smoke/types.ts
+1
-1
mock-desktop-api.ts
apps/ui/src/lib/mock-desktop-api.ts
+34
-4
chatCancelSource.test.ts
apps/ui/test/chatCancelSource.test.ts
+36
-0
index.ts
packages/gateway-client/src/index.ts
+45
-1
chatCancelSource.test.ts
packages/gateway-client/test/chatCancelSource.test.ts
+16
-0
index.ts
packages/shared-types/src/index.ts
+27
-1
chatCancelApiSource.test.ts
packages/shared-types/test/chatCancelApiSource.test.ts
+18
-0
No files found.
apps/desktop/src/main/ipc.ts
View file @
bdb82411
This diff is collapsed.
Click to expand it.
apps/desktop/src/preload/index.ts
View file @
bdb82411
...
@@ -95,6 +95,7 @@ const desktopApi: DesktopApi = {
...
@@ -95,6 +95,7 @@ const desktopApi: DesktopApi = {
readImageAttachmentDataUrl
:
(
attachment
:
ChatAttachment
)
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatReadImageAttachmentDataUrl
,
attachment
),
readImageAttachmentDataUrl
:
(
attachment
:
ChatAttachment
)
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatReadImageAttachmentDataUrl
,
attachment
),
sendPrompt
:
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
attachments
?:
ChatAttachment
[])
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatSendPrompt
,
sessionId
,
prompt
,
skillId
,
attachments
),
sendPrompt
:
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
attachments
?:
ChatAttachment
[])
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatSendPrompt
,
sessionId
,
prompt
,
skillId
,
attachments
),
streamPrompt
:
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
attachments
?:
ChatAttachment
[])
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatStreamPrompt
,
sessionId
,
prompt
,
skillId
,
attachments
),
streamPrompt
:
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
attachments
?:
ChatAttachment
[])
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatStreamPrompt
,
sessionId
,
prompt
,
skillId
,
attachments
),
cancelStream
:
(
requestId
:
string
,
runId
?:
string
,
sessionId
?:
string
)
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatCancelStream
,
requestId
,
runId
,
sessionId
),
onStreamEvent
:
(
listener
:
ChatStreamListener
)
=>
{
onStreamEvent
:
(
listener
:
ChatStreamListener
)
=>
{
const
wrapped
=
(
_event
:
Electron
.
IpcRendererEvent
,
payload
:
Parameters
<
ChatStreamListener
>
[
0
])
=>
{
const
wrapped
=
(
_event
:
Electron
.
IpcRendererEvent
,
payload
:
Parameters
<
ChatStreamListener
>
[
0
])
=>
{
listener
(
payload
);
listener
(
payload
);
...
...
apps/desktop/test/chatCancelIpcSource.test.ts
0 → 100644
View file @
bdb82411
import
test
from
"node:test"
import
assert
from
"node:assert/strict"
import
{
readFileSync
}
from
"node:fs"
const
ipcSource
=
readFileSync
(
new
URL
(
"../src/main/ipc.ts"
,
import
.
meta
.
url
),
"utf8"
)
const
preloadSource
=
readFileSync
(
new
URL
(
"../src/preload/index.ts"
,
import
.
meta
.
url
),
"utf8"
)
test
(
"desktop IPC registers chat cancel stream handler"
,
()
=>
{
assert
.
match
(
ipcSource
,
/activeChatStreams/
)
assert
.
match
(
ipcSource
,
/cancelStream
\s
*=
\s
*async/
)
assert
.
match
(
ipcSource
,
/IPC_CHANNELS
\.
chatCancelStream/
)
assert
.
match
(
preloadSource
,
/cancelStream:
\(
requestId: string, runId
\?
: string, sessionId
\?
: string
\)
/
)
})
test
(
"desktop cancel marks assistant stream as stopped and broadcasts cancelled event"
,
()
=>
{
assert
.
match
(
ipcSource
,
/statusLabel:
\s
*"已停止"/
)
assert
.
match
(
ipcSource
,
/type:
\s
*"cancelled"/
)
assert
.
match
(
ipcSource
,
/gatewayClient
\.
cancelChatRun/
)
})
apps/ui/src/App.tsx
View file @
bdb82411
...
@@ -31,6 +31,7 @@ import {
...
@@ -31,6 +31,7 @@ import {
NavIcon
,
NavIcon
,
RedBookIcon
,
RedBookIcon
,
RefreshIcon
,
RefreshIcon
,
StopIcon
,
ThumbIcon
,
ThumbIcon
,
TrashIcon
,
TrashIcon
,
getIntentSuggestionIcon
,
getIntentSuggestionIcon
,
...
@@ -703,7 +704,8 @@ export default function App() {
...
@@ -703,7 +704,8 @@ export default function App() {
sending
,
sending
,
streamSmoke
,
streamSmoke
,
activeStreamRef
,
activeStreamRef
,
submitPrompt
submitPrompt
,
cancelActiveStream
}
=
useChatStreamingController
({
}
=
useChatStreamingController
({
desktopApi
,
desktopApi
,
viewMode
,
viewMode
,
...
@@ -784,9 +786,9 @@ export default function App() {
...
@@ -784,9 +786,9 @@ export default function App() {
normalizeError
:
err
normalizeError
:
err
});
});
const
sendButtonLabel
=
sendPhase
===
"preparing"
const
sendButtonLabel
=
sendPhase
===
"preparing"
?
ui
.
preparing
?
"停止生成"
:
sendPhase
===
"streaming"
||
sendPhase
===
"finalizing"
:
sendPhase
===
"streaming"
||
sendPhase
===
"finalizing"
?
ui
.
generating
?
"停止生成"
:
!
isBound
:
!
isBound
?
ui
.
bindFirst
?
ui
.
bindFirst
:
ui
.
send
;
:
ui
.
send
;
...
@@ -1429,8 +1431,9 @@ export default function App() {
...
@@ -1429,8 +1431,9 @@ export default function App() {
skills
:
effectiveSkills
,
skills
:
effectiveSkills
,
skillMenuOpen
,
skillMenuOpen
,
attachmentIcon
:
<
AttachmentIcon
/>,
attachmentIcon
:
<
AttachmentIcon
/>,
submitIcon
:
<
ArrowUpIcon
/>,
submitIcon
:
sendPhase
!==
"idle"
?
<
StopIcon
/>
:
<
ArrowUpIcon
/>,
onSubmit
:
sendPrompt
,
onSubmit
:
sendPrompt
,
onCancel
:
cancelActiveStream
,
onPromptChange
:
setPrompt
,
onPromptChange
:
setPrompt
,
onTextareaKeyDown
:
handleComposerKeyDown
,
onTextareaKeyDown
:
handleComposerKeyDown
,
onAttachmentSelection
:
handleAttachmentSelection
,
onAttachmentSelection
:
handleAttachmentSelection
,
...
...
apps/ui/src/components/icons/AppIcons.tsx
View file @
bdb82411
...
@@ -328,6 +328,14 @@ export function ArrowUpIcon() {
...
@@ -328,6 +328,14 @@ export function ArrowUpIcon() {
);
);
}
}
export
function
StopIcon
()
{
return
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
rect
x=
"7"
y=
"7"
width=
"10"
height=
"10"
rx=
"1.8"
fill=
"currentColor"
/>
</
svg
>
);
}
export
function
RefreshIcon
()
{
export
function
RefreshIcon
()
{
return
(
return
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
...
...
apps/ui/src/features/chat/ChatComposer.tsx
View file @
bdb82411
...
@@ -40,6 +40,7 @@ interface ChatComposerProps {
...
@@ -40,6 +40,7 @@ interface ChatComposerProps {
attachmentIcon
:
ReactNode
attachmentIcon
:
ReactNode
submitIcon
:
ReactNode
submitIcon
:
ReactNode
onSubmit
:
()
=>
void
|
Promise
<
void
>
onSubmit
:
()
=>
void
|
Promise
<
void
>
onCancel
:
()
=>
void
|
Promise
<
void
>
onPromptChange
:
(
value
:
string
)
=>
void
onPromptChange
:
(
value
:
string
)
=>
void
onTextareaKeyDown
:
(
event
:
ReactKeyboardEvent
<
HTMLTextAreaElement
>
)
=>
void
|
Promise
<
void
>
onTextareaKeyDown
:
(
event
:
ReactKeyboardEvent
<
HTMLTextAreaElement
>
)
=>
void
|
Promise
<
void
>
onAttachmentSelection
:
(
event
:
ChangeEvent
<
HTMLInputElement
>
)
=>
void
onAttachmentSelection
:
(
event
:
ChangeEvent
<
HTMLInputElement
>
)
=>
void
...
@@ -74,6 +75,7 @@ export function ChatComposer({
...
@@ -74,6 +75,7 @@ export function ChatComposer({
attachmentIcon
,
attachmentIcon
,
submitIcon
,
submitIcon
,
onSubmit
,
onSubmit
,
onCancel
,
onPromptChange
,
onPromptChange
,
onTextareaKeyDown
,
onTextareaKeyDown
,
onAttachmentSelection
,
onAttachmentSelection
,
...
@@ -159,13 +161,14 @@ export function ChatComposer({
...
@@ -159,13 +161,14 @@ export function ChatComposer({
</
button
>
</
button
>
</
div
>
</
div
>
<
button
<
button
type=
"submit"
type=
{
sending
?
"button"
:
"submit"
}
className=
{
"composer-submit"
+
(
sending
?
" is-busy"
:
""
)
}
className=
{
"composer-submit"
+
(
sending
?
" is-busy"
:
""
)
}
disabled=
{
!
canSend
}
disabled=
{
sending
?
false
:
!
canSend
}
onClick=
{
sending
?
onCancel
:
undefined
}
aria
-
label=
{
sendButtonLabel
}
aria
-
label=
{
sendButtonLabel
}
title=
{
sendButtonLabel
}
title=
{
sendButtonLabel
}
>
>
{
s
ending
?
<
span
className=
"composer-submit-spinner"
aria
-
hidden=
"true"
/>
:
s
ubmitIcon
}
{
submitIcon
}
<
span
className=
"visually-hidden"
>
{
sendButtonLabel
}
</
span
>
<
span
className=
"visually-hidden"
>
{
sendButtonLabel
}
</
span
>
</
button
>
</
button
>
</
div
>
</
div
>
...
...
apps/ui/src/features/chat/ConversationWorkspaceView.tsx
View file @
bdb82411
...
@@ -130,6 +130,7 @@ interface ConversationWorkspaceViewProps {
...
@@ -130,6 +130,7 @@ interface ConversationWorkspaceViewProps {
attachmentIcon
:
ReactNode
attachmentIcon
:
ReactNode
submitIcon
:
ReactNode
submitIcon
:
ReactNode
onSubmit
:
()
=>
void
|
Promise
<
void
>
onSubmit
:
()
=>
void
|
Promise
<
void
>
onCancel
:
()
=>
void
|
Promise
<
void
>
onPromptChange
:
(
value
:
string
)
=>
void
onPromptChange
:
(
value
:
string
)
=>
void
onTextareaKeyDown
:
(
event
:
ReactKeyboardEvent
<
HTMLTextAreaElement
>
)
=>
void
|
Promise
<
void
>
onTextareaKeyDown
:
(
event
:
ReactKeyboardEvent
<
HTMLTextAreaElement
>
)
=>
void
|
Promise
<
void
>
onAttachmentSelection
:
(
event
:
ChangeEvent
<
HTMLInputElement
>
)
=>
void
onAttachmentSelection
:
(
event
:
ChangeEvent
<
HTMLInputElement
>
)
=>
void
...
@@ -221,6 +222,7 @@ export function ConversationWorkspaceView({
...
@@ -221,6 +222,7 @@ export function ConversationWorkspaceView({
attachmentIcon
,
attachmentIcon
,
submitIcon
,
submitIcon
,
onSubmit
,
onSubmit
,
onCancel
,
onPromptChange
,
onPromptChange
,
onTextareaKeyDown
,
onTextareaKeyDown
,
onAttachmentSelection
,
onAttachmentSelection
,
...
@@ -387,6 +389,7 @@ export function ConversationWorkspaceView({
...
@@ -387,6 +389,7 @@ export function ConversationWorkspaceView({
attachmentIcon=
{
attachmentIcon
}
attachmentIcon=
{
attachmentIcon
}
submitIcon=
{
submitIcon
}
submitIcon=
{
submitIcon
}
onSubmit=
{
onSubmit
}
onSubmit=
{
onSubmit
}
onCancel=
{
onCancel
}
onPromptChange=
{
onPromptChange
}
onPromptChange=
{
onPromptChange
}
onTextareaKeyDown=
{
onTextareaKeyDown
}
onTextareaKeyDown=
{
onTextareaKeyDown
}
onAttachmentSelection=
{
onAttachmentSelection
}
onAttachmentSelection=
{
onAttachmentSelection
}
...
...
apps/ui/src/features/chat/useChatStreamingController.ts
View file @
bdb82411
This diff is collapsed.
Click to expand it.
apps/ui/src/features/smoke/types.ts
View file @
bdb82411
export
type
SmokeStreamPhase
=
"idle"
|
"requested"
|
"started"
|
"streaming"
|
"completed"
|
"fallback"
|
"error"
export
type
SmokeStreamPhase
=
"idle"
|
"requested"
|
"started"
|
"streaming"
|
"completed"
|
"fallback"
|
"error"
|
"cancelled"
export
interface
SmokeStreamSnapshot
{
export
interface
SmokeStreamSnapshot
{
phase
:
SmokeStreamPhase
phase
:
SmokeStreamPhase
...
...
apps/ui/src/lib/mock-desktop-api.ts
View file @
bdb82411
...
@@ -24,6 +24,7 @@ const mockUi = {
...
@@ -24,6 +24,7 @@ const mockUi = {
waitingReply
:
"已收到问题,正在组织回答"
waitingReply
:
"已收到问题,正在组织回答"
}
as
const
}
as
const
const
mockChatStreamListeners
=
new
Set
<
ChatStreamListener
>
();
const
mockChatStreamListeners
=
new
Set
<
ChatStreamListener
>
();
const
mockChatStreamTimers
=
new
Map
<
string
,
number
[]
>
();
function
emitMockChatStreamEvent
(
event
:
ChatStreamEvent
)
{
function
emitMockChatStreamEvent
(
event
:
ChatStreamEvent
)
{
for
(
const
listener
of
mockChatStreamListeners
)
{
for
(
const
listener
of
mockChatStreamListeners
)
{
...
@@ -401,21 +402,28 @@ export const mockDesktopApi = {
...
@@ -401,21 +402,28 @@ export const mockDesktopApi = {
const
executionPolicy
=
{
source
:
"client-config"
as
const
,
modelId
:
"qwen3.6-plus"
,
modelLabel
:
"qwen3.6-plus"
,
routingMode
:
"platform-managed"
as
const
,
skillId
,
skillName
:
skillId
,
message
:
"mock"
};
const
executionPolicy
=
{
source
:
"client-config"
as
const
,
modelId
:
"qwen3.6-plus"
,
modelLabel
:
"qwen3.6-plus"
,
routingMode
:
"platform-managed"
as
const
,
skillId
,
skillName
:
skillId
,
message
:
"mock"
};
const
replyText
=
"Mock: "
+
prompt
;
const
replyText
=
"Mock: "
+
prompt
;
const
chunks
=
replyText
.
match
(
/.
{1,6}
/g
)
??
[
replyText
];
const
chunks
=
replyText
.
match
(
/.
{1,6}
/g
)
??
[
replyText
];
const
timers
:
number
[]
=
[];
const
scheduleStreamTimer
=
(
handler
:
()
=>
void
,
delay
:
number
)
=>
{
const
timer
=
window
.
setTimeout
(
handler
,
delay
);
timers
.
push
(
timer
);
};
mockChatStreamTimers
.
set
(
requestId
,
timers
);
let
fullText
=
""
;
let
fullText
=
""
;
window
.
setTimeout
(()
=>
{
scheduleStreamTimer
(()
=>
{
emitMockChatStreamEvent
({
type
:
"status"
,
requestId
,
sessionId
,
runId
,
stage
:
"prepare-request"
,
label
:
mockUi
.
preparingReply
});
emitMockChatStreamEvent
({
type
:
"status"
,
requestId
,
sessionId
,
runId
,
stage
:
"prepare-request"
,
label
:
mockUi
.
preparingReply
});
emitMockChatStreamEvent
({
type
:
"started"
,
requestId
,
sessionId
,
runId
,
executionPolicy
});
emitMockChatStreamEvent
({
type
:
"started"
,
requestId
,
sessionId
,
runId
,
executionPolicy
});
},
0
);
},
0
);
window
.
setTimeout
(()
=>
{
scheduleStreamTimer
(()
=>
{
emitMockChatStreamEvent
({
type
:
"status"
,
requestId
,
sessionId
,
runId
,
stage
:
"await-model"
,
label
:
mockUi
.
waitingReply
});
emitMockChatStreamEvent
({
type
:
"status"
,
requestId
,
sessionId
,
runId
,
stage
:
"await-model"
,
label
:
mockUi
.
waitingReply
});
},
30
);
},
30
);
chunks
.
forEach
((
chunk
,
index
)
=>
{
chunks
.
forEach
((
chunk
,
index
)
=>
{
window
.
setTimeout
(()
=>
{
scheduleStreamTimer
(()
=>
{
fullText
+=
chunk
;
fullText
+=
chunk
;
emitMockChatStreamEvent
({
type
:
"delta"
,
requestId
,
sessionId
,
runId
,
textDelta
:
chunk
,
fullText
});
emitMockChatStreamEvent
({
type
:
"delta"
,
requestId
,
sessionId
,
runId
,
textDelta
:
chunk
,
fullText
});
},
90
*
(
index
+
1
));
},
90
*
(
index
+
1
));
});
});
window
.
setTimeout
(()
=>
{
scheduleStreamTimer
(()
=>
{
mockChatStreamTimers
.
delete
(
requestId
);
emitMockChatStreamEvent
({
emitMockChatStreamEvent
({
type
:
"completed"
,
type
:
"completed"
,
requestId
,
requestId
,
...
@@ -427,6 +435,28 @@ export const mockDesktopApi = {
...
@@ -427,6 +435,28 @@ export const mockDesktopApi = {
},
90
*
(
chunks
.
length
+
1
));
},
90
*
(
chunks
.
length
+
1
));
return
{
requestId
,
sessionId
,
runId
,
userMessageId
,
assistantMessageId
,
executionPolicy
};
return
{
requestId
,
sessionId
,
runId
,
userMessageId
,
assistantMessageId
,
executionPolicy
};
},
},
cancelStream
:
async
(
requestId
:
string
,
runId
?:
string
,
sessionId
?:
string
)
=>
{
for
(
const
timer
of
mockChatStreamTimers
.
get
(
requestId
)
??
[])
{
window
.
clearTimeout
(
timer
);
}
mockChatStreamTimers
.
delete
(
requestId
);
emitMockChatStreamEvent
({
type
:
"cancelled"
,
requestId
,
sessionId
,
runId
,
message
:
"已停止"
,
remoteCancelled
:
false
});
return
{
requestId
,
sessionId
,
runId
,
localCancelled
:
true
,
remoteCancelled
:
false
,
message
:
"已停止"
};
},
onStreamEvent
:
(
listener
:
ChatStreamListener
)
=>
{
onStreamEvent
:
(
listener
:
ChatStreamListener
)
=>
{
mockChatStreamListeners
.
add
(
listener
);
mockChatStreamListeners
.
add
(
listener
);
return
()
=>
{
return
()
=>
{
...
...
apps/ui/test/chatCancelSource.test.ts
0 → 100644
View file @
bdb82411
import
test
from
"node:test"
import
assert
from
"node:assert/strict"
import
{
readFileSync
}
from
"node:fs"
const
controllerSource
=
readFileSync
(
new
URL
(
"../src/features/chat/useChatStreamingController.ts"
,
import
.
meta
.
url
),
"utf8"
)
const
composerSource
=
readFileSync
(
new
URL
(
"../src/features/chat/ChatComposer.tsx"
,
import
.
meta
.
url
),
"utf8"
)
const
mockSource
=
readFileSync
(
new
URL
(
"../src/lib/mock-desktop-api.ts"
,
import
.
meta
.
url
),
"utf8"
)
test
(
"chat streaming controller exposes cancelActiveStream and ignores later events"
,
()
=>
{
assert
.
match
(
controllerSource
,
/cancelActiveStream/
)
assert
.
match
(
controllerSource
,
/desktopApi
\.
chat
\.
cancelStream/
)
assert
.
match
(
controllerSource
,
/stoppedRequestIdsRef/
)
assert
.
match
(
controllerSource
,
/event
\.
type === "cancelled"/
)
})
test
(
"chat streaming controller scopes preparing cancellation per submission"
,
()
=>
{
assert
.
match
(
controllerSource
,
/cancelledSubmissionIdsRef/
)
assert
.
match
(
controllerSource
,
/pendingSubmissionIdRef/
)
assert
.
doesNotMatch
(
controllerSource
,
/preStreamCancelRequestedRef/
)
})
test
(
"chat streaming controller clears stopped request ids on terminal events"
,
()
=>
{
assert
.
match
(
controllerSource
,
/stoppedRequestIdsRef
\.
current
\.
delete
\(
event
\.
requestId
\)
/
)
})
test
(
"composer can submit a stop action while sending"
,
()
=>
{
assert
.
match
(
composerSource
,
/onCancel/
)
assert
.
match
(
composerSource
,
/type=
\{
sending
\?
"button" : "submit"
\}
/
)
assert
.
match
(
composerSource
,
/onClick=
\{
sending
\?
onCancel : undefined
\}
/
)
})
test
(
"mock desktop API cancels pending stream timers"
,
()
=>
{
assert
.
match
(
mockSource
,
/mockChatStreamTimers/
)
assert
.
match
(
mockSource
,
/cancelStream/
)
assert
.
match
(
mockSource
,
/window
\.
clearTimeout/
)
})
packages/gateway-client/src/index.ts
View file @
bdb82411
...
@@ -114,6 +114,12 @@ export interface GatewayPromptStreamStart {
...
@@ -114,6 +114,12 @@ export interface GatewayPromptStreamStart {
completion
:
Promise
<
ChatMessage
>
;
completion
:
Promise
<
ChatMessage
>
;
}
}
export
interface
GatewayCancelChatRunResult
{
runId
:
string
;
localCancelled
:
boolean
;
remoteCancelled
:
boolean
;
}
export
interface
GatewayPromptStreamDelta
{
export
interface
GatewayPromptStreamDelta
{
sessionId
:
string
;
sessionId
:
string
;
runId
:
string
;
runId
:
string
;
...
@@ -483,6 +489,45 @@ export class GatewayClient {
...
@@ -483,6 +489,45 @@ export class GatewayClient {
return { sessionId, runId, completion };
return { sessionId, runId, completion };
}
}
async cancelChatRun(runId: string): Promise<GatewayCancelChatRunResult> {
const pending = this.pendingChatRuns.get(runId);
if (pending) {
clearTimeout(pending.timer);
this.pendingChatRuns.delete(runId);
pending.resolve({
id: `
$
{
pending
.
sessionKey
}:
$
{
runId
}
:cancelled`
,
role
:
"assistant"
,
content
:
pending
.
accumulatedText
,
createdAt
:
new
Date
().
toISOString
()
});
}
const
availableMethods
=
this
.
statusSnapshot
.
availableMethods
??
[];
if
(
!
availableMethods
.
includes
(
"chat.cancel"
))
{
return
{
runId
,
localCancelled
:
Boolean
(
pending
),
remoteCancelled
:
false
};
}
try
{
await
this
.
request
(
"chat.cancel"
,
{
runId
});
return
{
runId
,
localCancelled
:
Boolean
(
pending
),
remoteCancelled
:
true
};
}
catch
(
error
)
{
this
.
appendLog
(
"warn"
,
`Gateway chat.cancel failed for
${
runId
}
:
${
error
instanceof
Error
?
error
.
message
:
String
(
error
)}
`
);
return
{
runId
,
localCancelled
:
Boolean
(
pending
),
remoteCancelled
:
false
};
}
}
private
async
handleEvent
(
frame
:
Record
<
string
,
unknown
>
):
Promise
<
void
>
{
private
async
handleEvent
(
frame
:
Record
<
string
,
unknown
>
):
Promise
<
void
>
{
const
eventName
=
String
(
frame
.
event
??
"unknown"
);
const
eventName
=
String
(
frame
.
event
??
"unknown"
);
...
@@ -1187,4 +1232,3 @@ export class GatewayClient {
...
@@ -1187,4 +1232,3 @@ export class GatewayClient {
return message;
return message;
}
}
}
}
packages/gateway-client/test/chatCancelSource.test.ts
0 → 100644
View file @
bdb82411
import
test
from
"node:test"
import
assert
from
"node:assert/strict"
import
{
readFileSync
}
from
"node:fs"
const
gatewaySource
=
readFileSync
(
new
URL
(
"../src/index.ts"
,
import
.
meta
.
url
),
"utf8"
)
test
(
"gateway client cancels local pending run even when remote cancel is unavailable"
,
()
=>
{
assert
.
match
(
gatewaySource
,
/async cancelChatRun
\(
runId: string
\)
/
)
assert
.
match
(
gatewaySource
,
/remoteCancelled: false/
)
assert
.
match
(
gatewaySource
,
/this
\.
pendingChatRuns
\.
delete
\(
runId
\)
/
)
})
test
(
"gateway client only sends cancel RPC when gateway advertises chat cancel"
,
()
=>
{
assert
.
match
(
gatewaySource
,
/availableMethods.*chat
\.
cancel/
s
)
assert
.
match
(
gatewaySource
,
/this
\.
request
\(
"chat
\.
cancel"/
)
})
packages/shared-types/src/index.ts
View file @
bdb82411
...
@@ -38,6 +38,7 @@
...
@@ -38,6 +38,7 @@
chatReadImageAttachmentDataUrl
:
"chat:read-image-attachment-data-url"
,
chatReadImageAttachmentDataUrl
:
"chat:read-image-attachment-data-url"
,
chatSendPrompt
:
"chat:send-prompt"
,
chatSendPrompt
:
"chat:send-prompt"
,
chatStreamPrompt
:
"chat:stream-prompt"
,
chatStreamPrompt
:
"chat:stream-prompt"
,
chatCancelStream
:
"chat:cancel-stream"
,
chatStreamEvent
:
"chat:stream-event"
,
chatStreamEvent
:
"chat:stream-event"
,
diagnosticsOpenControlUi
:
"diagnostics:open-control-ui"
,
diagnosticsOpenControlUi
:
"diagnostics:open-control-ui"
,
diagnosticsExportSnapshot
:
"diagnostics:export-snapshot"
,
diagnosticsExportSnapshot
:
"diagnostics:export-snapshot"
,
...
@@ -509,6 +510,21 @@ export interface ChatStreamPromptResult {
...
@@ -509,6 +510,21 @@ export interface ChatStreamPromptResult {
executionPolicy
?:
ChatExecutionPolicy
;
executionPolicy
?:
ChatExecutionPolicy
;
}
}
export
interface
ChatCancelStreamRequest
{
requestId
:
string
;
runId
?:
string
;
sessionId
?:
string
;
}
export
interface
ChatCancelStreamResult
{
requestId
:
string
;
sessionId
?:
string
;
runId
?:
string
;
localCancelled
:
boolean
;
remoteCancelled
:
boolean
;
message
:
string
;
}
export
interface
ChatStreamStartedEvent
{
export
interface
ChatStreamStartedEvent
{
type
:
"started"
;
type
:
"started"
;
requestId
:
string
;
requestId
:
string
;
...
@@ -554,7 +570,16 @@ export interface ChatStreamErrorEvent {
...
@@ -554,7 +570,16 @@ export interface ChatStreamErrorEvent {
errorCategory
?:
string
;
errorCategory
?:
string
;
}
}
export
type
ChatStreamEvent
=
ChatStreamStartedEvent
|
ChatStreamStatusEvent
|
ChatStreamDeltaEvent
|
ChatStreamCompletedEvent
|
ChatStreamErrorEvent
;
export
interface
ChatStreamCancelledEvent
{
type
:
"cancelled"
;
requestId
:
string
;
sessionId
?:
string
;
runId
?:
string
;
message
:
string
;
remoteCancelled
:
boolean
;
}
export
type
ChatStreamEvent
=
ChatStreamStartedEvent
|
ChatStreamStatusEvent
|
ChatStreamDeltaEvent
|
ChatStreamCompletedEvent
|
ChatStreamErrorEvent
|
ChatStreamCancelledEvent
;
export
type
ChatStreamListener
=
(
event
:
ChatStreamEvent
)
=>
void
;
export
type
ChatStreamListener
=
(
event
:
ChatStreamEvent
)
=>
void
;
...
@@ -965,6 +990,7 @@ export interface DesktopApi {
...
@@ -965,6 +990,7 @@ export interface DesktopApi {
readImageAttachmentDataUrl
(
attachment
:
ChatAttachment
):
Promise
<
string
|
null
>
;
readImageAttachmentDataUrl
(
attachment
:
ChatAttachment
):
Promise
<
string
|
null
>
;
sendPrompt
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
attachments
?:
ChatAttachment
[]):
Promise
<
PromptResult
>
;
sendPrompt
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
attachments
?:
ChatAttachment
[]):
Promise
<
PromptResult
>
;
streamPrompt
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
attachments
?:
ChatAttachment
[]):
Promise
<
ChatStreamPromptResult
>
;
streamPrompt
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
attachments
?:
ChatAttachment
[]):
Promise
<
ChatStreamPromptResult
>
;
cancelStream
(
requestId
:
string
,
runId
?:
string
,
sessionId
?:
string
):
Promise
<
ChatCancelStreamResult
>
;
onStreamEvent
(
listener
:
ChatStreamListener
):
()
=>
void
;
onStreamEvent
(
listener
:
ChatStreamListener
):
()
=>
void
;
};
};
diagnostics
:
{
diagnostics
:
{
...
...
packages/shared-types/test/chatCancelApiSource.test.ts
0 → 100644
View file @
bdb82411
import
test
from
"node:test"
import
assert
from
"node:assert/strict"
import
{
readFileSync
}
from
"node:fs"
const
sharedTypesSource
=
readFileSync
(
new
URL
(
"../src/index.ts"
,
import
.
meta
.
url
),
"utf8"
)
test
(
"shared desktop chat API exposes cancel stream contract"
,
()
=>
{
assert
.
match
(
sharedTypesSource
,
/chatCancelStream:
\s
*"chat:cancel-stream"/
)
assert
.
match
(
sharedTypesSource
,
/export interface ChatCancelStreamRequest/
)
assert
.
match
(
sharedTypesSource
,
/export interface ChatCancelStreamResult/
)
assert
.
match
(
sharedTypesSource
,
/cancelStream
\(
requestId: string, runId
\?
: string, sessionId
\?
: string
\)
: Promise<ChatCancelStreamResult>/
)
})
test
(
"shared stream events include cancelled payload"
,
()
=>
{
assert
.
match
(
sharedTypesSource
,
/export interface ChatStreamCancelledEvent/
)
assert
.
match
(
sharedTypesSource
,
/type:
\s
*"cancelled"/
)
assert
.
match
(
sharedTypesSource
,
/ChatStreamEvent = .*ChatStreamCancelledEvent/
s
)
})
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment