Skip to content

Commit 5e3e658

Browse files
Travisuncursoragent
andcommitted
feat: stream chat tool progress, fix MCP timeouts, and polish composer disclaimer.
Expose SSE chat progress with Chinese tool labels in the UI, extend MCP tool timeouts for mining workflows, use pre-close before market open, and add the AI disclaimer below the composer panel. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a8760d7 commit 5e3e658

20 files changed

Lines changed: 1101 additions & 64 deletions

File tree

apps/server/src/index.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { randomUUID } from 'node:crypto'
22
import Fastify from 'fastify'
3-
import { AgentEngine, fetchOpenAiModelList, getDataLayerPaths, resolveProjectRoot, type SessionContextRef } from '@inno-a-stock/agent'
3+
import { AgentEngine, fetchOpenAiModelList, getDataLayerPaths, resolveProjectRoot, type ChatProgressEvent, type SessionContextRef } from '@inno-a-stock/agent'
44
import { ResearchHub } from '@inno-a-stock/research-hub'
55
import { listTemplates, REGISTRY } from '@inno-a-stock/stock-eval'
66
import {
@@ -453,6 +453,46 @@ app.post<{ Params: { id: string }; Body: { message: string; selected_text: strin
453453
},
454454
)
455455

456+
app.post<{ Params: { id: string }; Body: { message: string; model?: string } }>(
457+
'/api/sessions/:id/chat/stream',
458+
async (req, reply) => {
459+
if (!req.body?.message?.trim()) return reply.code(400).send({ error: 'message required' })
460+
461+
reply.hijack()
462+
reply.raw.writeHead(200, {
463+
'Content-Type': 'text/event-stream; charset=utf-8',
464+
'Cache-Control': 'no-cache, no-transform',
465+
Connection: 'keep-alive',
466+
'X-Accel-Buffering': 'no',
467+
})
468+
469+
const write = (event: ChatProgressEvent) => {
470+
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
471+
}
472+
473+
try {
474+
await agent.chat(
475+
req.params.id,
476+
req.body.message,
477+
req.body.model,
478+
{ onProgress: write },
479+
)
480+
} catch (e) {
481+
const message = e instanceof Error ? e.message : String(e)
482+
write({ type: 'error', message })
483+
write({
484+
type: 'done',
485+
reply: message,
486+
tools_used: [],
487+
session_id: req.params.id,
488+
tool_steps: [],
489+
})
490+
} finally {
491+
reply.raw.end()
492+
}
493+
},
494+
)
495+
456496
app.post<{ Params: { id: string }; Body: { message: string; model?: string } }>(
457497
'/api/sessions/:id/chat',
458498
async (req, reply) => {

client-ui/src/api/client.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ApiResponse } from '../types/schemas'
2+
import type { ChatProgressEvent } from '../types/chatProgress'
23
import type { ChatDisplayMessage, EphemeralAskTurn, SessionContextRef, SessionMeta, AvailableModel } from '../types/chat'
34

45
/** Vite dev/preview proxies /api → backend (default :8711). */
@@ -546,3 +547,58 @@ export async function sendSessionChat(
546547
}),
547548
}, CHAT_REQUEST_TIMEOUT)
548549
}
550+
551+
export async function streamSessionChat(
552+
sessionId: string,
553+
message: string,
554+
onEvent: (event: ChatProgressEvent) => void,
555+
model?: string,
556+
signal?: AbortSignal,
557+
): Promise<void> {
558+
const resp = await fetchWithTimeout(`${API_BASE}/sessions/${sessionId}/chat/stream`, {
559+
method: 'POST',
560+
headers: {
561+
'Content-Type': 'application/json',
562+
Accept: 'text/event-stream',
563+
},
564+
body: JSON.stringify({
565+
message,
566+
...(model ? { model } : {}),
567+
}),
568+
signal,
569+
}, CHAT_REQUEST_TIMEOUT)
570+
571+
if (!resp.ok) {
572+
const err = await resp.json().catch(() => ({})) as { error?: string }
573+
throw new Error(err.error || `API error: ${resp.status}`)
574+
}
575+
if (!resp.body) throw new Error('流式响应不可用')
576+
577+
const reader = resp.body.getReader()
578+
const decoder = new TextDecoder()
579+
let buffer = ''
580+
581+
while (true) {
582+
const { done, value } = await reader.read()
583+
if (done) break
584+
buffer += decoder.decode(value, { stream: true })
585+
const chunks = buffer.split('\n\n')
586+
buffer = chunks.pop() ?? ''
587+
for (const chunk of chunks) {
588+
const line = chunk.split('\n').find(row => row.startsWith('data: '))
589+
if (!line) continue
590+
try {
591+
onEvent(JSON.parse(line.slice(6)) as ChatProgressEvent)
592+
} catch {
593+
/* ignore malformed chunk */
594+
}
595+
}
596+
}
597+
598+
if (buffer.trim()) {
599+
const line = buffer.split('\n').find(row => row.startsWith('data: '))
600+
if (line) {
601+
onEvent(JSON.parse(line.slice(6)) as ChatProgressEvent)
602+
}
603+
}
604+
}

