feat(chat): switch to the conversation on send with a startup placeholder (TODO 08)
This commit is contained in:
1 parent
0b549ca794
commit
6173a77918
9 files changed
+404
-22
No files matched your search
@@ -221,10 +221,22 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise<void> => {
|
||||
if ((!text && !attachments.length && !quotes.length) || running || sending) return
|
||||
followBottom.current = true
|
||||
await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode })
|
||||
const destination = draftMoves.current.get(draftKey) ?? draftKey
|
||||
draftMoves.current.delete(draftKey)
|
||||
setDrafts((all) => ({ ...all, [draftKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } }))
|
||||
// 原版发送即清空输入框并立即切到对话页:先清空草稿,失败时再恢复(TODO 08)。
|
||||
const submitKey = draftKey
|
||||
const restore = { ...entry, text, attachments, resources, quotes }
|
||||
setDrafts((all) => ({ ...all, [submitKey]: { text: '', attachments: [] } }))
|
||||
try {
|
||||
await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode })
|
||||
// 会话创建后草稿所有权已迁移到会话 id;两处都清空,避免留下旧文本。
|
||||
const destination = draftMoves.current.get(submitKey) ?? submitKey
|
||||
draftMoves.current.delete(submitKey)
|
||||
setDrafts((all) => ({ ...all, [submitKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } }))
|
||||
} catch (error) {
|
||||
const destination = draftMoves.current.get(submitKey)
|
||||
draftMoves.current.delete(submitKey)
|
||||
setDrafts((all) => ({ ...all, [submitKey]: restore, ...(destination && destination !== submitKey ? { [destination]: restore } : {}) }))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** 就地编辑:进入、取消、提交(提交走 send 的 action='edit' 分支,与原版 message.edit.submit 对齐)。 */
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { InteractionResponse, MessageQuote, PlanContent } from '@shared/con
|
||||
import MarkdownContent from './MarkdownContent'
|
||||
import StreamingFooter from '../workbuddy/conversation/StreamingFooter'
|
||||
import StreamingTurnDuration from '../workbuddy/conversation/StreamingTurnDuration'
|
||||
import { resolvePrepareText } from '../workbuddy/conversation/prepare-phases'
|
||||
import { getMessageParts } from '@shared/message-parts'
|
||||
import { projectWorkBuddyHistoryTool } from '@shared/workbuddy-history-projection'
|
||||
import { MessageQuoteTag } from '../workbuddy/conversation/MessageQuoteTag'
|
||||
@@ -265,7 +266,7 @@ export default function MessageBubble({ message, onQuote, quotedSourceIds, darkM
|
||||
return option ? onRespond?.(id, { kind: 'approval', optionId: option.optionId }) : undefined
|
||||
}} /> : interaction.answer && !message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer) ? <QuestionAnswerTranscript key={interaction.id} questionAnswer={interaction.answer} /> : null)}
|
||||
|
||||
{streaming && <StreamingFooter />}
|
||||
{streaming && <StreamingFooter prepareText={resolvePrepareText(message.prepareStage)} />}
|
||||
|
||||
{message.status === 'error' && message.error ? (
|
||||
<div className="msg__status msg__status--error">{message.error}</div>
|
||||
|
||||
@@ -8,10 +8,30 @@ import type { ThinkingLevel } from '@shared/workspaces'
|
||||
import type { AgentMode } from '@shared/conversation'
|
||||
import { expertText, type ExpertActivation } from '@shared/capabilities'
|
||||
import { ChatStreamCommitter, type ChatStreamKey } from './chat-stream-committer'
|
||||
import { PENDING_START_STAGE } from '../workbuddy/conversation/prepare-phases'
|
||||
|
||||
interface SummonExpertOptions { newTask?: boolean; initPrompt?: string }
|
||||
|
||||
interface RunningState { runId: string; assistantId: string; sessionId?: string }
|
||||
|
||||
/**
|
||||
* 新任务发送后、主进程回合就绪前渲染的乐观占位(用户消息 + 助手占位)。
|
||||
* id 以 local- 开头,只在渲染进程存在;真实消息到达或被替换/清理。
|
||||
*/
|
||||
function optimisticStartMessages(draftOwner: string, text: string, options: Omit<SendPayload, 'sessionId' | 'text'>): ChatMessage[] {
|
||||
const startedAt = Date.now()
|
||||
return [
|
||||
{ id: `local-user-${draftOwner}`, role: 'user', content: text, createdAt: startedAt, status: 'done',
|
||||
attachments: options.attachments, resources: options.resources, quotes: options.quotes },
|
||||
{ id: `local-assistant-${draftOwner}`, role: 'assistant', content: '', tools: [], parts: [],
|
||||
createdAt: startedAt, turnStartedAt: startedAt, status: 'streaming', prepareStage: PENDING_START_STAGE }
|
||||
]
|
||||
}
|
||||
|
||||
function isOptimisticStartMessage(message: ChatMessage): boolean {
|
||||
return message.id.startsWith('local-user-') || message.id.startsWith('local-assistant-')
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
sessions: SessionMeta[]
|
||||
currentSessionId: string | null
|
||||
@@ -27,7 +47,7 @@ interface ChatState {
|
||||
draftId: number
|
||||
init(): Promise<void>
|
||||
selectSession(id: string): Promise<void>
|
||||
newSession(context?: SessionContext): Promise<SessionMeta>
|
||||
newSession(context?: SessionContext, seedMessages?: ChatMessage[]): Promise<SessionMeta>
|
||||
startTask(context?: SessionContext): void
|
||||
selectModel(id: string): Promise<void>
|
||||
selectExpert(id: string | null): Promise<void>
|
||||
@@ -225,14 +245,14 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
window.dispatchEvent(new CustomEvent('ycode:expert-input', { detail: { owner: targetOwner, clear: true } }))
|
||||
}
|
||||
},
|
||||
async newSession(context) {
|
||||
async newSession(context, seedMessages: ChatMessage[] = []) {
|
||||
const previousSessionId = get().currentSessionId
|
||||
if (previousSessionId) streamCommitter.flushSession(previousSessionId)
|
||||
const version = ++selectionVersion
|
||||
const created = await window.ycode.session.create(undefined, context)
|
||||
set((state) => ({ sessions: [created, ...state.sessions],
|
||||
sessionMessages: { ...state.sessionMessages, [created.id]: [] },
|
||||
...(version === selectionVersion ? { currentSessionId: created.id, messages: [], running: null,
|
||||
sessionMessages: { ...state.sessionMessages, [created.id]: seedMessages },
|
||||
...(version === selectionVersion ? { currentSessionId: created.id, messages: seedMessages, running: null,
|
||||
sending: false, loading: false, error: null } : {}) }))
|
||||
return created
|
||||
},
|
||||
@@ -286,13 +306,20 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
let sessionId = get().currentSessionId
|
||||
if (!sessionId) {
|
||||
if (get().sending) return
|
||||
// 原版在新任务发送后立即切到对话页:先渲染乐观的「用户消息 + 助手占位」,
|
||||
// 等主进程建好会话与回合(可能包含运行环境启动)后再用真实消息替换(TODO 08)。
|
||||
const draftOwner = `new-${get().draftId}`
|
||||
set({ sending: true })
|
||||
const optimistic = optimisticStartMessages(draftOwner, trimmed, options)
|
||||
set({ sending: true, messages: optimistic })
|
||||
try {
|
||||
sessionId = (await get().newSession({ ...get().draftContext })).id
|
||||
sessionId = (await get().newSession({ ...get().draftContext }, optimistic)).id
|
||||
if (typeof window.dispatchEvent === 'function') window.dispatchEvent(new CustomEvent('ycode:composer-owner-migrated', { detail: { from: draftOwner, to: sessionId } }))
|
||||
}
|
||||
catch (error) { set({ sending: false }); throw error }
|
||||
catch (error) {
|
||||
set((state) => ({ sending: false, messages: state.currentSessionId === null ? [] : state.messages,
|
||||
error: error instanceof Error ? error.message : String(error) }))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (!sessionId || get().sendingSessions.includes(sessionId) || currentRun(get().runs, sessionId)) return
|
||||
const owner = sessionId
|
||||
@@ -306,7 +333,8 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
assistantDisplay: result.assistantDisplay, createdAt: Date.now(),
|
||||
turnStartedAt: result.userMessage.createdAt, status: 'streaming' }
|
||||
set((state) => {
|
||||
const history = state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? []
|
||||
const history = (state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? [])
|
||||
.filter((item) => !isOptimisticStartMessage(item))
|
||||
const messages = result.messages ?? [...history, result.userMessage, placeholder]
|
||||
const running = { runId: result.runId, assistantId: result.assistantMessageId, sessionId: owner }
|
||||
return { sessionMessages: { ...state.sessionMessages, [owner]: messages },
|
||||
@@ -320,8 +348,14 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
void window.ycode.session.list().then((sessions) => set({ sessions })).catch(() => undefined)
|
||||
} catch (error) {
|
||||
pendingEvents.delete(owner)
|
||||
set((state) => ({ sendingSessions: state.sendingSessions.filter((id) => id !== owner),
|
||||
...(state.currentSessionId === owner ? { sending: false, error: error instanceof Error ? error.message : String(error) } : {}) }))
|
||||
// 首条消息发送失败:移除乐观占位(用户草稿由输入框恢复),不留下未持久化的消息。
|
||||
set((state) => {
|
||||
const base = state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? []
|
||||
const cleaned = base.filter((item) => !isOptimisticStartMessage(item))
|
||||
return { sessionMessages: { ...state.sessionMessages, [owner]: cleaned },
|
||||
sendingSessions: state.sendingSessions.filter((id) => id !== owner),
|
||||
...(state.currentSessionId === owner ? { messages: cleaned, sending: false, error: error instanceof Error ? error.message : String(error) } : {}) }
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
@@ -97,9 +97,11 @@ function activeBrandName(): string {
|
||||
|
||||
export interface StreamingFooterProps {
|
||||
locale?: string
|
||||
/** 新任务启动阶段文案(原版 chat.prepare.<stage>);提供时替换「思考中」。 */
|
||||
prepareText?: string | null
|
||||
}
|
||||
|
||||
export function StreamingFooter({ locale }: StreamingFooterProps): React.JSX.Element {
|
||||
export function StreamingFooter({ locale, prepareText }: StreamingFooterProps): React.JSX.Element {
|
||||
const resolvedLocale = locale ?? activeLocale()
|
||||
const brandName = activeBrandName()
|
||||
const tips = useMemo(() => getSourceLoadingTips(resolvedLocale, brandName), [brandName, resolvedLocale])
|
||||
@@ -112,7 +114,7 @@ export function StreamingFooter({ locale }: StreamingFooterProps): React.JSX.Ele
|
||||
isPaused: () => isPaused.current
|
||||
}), [tips])
|
||||
|
||||
const thinkingLabel = conversationLabel('message.loading.thinking', {}, resolveLoadingTipLocale(resolvedLocale) === 'en' ? 'en' : 'zh-cn')
|
||||
const thinkingLabel = prepareText ?? conversationLabel('message.loading.thinking', {}, resolveLoadingTipLocale(resolvedLocale) === 'en' ? 'en' : 'zh-cn')
|
||||
|
||||
return <div className="wb-cr-streaming-footer">
|
||||
<div className="_loadingWithCredit_ttf8p_463" style={{ '--cb-assistant-inline-padding': '0px' } as CSSProperties}>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Recovered from REA ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb
|
||||
// (WorkBuddy 5.8.2 renderer/assets/contexts-BMeWSUHk.js, SHA-256
|
||||
// 5f35b7489b107e21ef452c8ee88a95265756f6fc444841eef869228bbc44768b);
|
||||
// 文案表与显示逻辑见 docs/对话启动提示文案-2026-10-16.md。
|
||||
// 原版按会话 prepareStatus.stage 选择 chat.prepare.<stage>;YCode 的 chat:send 在
|
||||
// 主进程启动运行环境期间是单个阻塞阶段,因此发送后的占位只使用 acquiring_runtime,
|
||||
// 不按字符串表臆造原版阶段顺序与持续时长。
|
||||
export const CONVERSATION_PREPARE_TEXT = {
|
||||
expert: '正在准备专家能力…',
|
||||
reading_conversation_settings: '正在读取对话设置…',
|
||||
resolving_system_prompt: '正在生成系统提示词…',
|
||||
configuring_model_and_tools: '正在配置模型与工具…',
|
||||
resolving_skill_requirements: '正在整理技能配置…',
|
||||
resolving_plugin_requirements: '正在整理插件配置…',
|
||||
preparing_environment: '正在准备运行环境…',
|
||||
mcp: '正在整理连接器配置…',
|
||||
checking_launch_config: '正在检查启动配置…',
|
||||
acquiring_runtime: '正在启动对话运行环境…'
|
||||
} as const
|
||||
|
||||
export type ConversationPrepareStage = keyof typeof CONVERSATION_PREPARE_TEXT
|
||||
|
||||
/** 新任务发送后、主进程回合就绪前的占位阶段(对应运行环境启动)。 */
|
||||
export const PENDING_START_STAGE: ConversationPrepareStage = 'acquiring_runtime'
|
||||
|
||||
export function resolvePrepareText(stage: string | null | undefined): string | null {
|
||||
if (!stage) return null
|
||||
return stage in CONVERSATION_PREPARE_TEXT ? CONVERSATION_PREPARE_TEXT[stage as ConversationPrepareStage] : null
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export interface ChatMessage {
|
||||
/** 本回合的终态 */
|
||||
status?: 'streaming' | 'done' | 'error' | 'cancelled'
|
||||
error?: string
|
||||
/** 渲染进程专用的启动占位阶段(新任务发送后、主进程回合就绪前);不参与持久化。 */
|
||||
prepareStage?: string
|
||||
}
|
||||
|
||||
export interface ToolCallRecord {
|
||||
|
||||
Reference in new issue
Block a user