feat(agent): rebuild the agent workspace around artifacts and sharing
Working with an agent meant reading a long transcript with no way to see what it produced, revisit an earlier conversation or hand an answer to someone else. Give every agent a page of its own: description, instructions, starters and the artifacts it already wrote, reachable by URL through the sidebar (agent collections now expand in place) and deep-linkable per agent. In the transcript, tool calls that produced files are linked back into the prose, images preview inline and deliverables are listed once at the end of an answer instead of being buried in the tool rows. A stream that drops mid-answer reconnects with `?after=`/`Last-Event-ID` and replays the missing deltas, long markdown is throttled so streaming stays smooth, and an answer can be rendered to an image in the browser and copied or downloaded without uploading anything. Also normalises the streaming delta batches the fanout now emits, and polls teammate presence in the sidebar so the dots stay current.
ZhenYi committed
Modified web/src/app/routes/router.tsx web/src/app/routes/router.tsx +1 −1 View web/src/app/routes/router.tsx
@@ -43,7 +43,7 @@
4343 { path: ':spaceName' },
4444 { path: ':spaceName/chat/:roomId?' },
4545 { path: ':spaceName/board/:boardId?' },
46 { path: ':spaceName/agent/:collectionId?' },
46 { path: ':spaceName/agent/:collectionId?/:agentId?' },
4747 { path: ':spaceName/repo/:repoName?' },
4848 { path: ':spaceName/members' },
4949 ],
Added web/src/pages/workspace/model/agent-artifacts.ts web/src/pages/workspace/model/agent-artifacts.ts +106 −0 View web/src/pages/workspace/model/agent-artifacts.ts
@@ -0,0 +1,106 @@
1import type { AgentMessageDto } from './agent-api'
2
3/** 工具输出尾部的上下文预算提示,剥离后再解析 JSON。 */
4export type ToolBudget = { text: string; percent: string | null }
5
6export type ArtifactRef = { id: string; name: string | null }
7
8export type ArtifactItem = {
9 id: string
10 name: string
11 mime: string | null
12 size: number | null
13 /** Run that produced the artifact — deliverables render at that run's end. */
14 runId: string | null
15}
16
17/** id -> display name, plus legacy storage-key/URI lookups for old transcripts. */
18export type TranscriptArtifacts = {
19 byId: Map<string, string | null>
20 byKey: Map<string, ArtifactRef>
21 byName: Map<string, ArtifactRef>
22 /** Registration order, deduplicated: drives the end-of-transcript grid. */
23 list: ArtifactItem[]
24}
25
26/** Download/redirect access to the runtime, injected by the chat container. */
27export type ArtifactAccess = {
28 /** Opens the runtime's redirect endpoint in a new tab. */
29 download: (artifactId: string) => void
30 /** Ready-to-use URL (redirect endpoint + token) for inline previews. */
31 resolveUrl: (artifactId: string) => Promise<string>
32}
33
34export function isRecord(value: unknown): value is Record<string, unknown> {
35 return typeof value === 'object' && value !== null && !Array.isArray(value)
36}
37
38const BUDGET_RE = /[\r\n]+\s*\[Context Budget:([^\]\n]*)\]\s*$/
39
40export function splitBudget(raw: string): { body: string; budget: ToolBudget | null } {
41 const match = BUDGET_RE.exec(raw)
42 if (!match) return { body: raw, budget: null }
43 const text = match[1].trim()
44 const percent = /(\d+(?:\.\d+)?%)/.exec(text)?.[1] ?? null
45 return { body: raw.slice(0, match.index), budget: { text, percent } }
46}
47
48const ARTIFACT_ID = /^[0-9a-fA-F-]{36}$/
49
50/** Accepts `artifact://<uuid>`, the legacy `artifact:u'<uuid>'` and raw uuids. */
51export function artifactIdOf(value: unknown): string | null {
52 if (typeof value !== 'string') return null
53 const bare = value
54 .trim()
55 .replace(/^artifact:\/\//, '')
56 .replace(/^artifact:/, '')
57 .replace(/^u'/, '')
58 .replace(/'$/, '')
59 return ARTIFACT_ID.test(bare) ? bare : null
60}
61
62export function collectArtifacts(messages: AgentMessageDto[]): TranscriptArtifacts {
63 const byId = new Map<string, string | null>()
64 const byKey = new Map<string, ArtifactRef>()
65 const byName = new Map<string, ArtifactRef>()
66 const list: ArtifactItem[] = []
67 const seen = new Set<string>()
68 for (const message of messages) {
69 if (message.role !== 'tool' || !message.content.trim()) continue
70 let parsed: unknown
71 try {
72 // Tool results carry a trailing context-budget reminder; strip it first.
73 parsed = JSON.parse(splitBudget(message.content).body.trim())
74 } catch {
75 continue
76 }
77 if (!isRecord(parsed)) continue
78 const records = isRecord(parsed.artifact) ? [parsed, parsed.artifact] : [parsed]
79 for (const record of records) {
80 const id = artifactIdOf(record.artifact) ?? artifactIdOf(record.artifact_id)
81 if (!id) continue
82 const name =
83 typeof record.name === 'string'
84 ? record.name
85 : typeof record.artifact_name === 'string'
86 ? record.artifact_name
87 : null
88 if (!byId.has(id)) byId.set(id, name)
89 const ref: ArtifactRef = { id, name }
90 if (typeof record.storage_key === 'string') byKey.set(record.storage_key, ref)
91 if (typeof record.uri === 'string') byKey.set(record.uri, ref)
92 if (name && !byName.has(name)) byName.set(name, ref)
93 if (!seen.has(id)) {
94 seen.add(id)
95 list.push({
96 id,
97 name: name ?? id,
98 mime: typeof record.mime_type === 'string' ? record.mime_type : null,
99 size: typeof record.size_bytes === 'number' ? record.size_bytes : null,
100 runId: message.run_id ?? null,
101 })
102 }
103 }
104 }
105 return { byId, byKey, byName, list }
106}
No newline at end of file
Modified web/src/pages/workspace/model/route.ts web/src/pages/workspace/model/route.ts +1 −0 View web/src/pages/workspace/model/route.ts
@@ -20,5 +20,6 @@
2020 roomId?: string
2121 boardId?: string
2222 agentCollectionId?: string
23 agentId?: string
2324 repoName?: string
2425}
Modified web/src/pages/workspace/model/workspace-route.ts web/src/pages/workspace/model/workspace-route.ts +3 −0 View web/src/pages/workspace/model/workspace-route.ts
@@ -16,7 +16,9 @@
1616 const rawSection = segments[3] ?? ''
1717 // The id belongs to whichever section is in the URL; a section-less space
1818 // root (`/app/minimax`) has none, exactly like its optional route segment.
19 // The agent section adds a second, deeper id: the selected agent.
1920 const rawId = segments[4] || undefined
21 const rawDetail = segments[5] || undefined
2022 const decode = (value: string | undefined) =>
2123 value === undefined ? undefined : decodeURIComponent(value)
2224 // `/app/explore` is a static sibling of `/app/:spaceName`, so it has no
@@ -46,6 +48,7 @@
4648 roomId: section === 'chat' ? decode(rawId) : undefined,
4749 boardId: section === 'board' ? decode(rawId) : undefined,
4850 agentCollectionId: section === 'agent' ? decode(rawId) : undefined,
51 agentId: section === 'agent' ? decode(rawDetail) : undefined,
4952 repoName: section === 'repo' ? decode(rawId) : undefined,
5053 }
5154}
Modified web/src/pages/workspace/ui/agent/agent-chat.tsx web/src/pages/workspace/ui/agent/agent-chat.tsx +328 −91 View web/src/pages/workspace/ui/agent/agent-chat.tsx
@@ -22,7 +22,15 @@
2222} from '../../model/agent-api'
2323import { fetchSpaceRepos } from '../../model/shell-api'
2424import type { RepoItem } from '../../model/workspace'
25import { AgentStreamingRow, AgentThinkingRow, AgentTranscript, AgentWorkingRow, type ArtifactAccess } from './agent-message'
25import {
26 AgentStreamingRow,
27 AgentThinkingRow,
28 AgentTranscript,
29 AgentWorkingRow,
30 type MessageActions,
31} from './agent-message'
32import { AgentShareModal, type ShareTarget } from './agent-share-modal'
33import { collectArtifacts, type ArtifactAccess, type ArtifactItem } from '../../model/agent-artifacts'
2634
2735export interface AgentChatProps {
2836 spaceName: string
@@ -30,10 +38,15 @@
3038 agent: AgentDto
3139 /** Space-management users may attach the platform MCP to a run. */
3240 canEnableMcp: boolean
41 /** Deliverables found in the transcript, reported for the detail panel. */
42 onArtifacts?: (items: ArtifactItem[], open: (artifactId: string) => void) => void
3343}
3444
3545const ACTIVE_RUN_STATES = new Set(['pending', 'queued', 'running', 'waiting'])
3646
47/** One operator answer: predefined selections, free text, or both. */
48type QuestionAnswer = { item_id: string; selected: string[]; text?: string }
49
3750/** Run-event kinds that should re-pull authoritative state (debounced). */
3851const STREAM_EVENT_KINDS = [
3952 'run_started',
@@ -54,10 +67,23 @@
5467 'log',
5568] as const
5669
70/** Event kinds whose refresh must also pull approvals/questions. */
71const PROMPT_EVENT_KINDS = new Set<string>([
72 'approval_requested',
73 'approval_decided',
74 'question_asked',
75 'question_answered',
76])
77
5778function errorMessage(error: unknown): string {
5879 return error instanceof ApiFailure ? error.message : String(error)
5980}
6081
82/** Mint failures worth retrying: network trouble or a runtime that is down. */
83function recoverableStreamError(error: unknown): boolean {
84 return !(error instanceof ApiFailure) || error.code >= 500
85}
86
6187/**
6288 * One agent's long-lived conversation: transcript, composer, run status plus the
6389 * runtime's approval/question prompts. Live output comes from the runtime's
@@ -65,7 +91,7 @@
6591 * minted by the main API (control plane stays gRPC); polling remains as the
6692 * fallback when streaming is unavailable.
6793 */
68export function AgentChat({ spaceName, collectionId, agent, canEnableMcp }: AgentChatProps) {
94export function AgentChat({ spaceName, collectionId, agent, canEnableMcp, onArtifacts }: AgentChatProps) {
6995 const { t } = useT()
7096 const [messages, setMessages] = useState<AgentMessageDto[]>([])
7197 const [runs, setRuns] = useState<AgentRunDto[]>([])
@@ -96,13 +122,29 @@
96122 const [busy, setBusy] = useState(false)
97123 const [error, setError] = useState<string | null>(null)
98124 const [loaded, setLoaded] = useState(false)
125 // 点赞/点踩后端接口尚未提供,先按智能体隔离存在本地。
126 const feedbackKey = `gitdata.agent.feedback.${agent.id}`
127 const [feedback, setFeedback] = useState<Record<string, 'up' | 'down'>>(() => {
128 try {
129 return JSON.parse(localStorage.getItem(feedbackKey) ?? '{}') as Record<string, 'up' | 'down'>
130 } catch {
131 return {}
132 }
133 })
134 const [shareTarget, setShareTarget] = useState<ShareTarget | null>(null)
99135 const [streaming, setStreaming] = useState('')
100136 const [streamingThink, setStreamingThink] = useState('')
101137 const [sseLive, setSseLive] = useState(false)
102138 const lastSequence = useRef(0)
103139 const lastEventId = useRef<string | null>(null)
104 const activeRunIdRef = useRef<string | null>(null)
140 const lastTokenSeq = useRef<string | null>(null)
141 const finishedRunIds = useRef<Set<string>>(new Set())
142 const streamingRunId = useRef<string | null>(null)
143 const pendingText = useRef('')
144 const pendingThink = useRef('')
145 const flushFrame = useRef<number | null>(null)
105146 const refreshTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
147 const pendingRefresh = useRef({ prompts: false, toolCalls: false })
106148 const scrollRef = useRef<HTMLDivElement>(null)
107149 const composerRef = useRef<HTMLTextAreaElement>(null)
108150 const [atBottom, setAtBottom] = useState(true)
@@ -163,7 +205,41 @@
163205 [artifactUrl],
164206 )
165207
166 const refresh = useCallback(async () => {
208 const artifacts = useMemo(() => collectArtifacts(messages), [messages])
209 const onArtifactsRef = useRef(onArtifacts)
210 useEffect(() => {
211 onArtifactsRef.current = onArtifacts
212 }, [onArtifacts])
213 useEffect(() => {
214 onArtifactsRef.current?.(artifacts.list, artifactAccess.download)
215 }, [artifacts.list, artifactAccess])
216
217 /** Coalesce token bursts into at most one state commit per animation frame. */
218 const flushPending = useCallback(() => {
219 if (flushFrame.current !== null) {
220 cancelAnimationFrame(flushFrame.current)
221 flushFrame.current = null
222 }
223 const text = pendingText.current
224 const think = pendingThink.current
225 pendingText.current = ''
226 pendingThink.current = ''
227 if (think) setStreamingThink(prev => prev + think)
228 if (text) setStreaming(prev => prev + text)
229 }, [])
230
231 const scheduleFlush = useCallback(() => {
232 if (flushFrame.current !== null) return
233 flushFrame.current = requestAnimationFrame(() => {
234 flushFrame.current = null
235 flushPending()
236 })
237 }, [flushPending])
238
239 const refreshStructure = useCallback(async () => {
240 // The buffer holds tokens that are not in `messages` yet; park them in state
241 // first so the clear below cannot drop text that raced the fetch.
242 flushPending()
167243 const after = lastSequence.current > 0 ? lastSequence.current : undefined
168244 const fresh = await fetchAgentMessages(spaceName, collectionId, agent.id, after)
169245 if (fresh.length > 0) {
@@ -174,21 +250,41 @@
174250 })
175251 // 正文一旦落库就由消息行接管;思考只在运行时也把它落成消息时才清,
176252 // 否则保留到最后(运行时不会持久化思考,它是本轮唯一记录)。
177 setStreaming('')
178 if (fresh.some(item => item.kind === 'reasoning')) setStreamingThink('')
253 // 只认当前流式 run 的落库消息,避免旧 run 的迟到消息清掉新 run 的缓冲。
254 const run = streamingRunId.current
255 if (fresh.some(item => item.role === 'assistant' && (run === null || item.run_id === run))) {
256 flushPending()
257 setStreaming('')
258 streamingRunId.current = null
259 }
260 if (
261 fresh.some(
262 item => item.kind === 'reasoning' && (run === null || item.run_id === run),
263 )
264 ) {
265 setStreamingThink('')
266 }
179267 }
180 const [nextRuns, nextApprovals, nextQuestions, nextToolCalls] = await Promise.all([
181 fetchAgentRuns(spaceName, collectionId, agent.id),
268 setRuns(await fetchAgentRuns(spaceName, collectionId, agent.id))
269 }, [spaceName, collectionId, agent.id, flushPending])
270
271 const refreshPrompts = useCallback(async () => {
272 const [nextApprovals, nextQuestions] = await Promise.all([
182273 fetchAgentApprovals(spaceName, collectionId, agent.id),
183274 fetchAgentQuestions(spaceName, collectionId, agent.id),
184 fetchAgentToolCalls(spaceName, collectionId, agent.id),
185275 ])
186 setRuns(nextRuns)
187276 setApprovals(nextApprovals)
188277 setQuestions(nextQuestions)
189 setToolCalls(nextToolCalls)
190278 }, [spaceName, collectionId, agent.id])
191279
280 const refreshToolCalls = useCallback(async () => {
281 setToolCalls(await fetchAgentToolCalls(spaceName, collectionId, agent.id))
282 }, [spaceName, collectionId, agent.id])
283
284 const refresh = useCallback(async () => {
285 await Promise.all([refreshStructure(), refreshPrompts(), refreshToolCalls()])
286 }, [refreshStructure, refreshPrompts, refreshToolCalls])
287
192288 useEffect(() => {
193289 // The component is keyed by agent id, so state starts clean on every switch.
194290 let active = true
@@ -210,34 +306,47 @@
210306
211307 const activeRunId = activeRun?.run_id
212308
213 useEffect(() => {
214 activeRunIdRef.current = activeRunId ?? null
215 }, [activeRunId])
216
217 /** Coalesce SSE-driven refreshes: replays and token bursts must not stampede. */
218 const refreshSoon = useCallback(() => {
219 if (refreshTimer.current) return
220 refreshTimer.current = setTimeout(() => {
221 refreshTimer.current = undefined
222 void refresh().catch(() => {})
223 }, 400)
224 }, [refresh])
309 /**
310 * Coalesce SSE-driven refreshes: replays and token bursts must not stampede.
311 * Which extra state to pull is decided by the triggering event kinds and
312 * unioned across everything that lands inside the debounce window.
313 */
314 const refreshSoon = useCallback(
315 (scope?: { prompts?: boolean; toolCalls?: boolean }) => {
316 if (scope?.prompts) pendingRefresh.current.prompts = true
317 if (scope?.toolCalls) pendingRefresh.current.toolCalls = true
318 if (refreshTimer.current) return
319 refreshTimer.current = setTimeout(() => {
320 refreshTimer.current = undefined
321 const pending = pendingRefresh.current
322 pendingRefresh.current = { prompts: false, toolCalls: false }
323 const tasks: Promise<void>[] = [refreshStructure()]
324 if (pending.prompts) tasks.push(refreshPrompts())
325 if (pending.toolCalls) tasks.push(refreshToolCalls())
326 void Promise.all(tasks).catch(() => {})
327 }, 400)
328 },
329 [refreshStructure, refreshPrompts, refreshToolCalls],
330 )
225331
226332 useEffect(
227333 () => () => {
228334 if (refreshTimer.current) clearTimeout(refreshTimer.current)
335 if (flushFrame.current !== null) cancelAnimationFrame(flushFrame.current)
229336 },
230337 [],
231338 )
232339
233340 // Direct fanout stream: mint a token, connect to /events + /tokens, fall back
234341 // to polling on any failure (the polling effect below keeps the transcript
235 // and prompts fresh either way).
342 // fresh either way). Both channels share one reconnect loop with exponential
343 // backoff: losing either one degrades to polling until it is back.
236344 useEffect(() => {
237345 let cancelled = false
238346 let events: EventSource | null = null
239347 let tokens: EventSource | null = null
240348 let retry: ReturnType<typeof setTimeout> | undefined
349 let attempt = 0
241350
242351 const closeStreams = () => {
243352 events?.close()
@@ -256,48 +365,77 @@
256365 // A malformed frame must not break the stream.
257366 }
258367 if (event.type === 'run_started' && runId) {
259 activeRunIdRef.current = runId
368 flushPending()
260369 setStreaming('')
261370 setStreamingThink('')
371 streamingRunId.current = null
262372 } else if (
263373 event.type === 'run_finished' ||
264374 event.type === 'run_error' ||
265375 event.type === 'run_suspended'
266376 ) {
377 if (runId) finishedRunIds.current.add(runId)
267378 // 结束时只清正文流;思考保留为「思考过程」,下一轮 run_started 再清。
379 flushPending()
268380 setStreaming('')
269381 }
270 refreshSoon()
382 refreshSoon({
383 prompts: PROMPT_EVENT_KINDS.has(event.type),
384 toolCalls: event.type === 'tool_call_finished',
385 })
386 }
387
388 const scheduleReconnect = () => {
389 if (cancelled) return
390 closeStreams()
391 setSseLive(false)
392 const delay = Math.min(30_000, 1000 * 2 ** attempt)
393 attempt += 1
394 if (retry) clearTimeout(retry)
395 retry = setTimeout(() => {
396 if (!cancelled) void connect()
397 }, delay)
271398 }
272399
273 const connect = async () => {
400 async function connect() {
274401 try {
275402 const grant = await createAgentStreamToken(spaceName, collectionId, agent.id)
276403 if (cancelled) return
277404 const base = `${grant.fanout_endpoint.replace(/\/+$/, '')}/v1/sessions/${encodeURIComponent(grant.conversation_id)}`
278405 const token = encodeURIComponent(grant.token)
279 const after = lastEventId.current
406 const eventAfter = lastEventId.current
280407 ? `&after=${encodeURIComponent(lastEventId.current)}`
281408 : ''
282 events = new EventSource(`${base}/events?token=${token}${after}`)
283 tokens = new EventSource(`${base}/tokens?token=${token}`)
409 const tokenAfter = lastTokenSeq.current
410 ? `&after=${encodeURIComponent(lastTokenSeq.current)}`
411 : ''
412 events = new EventSource(`${base}/events?token=${token}${eventAfter}`)
413 tokens = new EventSource(`${base}/tokens?token=${token}${tokenAfter}`)
284414 events.onopen = () => {
285 if (!cancelled) setSseLive(true)
286 }
287 events.onerror = () => {
288415 if (cancelled) return
289 setSseLive(false)
290 closeStreams()
291 retry = setTimeout(() => {
292 if (!cancelled) void connect()
293 }, 15_000)
416 const wasRetry = attempt > 0
417 attempt = 0
418 setSseLive(true)
419 // A reconnect may have missed run events; pull the authoritative tail.
420 if (wasRetry) void refreshStructure().catch(() => {})
294421 }
422 events.onerror = scheduleReconnect
423 tokens.onerror = scheduleReconnect
295424 for (const kind of STREAM_EVENT_KINDS) {
296425 events.addEventListener(kind, onRunEvent)
297426 }
298427 tokens.addEventListener('token', event => {
299428 const message = event as MessageEvent
300 if (message.lastEventId) lastEventId.current = message.lastEventId
429 if (message.lastEventId) {
430 const seq = Number(message.lastEventId)
431 const last = Number(lastTokenSeq.current)
432 // The replay ring only holds a window; a jumped cursor means the
433 // middle of the stream is gone, so resync from persisted messages.
434 if (Number.isFinite(seq) && Number.isFinite(last) && last > 0 && seq > last + 1) {
435 void refreshStructure().catch(() => {})
436 }
437 lastTokenSeq.current = message.lastEventId
438 }
301439 let payload: {
302440 token?: string
303441 run_id?: string | null
@@ -311,14 +449,20 @@
311449 }
312450 if (payload.agent_role || !payload.token) return
313451 const runId = payload.run_id ?? null
314 if (runId && activeRunIdRef.current && runId !== activeRunIdRef.current) return
452 if (runId && finishedRunIds.current.has(runId)) return
315453 // 思维链与正文分开累积:runtime 在 token 上标记 reasoning。
316 if (payload.reasoning) setStreamingThink(prev => prev + payload.token)
317 else setStreaming(prev => prev + payload.token)
454 if (runId) streamingRunId.current = runId
455 if (payload.reasoning) pendingThink.current += payload.token
456 else pendingText.current += payload.token
457 scheduleFlush()
318458 })
319 } catch {
320 // Streaming unavailable (runtime off / fanout not configured): polling only.
321 if (!cancelled) setSseLive(false)
459 } catch (error) {
460 if (cancelled) return
461 // Runtime off / fanout not configured: polling only. Server-side trouble
462 // is worth retrying; a hard client error (forbidden, missing session)
463 // is not.
464 setSseLive(false)
465 if (recoverableStreamError(error)) scheduleReconnect()
322466 }
323467 }
324468
@@ -328,18 +472,32 @@
328472 closeStreams()
329473 if (retry) clearTimeout(retry)
330474 }
331 }, [spaceName, collectionId, agent.id, refreshSoon])
475 }, [
476 spaceName,
477 collectionId,
478 agent.id,
479 refreshSoon,
480 refreshStructure,
481 flushPending,
482 scheduleFlush,
483 ])
332484
333485 useEffect(() => {
334486 if (!activeRunId) return
487 let tick = 0
335488 const timer = setInterval(
336489 () => {
337 void refresh().catch(() => {})
490 tick += 1
491 const tasks: Promise<void>[] = [refreshStructure()]
492 // Prompt state only rides the slow offline cadence as a fallback; while
493 // SSE is live its events already trigger a refresh.
494 if (!sseLive && tick % 5 === 0) tasks.push(refreshPrompts())
495 void Promise.all(tasks).catch(() => {})
338496 },
339497 sseLive ? 5000 : 1500,
340498 )
341499 return () => clearInterval(timer)
342 }, [activeRunId, refresh, sseLive])
500 }, [activeRunId, refreshStructure, refreshPrompts, sseLive])
343501
344502 // Only stick to the bottom while the reader is already there; scrolling up
345503 // pauses the auto-follow and reveals the jump-to-bottom button. The pin runs
@@ -460,7 +618,7 @@
460618 }
461619 }
462620
463 const answer = async (questionId: string, answers: { item_id: string; selected: string[] }[]) => {
621 const answer = async (questionId: string, answers: QuestionAnswer[]) => {
464622 setError(null)
465623 try {
466624 await answerAgentQuestions(spaceName, collectionId, agent.id, questionId, { answers })
@@ -470,6 +628,48 @@
470628 }
471629 }
472630
631 const rateMessage = useCallback(
632 (messageId: string, rating: 'up' | 'down') => {
633 setFeedback(current => {
634 const next = { ...current }
635 // 再点同一边 = 取消评价。
636 if (next[messageId] === rating) delete next[messageId]
637 else next[messageId] = rating
638 try {
639 localStorage.setItem(feedbackKey, JSON.stringify(next))
640 } catch {
641 // 存储不可用时只保留在内存。
642 }
643 return next
644 })
645 },
646 [feedbackKey],
647 )
648
649 const shareMessage = useCallback(
650 (messageId: string, body: HTMLElement) => {
651 // 消息 id 形如 `message:u'<uuid>`;取 uuid 前 8 位做文件名后缀。
652 const suffix =
653 /([0-9a-f]{8})-[0-9a-f]{4}/i.exec(messageId)?.[1] ?? Date.now().toString(36)
654 const safeName = agent.name.replace(/[\\/:*?"<>|]/g, '-')
655 setShareTarget({ element: body, filename: `${safeName}-${suffix}.png` })
656 },
657 [agent.name],
658 )
659
660 const messageActions = useMemo<MessageActions>(
661 () => ({
662 feedbackOf: id => feedback[id] ?? null,
663 feedback: rateMessage,
664 regenerate: () => {
665 // 后端尚未提供重新生成接口,按钮保持禁用。
666 },
667 canRegenerate: false,
668 share: shareMessage,
669 }),
670 [feedback, rateMessage, shareMessage],
671 )
672
473673 return (
474674 <div className="flex min-h-0 min-w-0 flex-1 flex-col">
475675 {error ? (
@@ -517,6 +717,7 @@
517717 messages={messages}
518718 toolCallByCallId={toolCallByCallId}
519719 artifactAccess={artifactAccess}
720 actions={messageActions}
520721 />
521722 {streamingThink ? <AgentThinkingRow text={streamingThink} live={Boolean(activeRun)} /> : null}
522723 {streaming ? <AgentStreamingRow text={streaming} /> : null}
@@ -581,7 +782,7 @@
581782
582783 <div className="px-4 pb-4 pt-1">
583784 <div className="mx-auto max-w-3xl">
584 <div className="flex flex-col rounded-2xl border border-shell-hairline bg-shell-surface shadow-[0_1px_2px_rgba(16,24,40,0.04)] transition-colors focus-within:border-shell-ink/20">
785 <div className="flex flex-col rounded-2xl border border-shell-hairline bg-shell-surface shadow-[0_4px_16px_rgba(16,24,40,0.08),0_1px_2px_rgba(16,24,40,0.06)] transition focus-within:border-shell-ink/20 focus-within:shadow-[0_6px_24px_rgba(16,24,40,0.12)]">
585786 <textarea
586787 ref={composerRef}
587788 value={draft}
@@ -650,6 +851,8 @@
650851 </div>
651852 </div>
652853 </div>
854
855 <AgentShareModal target={shareTarget} onClose={() => setShareTarget(null)} />
653856 </div>
654857 )
655858}
@@ -660,10 +863,11 @@
660863 onSubmit,
661864}: {
662865 question: AgentQuestionDto
663 onSubmit: (answers: { item_id: string; selected: string[] }[]) => void
866 onSubmit: (answers: QuestionAnswer[]) => void
664867}) {
665868 const { t } = useT()
666869 const [selected, setSelected] = useState<Record<string, string[]>>({})
870 const [notes, setNotes] = useState<Record<string, string>>({})
667871
668872 const toggle = (itemId: string, label: string, multiple: boolean) => {
669873 setSelected(current => {
@@ -676,51 +880,84 @@
676880 })
677881 }
678882
883 // Every item must carry a selection or free text: the runtime rejects a
884 // partially answered batch wholesale, so block the submit up front.
885 const complete = question.questions.every(
886 item =>
887 (selected[item.item_id]?.length ?? 0) > 0 || (notes[item.item_id]?.trim().length ?? 0) > 0,
888 )
889
890 const submit = () =>
891 onSubmit(
892 question.questions.map(item => ({
893 item_id: item.item_id,
894 selected: selected[item.item_id] ?? [],
895 text: notes[item.item_id]?.trim() || undefined,
896 })),
897 )
898
679899 return (
680900 <div className="mx-auto w-full max-w-3xl px-4 pb-2">
681901 <div className="rounded-lg border border-shell-hairline bg-shell-surface p-3">
682902 <p className="text-[13px] font-semibold text-shell-ink">{t('agent.question.title')}</p>
683 {question.questions.map(item => (
684 <div key={item.item_id} className="mt-2">
685 <p className="text-[12.5px] leading-5 text-shell-ink/90">
686 {item.header ? `${item.header}: ` : ''}
687 {item.question}
688 </p>
689 <div className="mt-1 flex flex-wrap gap-1.5">
690 {item.options.map(option => {
691 const active = (selected[item.item_id] ?? []).includes(option.label)
692 return (
693 <button
694 key={option.label}
695 type="button"
696 onClick={() => toggle(item.item_id, option.label, item.multiple)}
697 title={option.description ?? undefined}
698 className={`rounded-full border px-2.5 py-1 text-[12px] ${
699 active
700 ? 'border-shell-ink bg-shell-active text-shell-ink'
701 : 'border-shell-hairline text-shell-ink/70 hover:bg-shell-active/60'
702 }`}
703 >
704 {option.label}
705 </button>
706 )
707 })}
903 {question.questions.map(item => {
904 const options = item.options
905 const active = selected[item.item_id] ?? []
906 return (
907 <div key={item.item_id} className="mt-2">
908 <p className="text-[12.5px] leading-5 text-shell-ink/90">
909 {item.header ? `${item.header}: ` : ''}
910 {item.question}
911 </p>
912 {options.length > 0 ? (
913 <div className="mt-1 flex flex-wrap gap-1.5">
914 {options.map(option => {
915 const picked = active.includes(option.label)
916 return (
917 <button
918 key={option.label}
919 type="button"
920 onClick={() => toggle(item.item_id, option.label, item.multiple)}
921 title={option.description ?? undefined}
922 className={`rounded-full border px-2.5 py-1 text-[12px] ${
923 picked
924 ? 'border-shell-ink bg-shell-active text-shell-ink'
925 : 'border-shell-hairline text-shell-ink/70 hover:bg-shell-active/60'
926 }`}
927 >
928 {option.label}
929 </button>
930 )
931 })}
932 </div>
933 ) : null}
934 <textarea
935 value={notes[item.item_id] ?? ''}
936 onChange={event =>
937 setNotes(current => ({ ...current, [item.item_id]: event.target.value }))
938 }
939 rows={1}
940 placeholder={t('agent.question.placeholder')}
941 className="mt-1.5 min-h-[32px] w-full resize-y rounded-md border border-shell-hairline bg-shell-field px-2.5 py-1.5 text-[12.5px] leading-5 text-shell-ink outline-none placeholder:text-shell-ink-muted focus:border-shell-ink/20"
942 />
708943 </div>
709 </div>
710 ))}
711 <button
712 type="button"
713 onClick={() =>
714 onSubmit(
715 question.questions
716 .map(item => ({ item_id: item.item_id, selected: selected[item.item_id] ?? [] }))
717 .filter(answer => answer.selected.length > 0),
718 )
719 }
720 className="mt-3 rounded-md bg-shell-ink px-3 py-1 text-[12px] font-semibold text-shell-surface hover:opacity-90"
721 >
722 {t('agent.question.submit')}
723 </button>
944 )
945 })}
946 <div className="mt-3 flex items-center gap-2">
947 <button
948 type="button"
949 onClick={submit}
950 disabled={!complete}
951 className="rounded-md bg-shell-ink px-3 py-1 text-[12px] font-semibold text-shell-surface hover:opacity-90 disabled:cursor-not-allowed disabled:bg-shell-field disabled:text-shell-ink-muted"
952 >
953 {t('agent.question.submit')}
954 </button>
955 {!complete ? (
956 <span className="text-[11.5px] text-shell-ink-muted">
957 {t('agent.question.required')}
958 </span>
959 ) : null}
960 </div>
724961 </div>
725962 </div>
726963 )
Modified web/src/pages/workspace/ui/agent/agent-message.tsx web/src/pages/workspace/ui/agent/agent-message.tsx +145 −171 View web/src/pages/workspace/ui/agent/agent-message.tsx
@@ -1,24 +1,36 @@
1import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
1import { Fragment, memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
22import {
33 Braces,
44 Check,
55 ChevronDown,
66 Copy,
77 Download,
8 File,
9 FileArchive,
108 FilePen,
11 FileSpreadsheet,
129 FileText,
1310 FolderOpen,
11 Forward,
1412 LineChart,
1513 Loader2,
1614 Plug,
15 RotateCcw,
1716 Terminal,
17 ThumbsDown,
18 ThumbsUp,
1819} from 'lucide-react'
1920import { formatBytes, formatTime, useT, type MessageKey, type TranslateFn } from '@/shared/i18n'
2021import { Markdown } from '@/shared/ui'
22import {
23 artifactIdOf,
24 collectArtifacts,
25 isRecord,
26 splitBudget,
27 type ArtifactAccess,
28 type ArtifactItem,
29 type ToolBudget,
30 type TranscriptArtifacts,
31} from '../../model/agent-artifacts'
2132import type { AgentMessageDto, AgentToolCallDto } from '../../model/agent-api'
33import { fileKind } from './artifact-kind'
2234
2335/**
2436 * One transcript row. Runtime messages arrive as flat text: assistant turns are
@@ -27,12 +39,13 @@
2739 * out of the flow of reading: every tool turn becomes a compact activity card
2840 * with a semantic title, a one-line summary and an expandable body.
2941 */
30export function AgentMessageView({
42export const AgentMessageView = memo(function AgentMessageView({
3143 message,
3244 toolCall,
3345 artifacts,
3446 artifactAccess,
3547 final,
48 actions,
3649}: {
3750 message: AgentMessageDto
3851 /** Runtime record for tool turns: the command/path, status and duration. */
@@ -41,6 +54,7 @@
4154 artifacts: TranscriptArtifacts
4255 artifactAccess?: ArtifactAccess
4356 final?: boolean
57 actions?: MessageActions
4458}) {
4559 if (message.kind === 'compaction') return <CompactionDivider />
4660 if (message.role === 'user') return <UserRow message={message} />
@@ -63,13 +77,14 @@
6377 return (
6478 <AssistantRow
6579 content={message.content}
66 time={message.created_at}
6780 artifacts={artifacts}
6881 artifactAccess={artifactAccess}
6982 final={final}
83 messageId={message.message_id}
84 actions={actions}
7085 />
7186 )
72}
87})
7388
7489
7590function CopyButton({ text }: { text: string }) {
@@ -90,9 +105,51 @@
90105 () => {},
91106 )
92107 }}
93 className="flex h-5 w-5 items-center justify-center rounded text-shell-ink-muted hover:bg-shell-active hover:text-shell-ink"
108 className="flex h-6 w-6 items-center justify-center rounded-md text-shell-ink-muted hover:bg-shell-active hover:text-shell-ink"
109 >
110 {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
111 </button>
112 )
113}
114
115/** 消息操作:除复制外由聊天容器注入;反馈先本地留存,重新生成等待后端接口。 */
116export type MessageActions = {
117 feedbackOf: (messageId: string) => 'up' | 'down' | null
118 feedback: (messageId: string, rating: 'up' | 'down') => void
119 regenerate: (messageId: string) => void
120 canRegenerate: boolean
121 /** 分享:把回答正文交给分享弹窗截屏导出。 */
122 share: (messageId: string, body: HTMLElement) => void
123}
124
125function ActionButton({
126 label,
127 disabled,
128 active,
129 onClick,
130 children,
131}: {
132 label: string
133 disabled?: boolean
134 active?: boolean
135 onClick: () => void
136 children: React.ReactNode
137}) {
138 return (
139 <button
140 type="button"
141 title={label}
142 aria-label={label}
143 aria-pressed={active}
144 disabled={disabled}
145 onClick={onClick}
146 className={`flex h-6 w-6 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent ${
147 active
148 ? 'bg-shell-active text-shell-ink'
149 : 'text-shell-ink-muted hover:bg-shell-active hover:text-shell-ink'
150 }`}
94151 >
95 {copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
152 {children}
96153 </button>
97154 )
98155}
@@ -105,7 +162,6 @@
105162 {message.content}
106163 </div>
107164 <div className="pointer-events-none absolute right-0 top-full flex items-center gap-1.5 pt-1 text-[11px] text-shell-ink-muted opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
108 <span>{formatTime(message.created_at)}</span>
109165 <CopyButton text={message.content} />
110166 </div>
111167 </div>
@@ -128,18 +184,21 @@
128184
129185function AssistantRow({
130186 content,
131 time,
132187 artifacts,
133188 artifactAccess,
134189 final,
190 messageId,
191 actions,
135192}: {
136193 content: string
137 time: number
138194 artifacts: TranscriptArtifacts
139195 artifactAccess?: ArtifactAccess
140 /** Only the conversation's last message keeps the time + copy footer. */
196 /** Only the conversation's last message keeps its action row always visible. */
141197 final?: boolean
198 messageId: string
199 actions?: MessageActions
142200}) {
201 const { t } = useT()
143202 const bodyRef = useRef<HTMLDivElement>(null)
144203 // Markdown cannot linkify text the model wrapped in code spans; rewrite the
145204 // rendered nodes instead so legacy `s3://` cells become download links too.
@@ -187,7 +246,7 @@
187246 }
188247 }, [content, artifacts, artifactAccess])
189248 return (
190 <div className="min-w-0">
249 <div className="group min-w-0">
191250 <div className="min-w-0">
192251 <div
193252 ref={bodyRef}
@@ -203,12 +262,43 @@
203262 <Markdown>{linkArtifacts(content, artifacts)}</Markdown>
204263 </div>
205264 </div>
206 {final ? (
207 <div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-shell-ink-muted">
208 <span>{formatTime(time)}</span>
209 <CopyButton text={content} />
210 </div>
211 ) : null}
265 <div
266 className={`mt-1 flex items-center gap-0.5 text-shell-ink-muted transition-opacity ${
267 final ? '' : 'opacity-0 focus-within:opacity-100 group-hover:opacity-100'
268 }`}
269 >
270 <CopyButton text={content} />
271 <ActionButton
272 label={actions?.canRegenerate ? t('agent.msg.regenerate') : t('agent.msg.regenerateHint')}
273 disabled={!actions?.canRegenerate}
274 onClick={() => actions?.regenerate(messageId)}
275 >
276 <RotateCcw className="h-3.5 w-3.5" />
277 </ActionButton>
278 <ActionButton
279 label={t('agent.msg.like')}
280 active={actions?.feedbackOf(messageId) === 'up'}
281 onClick={() => actions?.feedback(messageId, 'up')}
282 >
283 <ThumbsUp className="h-3.5 w-3.5" />
284 </ActionButton>
285 <ActionButton
286 label={t('agent.msg.dislike')}
287 active={actions?.feedbackOf(messageId) === 'down'}
288 onClick={() => actions?.feedback(messageId, 'down')}
289 >
290 <ThumbsDown className="h-3.5 w-3.5" />
291 </ActionButton>
292 <ActionButton
293 label={t('agent.msg.share')}
294 onClick={() => {
295 const body = bodyRef.current
296 if (body) actions?.share(messageId, body)
297 }}
298 >
299 <Forward className="h-3.5 w-3.5" />
300 </ActionButton>
301 </div>
212302 </div>
213303 )
214304}
@@ -247,12 +337,38 @@
247337 )
248338}
249339
250/** 正文流:与最终消息同排版,只多一个光标;内容持久化后由消息行接管。 */
340/** Huge outputs would re-parse Markdown on every tick; fall back to plain text. */
341const STREAM_MARKDOWN_LIMIT = 64 * 1024
342const STREAM_MARKDOWN_INTERVAL_MS = 120
343
344/**
345 * 正文流:与最终消息同排版;Markdown 以约 120ms 节流增量渲染,避免逐帧重解析。
346 * 尾随的块状光标作为文本追加,保证光标始终贴在最后一行内容之后。
347 */
251348export function AgentStreamingRow({ text }: { text: string }) {
349 const [shown, setShown] = useState(text)
350 const lastRender = useRef(0)
351 const overflow = text.length > STREAM_MARKDOWN_LIMIT
352 useEffect(() => {
353 if (overflow) return
354 const delay = Math.max(0, STREAM_MARKDOWN_INTERVAL_MS - (performance.now() - lastRender.current))
355 const timer = setTimeout(() => {
356 lastRender.current = performance.now()
357 setShown(text)
358 }, delay)
359 return () => clearTimeout(timer)
360 }, [text, overflow])
361 if (overflow) {
362 return (
363 <div className="min-w-0 whitespace-pre-wrap break-words text-[13px] leading-6 text-shell-ink">
364 {text}
365 <span className="ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] animate-pulse bg-shell-ink-muted" />
366 </div>
367 )
368 }
252369 return (
253 <div className="min-w-0 whitespace-pre-wrap break-words text-[13px] leading-6 text-shell-ink">
254 {text}
255 <span className="ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] animate-pulse bg-shell-ink-muted" />
370 <div className="min-w-0 text-[13px] leading-6 text-shell-ink">
371 <Markdown>{`${shown}\u258d`}</Markdown>
256372 </div>
257373 )
258374}
@@ -308,8 +424,6 @@
308424 )
309425}
310426
311type ToolBudget = { text: string; percent: string | null }
312
313427type ToolView =
314428 | { kind: 'terminal'; exit: number | null; stdout: string; stderr: string; truncated: boolean }
315429 | { kind: 'write'; path: string; bytes: number | null }
@@ -345,10 +459,6 @@
345459 summary: string
346460}
347461
348function isRecord(value: unknown): value is Record<string, unknown> {
349 return typeof value === 'object' && value !== null && !Array.isArray(value)
350}
351
352462function firstLine(text: string): string | null {
353463 for (const line of text.split('\n')) {
354464 const trimmed = line.trim()
@@ -357,30 +467,6 @@
357467 return null
358468}
359469
360const BUDGET_RE = /[\r\n]+\s*\[Context Budget:([^\]\n]*)\]\s*$/
361
362function splitBudget(raw: string): { body: string; budget: ToolBudget | null } {
363 const match = BUDGET_RE.exec(raw)
364 if (!match) return { body: raw, budget: null }
365 const text = match[1].trim()
366 const percent = /(\d+(?:\.\d+)?%)/.exec(text)?.[1] ?? null
367 return { body: raw.slice(0, match.index), budget: { text, percent } }
368}
369
370const ARTIFACT_ID = /^[0-9a-fA-F-]{36}$/
371
372/** Accepts `artifact://<uuid>`, the legacy `artifact:u'<uuid>'` and raw uuids. */
373function artifactIdOf(value: unknown): string | null {
374 if (typeof value !== 'string') return null
375 const bare = value
376 .trim()
377 .replace(/^artifact:\/\//, '')
378 .replace(/^artifact:/, '')
379 .replace(/^u'/, '')
380 .replace(/'$/, '')
381 return ARTIFACT_ID.test(bare) ? bare : null
382}
383
384470/**
385471 * Artifact results collapse to one card shape. Both the placeholder output
386472 * (`artifact://…`) and the legacy output (storage key + signed URL) carry the
@@ -686,80 +772,6 @@
686772}
687773
688774
689type ArtifactRef = { id: string; name: string | null }
690
691type ArtifactItem = {
692 id: string
693 name: string
694 mime: string | null
695 size: number | null
696 /** Run that produced the artifact — deliverables render at that run's end. */
697 runId: string | null
698}
699
700/** id -> display name, plus legacy storage-key/URI lookups for old transcripts. */
701type TranscriptArtifacts = {
702 byId: Map<string, string | null>
703 byKey: Map<string, ArtifactRef>
704 byName: Map<string, ArtifactRef>
705 /** Registration order, deduplicated: drives the end-of-transcript grid. */
706 list: ArtifactItem[]
707}
708
709/** Download/redirect access to the runtime, injected by the chat container. */
710export type ArtifactAccess = {
711 /** Opens the runtime's redirect endpoint in a new tab. */
712 download: (artifactId: string) => void
713 /** Ready-to-use URL (redirect endpoint + token) for inline previews. */
714 resolveUrl: (artifactId: string) => Promise<string>
715}
716
717function collectArtifacts(messages: AgentMessageDto[]): TranscriptArtifacts {
718 const byId = new Map<string, string | null>()
719 const byKey = new Map<string, ArtifactRef>()
720 const byName = new Map<string, ArtifactRef>()
721 const list: ArtifactItem[] = []
722 const seen = new Set<string>()
723 for (const message of messages) {
724 if (message.role !== 'tool' || !message.content.trim()) continue
725 let parsed: unknown
726 try {
727 // Tool results carry a trailing context-budget reminder; strip it first.
728 parsed = JSON.parse(splitBudget(message.content).body.trim())
729 } catch {
730 continue
731 }
732 if (!isRecord(parsed)) continue
733 const records = isRecord(parsed.artifact) ? [parsed, parsed.artifact] : [parsed]
734 for (const record of records) {
735 const id = artifactIdOf(record.artifact) ?? artifactIdOf(record.artifact_id)
736 if (!id) continue
737 const name =
738 typeof record.name === 'string'
739 ? record.name
740 : typeof record.artifact_name === 'string'
741 ? record.artifact_name
742 : null
743 if (!byId.has(id)) byId.set(id, name)
744 const ref: ArtifactRef = { id, name }
745 if (typeof record.storage_key === 'string') byKey.set(record.storage_key, ref)
746 if (typeof record.uri === 'string') byKey.set(record.uri, ref)
747 if (name && !byName.has(name)) byName.set(name, ref)
748 if (!seen.has(id)) {
749 seen.add(id)
750 list.push({
751 id,
752 name: name ?? id,
753 mime: typeof record.mime_type === 'string' ? record.mime_type : null,
754 size: typeof record.size_bytes === 'number' ? record.size_bytes : null,
755 runId: message.run_id ?? null,
756 })
757 }
758 }
759 }
760 return { byId, byKey, byName, list }
761}
762
763775function artifactLabel(text: string): string {
764776 return `\`${text.replace(/`/g, '')}\``
765777}
@@ -1050,47 +1062,6 @@
10501062 )
10511063}
10521064
1053function fileKind(
1054 item: ArtifactItem,
1055 t: TranslateFn,
1056): { label: string; icon: React.ReactNode; accent: string } {
1057 const extension = item.name.split('.').pop()?.toLowerCase() ?? ''
1058 const mime = item.mime ?? ''
1059 if (mime.includes('zip') || ['zip', 'tar', 'gz', 'tgz'].includes(extension)) {
1060 return {
1061 label: t('agent.msg.fileKind.archive'),
1062 icon: <FileArchive className="h-4 w-4" />,
1063 accent: 'bg-[#f59e0b]/10 text-[#b45309]',
1064 }
1065 }
1066 if (extension === 'md' || mime.includes('markdown')) {
1067 return {
1068 label: t('agent.msg.fileKind.markdown'),
1069 icon: <FileText className="h-4 w-4" />,
1070 accent: 'bg-[#3b82f6]/10 text-[#1d4ed8]',
1071 }
1072 }
1073 if (extension === 'csv' || extension === 'tsv' || mime.includes('csv')) {
1074 return {
1075 label: t('agent.msg.fileKind.table'),
1076 icon: <FileSpreadsheet className="h-4 w-4" />,
1077 accent: 'bg-[#10b981]/10 text-[#047857]',
1078 }
1079 }
1080 if (mime.startsWith('text/') || extension === 'txt') {
1081 return {
1082 label: t('agent.msg.fileKind.text'),
1083 icon: <FileText className="h-4 w-4" />,
1084 accent: 'bg-[#64748b]/10 text-[#475569]',
1085 }
1086 }
1087 return {
1088 label: extension ? extension.toUpperCase() : t('agent.msg.fileKind.file'),
1089 icon: <File className="h-4 w-4" />,
1090 accent: 'bg-shell-field text-shell-ink-muted',
1091 }
1092}
1093
10941065/**
10951066 * Manus-style deliverables: every non-image artifact of the conversation,
10961067 * collected into a card grid at the very end of the transcript.
@@ -1179,14 +1150,16 @@
11791150}
11801151
11811152/** The transcript: text turns render as messages, tool stretches as batches. */
1182export function AgentTranscript({
1153export const AgentTranscript = memo(function AgentTranscript({
11831154 messages,
11841155 toolCallByCallId,
11851156 artifactAccess,
1157 actions,
11861158}: {
11871159 messages: AgentMessageDto[]
11881160 toolCallByCallId: Map<string, AgentToolCallDto>
11891161 artifactAccess?: ArtifactAccess
1162 actions?: MessageActions
11901163}) {
11911164 const items = useMemo(() => buildTranscript(messages), [messages])
11921165 const artifacts = useMemo(() => collectArtifacts(messages), [messages])
@@ -1230,6 +1203,7 @@
12301203 : undefined
12311204 }
12321205 artifactAccess={artifactAccess}
1206 actions={actions}
12331207 />
12341208 )}
12351209 {artifactAccess && runDeliverables && runDeliverables.length > 0 ? (
@@ -1240,4 +1214,4 @@
12401214 })}
12411215 </>
12421216 )
1243}
No newline at end of file
1217})
No newline at end of file
Added web/src/pages/workspace/ui/agent/agent-share-modal.tsx web/src/pages/workspace/ui/agent/agent-share-modal.tsx +198 −0 View web/src/pages/workspace/ui/agent/agent-share-modal.tsx
@@ -0,0 +1,198 @@
1import { useEffect, useState } from 'react'
2import { toPng } from 'html-to-image'
3import { Check, Copy, Download, Loader2, X } from 'lucide-react'
4import { useT } from '@/shared/i18n'
5
6export interface ShareTarget {
7 /** 回答正文的 DOM 节点,截屏导出用。 */
8 element: HTMLElement
9 filename: string
10}
11
12/**
13 * 分享这条回答:前端把正文节点截屏导出为 PNG,再让用户下载或复制图片。
14 * 这与 ChatGPT 的分享弹窗同构,但不经过任何服务端,图片不会离开浏览器。
15 */
16export function AgentShareModal({
17 target,
18 onClose,
19}: {
20 target: ShareTarget | null
21 onClose: () => void
22}) {
23 // 关闭即卸载,每次打开都是全新的预览组件,无需在 effect 里重置状态。
24 if (!target) return null
25 return <ShareDialog target={target} onClose={onClose} />
26}
27
28function ShareDialog({ target, onClose }: { target: ShareTarget; onClose: () => void }) {
29 const { t } = useT()
30 const [png, setPng] = useState<string | null>(null)
31 const [failed, setFailed] = useState(false)
32 const [copied, setCopied] = useState(false)
33 const [copyFailed, setCopyFailed] = useState(false)
34 const [downloaded, setDownloaded] = useState(false)
35 const [attempt, setAttempt] = useState(0)
36
37 useEffect(() => {
38 let cancelled = false
39 let timer: ReturnType<typeof setTimeout> | undefined
40 const background = getComputedStyle(target.element).backgroundColor
41 // html-to-image 在隐藏标签页里会等不到 requestAnimationFrame 而挂起,
42 // 用超时兜底并提供重试,避免弹窗永远转圈。
43 const timeout = new Promise<never>((_, reject) => {
44 timer = setTimeout(() => reject(new Error('share timeout')), 15_000)
45 })
46 void Promise.race([
47 toPng(target.element, {
48 pixelRatio: 2,
49 // Google Fonts 的样式表是跨域的,读取 cssRules 会被 CORS 拒绝;
50 // 界面字体本身是系统字体栈,跳过内联不影响正文观感。
51 skipFonts: true,
52 backgroundColor: background && background !== 'rgba(0, 0, 0, 0)' ? background : '#ffffff',
53 style: { padding: '24px' },
54 }),
55 timeout,
56 ]).then(
57 url => {
58 if (!cancelled) setPng(url)
59 },
60 () => {
61 if (!cancelled) setFailed(true)
62 },
63 )
64 return () => {
65 cancelled = true
66 if (timer) clearTimeout(timer)
67 }
68 }, [target, attempt])
69
70 const retry = () => {
71 setFailed(false)
72 setAttempt(value => value + 1)
73 }
74
75 const download = () => {
76 if (!png) return
77 const anchor = document.createElement('a')
78 anchor.href = png
79 anchor.download = target.filename
80 anchor.click()
81 // 桌面浏览器可能没有任何下载提示,用按钮文案给出确认。
82 setDownloaded(true)
83 setTimeout(() => setDownloaded(false), 1500)
84 }
85
86 const copyImage = async () => {
87 if (!png || typeof ClipboardItem === 'undefined') return
88 try {
89 const blob = await (await fetch(png)).blob()
90 await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
91 setCopied(true)
92 setTimeout(() => setCopied(false), 1500)
93 } catch {
94 setCopyFailed(true)
95 setTimeout(() => setCopyFailed(false), 2000)
96 }
97 }
98
99 const canCopy = typeof ClipboardItem !== 'undefined'
100
101 return (
102 <div
103 role="dialog"
104 aria-modal
105 aria-label={t('agent.share.title')}
106 onClick={onClose}
107 className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
108 >
109 <div
110 onClick={event => event.stopPropagation()}
111 className="w-full max-w-md rounded-2xl border border-shell-hairline bg-shell-surface p-4 shadow-xl"
112 >
113 <div className="flex items-center justify-between">
114 <p className="text-[14px] font-semibold text-shell-ink">{t('agent.share.title')}</p>
115 <button
116 type="button"
117 aria-label={t('common.action.close')}
118 onClick={onClose}
119 className="flex h-7 w-7 items-center justify-center rounded-md text-shell-ink-muted hover:bg-shell-active hover:text-shell-ink"
120 >
121 <X className="h-4 w-4" />
122 </button>
123 </div>
124
125 <div className="mt-3 flex min-h-[180px] items-center justify-center overflow-hidden rounded-xl border border-shell-hairline bg-shell-field">
126 {failed ? (
127 <div className="flex flex-col items-center gap-2 px-6 text-center">
128 <p className="text-[12.5px] leading-5 text-shell-ink-muted">{t('agent.share.failed')}</p>
129 <button
130 type="button"
131 onClick={retry}
132 className="rounded-md border border-shell-hairline px-2.5 py-1 text-[12px] font-medium text-shell-ink hover:bg-shell-active"
133 >
134 {t('agent.share.retry')}
135 </button>
136 </div>
137 ) : png ? (
138 <img src={png} alt={t('agent.share.preview')} className="max-h-[360px] w-auto max-w-full" />
139 ) : (
140 <span className="flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
141 <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
142 {t('agent.share.generating')}
143 </span>
144 )}
145 </div>
146
147 <p className="mt-2 text-[11.5px] leading-4 text-shell-ink-muted">{t('agent.share.note')}</p>
148
149 <div className="mt-4 flex items-center justify-center gap-8">
150 <ShareAction
151 icon={downloaded ? <Check className="h-4 w-4" aria-hidden /> : <Download className="h-4 w-4" aria-hidden />}
152 label={downloaded ? t('agent.share.downloaded') : t('agent.share.download')}
153 disabled={!png}
154 onClick={download}
155 />
156 <ShareAction
157 icon={copied || copyFailed ? <Check className="h-4 w-4" aria-hidden /> : <Copy className="h-4 w-4" aria-hidden />}
158 label={
159 copied
160 ? t('agent.share.copied')
161 : copyFailed
162 ? t('agent.share.copyFailed')
163 : t('agent.share.copyImage')
164 }
165 disabled={!png || !canCopy}
166 onClick={() => void copyImage()}
167 />
168 </div>
169 </div>
170 </div>
171 )
172}
173
174function ShareAction({
175 icon,
176 label,
177 disabled,
178 onClick,
179}: {
180 icon: React.ReactNode
181 label: string
182 disabled?: boolean
183 onClick: () => void
184}) {
185 return (
186 <button
187 type="button"
188 onClick={onClick}
189 disabled={disabled}
190 className="flex flex-col items-center gap-1.5 disabled:cursor-not-allowed disabled:opacity-40"
191 >
192 <span className="flex h-10 w-10 items-center justify-center rounded-full bg-shell-field text-shell-ink transition-colors hover:bg-shell-active">
193 {icon}
194 </span>
195 <span className="text-[11.5px] text-shell-ink-muted">{label}</span>
196 </button>
197 )
198}
No newline at end of file
Modified web/src/pages/workspace/ui/agent/agent-view.tsx web/src/pages/workspace/ui/agent/agent-view.tsx +171 −78 View web/src/pages/workspace/ui/agent/agent-view.tsx
@@ -1,6 +1,7 @@
11import { useState } from 'react'
2import { Bot, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
3import { useT } from '@/shared/i18n'
2import { useNavigate } from 'react-router-dom'
3import { Bot, Download, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
4import { formatBytes, useT } from '@/shared/i18n'
45import { ApiFailure, useAsync } from '@/shared/lib'
56import type { AgentCollectionItem } from '../../model/workspace'
67import {
@@ -12,10 +13,14 @@
1213import { ChatLayout } from '../layout/chat-layout'
1314import { AgentBuilderModal } from './agent-builder-modal'
1415import { AgentChat } from './agent-chat'
16import { fileKind } from './artifact-kind'
17import type { ArtifactItem } from '../../model/agent-artifacts'
1518
1619export interface AgentViewProps {
1720 spaceName: string
1821 collectionId: string
22 /** Agent selected in the URL — the sidebar submenu drives it. */
23 agentId?: string
1924 /** Row from the sidebar's agent list — titles the frame before the fetch lands. */
2025 collectionHint?: AgentCollectionItem
2126 /** AppShell slots owned by the workspace frame. */
@@ -36,17 +41,24 @@
3641}
3742
3843/**
39 * Route-level agent-set page: the set's agents on the left, one agent's
40 * runtime conversation on the right. Every runtime call is proxied by the main
41 * API over gRPC; this page never reaches sandbox-agent directly.
44 * Route-level agent-set page: the set's agents open as a sidebar submenu, one
45 * agent's runtime conversation fills the column and the aside describes the
46 * selected agent. Every runtime call is proxied by the main API over gRPC;
47 * this page never reaches sandbox-agent directly.
4248 */
43export function AgentView({ spaceName, collectionId, collectionHint, shell, onChanged }: AgentViewProps) {
49export function AgentView({ spaceName, collectionId, agentId, collectionHint, shell, onChanged }: AgentViewProps) {
4450 const { t } = useT()
51 const navigate = useNavigate()
4552 const [reload, setReload] = useState(0)
4653 const [creating, setCreating] = useState(false)
47 const [selectedId, setSelectedId] = useState<string | null>(null)
4854 const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
4955 const [error, setError] = useState<string | null>(null)
56 // 转录产物快照由 AgentChat 上报,按智能体 id 归属,切换智能体时不会串台。
57 const [artifacts, setArtifacts] = useState<{
58 agentId: string
59 items: ArtifactItem[]
60 open: ((artifactId: string) => void) | null
61 }>({ agentId: '', items: [], open: null })
5062
5163 const collection = useAsync(
5264 () => fetchAgentCollection(spaceName, collectionId),
@@ -54,17 +66,20 @@
5466 )
5567 const agents = useAsync(() => fetchAgents(spaceName, collectionId), [spaceName, collectionId, reload])
5668
57 // No effect needed: an explicit choice wins, otherwise the first agent is the
58 // selected one — deleting the selected agent falls back on its own.
69 const collectionPath = `/app/${encodeURIComponent(spaceName)}/agent/${encodeURIComponent(collectionId)}`
70 // The URL owns the selection; without an explicit agent the first one shows —
71 // deleting the selected agent falls back on its own.
5972 const selected =
60 agents.data?.find(agent => agent.id === selectedId) ?? agents.data?.[0]
73 agents.data?.find(agent => agent.id === agentId) ?? agents.data?.[0]
6174 const title = collection.data?.name || collectionHint?.name || t('agent.titleFallback')
75 const visibleArtifacts = artifacts.agentId === selected?.id ? artifacts.items : []
6276
6377 const remove = async (agent: AgentDto) => {
6478 setError(null)
6579 try {
6680 await deleteAgent(spaceName, collectionId, agent.id)
6781 setConfirmDelete(null)
82 if (agent.id === agentId) navigate(collectionPath, { replace: true })
6883 setReload(key => key + 1)
6984 onChanged()
7085 } catch (err) {
@@ -115,6 +130,13 @@
115130 collectionId={collectionId}
116131 agent={selected}
117132 canEnableMcp={collection.data?.can_enable_mcp ?? false}
133 onArtifacts={(items, open) =>
134 setArtifacts(prev =>
135 prev.agentId === selected.id && prev.items === items
136 ? prev
137 : { agentId: selected.id, items, open },
138 )
139 }
118140 />
119141 ) : (
120142 <div className="flex min-h-0 flex-1 items-center justify-center">
@@ -128,81 +150,152 @@
128150 )}
129151
130152 <aside className="flex w-[calc(var(--nav-rail)_+_var(--nav-side))] min-w-0 shrink-0 flex-col border-l border-shell-hairline">
131 <div className="min-h-0 flex-1 overflow-y-auto p-2">
132 {agents.loading && !agents.data ? (
133 <div className="flex justify-center py-6">
134 <Loader2 className="h-4 w-4 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
153 {agents.loading && !agents.data ? (
154 <div className="flex justify-center py-6">
155 <Loader2 className="h-4 w-4 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
156 </div>
157 ) : selected ? (
158 <div className="min-h-0 flex-1 overflow-y-auto p-3">
159 <div className="flex items-center gap-2.5">
160 {selected.avatar_url ? (
161 <img
162 src={selected.avatar_url}
163 alt=""
164 className="h-10 w-10 shrink-0 rounded-full object-cover"
165 />
166 ) : (
167 <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-shell-active">
168 <Bot className="h-5 w-5 text-shell-ink-muted" aria-hidden />
169 </span>
170 )}
171 <div className="min-w-0">
172 <p className="truncate text-[14px] font-semibold text-shell-ink">{selected.name}</p>
173 <p className="flex items-center gap-1.5 text-[11.5px] text-shell-ink-muted">
174 <span>#{selected.number}</span>
175 {selected.is_archived ? (
176 <span className="rounded-full bg-shell-tile px-1.5 text-[10.5px] leading-4">
177 {t('agent.detail.archived')}
178 </span>
179 ) : null}
180 </p>
181 </div>
135182 </div>
136 ) : (agents.data ?? []).length === 0 ? (
137 <p className="px-2 py-6 text-center text-[12.5px] leading-5 text-shell-ink-muted">
138 {t('agent.empty')}
139 </p>
140 ) : (
141 <ul className="flex flex-col gap-0.5">
142 {(agents.data ?? []).map(agent => {
143 const active = agent.id === selected?.id
144 return (
145 <li key={agent.id}>
146 <div
147 className={`group flex items-center gap-2 rounded-md px-2 py-1.5 ${
148 active ? 'bg-shell-active' : 'hover:bg-shell-active/60'
149 }`}
183
184 <section className="mt-4">
185 <h3 className="text-[11px] font-semibold uppercase tracking-wide text-shell-ink-muted">
186 {t('agent.detail.description')}
187 </h3>
188 <p className="mt-1 whitespace-pre-wrap break-words text-[12.5px] leading-5 text-shell-ink/90">
189 {selected.description || t('agent.detail.empty')}
190 </p>
191 </section>
192
193 <section className="mt-4">
194 <h3 className="text-[11px] font-semibold uppercase tracking-wide text-shell-ink-muted">
195 {t('agent.detail.instructions')}
196 </h3>
197 <p className="mt-1 max-h-56 overflow-y-auto whitespace-pre-wrap break-words rounded-md bg-shell-field px-2.5 py-2 text-[12px] leading-5 text-shell-ink/90">
198 {selected.instructions || t('agent.detail.empty')}
199 </p>
200 </section>
201
202 {selected.starters.length > 0 ? (
203 <section className="mt-4">
204 <h3 className="text-[11px] font-semibold uppercase tracking-wide text-shell-ink-muted">
205 {t('agent.detail.starters')}
206 </h3>
207 <div className="mt-1 flex flex-col gap-1">
208 {selected.starters.map(starter => (
209 <p
210 key={starter}
211 title={starter}
212 className="truncate rounded-md border border-shell-hairline px-2 py-1 text-[11.5px] text-shell-ink/80"
150213 >
214 {starter}
215 </p>
216 ))}
217 </div>
218 </section>
219 ) : null}
220
221 <section className="mt-4">
222 <h3 className="text-[11px] font-semibold uppercase tracking-wide text-shell-ink-muted">
223 {t('agent.detail.artifacts')}
224 </h3>
225 {visibleArtifacts.length === 0 ? (
226 <p className="mt-1 text-[12.5px] leading-5 text-shell-ink-muted">
227 {t('agent.detail.noArtifacts')}
228 </p>
229 ) : (
230 <div className="mt-1 flex flex-col gap-1.5">
231 {visibleArtifacts.map(item => {
232 const kind = fileKind(item, t)
233 return (
151234 <button
235 key={item.id}
152236 type="button"
153 onClick={() => setSelectedId(agent.id)}
154 className="flex min-w-0 flex-1 items-center gap-2 text-left"
237 onClick={() => artifacts.open?.(item.id)}
238 title={item.name}
239 className="flex w-full items-center gap-2 rounded-lg border border-shell-hairline bg-shell-surface px-2 py-1.5 text-left hover:bg-shell-active/40"
155240 >
156 {agent.avatar_url ? (
157 <img
158 src={agent.avatar_url}
159 alt=""
160 className="h-4 w-4 shrink-0 rounded-full object-cover"
161 />
162 ) : (
163 <Bot className="h-4 w-4 shrink-0 text-shell-ink-muted" />
164 )}
165 <span className="truncate text-[13px] font-medium text-shell-ink">
166 {agent.name}
241 <span
242 className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md ${kind.accent}`}
243 >
244 {kind.icon}
167245 </span>
168 <span className="ml-auto shrink-0 text-[11px] text-shell-ink-muted">
169 #{agent.number}
246 <span className="min-w-0 flex-1">
247 <span className="block truncate text-[12px] font-medium text-shell-ink">
248 {item.name}
249 </span>
250 <span className="block truncate text-[10.5px] text-shell-ink-muted">
251 {[kind.label, item.size === null ? null : formatBytes(item.size)]
252 .filter(Boolean)
253 .join(' · ')}
254 </span>
170255 </span>
256 <Download className="h-3 w-3 shrink-0 text-shell-ink-muted" aria-hidden />
171257 </button>
172 {confirmDelete === agent.id ? (
173 <span className="flex shrink-0 gap-1">
174 <button
175 type="button"
176 onClick={() => void remove(agent)}
177 className="rounded bg-[#e5484d] px-1.5 text-[11px] font-semibold text-white"
178 >
179 {t('common.action.confirm')}
180 </button>
181 <button
182 type="button"
183 onClick={() => setConfirmDelete(null)}
184 className="rounded px-1 text-[11px] text-shell-ink-muted hover:bg-shell-active"
185 >
186 {t('common.action.cancel')}
187 </button>
188 </span>
189 ) : (
190 <button
191 type="button"
192 onClick={() => setConfirmDelete(agent.id)}
193 title={t('agent.delete')}
194 className="hidden h-6 w-6 shrink-0 items-center justify-center rounded text-shell-ink-muted hover:bg-shell-active group-hover:flex"
195 >
196 <Trash2 className="h-3.5 w-3.5" />
197 </button>
198 )}
199 </div>
200 </li>
201 )
202 })}
203 </ul>
204 )}
205 </div>
258 )
259 })}
260 </div>
261 )}
262 </section>
263
264 <div className="mt-5 border-t border-shell-hairline pt-3">
265 {confirmDelete === selected.id ? (
266 <div className="flex gap-2">
267 <button
268 type="button"
269 onClick={() => void remove(selected)}
270 className="rounded-md bg-[#e5484d] px-3 py-1 text-[12px] font-semibold text-white hover:opacity-90"
271 >
272 {t('common.action.confirm')}
273 </button>
274 <button
275 type="button"
276 onClick={() => setConfirmDelete(null)}
277 className="rounded-md px-3 py-1 text-[12px] text-shell-ink-muted hover:bg-shell-active"
278 >
279 {t('common.action.cancel')}
280 </button>
281 </div>
282 ) : (
283 <button
284 type="button"
285 onClick={() => setConfirmDelete(selected.id)}
286 className="flex items-center gap-1.5 text-[12px] font-medium text-[#b42318] hover:underline"
287 >
288 <Trash2 className="h-3.5 w-3.5" aria-hidden />
289 {t('agent.delete')}
290 </button>
291 )}
292 </div>
293 </div>
294 ) : (
295 <p className="px-3 py-6 text-center text-[12.5px] leading-5 text-shell-ink-muted">
296 {t('agent.empty')}
297 </p>
298 )}
206299 </aside>
207300
208301 <AgentBuilderModal
@@ -213,7 +306,7 @@
213306 onClose={() => setCreating(false)}
214307 onCreated={agent => {
215308 setCreating(false)
216 setSelectedId(agent.id)
309 navigate(`${collectionPath}/${encodeURIComponent(agent.id)}`)
217310 setReload(key => key + 1)
218311 onChanged()
219312 }}
Added web/src/pages/workspace/ui/agent/artifact-kind.tsx web/src/pages/workspace/ui/agent/artifact-kind.tsx +45 −0 View web/src/pages/workspace/ui/agent/artifact-kind.tsx
@@ -0,0 +1,45 @@
1import { File, FileArchive, FileSpreadsheet, FileText } from 'lucide-react'
2import type { TranslateFn } from '@/shared/i18n'
3import type { ArtifactItem } from '../../model/agent-artifacts'
4
5/** 产物卡片的类型图标与配色,供转录与详情面板共用。 */
6export function fileKind(
7 item: ArtifactItem,
8 t: TranslateFn,
9): { label: string; icon: React.ReactNode; accent: string } {
10 const extension = item.name.split('.').pop()?.toLowerCase() ?? ''
11 const mime = item.mime ?? ''
12 if (mime.includes('zip') || ['zip', 'tar', 'gz', 'tgz'].includes(extension)) {
13 return {
14 label: t('agent.msg.fileKind.archive'),
15 icon: <FileArchive className="h-4 w-4" />,
16 accent: 'bg-[#f59e0b]/10 text-[#b45309]',
17 }
18 }
19 if (extension === 'md' || mime.includes('markdown')) {
20 return {
21 label: t('agent.msg.fileKind.markdown'),
22 icon: <FileText className="h-4 w-4" />,
23 accent: 'bg-[#3b82f6]/10 text-[#1d4ed8]',
24 }
25 }
26 if (extension === 'csv' || extension === 'tsv' || mime.includes('csv')) {
27 return {
28 label: t('agent.msg.fileKind.table'),
29 icon: <FileSpreadsheet className="h-4 w-4" />,
30 accent: 'bg-[#10b981]/10 text-[#047857]',
31 }
32 }
33 if (mime.startsWith('text/') || extension === 'txt') {
34 return {
35 label: t('agent.msg.fileKind.text'),
36 icon: <FileText className="h-4 w-4" />,
37 accent: 'bg-[#64748b]/10 text-[#475569]',
38 }
39 }
40 return {
41 label: extension ? extension.toUpperCase() : t('agent.msg.fileKind.file'),
42 icon: <File className="h-4 w-4" />,
43 accent: 'bg-shell-field text-shell-ink-muted',
44 }
45}
No newline at end of file
Modified web/src/pages/workspace/ui/sidebar-nav.tsx web/src/pages/workspace/ui/sidebar-nav.tsx +205 −22 View web/src/pages/workspace/ui/sidebar-nav.tsx
@@ -9,6 +9,7 @@
99 Folder,
1010 FolderGit2,
1111 Hash,
12 Loader2,
1213 SquareKanban,
1314 Star,
1415 Volume2,
@@ -17,6 +18,7 @@
1718import { useT } from '@/shared/i18n'
1819import { ApiFailure } from '@/shared/lib'
1920import { getVoiceSpaceStates, isVoiceRoom } from '@/entities/chat'
21import { fetchAgents, type AgentDto } from '../model/agent-api'
2022import { betweenPositions, STRIDE } from '../model/nav-order'
2123import {
2224 prefetchBoardView,
@@ -37,6 +39,7 @@
3739 activeRoomId?: string
3840 activeBoardId?: string
3941 activeAgentCollectionId?: string
42 activeAgentId?: string
4043 activeRepoName?: string
4144 /** Both move endpoints require the space's `manage_channels` permission. */
4245 canOrganize?: boolean
@@ -95,6 +98,7 @@
9598 activeRoomId,
9699 activeBoardId,
97100 activeAgentCollectionId,
101 activeAgentId,
98102 activeRepoName,
99103 canOrganize = false,
100104 onChanged,
@@ -146,6 +150,66 @@
146150 }
147151 }, [spaceName])
148152
153 /* ------------------------------------------------- agent submenu ------- */
154
155 // 智能体集在导航里就地展开:成员按需拉取并缓存,URL 上的集自动展开。
156 const [openAgents, setOpenAgents] = useState<Set<string>>(() => new Set())
157 const [collectionAgents, setCollectionAgents] = useState<Record<string, AgentDto[]>>({})
158 const [loadingAgents, setLoadingAgents] = useState<Set<string>>(() => new Set())
159 const agentsCache = useRef<Record<string, AgentDto[]>>({})
160 // 同步的并发闸门(渲染用的是 loadingAgents 状态,ref 只在回调里读写)。
161 const loadingRef = useRef<Set<string>>(new Set())
162
163 const loadCollection = useCallback(
164 async (collectionId: string, force = false) => {
165 if (!force && agentsCache.current[collectionId]) {
166 setCollectionAgents(agentsCache.current)
167 return
168 }
169 if (loadingRef.current.has(collectionId)) return
170 loadingRef.current.add(collectionId)
171 setLoadingAgents(current => new Set([...current, collectionId]))
172 try {
173 const rows = await fetchAgents(spaceName, collectionId)
174 agentsCache.current = { ...agentsCache.current, [collectionId]: rows }
175 setCollectionAgents(agentsCache.current)
176 } catch {
177 // 加载失败不阻塞导航;下次展开或列表刷新时会重试。
178 } finally {
179 loadingRef.current.delete(collectionId)
180 setLoadingAgents(current => {
181 const next = new Set(current)
182 next.delete(collectionId)
183 return next
184 })
185 }
186 },
187 [spaceName],
188 )
189
190 const toggleAgents = useCallback(
191 (collectionId: string) => {
192 const opening = !openAgents.has(collectionId)
193 setOpenAgents(current => {
194 const next = new Set(current)
195 if (opening) next.add(collectionId)
196 else next.delete(collectionId)
197 return next
198 })
199 if (opening) void loadCollection(collectionId)
200 },
201 [openAgents, loadCollection],
202 )
203
204 // 集列表刷新(新建/删除智能体后)时重载已展开的集。
205 const openAgentsRef = useRef(openAgents)
206 useEffect(() => {
207 openAgentsRef.current = openAgents
208 }, [openAgents])
209 useEffect(() => {
210 for (const id of openAgentsRef.current) void loadCollection(id, true)
211 }, [agents, loadCollection])
212
149213 /* ------------------------------------------------------ view model ----- */
150214
151215 const flat: { node: DirNode; parent: string | null }[] = []
@@ -254,8 +318,8 @@
254318 tailAt += 1
255319 }
256320 }
257 // Assets the tree does not cover, plus the private-message conversations: the
258 // directory owns a space's channels, repos and boards, never a DM.
321 // Assets the tree does not cover: the directory owns a space's channels,
322 // repos and boards, so these lists stay the fallback for what it misses.
259323 appendTail(
260324 repos
261325 .filter(repo => !coveredRepoIds.has(repo.id))
@@ -309,25 +373,30 @@
309373 voice: false,
310374 assetId: agent.id,
311375 })),
312 )
313 appendTail(
314 dms.map(dm => ({
315 key: `m:${dm.id}`,
316 // No move endpoint exists for a DM, and `id` stays empty so `movable`
317 // refuses a drag instead of failing one.
318 type: 'room' as const,
319 id: '',
320 kind: 'dm' as RowKind,
321 label: dm.peer?.display_name || dm.peer?.username || dm.name,
322 href: `/app/${spacePath}/chat/${dm.id}`,
323 parentId: null,
324 position: 0,
325 unread: dm.unread_count,
326 favorite: false,
327 voice: false,
328 peer: dm.peer ?? null,
329 })),
330376 )
377 // 私聊属于个人作用域:只在 `@me` 的导航里渲染,空间的导航不出现。
378 if (spaceName === '@me') {
379 appendTail(
380 dms.map(dm => ({
381 key: `m:${dm.id}`,
382 // No move endpoint exists for a DM, and `id` stays empty so `movable`
383 // refuses a drag instead of failing one.
384 type: 'room' as const,
385 id: '',
386 kind: 'dm' as RowKind,
387 label: dm.peer?.display_name || dm.peer?.username || dm.name,
388 href: `/app/@me/chat/${dm.id}`,
389 parentId: null,
390 position: 0,
391 unread: dm.unread_count,
392 favorite: false,
393 voice: false,
394 peer: dm.peer ?? null,
395 // 选中的仍然是用户 id:`id` 留给移动端点,这里只用于高亮。
396 assetId: dm.id,
397 })),
398 )
399 }
331400
332401 const byPosition = (a: View, b: View) => a.position - b.position || (a.key < b.key ? -1 : 1)
333402 for (const bucket of levels.values()) bucket.sort(byPosition)
@@ -474,9 +543,11 @@
474543 void prefetchRepoView(spaceName, view.label)
475544 } else if (view.kind === 'board' && view.assetId) {
476545 prefetchBoardView(spaceName, view.assetId)
546 } else if (view.kind === 'agent' && view.assetId) {
547 void loadCollection(view.assetId)
477548 }
478549 },
479 [spaceName],
550 [spaceName, loadCollection],
480551 )
481552
482553 /** Middle band of a folder = "into it"; elsewhere the half decides before/after. */
@@ -491,12 +562,73 @@
491562
492563 /* ---------------------------------------------------------- render ----- */
493564
565 /** One collection's agents, rendered as indented rows under its nav row. */
566 function renderAgentChildren(collectionId: string, depth: number): ReactNode[] {
567 const rows = collectionAgents[collectionId]
568 if (!rows) {
569 if (!loadingAgents.has(collectionId)) return []
570 return [
571 <p
572 key={`agent-loading-${collectionId}`}
573 className="flex h-7 items-center gap-1.5 text-[11.5px] text-shell-ink-muted"
574 style={{ paddingLeft: 8 + depth * 14 }}
575 >
576 <Loader2 className="h-3 w-3 animate-spin" aria-hidden />
577 {t('ui.loading')}
578 </p>,
579 ]
580 }
581 if (rows.length === 0) {
582 return [
583 <p
584 key={`agent-empty-${collectionId}`}
585 className="py-1 text-[11.5px] text-shell-ink-muted"
586 style={{ paddingLeft: 8 + depth * 14 }}
587 >
588 {t('agent.menu.empty')}
589 </p>,
590 ]
591 }
592 return rows.map(agent => {
593 const active = agent.id === activeAgentId
594 return (
595 <Link
596 key={agent.id}
597 to={`/app/${spacePath}/agent/${encodeURIComponent(collectionId)}/${encodeURIComponent(agent.id)}`}
598 title={agent.name}
599 aria-current={active ? 'page' : undefined}
600 className={`flex h-7 items-center gap-1.5 rounded-md pr-2 text-[13px] font-medium ${
601 active
602 ? 'bg-shell-active text-shell-ink'
603 : 'text-shell-ink/60 hover:bg-shell-active/60 hover:text-shell-ink'
604 }`}
605 style={{ paddingLeft: 8 + depth * 14 }}
606 >
607 {agent.avatar_url ? (
608 <img
609 src={agent.avatar_url}
610 alt=""
611 className="h-3.5 w-3.5 shrink-0 rounded-full object-cover"
612 loading="lazy"
613 />
614 ) : (
615 <Bot className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
616 )}
617 <span className="truncate">{agent.name}</span>
618 <span className="ml-auto shrink-0 text-[11px] text-shell-ink-muted">#{agent.number}</span>
619 </Link>
620 )
621 })
622 }
623
494624 function renderLevel(parentId: string | null, depth: number, seen: Set<string>): ReactNode[] {
495625 const rows: ReactNode[] = []
496626 for (const view of levels.get(parentId) ?? []) {
497627 if (view.kind === 'folder' && seen.has(view.id)) continue
498628 const isFolder = view.kind === 'folder'
499629 const isCollapsed = isFolder && collapsed.has(view.id)
630 const isAgent = view.kind === 'agent' && Boolean(view.assetId)
631 const agentOpen = isAgent && openAgents.has(view.assetId as string)
500632 rows.push(
501633 <Row
502634 key={view.key}
@@ -505,6 +637,7 @@
505637 collapsed={isCollapsed}
506638 active={
507639 (view.kind === 'room' && view.id === activeRoomId) ||
640 (view.kind === 'dm' && Boolean(activeRoomId) && view.assetId === activeRoomId) ||
508641 (view.kind === 'board' && Boolean(activeBoardId) && view.assetId === activeBoardId) ||
509642 (view.kind === 'agent' && Boolean(activeAgentCollectionId) && view.assetId === activeAgentCollectionId) ||
510643 (view.kind === 'repo' && view.label === activeRepoName)
@@ -512,12 +645,16 @@
512645 dragging={dragKey === view.key}
513646 drop={target?.key === view.key ? target.mode : null}
514647 onToggle={() => setCollapsed(current => (current.has(view.id) ? without(current, view.id) : new Set([...current, view.id])))}
648 expandable={isAgent}
649 expanded={agentOpen}
650 onExpand={isAgent ? () => toggleAgents(view.assetId as string) : undefined}
515651 handlers={handlersFor(view)}
516652 onHover={hoverPrefetch}
517653 voiceCount={spaceName === '@me' ? undefined : voiceCounts[view.id]}
518654 />,
519655 )
520656 if (isFolder && !isCollapsed) rows.push(...renderLevel(view.id, depth + 1, new Set([...seen, view.id])))
657 if (agentOpen) rows.push(...renderAgentChildren(view.assetId as string, depth + 1))
521658 }
522659 return rows
523660 }
@@ -546,7 +683,11 @@
546683 </p>
547684 ) : null}
548685 <div {...rootProps}>{renderLevel(null, 0, new Set())}</div>
549 {!levels.size ? <p className="px-2 py-5 text-sm leading-6 text-shell-ink-muted">{t('nav.empty')}</p> : null}
686 {!levels.size ? (
687 <p className="px-2 py-5 text-sm leading-6 text-shell-ink-muted">
688 {t(spaceName === '@me' ? 'nav.emptyDms' : 'nav.empty')}
689 </p>
690 ) : null}
550691 {dragKey ? (
551692 <div
552693 onDragOver={event => {
@@ -597,6 +738,9 @@
597738 dragging,
598739 drop,
599740 onToggle,
741 expandable,
742 expanded,
743 onExpand,
600744 handlers,
601745 onHover,
602746 voiceCount,
@@ -608,6 +752,10 @@
608752 dragging: boolean
609753 drop: DropMode | null
610754 onToggle: () => void
755 /** Agent collections carry a chevron that opens their agent submenu. */
756 expandable?: boolean
757 expanded?: boolean
758 onExpand?: () => void
611759 handlers: Record<string, unknown>
612760 /** Hover prefetch, delayed so a cursor sweeping the list fires nothing. */
613761 onHover: (view: View) => void
@@ -701,6 +849,41 @@
701849 </>
702850 )
703851
852 if (expandable && view.href) {
853 return (
854 <div
855 {...handlers}
856 data-kind="agent"
857 title={view.label}
858 className={`${shared} ${
859 active ? 'bg-shell-active text-shell-ink' : 'text-shell-ink/60 hover:bg-shell-active/60 hover:text-shell-ink'
860 }`}
861 style={indent}
862 >
863 <button
864 type="button"
865 aria-expanded={expanded}
866 aria-label={t('agent.menu.toggle')}
867 onClick={onExpand}
868 className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-shell-ink-muted hover:bg-shell-surface"
869 >
870 {expanded ? (
871 <ChevronDown className="h-3.5 w-3.5" aria-hidden />
872 ) : (
873 <ChevronRight className="h-3.5 w-3.5" aria-hidden />
874 )}
875 </button>
876 <Link
877 {...hover}
878 to={view.href}
879 aria-current={active ? 'page' : undefined}
880 className="flex min-w-0 flex-1 items-center gap-1.5"
881 >
882 {body}
883 </Link>
884 </div>
885 )
886 }
704887 if (!view.href) {
705888 return (
706889 <div {...handlers} className={`${shared} text-shell-ink/60`} style={indent}>
Modified web/src/pages/workspace/ui/workspace-shell.tsx web/src/pages/workspace/ui/workspace-shell.tsx +4 −1 View web/src/pages/workspace/ui/workspace-shell.tsx
@@ -37,7 +37,7 @@
3737 // notification points here); the param is consumed on open so a later
3838 // manual open starts from the default section again.
3939 const [searchParams, setSearchParams] = useSearchParams()
40 const { mode, spaceName, roomId, boardId, agentCollectionId, repoName } = route
40 const { mode, spaceName, roomId, boardId, agentCollectionId, agentId, repoName } = route
4141 const exploring = mode === 'explore'
4242 const [creatingSpace, setCreatingSpace] = useState(false)
4343 const [spaceReload, setSpaceReload] = useState(0)
@@ -202,6 +202,7 @@
202202 navPinned: personal ? <NotifyNavItem /> : undefined,
203203 nav: spaceName ? (
204204 <SidebarNav
205 key={spaceName}
205206 spaceName={spaceName}
206207 rooms={rooms.data ?? []}
207208 dirs={dirs.data ?? []}
@@ -212,6 +213,7 @@
212213 activeRoomId={roomId}
213214 activeBoardId={boardId}
214215 activeAgentCollectionId={agentCollectionId}
216 activeAgentId={agentId}
215217 activeRepoName={repoName}
216218 canOrganize={canOrganize}
217219 onChanged={() => setListsReload(key => key + 1)}
@@ -325,6 +327,7 @@
325327 <AgentView
326328 spaceName={spaceName}
327329 collectionId={agentCollectionId ?? ''}
330 agentId={agentId}
328331 collectionHint={agentCollection}
329332 shell={shell}
330333 onChanged={() => setListsReload(key => key + 1)}
Modified web/src/shared/i18n/messages/en/agent.ts web/src/shared/i18n/messages/en/agent.ts +27 −0 View web/src/shared/i18n/messages/en/agent.ts
@@ -6,6 +6,15 @@
66 'agent.empty': 'This agent set has no agents yet — create one from the top right.',
77 'agent.selectHint': 'Pick an agent on the left to start talking, or create one first.',
88 'agent.delete': 'Delete agent',
9 'agent.menu.empty': 'No agents yet',
10 'agent.menu.toggle': 'Toggle agents',
11 'agent.detail.description': 'Description',
12 'agent.detail.instructions': 'Instructions',
13 'agent.detail.starters': 'Starters',
14 'agent.detail.artifacts': 'Artifacts',
15 'agent.detail.noArtifacts': 'No artifacts yet',
16 'agent.detail.empty': 'Not set',
17 'agent.detail.archived': 'Archived',
918 'agent.chat.empty': 'Describe a task to “{name}”; it runs in a sandbox and reports back.',
1019 'agent.chat.placeholder': 'Message {name}',
1120 'agent.chat.send': 'Send',
@@ -52,6 +61,22 @@
5261 'agent.chat.noSession': 'This agent has no runtime session yet.',
5362 'agent.msg.copy': 'Copy',
5463 'agent.msg.copied': 'Copied',
64 'agent.msg.regenerate': 'Regenerate',
65 'agent.msg.regenerateHint': 'Regenerate (needs backend support)',
66 'agent.msg.like': 'Helpful',
67 'agent.msg.dislike': 'Not helpful',
68 'agent.msg.share': 'Share',
69 'agent.share.title': 'Share this answer',
70 'agent.share.preview': 'Answer image preview',
71 'agent.share.generating': 'Generating image…',
72 'agent.share.failed': 'Image generation failed or timed out. Keep the page in the foreground and retry.',
73 'agent.share.retry': 'Try again',
74 'agent.share.note': 'The image contains only this answer and is rendered locally in your browser.',
75 'agent.share.download': 'Download image',
76 'agent.share.downloaded': 'Downloaded',
77 'agent.share.copyImage': 'Copy image',
78 'agent.share.copied': 'Copied',
79 'agent.share.copyFailed': 'Copy failed',
5580 'agent.msg.reasoning': 'Thinking',
5681 'agent.msg.working': 'Working',
5782 'agent.msg.compaction': 'Context compacted',
@@ -89,6 +114,8 @@
89114 'agent.approval.reject': 'Reject',
90115 'agent.question.title': 'The agent needs your answer',
91116 'agent.question.submit': 'Submit answer',
117 'agent.question.placeholder': 'Type your answer…',
118 'agent.question.required': 'Pick an option or type an answer',
92119 'agent.form.title': 'New agent',
93120 'agent.form.description': 'Creating an agent opens a long-lived runtime session; instructions are fixed afterwards and the provider/model come from the platform default.',
94121 'agent.form.name': 'Name',
Modified web/src/shared/i18n/messages/en/nav.ts web/src/shared/i18n/messages/en/nav.ts +1 −0 View web/src/shared/i18n/messages/en/nav.ts
@@ -1,6 +1,7 @@
11/** Workspace navigation: sidebar rows, drag-and-drop notices, empty states. */
22export const nav = {
33 'nav.empty': 'Nothing here yet. Create a channel from the space menu to start collaborating.',
4 'nav.emptyDms': 'No direct messages yet. Start one from a member profile in a space.',
45 'nav.dropHere': 'Drop here: move to root',
56 'nav.savingPosition': 'Saving position…',
67 'nav.moveFailed': 'Move failed. Please try again.',
Modified web/src/shared/i18n/messages/zh-CN/agent.ts web/src/shared/i18n/messages/zh-CN/agent.ts +27 −0 View web/src/shared/i18n/messages/zh-CN/agent.ts
@@ -6,6 +6,15 @@
66 'agent.empty': '这个智能体集还没有智能体,点击右上角新建。',
77 'agent.selectHint': '在左侧选择一个智能体开始对话,或先新建一个。',
88 'agent.delete': '删除智能体',
9 'agent.menu.empty': '暂无智能体',
10 'agent.menu.toggle': '展开或收起智能体',
11 'agent.detail.description': '描述',
12 'agent.detail.instructions': '系统指令',
13 'agent.detail.starters': '对话开场',
14 'agent.detail.artifacts': '产物',
15 'agent.detail.noArtifacts': '暂无产物',
16 'agent.detail.empty': '未填写',
17 'agent.detail.archived': '已归档',
918 'agent.chat.empty': '向「{name}」描述你的任务,它会在沙箱里执行并回报结果。',
1019 'agent.chat.placeholder': '向 {name} 发送消息',
1120 'agent.chat.send': '发送',
@@ -52,6 +61,22 @@
5261 'agent.chat.noSession': '该智能体尚未绑定运行时会话。',
5362 'agent.msg.copy': '复制',
5463 'agent.msg.copied': '已复制',
64 'agent.msg.regenerate': '重新生成',
65 'agent.msg.regenerateHint': '重新生成(后端支持后可用)',
66 'agent.msg.like': '有帮助',
67 'agent.msg.dislike': '没帮助',
68 'agent.msg.share': '分享',
69 'agent.share.title': '分享这条回答',
70 'agent.share.preview': '回答图片预览',
71 'agent.share.generating': '正在生成图片…',
72 'agent.share.failed': '图片生成失败或超时;保持页面在前台后重试。',
73 'agent.share.retry': '重试',
74 'agent.share.note': '图片只包含这条回答的正文,在浏览器本地生成,不会上传。',
75 'agent.share.download': '下载图片',
76 'agent.share.downloaded': '已下载',
77 'agent.share.copyImage': '复制图片',
78 'agent.share.copied': '已复制',
79 'agent.share.copyFailed': '复制失败',
5580 'agent.msg.reasoning': '思考过程',
5681 'agent.msg.working': '正在工作',
5782 'agent.msg.compaction': '上下文已压缩',
@@ -89,6 +114,8 @@
89114 'agent.approval.reject': '拒绝',
90115 'agent.question.title': '智能体需要你的确认',
91116 'agent.question.submit': '提交回答',
117 'agent.question.placeholder': '输入你的回答…',
118 'agent.question.required': '请选择选项或填写回答',
92119 'agent.form.title': '新建智能体',
93120 'agent.form.description': '创建后将立即在运行时建立长期会话;instructions 之后不可修改,服务商与模型由平台默认提供。',
94121 'agent.form.name': '名称',
Modified web/src/shared/i18n/messages/zh-CN/nav.ts web/src/shared/i18n/messages/zh-CN/nav.ts +1 −0 View web/src/shared/i18n/messages/zh-CN/nav.ts
@@ -1,6 +1,7 @@
11/** Workspace navigation: sidebar rows, drag-and-drop notices, empty states. */
22export const nav = {
33 'nav.empty': '这里还没有内容。使用空间菜单创建频道,开始一起协作。',
4 'nav.emptyDms': '还没有私聊。从空间的成员资料发起一个私聊吧。',
45 'nav.dropHere': '放到这里:移到根目录',
56 'nav.savingPosition': '正在保存位置…',
67 'nav.moveFailed': '移动失败,请稍后重试。',
Modified web/bun.lock web/bun.lock +3 −0 View web/bun.lock
@@ -14,6 +14,7 @@
1414 "dompurify": "^3.4.15",
1515 "emoji-picker-react": "^4.22.2",
1616 "events": "^3.3.0",
17 "html-to-image": "^1.11.13",
1718 "livekit-client": "2.22.3",
1819 "loglevel": "^1.9.2",
1920 "lucide-react": "^1.39.0",
@@ -365,6 +366,8 @@
365366
366367 "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
367368
369 "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
370
368371 "http2-client": ["http2-client@1.3.5", "", {}, "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA=="],
369372
370373 "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
Modified web/package.json web/package.json +1 −0 View web/package.json
@@ -21,6 +21,7 @@
2121 "dompurify": "^3.4.15",
2222 "emoji-picker-react": "^4.22.2",
2323 "events": "^3.3.0",
24 "html-to-image": "^1.11.13",
2425 "livekit-client": "2.22.3",
2526 "loglevel": "^1.9.2",
2627 "lucide-react": "^1.39.0",