@@ -6,6 +6,7 @@ import type {
TeacherDefinition ,
TeacherCatalog ,
TeacherReference ,
TeacherRequest ,
TeacherSend ,
TeacherTopic ,
TeacherTopicList ,
@@ -19,6 +20,13 @@ const conversationViews = new Map<string, { topic: TeacherTopic | null; before:
const messageOrderKey = ( request : TeacherTopic [ 'requests' ] [ number ] ) = > request . origin
? [ request . origin . projectId , request . origin . topicId , request . origin . requestId ] . join ( ':' ) : request . id ;
function restorePendingSend ( saved? : TeacherSend , legacyProjectId? : string ) : TeacherSend | null {
if ( ! saved ) return null ;
// Keep an interrupted send retryable without restoring retired UI protocol fields.
const { requestId , text , references , intent , sourceConversationId , projectId } = saved ;
return { requestId , text , references , intent , sourceConversationId , . . . ( projectId || legacyProjectId ? { projectId : projectId ? ? legacyProjectId } : { } ) } ;
}
export function TeacherChatPanel ( props : Parameters < typeof ProjectTeacherChatPanel > [ 0 ] ) {
const account = useAuthStore ( state = > state . user ? . userId ? ? state . user ? . username ? ? 'signed-out' ) ;
return < ProjectTeacherChatPanel key = { ` ${ account } : ${ props . projectId } : ${ props . selectedAgent ? . teacher_id ? ? 'legacy' } : ${ props . draftRevision ? ? '' } ` } { ...props } / > ;
@@ -85,7 +93,6 @@ function ProjectTeacherChatPanel({
try { localStorage . setItem ( draftKey , JSON . stringify ( { text , references , pending , . . . referenceScopeRef . current } ) ) ; } catch { /* Draft remains in the open panel. */ }
} , [ draftKey ] ) ;
const label = '智能体' ;
const helpEnabled = ! draftRevision ;
const [ historyOpen , setHistoryOpen ] = useState ( false ) ;
const [ before , setBefore ] = useState < string | null > ( cachedView ? . before ? ? null ) ;
const [ loadingOlder , setLoadingOlder ] = useState ( false ) ;
@@ -138,7 +145,8 @@ function ProjectTeacherChatPanel({
: topic.definition.config_id
? teachers . some ( ( item ) = > item . teacher_id === topic . definition . config_id )
: legacyEnabled ;
const pending = useRef < TeacherSend | null > ( saved . pending ? ? null ) ;
const pending = useRef < TeacherSend | null > ( restorePendingSend ( saved . pending , continuous ? saved . projectId ? ? projectId : undefined ) ) ;
const sendInFlight = useRef ( false ) ;
useEffect ( ( ) = > {
persistDraft ( text , references , pending . current ) ;
} , [ text , references , persistDraft ] ) ;
@@ -384,17 +392,35 @@ function ProjectTeacherChatPanel({
if ( version === generation . current ) setBusy ( false ) ;
}
} ;
const send = async ( action? : Pick < TeacherSend , 'text' | 'intent' > ) = > {
const retryScopeMatches = ( request : TeacherRequest ) = > ! continuous
|| ( request . projectId === projectId && ( request . sourceConversationId ? ? '' ) === sourceId ) ;
const send = async ( action? : Pick < TeacherSend , 'text' | 'intent' > , retry? : TeacherRequest ) = > {
// A historical retry stays with its original topic and published Agent.
if ( retry && ( topicRef . current ? . id !== topic ? . id || ! topic || ! retryScopeMatches ( retry )
|| ( ! continuous && topic . projectId !== projectId )
|| ( selectedAgentId && topic . definition . config_id !== selectedAgentId )
|| ! topic . requests . some ( ( request ) = > request === retry && request . status === 'completed' && request . replyIncomplete ) ) ) return ;
const question = action ? . text ? ? text ;
const questionReferences = action ? [ ] : references ;
if ( ! question . trim ( ) || busy || ! topicEnabled || topicRef . current ? . requests . some ( ( request ) = > [ 'preparing' , 'running' ] . includes ( request . status ) ) ) return ;
if ( continuous && questionReferences . length && ( referenceScope . project Id ! == projectId || referenceScope . sourceId !== sourceId ) ) {
const questionReferences = retry ? . references ? ? ( action ? [ ] : references ) ;
const questionSourceId = retry
? retry . sourceConversationId ? ? ( continuous || topic ? . sourceConversation Id = == ' project' ? undefined : topic ? . sourceConversationId )
: sourceId ;
if ( ! question . trim ( ) || busy || sendInFlight . current || ! topicEnabled || topicRef . current ? . requests . some ( ( request ) = > [ 'preparing' , 'running' ] . includes ( request . status ) ) ) return ;
if ( continuous && ! retry && questionReferences . length && ( referenceScope . projectId !== projectId || referenceScope . sourceId !== sourceId ) ) {
setError ( '引用来自另一个项目或操作对话,请切回来源,或移除引用后再提问。' ) ; return ;
}
if ( continuous && pending . current ? . text === question && pending . current . projectI d && pending . current . projectId !== projec tId) {
if ( continuous && pending . current && ! topicRef . current ? . requests . some ( request = > request . i d === pending . current ? . reques tId)
&& ( pending . current . text === question || ( action ? . intent === 'suggestions' && pending . current . intent === 'suggestions' ) )
&& pending . current . projectId && pending . current . projectId !== projectId ) {
setError ( '上次提问仍绑定原项目,请切回原项目确认结果后重试。' ) ; return ;
}
if ( continuous && ! retry && action ? . intent === 'suggestions' && pending . current ? . intent === 'suggestions'
&& ! topicRef . current ? . requests . some ( request = > request . id === pending . current ? . requestId )
&& pending . current . sourceConversationId !== ( questionSourceId || undefined ) ) {
setError ( '上次提问仍绑定原操作对话,请切回原操作对话确认结果后重试。' ) ; return ;
}
const version = generation . current ;
sendInFlight . current = true ;
setBusy ( true ) ;
setError ( '' ) ;
let current = topicRef . current ;
@@ -413,20 +439,22 @@ function ProjectTeacherChatPanel({
const previous = pending . current && ! current ? . requests . some ( ( request ) = > request . id === pending . current ? . requestId )
? pending.current
: null ;
// Retry an uncertain saved request verbatim, including pre-upgrade presentation fields .
// Copy changes must not create another charge for an uncertain hint request .
const retrySuggestions = ! retry && action ? . intent === 'suggestions' && previous ? . intent === 'suggestions' ;
const input =
previous &&
previous . text === question &&
( ! continuous || previous . projectId === projectId ) &&
previous . sourceConversationId === ( sourceId || undefined ) &&
( previous . intent ? ? 'question' ) === ( action ? . intent ? ? 'question' ) &&
JSON . stringify ( previous . references ) === JSON . stringify ( questionReferences )
previous && ( ! continuous || ( previous . projectId === projectId
&& previous . sourceConversationId === ( questionSourceId || undefined ) ) ) && ( retrySuggestions || (
previous . text === question &&
( previous . intent ? ? 'question' ) === ( action ? . intent ? ? 'question' ) &&
JSON . stringify ( previous . references ) === JSON . stringify ( questionReferences ) &&
( ! retry || previous . sourceConversationId === ( questionSourceId || undefined ) )
) )
? previous
: {
requestId : crypto.randomUUID ( ) , text : question , references : questionReferences ,
. . . ( continuous ? { projectId } : { } ) ,
. . . ( action ? . intent ? { intent : action.intent } : { } ) ,
. . . ( s ourceId ? { sourceConversationId : s ourceId } : { } ) ,
. . . ( questionS ourceId ? { sourceConversationId : questionS ourceId } : { } ) ,
} ;
pending . current = input ;
persistDraft ( text , references , input ) ;
@@ -436,7 +464,7 @@ function ProjectTeacherChatPanel({
consume ( next ) ;
pending . current = null ;
if ( action ) {
persistDraft ( text , references ) ;
persistDraft ( draftRef . current . text , draftRef . current . references ) ;
} else {
setText ( '' ) ;
setReferences ( [ ] ) ;
@@ -447,6 +475,7 @@ function ProjectTeacherChatPanel({
if ( version === generation . current )
setError ( e instanceof Error ? e . message : '提问失败,请保留问题后重试' ) ;
} finally {
sendInFlight . current = false ;
if ( version === generation . current ) setBusy ( false ) ;
}
} ;
@@ -454,8 +483,7 @@ function ProjectTeacherChatPanel({
( request ) = > request . status === 'preparing' || request . status === 'running'
) ;
const helpUnavailable = busy || ! topicEnabled || Boolean ( running ) ;
const askForSuggestions = ( ) = > void send ( { text : '帮我 看看' , intent : 'suggestions' } ) ;
const askForGuidance = ( ) = > void send ( { text : '我也说不清,你带我看看' , intent : 'guided-help' } ) ;
const askForSuggestions = ( ) = > void send ( { text : '继续 看看👀 ' , intent : 'suggestions' } ) ;
const changeText = ( next : string ) = > { setText ( next ) ; persistDraft ( next , references , pending . current ) ; } ;
const removeReferences = ( ) = > { setReferences ( [ ] ) ; persistDraft ( text , [ ] , pending . current ) ; } ;
const questions = definition ? . suggested_questions ? ? [ ] ;
@@ -512,22 +540,25 @@ function ProjectTeacherChatPanel({
< / li > ) }
< / ul >
< / details > }
{ request . intent === 'suggestions' ? < >
{ request . status === 'completed' && request . replyIncomplete ? < div className = "space-y-3" >
< p role = "status" className = "text-[13px] leading-7 text-muted-foreground" > 这 次 回 复 未 能 完 整 显 示 < / p >
{ request . intent !== 'check-in' && request . text . trim ( ) && < button type = "button" disabled = { helpUnavailable || ! retryScopeMatches ( request ) } onClick = { ( ) = > void send ( { text : request.text , intent : request.intent } , request ) } className = "consultation-retry" > 重 新 回 答 < / button > }
{ ! retryScopeMatches ( request ) && < p className = "text-xs text-muted-foreground" > 回 到 原 项 目 和 操 作 对 话 后 , 可 以 重 新 回 答 。 < / p > }
< / div > : request . intent === 'suggestions' ? < >
{ [ 'running' , 'preparing' ] . includes ( request . status )
? < p role = "status" className = "flex items-center gap-2 text-[13px] leading-7 text-muted-foreground" > < Loader2 className = "h-3.5 w-3.5 animate-spin motion-reduce:animate-none" / > 我 看 看 你 最 近 做 到 了 哪 里 … < / p >
: request . status === 'completed' && < >
< TeacherReply text = { request . response } / >
< div className = "consultation-follow-ups" aria-label = "可以和智能体聊的问题" >
{ request . suggestedQuestions ? . map ( ( question ) = > < button key = { question } type = "button" disabled = { helpUnavailable } onClick = { ( ) = > void send ( { text : question } ) } className = "consultation-follow-up" > < span className = "min-w-0 flex-1" > { question } < / span > < ChevronRight className = "h-4 w-4 shrink-0 opacity-70" aria-hidden = "true" / > < / button > ) }
{ request . suggestedQuestions ? . map ( ( question , index ) = > < button key = { ` ${ request . id } : ${ index } ` } type = "button" disabled = { helpUnavailable } onClick = { ( ) = > void send ( { text : question } ) } className = "consultation-follow-up" > < span className = "min-w-0 flex-1" > { question } < / span > < ChevronRight className = "h-4 w-4 shrink-0 opacity-70" aria-hidden = "true" / > < / button > ) }
< / div >
< button type = "button" disabled = { helpUnavailable } onClick = { askForGuidance } className = "consultation-guided-help min-h-10 text-left text-xs leading-6 underline decoration-current/30 underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50" > 我 也 说 不 清 , 你 带 我 看 看 < / button >
< / > }
{ [ 'failed' , 'cancelled' , 'interrupted' ] . includes ( request . status ) && < button type = "button" disabled = { helpUnavailable } onClick = { askForSuggestions } className = "consultation-guided-help min-h-10 text-xs underline underline-offset-4 disabled:opacity-50 " > 再 请 智 能 体 看 看 < / button > }
< / > : request . response ? < TeacherReply text = { request . response } / > : < div className = "text-[13px] leading-7" > { [ 'running' , 'preparing' ] . includes ( request . status ) ? < span role = "status" className = "inline-flex items-center gap-2 text-muted-foreground" > < Loader2 className = "h-3.5 w-3.5 animate-spin motion-reduce:animate-none" / > { ` ${ label }正在想… ` } < / span > : [ 'cancelled' , 'interrupted' ] . includes ( request . status ) ? '这次先停在这里。想继续时可以再说说。' : '' } < / div > }
{ helpEnabled && request. intent !== 'suggestions' && request . status === 'completed' && Boolean ( request . suggestedQuestions ? . length ) && < div className = "consultation-follow-ups" aria-label = "接着聊" >
{ request . suggestedQuestions ? . map ( question = > < button type = "button" key = { question } disabled = { helpUnavailable } onClick = { ( ) = > void send ( { text : question } ) } className = "consultation-follow-up" > < span className = "min-w-0 flex-1" > { question } < / span > < ChevronRight className = "h-4 w-4 shrink-0 opacity-70" aria-hidden = "true" / > < / button > ) }
{ [ 'failed' , 'cancelled' , 'interrupted' ] . includes ( request . status ) && < button type = "button" disabled = { helpUnavailable } onClick = { askForSuggestions } className = "consultation-retry " > 继 续 看 看 👀 < / button > }
< / > : request . response ? < TeacherReply text = { request . response } / > : < div className = "text-[13px] leading-7" > { [ 'running' , 'preparing' ] . includes ( request . status ) ? < span role = "status" className = "inline-flex items-center gap-2 text-muted-foreground" > < Loader2 className = "h-3.5 w-3.5 animate-spin motion-reduce:animate-none" / > { label }正 在 想 … < / span > : [ 'cancelled' , 'interrupted' ] . includes ( request . status ) ? '这次先停在这里。想继续时可以再说说。' : '' } < / div > }
{ request . intent !== 'suggestions' && request . status === 'completed' && ! request . replyIncomplete && Boolean ( request . suggestedQuestions ? . length ) && < div className = "consultation-follow-ups" aria-label = "接着聊" >
{ request . suggestedQuestions ? . map ( ( question , index ) = > < button type = "button" key = { ` ${ request . id } : ${ index } ` } disabled = { helpUnavailable } onClick = { ( ) = > void send ( { text : question } ) } className = "consultation-follow-up" > < span className = "min-w-0 flex-1" > { question } < / span > < ChevronRight className = "h-4 w-4 shrink-0 opacity-70" aria-hidden = "true" / > < / button > ) }
< / div > }
{ request . discussionError && < p className = "text-xs leading-6 text-muted-foreground" > { request . discussionError } < / p > }
{ request . status === 'completed' && ! request . replyIncomplete && request . replyParseError && < p role = "status" className = "text-xs text-muted-foreground" > 回 答 正 文 已 保 留 , 部 分 附 加 内 容 未 能 显 示 。 < / p > }
{ request . unparsedResponse && < details className = "min-w-0 text-xs text-muted-foreground" >
< summary className = "cursor-pointer" > 查 看 收 到 的 原 始 内 容 < / summary >
< pre className = "mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border p-3" > { request . unparsedResponse } < / pre >
@@ -535,7 +566,7 @@ function ProjectTeacherChatPanel({
{ request . progress && [ 'running' , 'preparing' ] . includes ( request . status ) && < p role = "status" className = "text-xs text-muted-foreground" > { request . progress } < / p > }
{ ( request . truncatedMessages ? ? 0 ) > 0 && < p className = "text-xs text-muted-foreground" > { draftRevision ? '较长的上下文已节选。' : '较长的上下文已节选,智能体可按需读取原文。' } < / p > }
{ request . error && < p className = "text-xs text-destructive" > { request . error } < / p > }
{ onBringBack && request . response && request . intent !== 'suggestions' && request . status === 'completed' && < div className = "text-[11px] text-muted-foreground" >
{ onBringBack && request . response && ! request . replyIncomplete && request . intent !== 'suggestions' && request . status === 'completed' && < div className = "text-[11px] text-muted-foreground" >
< button disabled = { continuous && ( request . projectId !== projectId || request . sourceConversationId !== sourceId ) } onClick = { ( ) = > onBringBack ( request . response ) } >
{ continuous && ( request . projectId !== projectId || request . sourceConversationId !== sourceId ) ? '请打开来源项目和操作对话后带回' : '带回主会话草稿' } < / button >
< / div > }
@@ -564,9 +595,10 @@ function ProjectTeacherChatPanel({
< textarea ref = { inputRef } aria-label = { ` 向 ${ label } 提问 ` } value = { text } disabled = { busy } maxLength = { 6000 } onChange = { ( e ) = > changeText ( e . target . value ) } placeholder = "和老师聊聊" rows = { 2 }
onKeyDown = { ( e ) = > { if ( e . key === 'Enter' && ! e . shiftKey && ! e . nativeEvent . isComposing ) { e . preventDefault ( ) ; void send ( ) ; } } }
className = "min-h-[60px] w-full resize-none border-0 bg-transparent text-[13px] leading-6 outline-none placeholder:text-muted-foreground/65" / >
< div className = "mt-2 flex items-center justify-between gap-2" data-testid = "consultation-composer-actions" >
{ helpEnabled ? < button type = "button" disabled = { helpUnavailable } onClick = { askForSuggestions } className = "consultation-help-prompt flex min-h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50" > 帮 我 看 看 < ChevronRight className = "h-3 w-3" aria-hidden = "true" / > < / button >
: < span / > }
< div className = "mt-2 flex min-h-11 items-center justify-between gap-2" data-testid = "consultation-composer-actions" >
< div className = "min-w-0 flex-1" >
{ ! draftRevision && ! text . trim ( ) && < button type = "button" disabled = { helpUnavailable } onClick = { askForSuggestions } className = "consultation-hint" > 继 续 看 看 👀 < / button > }
< / div >
{ running ? < button type = "button" className = "rounded-lg border px-3 py-2 text-xs" onClick = { ( ) = > void teacherApi . cancel ( topicBase , endpointId , running . id ) . then ( consume ) . catch ( ( ) = > setError ( '暂时无法停止,请稍后重试。' ) ) } > 停 止 回 复 < / button >
: < button type = "submit" aria-label = "提问" title = { busy ? '正在发送…' : '发送' } disabled = { busy || ! topicEnabled || ! text . trim ( ) } className = "consultation-send flex h-8 w-8 items-center justify-center rounded-full text-white disabled:opacity-30" > < ArrowUp className = "h-4 w-4" / > < / button > }
< / div >