Skip to content

Commit bc9a9f0

Browse files
committed
feat: display reasoning content in chat
1 parent 0fc3b79 commit bc9a9f0

5 files changed

Lines changed: 157 additions & 70 deletions

File tree

src/App.tsx

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,40 @@ const modelsKey = "iaslate_models";
2828

2929
const defaultSystemPrompt = "You are a helpful assistant.";
3030

31+
const extractTextDelta = (input: unknown): string => {
32+
if (!input) {
33+
return "";
34+
}
35+
if (typeof input === "string") {
36+
return input;
37+
}
38+
if (Array.isArray(input)) {
39+
return input
40+
.map((part) => {
41+
if (typeof part === "string") {
42+
return part;
43+
}
44+
if (typeof part === "object" && part !== null) {
45+
if (
46+
"text" in part &&
47+
typeof (part as { text?: unknown }).text === "string"
48+
) {
49+
return (part as { text?: string }).text ?? "";
50+
}
51+
if (
52+
"content" in part &&
53+
typeof (part as { content?: unknown }).content === "string"
54+
) {
55+
return (part as { content?: string }).content ?? "";
56+
}
57+
}
58+
return "";
59+
})
60+
.join("");
61+
}
62+
return "";
63+
};
64+
3165
const App = () => {
3266
const [baseURL, setBaseURL] = useState("");
3367
const [apiKey, setAPIKey] = useState("");
@@ -336,11 +370,19 @@ const App = () => {
336370
);
337371
setIsGenerating(true);
338372
for await (const chunk of stream) {
339-
const delta = chunk.choices[0]?.delta?.content ?? "";
340-
if (!delta) {
373+
const delta = chunk.choices[0]?.delta;
374+
const contentDelta = extractTextDelta(delta?.content);
375+
const reasoningDelta = extractTextDelta(
376+
(delta as { reasoning_content?: unknown } | undefined)
377+
?.reasoning_content,
378+
);
379+
if (!contentDelta && !reasoningDelta) {
341380
continue;
342381
}
343-
appendToNode(assistantId, delta);
382+
appendToNode(assistantId, {
383+
content: contentDelta,
384+
reasoning: reasoningDelta,
385+
});
344386
}
345387
setNodeStatus(assistantId, "final");
346388
} catch (error) {

src/components/MessageItem.tsx

Lines changed: 76 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ const MessageItem = ({
2828
}: MessageItemProps) => {
2929
const [isHovered, setIsHovered] = useState(false);
3030
const [hasBeenClicked, setHasBeenClicked] = useState(false);
31+
const [isReasoningExpanded, setIsReasoningExpanded] = useState(true);
32+
const reasoningText = message.reasoning_content?.trim();
3133

3234
const handleCopy = async () => {
3335
try {
@@ -43,63 +45,87 @@ const MessageItem = ({
4345
return (
4446
<div
4547
className={twJoin(
46-
"mb-2 hover:bg-slate-50",
48+
"mb-2 w-full hover:bg-slate-50",
4749
isEditing && "bg-sky-100 opacity-50",
4850
)}
4951
onMouseOver={() => setIsHovered(true)}
5052
onMouseLeave={() => setIsHovered(false)}
5153
>
52-
<div className="flex items-center gap-2">
53-
<p
54-
className="my-0 font-bold"
55-
onClick={() => {
56-
setHasBeenClicked((value) => !value);
57-
}}
58-
>
59-
{message.role}
60-
</p>
61-
{(hasBeenClicked || isHovered) && (
62-
<>
54+
<div className="w-full max-w-[65ch]">
55+
<div className="flex items-center gap-2">
56+
<p
57+
className="my-0 font-bold"
58+
onClick={() => {
59+
setHasBeenClicked((value) => !value);
60+
}}
61+
>
62+
{message.role}
63+
</p>
64+
{(hasBeenClicked || isHovered) && (
65+
<>
66+
<UnstyledButton
67+
className="i-lucide-copy text-slate-400 hover:text-slate-600 transition"
68+
onClick={handleCopy}
69+
/>
70+
<UnstyledButton
71+
className="i-lucide-edit text-slate-400 hover:text-slate-600 transition"
72+
onClick={onEdit}
73+
/>
74+
<Popover width={200} position="bottom" withArrow>
75+
<Popover.Target>
76+
<UnstyledButton className="i-lucide-trash text-slate-400 hover:text-slate-600 transition" />
77+
</Popover.Target>
78+
<Popover.Dropdown>
79+
<div className="flex flex-col">
80+
<Text>Delete?</Text>
81+
<Button
82+
className="min-w-0 h-auto flex-1 !p-1 text-xs self-end"
83+
onClick={onDelete}
84+
>
85+
Yes
86+
</Button>
87+
</div>
88+
</Popover.Dropdown>
89+
</Popover>
90+
<UnstyledButton
91+
className="i-lucide-unlink text-slate-400 hover:text-slate-600 transition"
92+
onClick={onDetach}
93+
/>
94+
<UnstyledButton
95+
className="i-lucide-git-branch-plus text-slate-400 hover:text-slate-600 transition"
96+
title="Branch from here"
97+
onClick={onBranch}
98+
/>
99+
</>
100+
)}
101+
</div>
102+
{reasoningText && (
103+
<div className="mb-2 rounded-md border border-slate-200">
63104
<UnstyledButton
64-
className="i-lucide-copy text-slate-400 hover:text-slate-600 transition"
65-
onClick={handleCopy}
66-
/>
67-
<UnstyledButton
68-
className="i-lucide-edit text-slate-400 hover:text-slate-600 transition"
69-
onClick={onEdit}
70-
/>
71-
<Popover width={200} position="bottom" withArrow>
72-
<Popover.Target>
73-
<UnstyledButton className="i-lucide-trash text-slate-400 hover:text-slate-600 transition" />
74-
</Popover.Target>
75-
<Popover.Dropdown>
76-
<div className="flex flex-col">
77-
<Text>Delete?</Text>
78-
<Button
79-
className="min-w-0 h-auto flex-1 !p-1 text-xs self-end"
80-
onClick={onDelete}
81-
>
82-
Yes
83-
</Button>
84-
</div>
85-
</Popover.Dropdown>
86-
</Popover>
87-
<UnstyledButton
88-
className="i-lucide-unlink text-slate-400 hover:text-slate-600 transition"
89-
onClick={onDetach}
90-
/>
91-
<UnstyledButton
92-
className="i-lucide-git-branch-plus text-slate-400 hover:text-slate-600 transition"
93-
title="Branch from here"
94-
onClick={onBranch}
95-
/>
96-
</>
105+
className="flex w-full items-center gap-2 px-2 pt-1 text-sm font-semibold text-slate-600"
106+
onClick={() => setIsReasoningExpanded((value) => !value)}
107+
aria-expanded={isReasoningExpanded}
108+
>
109+
<div
110+
className={twJoin(
111+
"i-lucide-chevron-down transition-transform",
112+
isReasoningExpanded && "rotate-180",
113+
)}
114+
/>
115+
<span>Reasoning</span>
116+
</UnstyledButton>
117+
{isReasoningExpanded && (
118+
<div className="twp prose prose-p:whitespace-pre-wrap px-2 pt-1 pb-3 text-sm text-slate-500">
119+
<Markdown remarkPlugins={[]}>{reasoningText}</Markdown>
120+
</div>
121+
)}
122+
</div>
97123
)}
98-
</div>
99-
<div className="twp prose prose-p:whitespace-pre-wrap">
100-
<Markdown remarkPlugins={[]}>
101-
{`${message.content}${isLast && isGenerating ? "▪️" : ""}`}
102-
</Markdown>
124+
<div className="twp prose prose-p:whitespace-pre-wrap">
125+
<Markdown remarkPlugins={[]}>
126+
{`${message.content}${isLast && isGenerating ? "▪️" : ""}`}
127+
</Markdown>
128+
</div>
103129
</div>
104130
</div>
105131
);

src/tree/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface TreeNode {
66
id: NodeID;
77
role: "system" | "user" | "assistant" | "tool";
88
text: string;
9+
reasoningContent?: string;
910
createdAt: number;
1011
status?: "draft" | "streaming" | "final" | "error";
1112
parentId: NodeID | null;

src/tree/useConversationTree.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ interface TreeState extends ConversationTree {
2424
activeTail: () => NodeID | undefined;
2525
createUserAfter: (parentId: NodeID | undefined, text: string) => NodeID;
2626
createAssistantAfter: (parentId: NodeID) => NodeID;
27-
appendToNode: (nodeId: NodeID, delta: string) => void;
27+
appendToNode: (
28+
nodeId: NodeID,
29+
delta: { content?: string; reasoning?: string },
30+
) => void;
2831
setNodeText: (nodeId: NodeID, text: string) => void;
2932
setNodeStatus: (
3033
nodeId: NodeID,
@@ -139,6 +142,7 @@ const isAncestor = (nodes: NodeMap, ancestorId: NodeID, nodeId: NodeID) => {
139142
const toMessage = (node: TreeNode): Message => ({
140143
role: node.role,
141144
content: node.text,
145+
reasoning_content: node.reasoningContent,
142146
_metadata: { uuid: node.id },
143147
});
144148

@@ -183,6 +187,7 @@ export const useConversationTree = create<TreeState>((set, get) => ({
183187
id,
184188
role,
185189
text: message.content,
190+
reasoningContent: message.reasoning_content,
186191
createdAt: existing?.createdAt ?? Date.now(),
187192
status: existing?.status,
188193
parentId,
@@ -284,6 +289,7 @@ export const useConversationTree = create<TreeState>((set, get) => ({
284289
id: newId,
285290
role: "assistant",
286291
text: "",
292+
reasoningContent: undefined,
287293
createdAt: Date.now(),
288294
status: "draft",
289295
parentId,
@@ -299,11 +305,20 @@ export const useConversationTree = create<TreeState>((set, get) => ({
299305
if (!node) {
300306
return state;
301307
}
308+
const nextContent =
309+
typeof delta.content === "string"
310+
? `${node.text}${delta.content}`
311+
: node.text;
312+
const nextReasoning =
313+
typeof delta.reasoning === "string"
314+
? `${node.reasoningContent ?? ""}${delta.reasoning}`
315+
: node.reasoningContent;
302316
const nodes: NodeMap = {
303317
...state.nodes,
304318
[nodeId]: {
305319
...node,
306-
text: `${node.text}${delta}`,
320+
text: nextContent,
321+
reasoningContent: nextReasoning,
307322
},
308323
};
309324
return { nodes } satisfies Partial<TreeState>;
@@ -366,25 +381,26 @@ export const useConversationTree = create<TreeState>((set, get) => ({
366381
};
367382
return withDerivedTree(nodes);
368383
}),
369-
cloneNode: (sourceId) => {
370-
const source = get().nodes[sourceId];
371-
if (!source) {
372-
return undefined;
373-
}
384+
cloneNode: (sourceId) => {
385+
const source = get().nodes[sourceId];
386+
if (!source) {
387+
return undefined;
388+
}
374389
const newId = uuidv4();
375390
set((state) => {
376391
const nodes: NodeMap = {
377392
...state.nodes,
378-
[newId]: {
379-
id: newId,
380-
role: source.role,
381-
text: source.text,
382-
createdAt: Date.now(),
383-
parentId: source.parentId ?? null,
384-
},
385-
};
386-
return withDerivedTree(nodes);
387-
});
393+
[newId]: {
394+
id: newId,
395+
role: source.role,
396+
text: source.text,
397+
reasoningContent: source.reasoningContent,
398+
createdAt: Date.now(),
399+
parentId: source.parentId ?? null,
400+
},
401+
};
402+
return withDerivedTree(nodes);
403+
});
388404
return newId;
389405
},
390406
removeNode: (id) =>
@@ -454,6 +470,7 @@ export const useConversationTree = create<TreeState>((set, get) => ({
454470
id,
455471
role: coerceRole(node.role),
456472
text: node.text ?? "",
473+
reasoningContent: node.reasoningContent,
457474
createdAt,
458475
status: node.status,
459476
parentId,

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export interface Message {
22
role: string;
33
content: string;
4+
reasoning_content?: string;
45
_metadata: {
56
uuid: string;
67
};

0 commit comments

Comments
 (0)