Skip to content
Open

Main #13

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ async function handleSend(content: string, messageAttachments: Attachment[]) {
}

// 正常发送消息(传递附件)
await chatStore.sendMessage(content, messageAttachments)
await chatStore.submitMessage(content, messageAttachments)
} catch (err) {
console.error('发送失败:', err)
}
Expand Down
128 changes: 124 additions & 4 deletions frontend/src/components/input/InputArea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -189,20 +189,41 @@ const hasAttachments = computed(() =>
props.attachments && props.attachments.length > 0
)

// 是否可以发送 - 只在等待响应或上传时禁用发送
// 例外:当有待确认工具时允许发送(用于带批注拒绝)
// 是否可以发送
// - 上传中禁用
// - 等待响应期间允许"发送"(会进入候补队列)
// - 例外:当有待确认工具时,发送用于带批注拒绝
const canSend = computed(() => {
const hasContent = inputValue.value.trim().length > 0 ||
(props.attachments && props.attachments.length > 0)

if (!hasContent) return false
if (props.uploading) return false

// 如果有待确认的工具,允许发送(作为批注拒绝)
if (chatStore.hasPendingToolConfirmation && hasContent) {
if (chatStore.hasPendingToolConfirmation) {
return true
}

return hasContent && !chatStore.isWaitingForResponse && !props.uploading
// 等待响应期间也允许发送(会进入候补队列)
return true
})

// 格式化候补队列消息预览
function formatQueuedPreview(item: any): string {
const text = String(item?.content || '').trim()
if (text) {
return text.length > 60 ? text.slice(0, 60) + '…' : text
}

const count = Array.isArray(item?.attachments) ? item.attachments.length : 0
if (count > 0) {
return `附件(${count})`
}

return ''
}

// 处理发送
function handleSend() {
if (!canSend.value) return
Expand Down Expand Up @@ -899,6 +920,33 @@ watch(() => settingsStore.promptModesVersion, () => {
</div>
</div>

<!-- 候补队列:等待响应期间提交的消息会先进入队列 -->
<div
v-if="chatStore.queuedMessages && chatStore.queuedMessages.length > 0"
class="queued-messages"
:class="{ paused: !!chatStore.error }"
>
<div
v-for="item in chatStore.queuedMessages"
:key="item.id"
class="queued-message-chip"
:class="{ paused: !!chatStore.error }"
:title="item.content || formatQueuedPreview(item)"
>
<i class="codicon codicon-clock queued-icon"></i>
<span class="queued-text">{{ formatQueuedPreview(item) }}</span>
<span v-if="chatStore.error" class="queued-paused-badge">暂停中</span>
<button
class="queued-remove"
type="button"
:title="t('common.remove')"
@click="chatStore.removeQueuedMessage(item.id)"
>
<i class="codicon codicon-close"></i>
</button>
</div>
</div>

<!-- 输入框容器(包含文件选择器) -->
<div class="input-box-container">
<!-- @ 文件选择面板 -->
Expand Down Expand Up @@ -1262,6 +1310,78 @@ watch(() => settingsStore.promptModesVersion, () => {
position: relative;
}

/* 候补队列 */
.queued-messages {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs, 4px);
}

.queued-message-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 2px 6px;
border-radius: 999px;
background: var(--vscode-textBlockQuote-background, rgba(127, 127, 127, 0.1));
border: 1px solid var(--vscode-panel-border, rgba(127, 127, 127, 0.25));
font-size: 12px;
line-height: 18px;
}

/* error 状态下:队列暂停,气泡高亮 */
.queued-message-chip.paused {
background: var(--vscode-inputValidation-errorBackground);
border-color: var(--vscode-inputValidation-errorBorder);
}

.queued-message-chip.paused .queued-icon,
.queued-message-chip.paused .queued-text,
.queued-message-chip.paused .queued-remove {
color: var(--vscode-inputValidation-errorForeground);
}

.queued-paused-badge {
padding: 0 6px;
border-radius: 999px;
font-size: 11px;
line-height: 16px;
border: 1px solid var(--vscode-inputValidation-errorBorder);
color: var(--vscode-inputValidation-errorForeground);
}

.queued-icon {
font-size: 12px;
opacity: 0.8;
}

.queued-text {
max-width: 360px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.queued-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
background: transparent;
color: var(--vscode-foreground);
opacity: 0.75;
cursor: pointer;
border-radius: 4px;
}

.queued-remove:hover {
opacity: 1;
background: var(--vscode-toolbar-hoverBackground);
}

/* 附件列表 */
.attachments-list {
display: flex;
Expand Down
49 changes: 29 additions & 20 deletions frontend/src/components/input/SendButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,29 +31,38 @@ function handleCancel() {
</script>

<template>
<!-- 取消按钮 - loading 状态下显示 -->
<button
v-if="loading"
class="send-button"
:title="t('components.input.stopGenerating')"
@click="handleCancel"
>
<i class="codicon codicon-primitive-square stop-icon"></i>
</button>

<!-- 发送按钮 - 正常状态下显示 -->
<button
v-else
class="send-button"
:disabled="disabled"
:title="t('components.input.send')"
@click="handleClick"
>
<i class="codicon codicon-send send-icon"></i>
</button>
<div class="send-button-group">
<!-- 取消按钮 - loading 状态下显示 -->
<button
v-if="loading"
type="button"
class="send-button"
:title="t('components.input.stopGenerating')"
@click="handleCancel"
>
<i class="codicon codicon-primitive-square stop-icon"></i>
</button>

<!-- 发送按钮 - 始终显示(等待响应时用于加入候补队列) -->
<button
type="button"
class="send-button"
:disabled="disabled"
:title="t('components.input.send')"
@click="handleClick"
>
<i class="codicon codicon-send send-icon"></i>
</button>
</div>
</template>

<style scoped>
.send-button-group {
display: inline-flex;
align-items: center;
gap: 4px;
}

.send-button {
display: inline-flex;
align-items: center;
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/stores/chat/conversationActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export async function createNewConversation(
state.isStreaming.value = false
state.streamingMessageId.value = null
state.isWaitingForResponse.value = false

// 清空待发送队列(切换/新建对话时不保留队列)
state.queuedMessages.value = []
}

/**
Expand Down Expand Up @@ -215,6 +218,9 @@ export async function switchConversation(
state.isStreaming.value = false
state.streamingMessageId.value = null
state.isWaitingForResponse.value = false

// 清空待发送队列(切换对话时不保留队列)
state.queuedMessages.value = []

// 如果是已持久化的对话,从后端加载历史和检查点
if (conv.isPersisted) {
Expand Down
17 changes: 15 additions & 2 deletions frontend/src/stores/chat/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import type {
WorkspaceFilter,
RetryStatus,
ConfigInfo,
ChatStoreState
ChatStoreState,
QueuedMessage
} from './types'

/**
Expand Down Expand Up @@ -50,6 +51,13 @@ export function createChatState(): ChatStoreState {

/** 当前流式消息ID */
const streamingMessageId = ref<string | null>(null)

/**
* 最近一次发起取消时对应的 assistant 消息 ID。
*
* 用途:解决“旧流 cancelled chunk 在新流开始后到达,误取消新流”的竞态。
*/
const pendingCancelledMessageId = ref<string | null>(null)

/** 等待AI响应状态 - 用于显示等待动画 */
const isWaitingForResponse = ref(false)
Expand Down Expand Up @@ -81,6 +89,9 @@ export function createChatState(): ChatStoreState {
/** 工作区筛选模式(默认当前工作区) */
const workspaceFilter = ref<WorkspaceFilter>('current')

/** 待发送消息队列 */
const queuedMessages = ref<QueuedMessage[]>([])

return {
conversations,
currentConversationId,
Expand All @@ -92,6 +103,7 @@ export function createChatState(): ChatStoreState {
isLoadingConversations,
error,
streamingMessageId,
pendingCancelledMessageId,
isWaitingForResponse,
retryStatus,
toolCallBuffer,
Expand All @@ -101,6 +113,7 @@ export function createChatState(): ChatStoreState {
deletingConversationIds,
currentWorkspaceUri,
inputValue,
workspaceFilter
workspaceFilter,
queuedMessages
}
}
48 changes: 41 additions & 7 deletions frontend/src/stores/chat/streamChunkHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,11 +420,36 @@ export function handleCheckpoints(
*/
export function handleCancelled(chunk: StreamChunk, state: ChatStoreState): void {
// 用户取消了请求
// 尝试获取目标消息:优先使用 streamingMessageId,如果已清除则尝试寻找最后一条助手消息
// 重要:在“取消旧流并立即开启新流”的场景下,旧流的 cancelled chunk 可能在新流开始后到达。
// 若直接用当前 streamingMessageId 处理,会误伤新流(删除占位消息/重置 isWaitingForResponse)。

const activeStreamingId = state.streamingMessageId.value
const pendingCancelledId = state.pendingCancelledMessageId.value

// 1) 优先使用 pendingCancelledMessageId(若它与当前 activeStreamingId 不同,说明 cancelled chunk 很可能属于旧流)
let targetMessageId: string | null = null

if (pendingCancelledId && pendingCancelledId !== activeStreamingId) {
targetMessageId = pendingCancelledId
} else if (activeStreamingId) {
targetMessageId = activeStreamingId
} else if (pendingCancelledId) {
targetMessageId = pendingCancelledId
}

// 2) 计算 target messageIndex
let messageIndex = -1
if (state.streamingMessageId.value) {
messageIndex = state.allMessages.value.findIndex(m => m.id === state.streamingMessageId.value)
} else {
if (targetMessageId) {
messageIndex = state.allMessages.value.findIndex(m => m.id === targetMessageId)

// 如果 cancelled chunk 指向的是“旧流”,但旧消息已不存在:忽略本次 cancelled,避免误伤新流
if (messageIndex === -1 && pendingCancelledId && pendingCancelledId !== activeStreamingId) {
state.pendingCancelledMessageId.value = null
return
}
}

if (messageIndex === -1) {
// 兼容性处理:如果 streamingMessageId 已被 cancelStream 清除,则寻找最后一条助手消息
// 仅当最后一条助手消息处于非流式状态(说明刚被 cancelStream 处理过)时才尝试更新其元数据
const lastMsgIndex = state.allMessages.value.length - 1
Expand Down Expand Up @@ -489,9 +514,18 @@ export function handleCancelled(chunk: StreamChunk, state: ChatStoreState): void
]
}
}
state.streamingMessageId.value = null
state.isStreaming.value = false
state.isWaitingForResponse.value = false
// 本次 cancelled chunk 已处理,清除 pendingCancelledMessageId
if (state.pendingCancelledMessageId.value) {
state.pendingCancelledMessageId.value = null
}

// 仅当 cancelled chunk 作用于“当前活跃流”时,才重置全局流状态
// 如果此时已经有新流在进行(activeStreamingId 与 targetMessageId 不同),则不要误伤新流。
if (activeStreamingId && targetMessageId === activeStreamingId) {
state.streamingMessageId.value = null
state.isStreaming.value = false
state.isWaitingForResponse.value = false
}
}

/**
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/stores/chat/toolActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ export async function cancelStreamAndRejectTools(
_computed: ChatStoreComputed
): Promise<void> {
if (!state.currentConversationId.value) return

// 记录本次取消针对的消息,避免 cancelled chunk 在新流开始后误取消新流
if (state.isStreaming.value) {
state.pendingCancelledMessageId.value = state.streamingMessageId.value
}

if (state.retryStatus.value) {
state.retryStatus.value = null
Expand Down Expand Up @@ -268,6 +273,9 @@ export async function cancelStream(

// 等待工具确认状态(包括 diff 工具等待用户操作)
if (!state.isStreaming.value) {
// awaitingConfirmation 场景通常已无活跃流,不会再收到 cancelled chunk
state.pendingCancelledMessageId.value = null

// 先调用后端 cancelStream 来关闭 diff 编辑器并拒绝工具
try {
await sendToExtension('cancelStream', {
Expand All @@ -292,6 +300,9 @@ export async function cancelStream(
// 先保存当前 streaming 消息 ID,因为 await 期间可能被其他事件清除
const currentStreamingId = state.streamingMessageId.value

// 记录本次取消针对的消息,避免 cancelled chunk 在新流开始后误取消新流
state.pendingCancelledMessageId.value = currentStreamingId

try {
await sendToExtension('cancelStream', {
conversationId: state.currentConversationId.value
Expand Down
Loading