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 { apiPost } from "@/lib/api/client"
import type { SnapStreamRequest } from "../contract/types" import type { SnapStreamRequest } from "../contract/types"
@@ -7,6 +7,7 @@ export interface SnapStreamHandlers {
onDone: () => void onDone: () => void
onTitle?: (title: string) => void onTitle?: (title: string) => void
onUsage?: (usage: LLMUsagePayload) => void onUsage?: (usage: LLMUsagePayload) => void
onStep?: (step: AgentStep) => void
onError?: (e: Error) => void onError?: (e: Error) => void
} }
@@ -25,6 +26,7 @@ export function snapStream(
onDone: () => handlers.onDone(), onDone: () => handlers.onDone(),
onTitle: handlers.onTitle, onTitle: handlers.onTitle,
onUsage: handlers.onUsage, onUsage: handlers.onUsage,
onStep: handlers.onStep,
onError: handlers.onError, 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), onToken: (d) => useSnapChatStore.getState().appendChunk(d),
onStep: (st) => useSnapChatStore.getState().applyStep(st),
// done 시점엔 백엔드가 이미 답변을 DB 에 저장함(streaming.py: persist→usage→done). // done 시점엔 백엔드가 이미 답변을 DB 에 저장함(streaming.py: persist→usage→done).
// detail 캐시를 무효화해 재진입 시 stale 스냅샷 대신 완성본을 받게 함. // detail 캐시를 무효화해 재진입 시 stale 스냅샷 대신 완성본을 받게 함.
onDone: () => { onDone: () => {
@@ -58,6 +58,7 @@ export default function NewChatPage() {
) : ( ) : (
// 세션 만드는 동안 — SessionChatPage 첫 화면과 같은 모양(내 말풍선 + 생각 중) // 세션 만드는 동안 — 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"> <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} /> <Message role="user" content={pending} />
<ThinkingBubble /> <ThinkingBubble />
</div> </div>
@@ -12,6 +12,7 @@ import { ChatHeader } from "../components/ChatHeader"
import { NavRail } from "../components/NavRail" import { NavRail } from "../components/NavRail"
import { Message } from "../components/Message" import { Message } from "../components/Message"
import { ThinkingBubble } from "../components/ThinkingBubble" import { ThinkingBubble } from "../components/ThinkingBubble"
import { ThinkingSteps, WorkingRow } from "../components/ThinkingSteps"
import { Composer } from "../components/Composer" import { Composer } from "../components/Composer"
export default function SessionChatPage() { export default function SessionChatPage() {
@@ -164,12 +165,20 @@ export default function SessionChatPage() {
const isLiveLast = const isLiveLast =
busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen
if (isLiveLast) { if (isLiveLast) {
const steps = m.steps ?? []
return ( return (
<div key={m.id}> <div key={m.id} className="flex flex-col gap-2">
<ThinkingBubble> <ThinkingBubble>
<ThinkingSteps
steps={steps}
isDone={!isStreaming}
answerStarted={m.content !== ""}
/>
{m.content === "" ? ( {m.content === "" ? (
// 첫 토큰 오기 전 빈 시간 메움 — 시머 텍스트로 "생각 중" 신호 // 첫 토큰 오기 전 빈 시간 메움 — 과정도 아직 없으면 시머로 "생각 중" 신호
<span className="shimmer-text text-sm"> </span> steps.length === 0 && (
<span className="shimmer-text text-sm"> </span>
)
) : ( ) : (
<StreamingText <StreamingText
text={m.content} text={m.content}
@@ -179,6 +188,7 @@ export default function SessionChatPage() {
/> />
)} )}
</ThinkingBubble> </ThinkingBubble>
<WorkingRow busy={isStreaming} />
</div> </div>
) )
} }
@@ -196,16 +206,25 @@ export default function SessionChatPage() {
) )
} }
return ( return (
<Message <div key={m.id} className="flex flex-col gap-2">
key={m.id} {m.steps && m.steps.length > 0 && (
role={m.role} <ThinkingSteps steps={m.steps} isDone answerStarted />
content={m.content} )}
totalTokens={m.totalTokens} <Message
elapsedMs={m.elapsedMs} 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>
</div> </div>
<Composer <Composer
@@ -151,3 +151,23 @@ describe("snapChatStore", () => {
expect(useSnapChatStore.getState().isRevealing).toBe(false) 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 { create } from "zustand"
import { randomId } from "@/lib/utils/randomId" import { randomId } from "@/lib/utils/randomId"
import type { SnapMessage, SnapRole } from "../contract/types" import type { SnapMessage, SnapRole } from "../contract/types"
import type { AgentStep } from "@/lib/streaming"
export interface SnapChatMessage { export interface SnapChatMessage {
id: string id: string
@@ -14,6 +15,8 @@ export interface SnapChatMessage {
totalTokens?: number totalTokens?: number
/** 이 답변 소요시간(ms). 라이브는 SSE, 과거는 DB. user 는 없음. */ /** 이 답변 소요시간(ms). 라이브는 SSE, 과거는 DB. user 는 없음. */
elapsedMs?: number elapsedMs?: number
/** 답변 전 과정(생각·도구). 라이브 턴에만 쌓임 — DB 엔 안 남아 재진입 땐 없음. */
steps?: AgentStep[]
} }
// 세션 컨텍스트 하드 한도 — 백엔드 settings.llm_context_limit 와 동기(reload 시 기본값). // 세션 컨텍스트 하드 한도 — 백엔드 settings.llm_context_limit 와 동기(reload 시 기본값).
@@ -45,6 +48,8 @@ interface SnapChatState {
/** 폴링 복구로 도착한 완성 답변을 꼬리에 붙이고 타자기 reveal 시작. */ /** 폴링 복구로 도착한 완성 답변을 꼬리에 붙이고 타자기 reveal 시작. */
appendRecoveredAssistant: (m: SnapMessage) => void appendRecoveredAssistant: (m: SnapMessage) => void
appendChunk: (chunk: string) => void appendChunk: (chunk: string) => void
/** step 이벤트 — 같은 id 면 reasoning 은 text 이어붙이고 tool 은 상태 덮어씀. */
applyStep: (step: AgentStep) => void
setStreaming: (v: boolean) => void setStreaming: (v: boolean) => void
setRevealing: (v: boolean) => void setRevealing: (v: boolean) => void
setController: (c: AbortController | null) => void setController: (c: AbortController | null) => void
@@ -123,6 +128,21 @@ export const useSnapChatStore = create<SnapChatState>((set, get) => ({
return { messages: next } 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 }), setStreaming: (v) => set({ isStreaming: v }),
setRevealing: (v) => set({ isRevealing: v }), setRevealing: (v) => set({ isRevealing: v }),
setController: (c) => set({ currentController: c }), setController: (c) => set({ currentController: c }),
+8 -4
View File
@@ -40,10 +40,14 @@ export interface ClarifyCandidate {
reason: string reason: string
} }
/** `step` 이벤트 — 에이전트 그래프 진행 단계 (agent.iter 데모용). */ /** `step` 이벤트 — 답변 전 과정 한 조각. reasoning 은 text 가 delta 로 누적, tool 은 같은 id 로 상태 갱신. */
export interface AgentStep { export interface AgentStep {
phase: string id: string
detail: string kind: "reasoning" | "tool"
text?: string
tool?: string
status?: string
title?: string
} }
/** `tool_call` 이벤트 — 모델이 도구 호출 시작. */ /** `tool_call` 이벤트 — 모델이 도구 호출 시작. */
@@ -63,7 +67,7 @@ export interface LLMStreamHandlers<TResult = unknown> {
onResult?: (data: TResult) => void onResult?: (data: TResult) => void
/** `clarify` 이벤트 — 라우팅 애매. 후보 받으면 token 없이 done으로 끝남 (선택) */ /** `clarify` 이벤트 — 라우팅 애매. 후보 받으면 token 없이 done으로 끝남 (선택) */
onClarify?: (candidates: ClarifyCandidate[]) => void onClarify?: (candidates: ClarifyCandidate[]) => void
/** `step` 이벤트 — 에이전트 진행 단계 (agent.iter 데모용, 선택) */ /** `step` 이벤트 — 생각/도구 과정 조각 (선택) */
onStep?: (step: AgentStep) => void onStep?: (step: AgentStep) => void
/** `tool_call` 이벤트 — 모델이 도구 호출 시작 (선택) */ /** `tool_call` 이벤트 — 모델이 도구 호출 시작 (선택) */
onToolCall?: (call: AgentToolCall) => void onToolCall?: (call: AgentToolCall) => void
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "codeassist-tauri" name = "codeassist-tauri"
version = "0.1.1" version = "0.1.2"
description = "CodeAssist 표준 Rust/Tauri 데스크톱 앱" description = "CodeAssist 표준 Rust/Tauri 데스크톱 앱"
authors = ["justdodev"] authors = ["justdodev"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CodeAssist", "productName": "CodeAssist",
"version": "0.1.1", "version": "0.1.2",
"identifier": "com.codeassist.app", "identifier": "com.codeassist.app",
"build": { "build": {
"beforeDevCommand": { "cwd": "../../2_frontend", "script": "npm run dev" }, "beforeDevCommand": { "cwd": "../../2_frontend", "script": "npm run dev" },
+11 -2
View File
@@ -134,8 +134,12 @@ class TurnState:
if mid in self.user_message_ids and mid not in self.assistant_message_ids: if mid in self.user_message_ids and mid not in self.assistant_message_ids:
return out return out
pid = props.get("partID", "") pid = props.get("partID", "")
if self.part_types.get(pid) != "text": ptype = self.part_types.get(pid)
return out # reasoning/tool 파트, 또는 아직 타입 모름(최종 스냅샷이 메워줌) if ptype == "reasoning":
out.append(("step", {"id": pid, "kind": "reasoning", "text": props["delta"]}))
return out
if ptype != "text":
return out # tool 파트, 또는 아직 타입 모름(최종 스냅샷이 메워줌)
delta = props["delta"] delta = props["delta"]
if pid not in self.text_by_part: if pid not in self.text_by_part:
self.part_order.append(pid) self.part_order.append(pid)
@@ -148,6 +152,11 @@ class TurnState:
part = props.get("part") or {} part = props.get("part") or {}
if part.get("id"): if part.get("id"):
self.part_types[part["id"]] = part.get("type", "") self.part_types[part["id"]] = part.get("type", "")
if part.get("type") == "tool":
# 도구 호출 진행 — 화면 "생각 과정" 에 이름·상태만. 입력/출력은 안 보냄(크고 사용자 관심 밖)
st = part.get("state") or {}
out.append(("step", {"id": part.get("id", ""), "kind": "tool", "tool": part.get("tool", ""), "status": st.get("status", ""), "title": st.get("title") or ""}))
return out
if part.get("type") != "text" or part.get("synthetic") or part.get("ignored"): if part.get("type") != "text" or part.get("synthetic") or part.get("ignored"):
return out return out
mid = part.get("messageID", "") mid = part.get("messageID", "")
+10 -2
View File
@@ -49,7 +49,7 @@ def test_turnstate_snapshot_path_without_delta():
def test_turnstate_ignores_reasoning_and_emits_title(): def test_turnstate_ignores_reasoning_and_emits_title():
st = TurnState("s1", "q") st = TurnState("s1", "q")
st.handle(_msg_updated("ma", "assistant")) st.handle(_msg_updated("ma", "assistant"))
assert st.handle(_part_updated("ma", "pr", "생각중", ptype="reasoning")) == [] assert st.handle(_part_updated("ma", "pr", "생각중", ptype="reasoning")) == [] # 스냅샷은 안 보냄(delta 만)
ev = {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "MARA 조회"}}} ev = {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "MARA 조회"}}}
assert st.handle(ev) == [("title", {"title": "MARA 조회"})] assert st.handle(ev) == [("title", {"title": "MARA 조회"})]
assert st.handle(ev) == [] # 같은 제목 반복 안 보냄 assert st.handle(ev) == [] # 같은 제목 반복 안 보냄
@@ -57,6 +57,14 @@ def test_turnstate_ignores_reasoning_and_emits_title():
assert st.handle(placeholder) == [] # OpenCode 기본 제목은 무시 assert st.handle(placeholder) == [] # OpenCode 기본 제목은 무시
def test_turnstate_tool_part_becomes_step():
st = TurnState("s1", "q")
st.handle(_msg_updated("ma", "assistant"))
ev = {"type": "message.part.updated", "properties": {"part": {"id": "pt1", "messageID": "ma", "sessionID": "s1", "type": "tool", "tool": "sap-icf_get_program_source", "state": {"status": "running", "title": "ZRMEM2016", "input": {"x": 1}}}}}
assert st.handle(ev) == [("step", {"id": "pt1", "kind": "tool", "tool": "sap-icf_get_program_source", "status": "running", "title": "ZRMEM2016"})]
assert st.accumulated() == "" # 도구는 답변 본문에 안 섞임
def _delta(mid, pid, delta, sid="s1", field="text"): def _delta(mid, pid, delta, sid="s1", field="text"):
return {"type": "message.part.delta", "properties": {"sessionID": sid, "messageID": mid, "partID": pid, "field": field, "delta": delta}} return {"type": "message.part.delta", "properties": {"sessionID": sid, "messageID": mid, "partID": pid, "field": field, "delta": delta}}
@@ -68,7 +76,7 @@ def test_turnstate_real_server_shape_delta_events():
st.handle(_part_updated("mu", "pu", "q")) st.handle(_part_updated("mu", "pu", "q"))
st.handle(_msg_updated("ma", "assistant")) st.handle(_msg_updated("ma", "assistant"))
assert st.handle(_part_updated("ma", "pr", "", ptype="reasoning")) == [] assert st.handle(_part_updated("ma", "pr", "", ptype="reasoning")) == []
assert st.handle(_delta("ma", "pr", "생각")) == [] # reasoning delta 는 안 보냄 assert st.handle(_delta("ma", "pr", "생각")) == [("step", {"id": "pr", "kind": "reasoning", "text": "생각"})]
assert st.handle(_part_updated("ma", "pt", "")) == [] # 빈 스냅샷 assert st.handle(_part_updated("ma", "pt", "")) == [] # 빈 스냅샷
assert st.handle(_delta("ma", "pt", "SELECT")) == [("token", {"delta": "SELECT"})] assert st.handle(_delta("ma", "pt", "SELECT")) == [("token", {"delta": "SELECT"})]
assert st.handle(_delta("ma", "pt", " SINGLE")) == [("token", {"delta": " SINGLE"})] assert st.handle(_delta("ma", "pt", " SINGLE")) == [("token", {"delta": " SINGLE"})]