From 5277481513f692cd34579518a670fbf5fbcfe39d Mon Sep 17 00:00:00 2001 From: lee-hyeon-cheol Date: Mon, 21 Sep 2026 21:03:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=EC=83=9D=EA=B0=81=20=EA=B3=BC?= =?UTF-8?q?=EC=A0=95(reasoning=C2=B7=EB=8F=84=EA=B5=AC=20=EC=8A=A4?= =?UTF-8?q?=ED=85=9D)=20=ED=86=A0=EA=B8=80=20+=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=EC=A4=91=20=EC=8A=A4=ED=94=BC=EB=84=88=C2=B7=EA=B2=BD=EA=B3=BC?= =?UTF-8?q?=20=EC=B4=88=20=E2=80=94=20ABAP=5FOPENCODE=20=EC=99=80=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EB=AA=A8=EC=96=91.=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=EA=B0=80=20step=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=ED=9D=98=EB=A6=BC.=200.1.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../src/features/snap/api/snap.stream.ts | 4 +- .../snap/components/ThinkingSteps.tsx | 86 +++++++++++++++++++ .../src/features/snap/hooks/useSnapChat.ts | 1 + .../src/features/snap/pages/NewChatPage.tsx | 1 + .../features/snap/pages/SessionChatPage.tsx | 41 ++++++--- .../features/snap/store/snapChatStore.test.ts | 20 +++++ .../src/features/snap/store/snapChatStore.ts | 20 +++++ 2_frontend/src/lib/streaming/streamLLM.ts | 12 ++- 4_rust_tauri/src-tauri/Cargo.toml | 2 +- 4_rust_tauri/src-tauri/tauri.conf.json | 2 +- 5_django_backend/apps/chat/stream.py | 13 ++- 5_django_backend/tests/test_stream.py | 12 ++- 12 files changed, 192 insertions(+), 22 deletions(-) create mode 100644 2_frontend/src/features/snap/components/ThinkingSteps.tsx diff --git a/2_frontend/src/features/snap/api/snap.stream.ts b/2_frontend/src/features/snap/api/snap.stream.ts index 6c01dde..7c87afc 100644 --- a/2_frontend/src/features/snap/api/snap.stream.ts +++ b/2_frontend/src/features/snap/api/snap.stream.ts @@ -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, }, }) diff --git a/2_frontend/src/features/snap/components/ThinkingSteps.tsx b/2_frontend/src/features/snap/components/ThinkingSteps.tsx new file mode 100644 index 0000000..a4e76f1 --- /dev/null +++ b/2_frontend/src/features/snap/components/ThinkingSteps.tsx @@ -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 ( +
+ + {open && + steps.map((st, i) => ( +
+ + Step {i + 1} + + {st.kind === "tool" ? ( + + {st.status === "running" || st.status === "pending" ? ( + + ) : ( + + )} + {st.tool || "tool"} + {st.title ? ` — ${st.title}` : ""} + + ) : ( +
+ {st.text} +
+ )} +
+ ))} +
+ ) +} + +/** 하단 "작성 중… · 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 ( +
+ + 작성 중… · {elapsed}초 +
+ ) +} diff --git a/2_frontend/src/features/snap/hooks/useSnapChat.ts b/2_frontend/src/features/snap/hooks/useSnapChat.ts index 1b9988c..d1edab2 100644 --- a/2_frontend/src/features/snap/hooks/useSnapChat.ts +++ b/2_frontend/src/features/snap/hooks/useSnapChat.ts @@ -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: () => { diff --git a/2_frontend/src/features/snap/pages/NewChatPage.tsx b/2_frontend/src/features/snap/pages/NewChatPage.tsx index e2e9368..49fab9c 100644 --- a/2_frontend/src/features/snap/pages/NewChatPage.tsx +++ b/2_frontend/src/features/snap/pages/NewChatPage.tsx @@ -58,6 +58,7 @@ export default function NewChatPage() { ) : ( // 세션 만드는 동안 — SessionChatPage 첫 화면과 같은 모양(내 말풍선 + 생각 중)
+ {/* eslint-disable-next-line jsx-a11y/aria-role -- Message 의 role 은 ARIA 아니고 화자 */}
diff --git a/2_frontend/src/features/snap/pages/SessionChatPage.tsx b/2_frontend/src/features/snap/pages/SessionChatPage.tsx index a11632d..374921c 100644 --- a/2_frontend/src/features/snap/pages/SessionChatPage.tsx +++ b/2_frontend/src/features/snap/pages/SessionChatPage.tsx @@ -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 ( -
+
+ {m.content === "" ? ( - // 첫 토큰 오기 전 빈 시간 메움 — 시머 텍스트로 "생각 중" 신호 - 생각하는 중… + // 첫 토큰 오기 전 빈 시간 메움 — 과정도 아직 없으면 시머로 "생각 중" 신호 + steps.length === 0 && ( + 생각하는 중… + ) ) : ( )} +
) } @@ -196,16 +206,25 @@ export default function SessionChatPage() { ) } return ( - +
+ {m.steps && m.steps.length > 0 && ( + + )} + +
) })} - {generatingRemotely && } + {generatingRemotely && ( +
+ + +
+ )}
{ 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() + }) +}) diff --git a/2_frontend/src/features/snap/store/snapChatStore.ts b/2_frontend/src/features/snap/store/snapChatStore.ts index 9c60d3a..56506c7 100644 --- a/2_frontend/src/features/snap/store/snapChatStore.ts +++ b/2_frontend/src/features/snap/store/snapChatStore.ts @@ -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((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 }), diff --git a/2_frontend/src/lib/streaming/streamLLM.ts b/2_frontend/src/lib/streaming/streamLLM.ts index d9a849a..ad1eca1 100644 --- a/2_frontend/src/lib/streaming/streamLLM.ts +++ b/2_frontend/src/lib/streaming/streamLLM.ts @@ -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 { 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 diff --git a/4_rust_tauri/src-tauri/Cargo.toml b/4_rust_tauri/src-tauri/Cargo.toml index 38da60f..362a040 100644 --- a/4_rust_tauri/src-tauri/Cargo.toml +++ b/4_rust_tauri/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeassist-tauri" -version = "0.1.1" +version = "0.1.2" description = "CodeAssist 표준 Rust/Tauri 데스크톱 앱" authors = ["justdodev"] edition = "2021" diff --git a/4_rust_tauri/src-tauri/tauri.conf.json b/4_rust_tauri/src-tauri/tauri.conf.json index 9a7bd5a..9474490 100644 --- a/4_rust_tauri/src-tauri/tauri.conf.json +++ b/4_rust_tauri/src-tauri/tauri.conf.json @@ -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" }, diff --git a/5_django_backend/apps/chat/stream.py b/5_django_backend/apps/chat/stream.py index 24d63e6..1b96ea3 100644 --- a/5_django_backend/apps/chat/stream.py +++ b/5_django_backend/apps/chat/stream.py @@ -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", "") diff --git a/5_django_backend/tests/test_stream.py b/5_django_backend/tests/test_stream.py index cf6d6ff..5362f08 100644 --- a/5_django_backend/tests/test_stream.py +++ b/5_django_backend/tests/test_stream.py @@ -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"})]