Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
616795be41 | ||
|
|
a54022c438 | ||
|
|
a617504268 | ||
|
|
4f4711a22a | ||
|
|
e5b65ca79a |
+4
-1
@@ -51,6 +51,7 @@ export function App() {
|
||||
const toast = useStore(s => s.toast)
|
||||
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
||||
const viewingArtifact = useStore(s => s.ui.viewingArtifact)
|
||||
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
||||
// authState:checking → ok(放行)| required(登录门)。
|
||||
const [authState, setAuthState] = useState<'checking' | 'required' | 'ok'>('checking')
|
||||
|
||||
@@ -140,7 +141,9 @@ export function App() {
|
||||
) : (
|
||||
<div className="right-panel collapsed" style={{ width: 44 }}>
|
||||
<div className="icon-only">
|
||||
<button title="展开工作台" onClick={store.toggleRight}>◧</button>
|
||||
<button title="展开收件箱" onClick={store.toggleRight}>◧
|
||||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { Message, Part, HITLCard } from '../contracts'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
|
||||
marked.setOptions({ breaks: true })
|
||||
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
|
||||
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
|
||||
|
||||
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
|
||||
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
@@ -16,7 +17,13 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
return <div className={live ? 'part-text cursor-blink' : 'part-text'}
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
|
||||
case 'thinking':
|
||||
return <div className="part-thinking">{part.text}</div>
|
||||
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
|
||||
return (
|
||||
<details className="part-thinking" open={live ? true : undefined}>
|
||||
<summary>思考过程</summary>
|
||||
<div className="part-thinking-body">{part.text}</div>
|
||||
</details>
|
||||
)
|
||||
case 'tool_call':
|
||||
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
|
||||
case 'tool_result': {
|
||||
@@ -93,7 +100,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
|
||||
</div>
|
||||
{split.process.length > 0 && (showProcessInline
|
||||
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)
|
||||
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!live} /></div>)
|
||||
: <ProcessCollapse parts={split.process} />)}
|
||||
{split.content.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!(live && i === split.content.length - 1 && p.type === 'text')} /></div>)}
|
||||
{live && <LiveAgentNotes live={live} />}
|
||||
@@ -102,6 +109,22 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
)
|
||||
}
|
||||
|
||||
// live 投影:run 进行中没有已落库的助手消息,把 LiveState 合成为同构的临时消息
|
||||
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);run 终态对账后由真消息替代。
|
||||
function liveToMessage(live: LiveState): Message {
|
||||
const parts: Part[] = []
|
||||
if (live.thinking) parts.push({ type: 'thinking', text: live.thinking, process: true })
|
||||
for (const tc of live.toolCalls) {
|
||||
parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: tc.name } })
|
||||
if (tc.status !== undefined) {
|
||||
parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: tc.name },
|
||||
toolResult: { callId: '', status: tc.status === 'error' ? 'error' : 'ok', summary: tc.summary } })
|
||||
}
|
||||
}
|
||||
if (live.text) parts.push({ type: 'text', text: live.text })
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
|
||||
}
|
||||
|
||||
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
||||
function ContextObsView({ obs }: { obs: ContextObs }) {
|
||||
return (
|
||||
@@ -212,7 +235,7 @@ export function Conversation() {
|
||||
|
||||
useEffect(() => {
|
||||
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
||||
}, [messages.length, live?.text, live?.thinking, activeHitl.length])
|
||||
}, [messages.length, live?.text, live?.thinking, live?.toolCalls.length, live?.phase, activeHitl.length])
|
||||
|
||||
const send = async () => {
|
||||
if (!text.trim() && attachmentIds.length === 0) return
|
||||
@@ -279,6 +302,8 @@ export function Conversation() {
|
||||
}
|
||||
return <AssistantMessage key={m.id} msg={m} live={live?.runId === m.runId ? live : null} />
|
||||
})}
|
||||
{live && !messages.some(m => m.runId === live.runId) &&
|
||||
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
|
||||
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
|
||||
{messages.length === 0 && <div className="empty-state">
|
||||
<div>发送消息开始对话</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ export function Workbench() {
|
||||
<>
|
||||
<div className="tabs">
|
||||
<span className="tab active">收件箱{pending > 0 ? ` ${pending}` : ''}</span>
|
||||
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}>🧹 收纳</button>
|
||||
<button className="tab" title="收起收件箱" onClick={store.toggleRight}>»</button>
|
||||
</div>
|
||||
<div className="tab-body">
|
||||
|
||||
+14
-4
@@ -308,10 +308,12 @@ export const store = {
|
||||
return
|
||||
}
|
||||
if (out.run) {
|
||||
set({
|
||||
live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() },
|
||||
messages: [...state.messages, out.message],
|
||||
})
|
||||
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
|
||||
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
|
||||
if (!state.live || state.live.runId !== out.run.id) {
|
||||
set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() } })
|
||||
}
|
||||
set({ messages: [...state.messages, out.message] })
|
||||
store.connectIfNeeded()
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -418,6 +420,14 @@ export const store = {
|
||||
toggleLeft() { setUI({ leftCollapsed: !state.ui.leftCollapsed }) },
|
||||
toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
|
||||
openWorkspace(id: string) { setUI({ activeWorkspace: id }) },
|
||||
/** 收纳策略(T5):一键收纳已完成卡(done→dismissed;resolved 盖章留痕不动)。 */
|
||||
async tidyCards() {
|
||||
const done = state.cards.filter(c => c.status === 'done')
|
||||
for (const c of done) {
|
||||
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
|
||||
}
|
||||
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
|
||||
},
|
||||
closeWorkspace() { setUI({ activeWorkspace: null }) },
|
||||
openInbox(cardId?: string) {
|
||||
setUI({ rightCollapsed: false, rightTab: 'inbox' })
|
||||
|
||||
+32
-6
@@ -39,25 +39,46 @@ button { font-family: inherit; }
|
||||
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
/* ---- 中栏对话 ---- */
|
||||
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
/* min-height:0 须随列向 flex 链下传(.main→.chat→.chat-body):flex 项的自动最小
|
||||
高度是内容高度,缺失时消息会撑破 .chat——chat-body 永不滚动、输入栏被顶出视口。 */
|
||||
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
.chat-header { padding: 10px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; background: var(--bg-raised); }
|
||||
.chat-title { font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-body { flex: 1; overflow-y: auto; padding: 18px max(18px, 12%); }
|
||||
.chat-body { flex: 1; overflow-y: auto; min-height: 0; padding: 18px max(18px, 12%); }
|
||||
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 8px; }
|
||||
.msg { margin-bottom: 18px; }
|
||||
.msg-role { font-size: 12px; color: var(--text-faint); margin-bottom: 4px; display: flex; gap: 8px; align-items: center; }
|
||||
.msg.user .bubble { background: var(--accent-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; white-space: pre-wrap; }
|
||||
.part { margin: 4px 0; }
|
||||
.part-thinking { color: var(--text-faint); font-size: 13px; border-left: 2px solid var(--border); padding-left: 10px; white-space: pre-wrap; }
|
||||
.part-thinking { color: var(--text-dim); font-size: 12.5px; border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
|
||||
.part-thinking summary { cursor: pointer; color: var(--text-faint); user-select: none; }
|
||||
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
|
||||
.part-tool { font-size: 12.5px; }
|
||||
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 8px; margin: 2px 4px 2px 0; font-family: var(--mono); }
|
||||
.tool-chip .status-ok { color: var(--ok); }
|
||||
.tool-chip .status-error { color: var(--err); }
|
||||
.part-text { white-space: pre-wrap; line-height: 1.7; }
|
||||
.part-text :is(h1,h2,h3) { margin: 0.6em 0 0.3em; }
|
||||
.part-text { line-height: 1.65; overflow-wrap: break-word; }
|
||||
/* 块级间距只由标签自身控制:禁用 pre-wrap(marked 输出的块间换行会被渲染成空行,
|
||||
叠加出"列表逐项空一行"的松散排版);行内换行语义交给 Markdown 段落。 */
|
||||
.part-text > *:first-child { margin-top: 0; }
|
||||
.part-text > *:last-child { margin-bottom: 0; }
|
||||
.part-text p { margin: 0.5em 0; }
|
||||
.part-text :is(h1,h2,h3,h4) { margin: 0.8em 0 0.35em; line-height: 1.35; font-weight: 600; }
|
||||
.part-text h1 { font-size: 1.3em; }
|
||||
.part-text h2 { font-size: 1.18em; }
|
||||
.part-text h3 { font-size: 1.06em; }
|
||||
.part-text :is(ul,ol) { margin: 0.4em 0; padding-left: 1.6em; }
|
||||
.part-text li { margin: 0.15em 0; }
|
||||
.part-text li > p { margin: 0.2em 0; }
|
||||
.part-text pre { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; }
|
||||
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
|
||||
.part-text :is(p,ul,ol) { margin: 0.4em 0; }
|
||||
.part-text pre code { background: none; padding: 0; }
|
||||
.part-text blockquote { margin: 0.5em 0; padding: 0.1em 0 0.1em 10px; border-left: 3px solid var(--border); color: var(--text-dim); }
|
||||
.part-text table { border-collapse: collapse; margin: 0.6em 0; font-size: 0.95em; display: block; max-width: 100%; overflow-x: auto; }
|
||||
.part-text th, .part-text td { border: 1px solid var(--border); padding: 4px 10px; text-align: left; }
|
||||
.part-text th { background: var(--bg-raised); font-weight: 600; }
|
||||
.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
|
||||
.part-text a { color: var(--accent); }
|
||||
.part-error { color: var(--err); background: color-mix(in srgb, var(--err) 8%, transparent); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
|
||||
.artifact-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); background: var(--accent-soft); border-radius: var(--radius-sm); padding: 4px 10px; cursor: pointer; font-size: 13px; }
|
||||
.artifact-chip:hover { filter: brightness(1.15); }
|
||||
@@ -196,3 +217,8 @@ button { font-family: inherit; }
|
||||
.project-badge{font-size:11px;padding:1px 8px;border-radius:8px;background:rgba(120,160,255,.15);color:var(--accent);white-space:nowrap}
|
||||
.tab-hint{font-size:10px;color:var(--text-faint);align-self:center}
|
||||
.empty-note{color:var(--text-faint);font-size:12px;padding:12px 4px}
|
||||
|
||||
/* 收纳策略(T5):收起态角标 + 收纳按钮 */
|
||||
.rail-badge{position:absolute;top:-4px;right:-6px;font-size:10px;background:var(--warn,#d29922);color:#fff;border-radius:8px;padding:0 5px;min-width:14px}
|
||||
.right-panel.collapsed .icon-only button{position:relative}
|
||||
.tab-action{font-size:11px;color:var(--text-faint)}
|
||||
|
||||
Reference in New Issue
Block a user