feat(chat): 생각 과정(reasoning·도구 스텝) 토글 + 작성 중 스피너·경과 초 — ABAP_OPENCODE 와 같은 모양. 백엔드가 step 이벤트로 흘림. 0.1.2
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
5ee599fb6b
commit
5277481513
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user