client-ui/src/chat/ChatApp.tsx

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import WorkspaceSplitDivider from './WorkspaceSplitDivider'
99
import {
1010
listSessions, createSession, getSession, deleteSession, forkSession, clearSessionContext,
1111
setSessionContext, ephemeralAsk,
12-
sendSessionChat, getHealth, listAvailableModels, setSessionModel,
12+
streamSessionChat, getHealth, listAvailableModels, setSessionModel,
1313
} from '../api/client'
1414
import type {
1515
ChatDisplayMessage, EphemeralAskTurn, MessageSelection, SessionContextRef, SessionSelectionContextRef,
1616
SessionMeta, AvailableModel,
1717
} from '../types/chat'
18+
import type { ChatLiveTrace } from '../types/chatProgress'
1819
import { previewSelectionText } from '../utils/formatContextRefPreview'
1920
import { innoTokens } from '../theme/tokens'
2021
import { useBreakpoint, useSidebarPreference, useSidebarOverlayMode, useSidebarResizeSync } from '../hooks/useBreakpoint'
@@ -190,6 +191,7 @@ export default function ChatApp() {
190191
const [contextRef, setContextRef] = useState<SessionContextRef | null>(null)
191192
const [input, setInput] = useState('')
192193
const [loading, setLoading] = useState(false)
194+
const [liveTrace, setLiveTrace] = useState<ChatLiveTrace | null>(null)
193195
const [error, setError] = useState('')
194196
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([])
195197
const [sessionModel, setSessionModelState] = useState<string | undefined>()
@@ -341,6 +343,7 @@ export default function ChatApp() {
341343

342344
setInput('')
343345
setLoading(true)
346+
setLiveTrace({ steps: [], thinkingLabel: '模型正在思考…' })
344347
setError('')
345348

346349
const optimistic: ChatDisplayMessage = {
@@ -350,11 +353,52 @@ export default function ChatApp() {
350353
}
351354
setMessages(prev => [...prev, optimistic])
352355

356+
let resolvedSessionId = sessionId
357+
353358
try {
354-
const result = await sendSessionChat(sessionId, msg, sessionModel)
355-
const sid = result.session_id && result.session_id !== sessionId ? result.session_id : sessionId
356-
if (result.session_id && result.session_id !== sessionId) {
357-
setActiveId(result.session_id)
359+
await streamSessionChat(sessionId, msg, (event) => {
360+
if (event.type === 'thinking') {
361+
setLiveTrace(prev => ({
362+
steps: prev?.steps ?? [],
363+
thinkingLabel: event.label,
364+
thinkingSnippet: event.snippet ?? prev?.thinkingSnippet,
365+
}))
366+
return
367+
}
368+
if (event.type === 'tool_start') {
369+
setLiveTrace(prev => ({
370+
thinkingLabel: prev?.thinkingLabel,
371+
thinkingSnippet: prev?.thinkingSnippet,
372+
steps: [...(prev?.steps ?? []), event.step],
373+
}))
374+
return
375+
}
376+
if (event.type === 'tool_done') {
377+
setLiveTrace(prev => ({
378+
thinkingLabel: prev?.thinkingLabel ?? '模型正在整理结果…',
379+
thinkingSnippet: prev?.thinkingSnippet,
380+
steps: (prev?.steps ?? []).map(step =>
381+
step.id === event.step.id ? event.step : step,
382+
),
383+
}))
384+
return
385+
}
386+
if (event.type === 'reply') {
387+
setLiveTrace(prev => ({
388+
steps: prev?.steps ?? [],
389+
thinkingLabel: '正在生成回复…',
390+
thinkingSnippet: prev?.thinkingSnippet,
391+
}))
392+
return
393+
}
394+
if (event.type === 'done') {
395+
resolvedSessionId = event.session_id || resolvedSessionId
396+
}
397+
}, sessionModel)
398+
399+
const sid = resolvedSessionId
400+
if (sid !== sessionId) {
401+
setActiveId(sid)
358402
}
359403
const fresh = await getSession(sid)
360404
setMessages(fresh.messages)
@@ -373,6 +417,7 @@ export default function ChatApp() {
373417
setMessages(prev => prev.slice(0, -1))
374418
}
375419
} finally {
420+
setLiveTrace(null)
376421
setLoading(false)
377422
}
378423
}
@@ -609,6 +654,7 @@ export default function ChatApp() {
609654
contextRef={contextRef}
610655
input={input}
611656
loading={loading}
657+
liveTrace={liveTrace}
612658
error={error}
613659
availableModels={availableModels}
614660
sessionModel={sessionModel}

client-ui/src/chat/ChatComposer.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ const useStyles = makeStyles({
6161
},
6262
panelWrap: {
6363
position: 'relative',
64+
display: 'flex',
65+
flexDirection: 'column',
66+
alignItems: 'stretch',
6467
width: '100%',
6568
boxSizing: 'border-box',
6669
marginBottom: `calc(-1 * ${innoTokens.chatComposerGroundExtend})`,
@@ -135,6 +138,7 @@ const useStyles = makeStyles({
135138
fontFamily: 'inherit',
136139
color: innoTokens.textPrimary,
137140
padding: 0,
141+
margin: 0,
138142
'::placeholder': {
139143
color: innoTokens.textTertiary,
140144
},
@@ -199,6 +203,20 @@ const useStyles = makeStyles({
199203
to: { opacity: 1 },
200204
},
201205
},
206+
disclaimer: {
207+
position: 'relative',
208+
zIndex: 1,
209+
display: 'block',
210+
flexShrink: 0,
211+
textAlign: 'center',
212+
fontSize: '12px',
213+
lineHeight: 1.45,
214+
color: innoTokens.textSecondary,
215+
width: '100%',
216+
margin: 0,
217+
padding: '9px 0 0',
218+
userSelect: 'none',
219+
},
202220
})
203221

204222
interface ChatComposerProps {
@@ -329,6 +347,9 @@ export default function ChatComposer({
329347
</div>
330348
</div>
331349
</div>
350+
<span className={s.disclaimer}>
351+
内容由AI生成,不构成投资建议,请核实重要信息
352+
</span>
332353
</div>
333354
</div>
334355
)

client-ui/src/chat/ChatMessageItem.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '@fluentui/react-icons'
88
import type { ChatDisplayMessage } from '../types/chat'
99
import MarkdownMessage from './MarkdownMessage'
10+
import ChatProcessTrace from './ChatProcessTrace'
1011
import { innoTokens } from '../theme/tokens'
1112
import { fadeInUp } from '../theme/mixins'
1213
import { formatFriendlyTime } from '../utils/formatFriendlyTime'
@@ -237,7 +238,21 @@ function ChatMessageItem({ message, index, isMobile = false, onFork }: Props) {
237238
{isUser
238239
? message.content
239240
: <MarkdownMessage content={message.content} />}
240-
{message.toolsUsed && message.toolsUsed.length > 0 && (
241+
{message.toolSteps && message.toolSteps.length > 0 && (
242+
<details style={{ marginTop: 12 }}>
243+
<summary style={{
244+
fontSize: 12,
245+
color: innoTokens.textTertiary,
246+
cursor: 'pointer',
247+
userSelect: 'none',
248+
}}
249+
>
250+
{`执行过程(${message.toolSteps.length} 步)`}
251+
</summary>
252+
<ChatProcessTrace steps={message.toolSteps} />
253+
</details>
254+
)}
255+
{!message.toolSteps?.length && message.toolsUsed && message.toolsUsed.length > 0 && (
241256
<div className={s.toolTags}>
242257
{message.toolsUsed.map(t => (
243258
<Badge key={t} size="small" className={s.toolBadge}>{t}</Badge>

0 commit comments

Comments
 (0)