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 === "" ? (
|
||||
// 첫 토큰 오기 전 빈 시간 메움 — 시머 텍스트로 "생각 중" 신호
|
||||
// 첫 토큰 오기 전 빈 시간 메움 — 과정도 아직 없으면 시머로 "생각 중" 신호
|
||||
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 (
|
||||
<div key={m.id} className="flex flex-col gap-2">
|
||||
{m.steps && m.steps.length > 0 && (
|
||||
<ThinkingSteps steps={m.steps} isDone answerStarted />
|
||||
)}
|
||||
<Message
|
||||
key={m.id}
|
||||
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 }),
|
||||
|
||||
@@ -40,10 +40,14 @@ export interface ClarifyCandidate {
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** `step` 이벤트 — 에이전트 그래프 진행 단계 (agent.iter 데모용). */
|
||||
/** `step` 이벤트 — 답변 전 과정 한 조각. reasoning 은 text 가 delta 로 누적, tool 은 같은 id 로 상태 갱신. */
|
||||
export interface AgentStep {
|
||||
phase: string
|
||||
detail: string
|
||||
id: string
|
||||
kind: "reasoning" | "tool"
|
||||
text?: string
|
||||
tool?: string
|
||||
status?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** `tool_call` 이벤트 — 모델이 도구 호출 시작. */
|
||||
@@ -63,7 +67,7 @@ export interface LLMStreamHandlers<TResult = unknown> {
|
||||
onResult?: (data: TResult) => void
|
||||
/** `clarify` 이벤트 — 라우팅 애매. 후보 받으면 token 없이 done으로 끝남 (선택) */
|
||||
onClarify?: (candidates: ClarifyCandidate[]) => void
|
||||
/** `step` 이벤트 — 에이전트 진행 단계 (agent.iter 데모용, 선택) */
|
||||
/** `step` 이벤트 — 생각/도구 과정 조각 (선택) */
|
||||
onStep?: (step: AgentStep) => void
|
||||
/** `tool_call` 이벤트 — 모델이 도구 호출 시작 (선택) */
|
||||
onToolCall?: (call: AgentToolCall) => void
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codeassist-tauri"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "CodeAssist 표준 Rust/Tauri 데스크톱 앱"
|
||||
authors = ["justdodev"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeAssist",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"identifier": "com.codeassist.app",
|
||||
"build": {
|
||||
"beforeDevCommand": { "cwd": "../../2_frontend", "script": "npm run dev" },
|
||||
|
||||
@@ -134,8 +134,12 @@ class TurnState:
|
||||
if mid in self.user_message_ids and mid not in self.assistant_message_ids:
|
||||
return out
|
||||
pid = props.get("partID", "")
|
||||
if self.part_types.get(pid) != "text":
|
||||
return out # reasoning/tool 파트, 또는 아직 타입 모름(최종 스냅샷이 메워줌)
|
||||
ptype = self.part_types.get(pid)
|
||||
if ptype == "reasoning":
|
||||
out.append(("step", {"id": pid, "kind": "reasoning", "text": props["delta"]}))
|
||||
return out
|
||||
if ptype != "text":
|
||||
return out # tool 파트, 또는 아직 타입 모름(최종 스냅샷이 메워줌)
|
||||
delta = props["delta"]
|
||||
if pid not in self.text_by_part:
|
||||
self.part_order.append(pid)
|
||||
@@ -148,6 +152,11 @@ class TurnState:
|
||||
part = props.get("part") or {}
|
||||
if part.get("id"):
|
||||
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"):
|
||||
return out
|
||||
mid = part.get("messageID", "")
|
||||
|
||||
@@ -49,7 +49,7 @@ def test_turnstate_snapshot_path_without_delta():
|
||||
def test_turnstate_ignores_reasoning_and_emits_title():
|
||||
st = TurnState("s1", "q")
|
||||
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 조회"}}}
|
||||
assert st.handle(ev) == [("title", {"title": "MARA 조회"})]
|
||||
assert st.handle(ev) == [] # 같은 제목 반복 안 보냄
|
||||
@@ -57,6 +57,14 @@ def test_turnstate_ignores_reasoning_and_emits_title():
|
||||
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"):
|
||||
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(_msg_updated("ma", "assistant"))
|
||||
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(_delta("ma", "pt", "SELECT")) == [("token", {"delta": "SELECT"})]
|
||||
assert st.handle(_delta("ma", "pt", " SINGLE")) == [("token", {"delta": " SINGLE"})]
|
||||
|
||||
Reference in New Issue
Block a user