feat(chat): 생각 과정(reasoning·도구 스텝) 토글 + 작성 중 스피너·경과 초 — ABAP_OPENCODE 와 같은 모양. 백엔드가 step 이벤트로 흘림. 0.1.2

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-21 21:03:51 +09:00
co-authored by Claude Fable 5.1
parent 5ee599fb6b
commit 5277481513
12 changed files with 192 additions and 22 deletions
@@ -1,4 +1,4 @@
import { streamLLM, type LLMUsagePayload } from "@/lib/streaming"
import { streamLLM, type AgentStep, type LLMUsagePayload } from "@/lib/streaming"
import { apiPost } from "@/lib/api/client"
import type { SnapStreamRequest } from "../contract/types"
@@ -7,6 +7,7 @@ export interface SnapStreamHandlers {
onDone: () => void
onTitle?: (title: string) => void
onUsage?: (usage: LLMUsagePayload) => void
onStep?: (step: AgentStep) => void
onError?: (e: Error) => void
}
@@ -25,6 +26,7 @@ export function snapStream(
onDone: () => handlers.onDone(),
onTitle: handlers.onTitle,
onUsage: handlers.onUsage,
onStep: handlers.onStep,
onError: handlers.onError,
},
})
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react"
import { ChevronRight, Loader2, Wrench } from "lucide-react"
import type { AgentStep } from "@/lib/streaming"
/** 생각/도구 과정 토글 — ABAP_OPENCODE 의 ThinkingBox 를 그대로 옮김.
* 진행 중엔 펼쳐진 채 흐르고, 답변 본문이 나오기 시작하거나 끝나면 자동으로 접힘. */
export function ThinkingSteps({
steps,
isDone,
answerStarted,
}: {
steps: AgentStep[]
isDone: boolean
answerStarted: boolean
}) {
const collapsed = isDone || answerStarted
const [open, setOpen] = useState(!collapsed)
useEffect(() => {
if (collapsed) setOpen(false)
}, [collapsed])
if (steps.length === 0) return null
return (
<div className="flex flex-col gap-1.5">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="text-muted-foreground hover:text-foreground flex items-center gap-1.5 self-start text-[12.5px] font-semibold"
>
<ChevronRight
size={13}
strokeWidth={1.5}
className={`transition-transform ${open ? "rotate-90" : ""}`}
/>
<span className="text-[11.5px] font-normal">{steps.length} steps</span>
{!isDone && <Loader2 size={11} className="animate-spin" />}
</button>
{open &&
steps.map((st, i) => (
<div
key={st.id}
className="border-border ml-1.5 flex items-start gap-2.5 border-l-2 py-0.5 pl-3"
>
<span className="text-muted-foreground mt-px shrink-0 text-[11px] font-semibold whitespace-nowrap">
Step {i + 1}
</span>
{st.kind === "tool" ? (
<span className="bg-muted border-border text-foreground inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-semibold">
{st.status === "running" || st.status === "pending" ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Wrench size={12} strokeWidth={1.5} />
)}
{st.tool || "tool"}
{st.title ? `${st.title}` : ""}
</span>
) : (
<div className="text-muted-foreground text-[12.5px] leading-[1.55] break-words whitespace-pre-wrap">
{st.text}
</div>
)}
</div>
))}
</div>
)
}
/** 하단 "작성 중… · N초" — 스피너 + 경과 초. busy 가 true 인 동안만 그림. */
export function WorkingRow({ busy }: { busy: boolean }) {
const [elapsed, setElapsed] = useState(0)
useEffect(() => {
if (!busy) return
setElapsed(0)
const start = Date.now()
const t = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000)
return () => clearInterval(t)
}, [busy])
if (!busy) return null
return (
<div className="text-muted-foreground flex items-center gap-2 text-[12.5px]">
<Loader2 size={14} className="animate-spin" />
· {elapsed}
</div>
)
}
@@ -39,6 +39,7 @@ export function useSnapChat(sessionId: string) {
},
{
onToken: (d) => useSnapChatStore.getState().appendChunk(d),
onStep: (st) => useSnapChatStore.getState().applyStep(st),
// done 시점엔 백엔드가 이미 답변을 DB 에 저장함(streaming.py: persist→usage→done).
// detail 캐시를 무효화해 재진입 시 stale 스냅샷 대신 완성본을 받게 함.
onDone: () => {
@@ -58,6 +58,7 @@ export default function NewChatPage() {
) : (
// 세션 만드는 동안 — SessionChatPage 첫 화면과 같은 모양(내 말풍선 + 생각 중)
<div className="mx-auto flex min-h-0 w-full max-w-[760px] flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
{/* eslint-disable-next-line jsx-a11y/aria-role -- Message 의 role 은 ARIA 아니고 화자 */}
<Message role="user" content={pending} />
<ThinkingBubble />
</div>
@@ -12,6 +12,7 @@ import { ChatHeader } from "../components/ChatHeader"
import { NavRail } from "../components/NavRail"
import { Message } from "../components/Message"
import { ThinkingBubble } from "../components/ThinkingBubble"
import { ThinkingSteps, WorkingRow } from "../components/ThinkingSteps"
import { Composer } from "../components/Composer"
export default function SessionChatPage() {
@@ -164,12 +165,20 @@ export default function SessionChatPage() {
const isLiveLast =
busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen
if (isLiveLast) {
const steps = m.steps ?? []
return (
<div key={m.id}>
<div key={m.id} className="flex flex-col gap-2">
<ThinkingBubble>
<ThinkingSteps
steps={steps}
isDone={!isStreaming}
answerStarted={m.content !== ""}
/>
{m.content === "" ? (
// 첫 토큰 오기 전 빈 시간 메움 — 시머 텍스트로 "생각 중" 신호
<span className="shimmer-text text-sm"> </span>
// 첫 토큰 오기 전 빈 시간 메움 — 과정도 아직 없으면 시머로 "생각 중" 신호
steps.length === 0 && (
<span className="shimmer-text text-sm"> </span>
)
) : (
<StreamingText
text={m.content}
@@ -179,6 +188,7 @@ export default function SessionChatPage() {
/>
)}
</ThinkingBubble>
<WorkingRow busy={isStreaming} />
</div>
)
}
@@ -196,16 +206,25 @@ export default function SessionChatPage() {
)
}
return (
<Message
key={m.id}
role={m.role}
content={m.content}
totalTokens={m.totalTokens}
elapsedMs={m.elapsedMs}
/>
<div key={m.id} className="flex flex-col gap-2">
{m.steps && m.steps.length > 0 && (
<ThinkingSteps steps={m.steps} isDone answerStarted />
)}
<Message
role={m.role}
content={m.content}
totalTokens={m.totalTokens}
elapsedMs={m.elapsedMs}
/>
</div>
)
})}
{generatingRemotely && <ThinkingBubble />}
{generatingRemotely && (
<div className="flex flex-col gap-2">
<ThinkingBubble />
<WorkingRow busy />
</div>
)}
</div>
</div>
<Composer
@@ -151,3 +151,23 @@ describe("snapChatStore", () => {
expect(useSnapChatStore.getState().isRevealing).toBe(false)
})
})
describe("applyStep", () => {
it("reasoning 은 같은 id 로 이어붙고 tool 은 상태 덮어씀, assistant 꼬리에만", () => {
const st = useSnapChatStore.getState()
st.reset()
st.addUserMessage("q")
st.applyStep({ id: "r1", kind: "reasoning", text: "x" }) // user 꼬리 → 무시
st.startAssistantMessage()
st.applyStep({ id: "r1", kind: "reasoning", text: "생각" })
st.applyStep({ id: "r1", kind: "reasoning", text: "중" })
st.applyStep({ id: "t1", kind: "tool", tool: "read", status: "running" })
st.applyStep({ id: "t1", kind: "tool", tool: "read", status: "completed", title: "a.abap" })
const last = useSnapChatStore.getState().messages.at(-1)!
expect(last.steps).toEqual([
{ id: "r1", kind: "reasoning", text: "생각중" },
{ id: "t1", kind: "tool", tool: "read", status: "completed", title: "a.abap" },
])
expect(useSnapChatStore.getState().messages[0].steps).toBeUndefined()
})
})
@@ -1,6 +1,7 @@
import { create } from "zustand"
import { randomId } from "@/lib/utils/randomId"
import type { SnapMessage, SnapRole } from "../contract/types"
import type { AgentStep } from "@/lib/streaming"
export interface SnapChatMessage {
id: string
@@ -14,6 +15,8 @@ export interface SnapChatMessage {
totalTokens?: number
/** 이 답변 소요시간(ms). 라이브는 SSE, 과거는 DB. user 는 없음. */
elapsedMs?: number
/** 답변 전 과정(생각·도구). 라이브 턴에만 쌓임 — DB 엔 안 남아 재진입 땐 없음. */
steps?: AgentStep[]
}
// 세션 컨텍스트 하드 한도 — 백엔드 settings.llm_context_limit 와 동기(reload 시 기본값).
@@ -45,6 +48,8 @@ interface SnapChatState {
/** 폴링 복구로 도착한 완성 답변을 꼬리에 붙이고 타자기 reveal 시작. */
appendRecoveredAssistant: (m: SnapMessage) => void
appendChunk: (chunk: string) => void
/** step 이벤트 — 같은 id 면 reasoning 은 text 이어붙이고 tool 은 상태 덮어씀. */
applyStep: (step: AgentStep) => void
setStreaming: (v: boolean) => void
setRevealing: (v: boolean) => void
setController: (c: AbortController | null) => void
@@ -123,6 +128,21 @@ export const useSnapChatStore = create<SnapChatState>((set, get) => ({
return { messages: next }
})
},
applyStep: (step) => {
const last = get().messages.at(-1)
if (!last || last.role !== "assistant") return
const steps = [...(last.steps ?? [])]
const i = steps.findIndex((x) => x.id === step.id)
if (i < 0) steps.push(step)
else if (step.kind === "reasoning")
steps[i] = { ...steps[i], text: (steps[i].text ?? "") + (step.text ?? "") }
else steps[i] = { ...steps[i], ...step }
set((s) => {
const next = [...s.messages]
next[next.length - 1] = { ...last, steps }
return { messages: next }
})
},
setStreaming: (v) => set({ isStreaming: v }),
setRevealing: (v) => set({ isRevealing: v }),
setController: (c) => set({ currentController: c }),