Compare commits
2 Commits
main
...
pr/chat-th
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
492946e0f1 | ||
|
|
e82e16cada |
@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
||||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
||||||
|
- UI: separate tool output from assistant reasoning and add a thinking toggle. (#1292) — thanks @bradleypriest.
|
||||||
### Fixes
|
### Fixes
|
||||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
||||||
|
|||||||
@ -84,6 +84,11 @@
|
|||||||
color: rgba(150, 150, 150, 1);
|
color: rgba(150, 150, 150, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-avatar.tool {
|
||||||
|
background: rgba(134, 142, 150, 0.2);
|
||||||
|
color: rgba(134, 142, 150, 1);
|
||||||
|
}
|
||||||
|
|
||||||
/* Minimal Bubble Design - dynamic width based on content */
|
/* Minimal Bubble Design - dynamic width based on content */
|
||||||
.chat-bubble {
|
.chat-bubble {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@ -12,10 +12,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-line.assistant,
|
.chat-line.assistant,
|
||||||
.chat-line.other {
|
.chat-line.other,
|
||||||
|
.chat-line.tool {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-line.tool .chat-bubble {
|
||||||
|
border-style: dashed;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-msg {
|
.chat-msg {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@ -2,6 +2,22 @@
|
|||||||
CHAT TEXT STYLING
|
CHAT TEXT STYLING
|
||||||
============================================= */
|
============================================= */
|
||||||
|
|
||||||
|
.chat-thinking {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .chat-thinking {
|
||||||
|
border-color: rgba(16, 24, 40, 0.18);
|
||||||
|
background: rgba(16, 24, 40, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-text {
|
.chat-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|||||||
@ -92,6 +92,18 @@ export function renderChatControls(state: AppViewState) {
|
|||||||
${refreshIcon}
|
${refreshIcon}
|
||||||
</button>
|
</button>
|
||||||
<span class="chat-controls__separator">|</span>
|
<span class="chat-controls__separator">|</span>
|
||||||
|
<button
|
||||||
|
class="btn btn--sm btn--icon ${state.settings.chatShowThinking ? "active" : ""}"
|
||||||
|
@click=${() =>
|
||||||
|
state.applySettings({
|
||||||
|
...state.settings,
|
||||||
|
chatShowThinking: !state.settings.chatShowThinking,
|
||||||
|
})}
|
||||||
|
aria-pressed=${state.settings.chatShowThinking}
|
||||||
|
title="Toggle assistant thinking/working output"
|
||||||
|
>
|
||||||
|
🧠
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn--sm btn--icon ${state.settings.chatFocusMode ? "active" : ""}"
|
class="btn btn--sm btn--icon ${state.settings.chatFocusMode ? "active" : ""}"
|
||||||
@click=${() =>
|
@click=${() =>
|
||||||
|
|||||||
@ -382,6 +382,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
void loadChatHistory(state);
|
void loadChatHistory(state);
|
||||||
},
|
},
|
||||||
thinkingLevel: state.chatThinkingLevel,
|
thinkingLevel: state.chatThinkingLevel,
|
||||||
|
showThinking: state.settings.chatShowThinking,
|
||||||
loading: state.chatLoading,
|
loading: state.chatLoading,
|
||||||
sending: state.chatSending,
|
sending: state.chatSending,
|
||||||
messages: state.chatMessages,
|
messages: state.chatMessages,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
|||||||
|
|
||||||
import { toSanitizedMarkdownHtml } from "../markdown";
|
import { toSanitizedMarkdownHtml } from "../markdown";
|
||||||
import type { MessageGroup } from "../types/chat-types";
|
import type { MessageGroup } from "../types/chat-types";
|
||||||
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
|
import { classifyMessage } from "./message-classifier";
|
||||||
import {
|
import {
|
||||||
extractText,
|
extractText,
|
||||||
extractThinking,
|
extractThinking,
|
||||||
@ -62,19 +62,25 @@ export function renderMessageGroup(
|
|||||||
group: MessageGroup,
|
group: MessageGroup,
|
||||||
opts: { onOpenSidebar?: (content: string) => void; showReasoning: boolean },
|
opts: { onOpenSidebar?: (content: string) => void; showReasoning: boolean },
|
||||||
) {
|
) {
|
||||||
const normalizedRole = normalizeRoleForGrouping(group.role);
|
const roleKind = group.role;
|
||||||
const who =
|
const who =
|
||||||
normalizedRole === "user"
|
roleKind === "user"
|
||||||
? "You"
|
? "You"
|
||||||
: normalizedRole === "assistant"
|
: roleKind === "assistant"
|
||||||
? "Assistant"
|
? "Assistant"
|
||||||
: normalizedRole;
|
: roleKind === "tool"
|
||||||
|
? "Tool"
|
||||||
|
: roleKind === "system"
|
||||||
|
? "System"
|
||||||
|
: roleKind;
|
||||||
const roleClass =
|
const roleClass =
|
||||||
normalizedRole === "user"
|
roleKind === "user"
|
||||||
? "user"
|
? "user"
|
||||||
: normalizedRole === "assistant"
|
: roleKind === "assistant"
|
||||||
? "assistant"
|
? "assistant"
|
||||||
: "other";
|
: roleKind === "tool"
|
||||||
|
? "tool"
|
||||||
|
: "other";
|
||||||
const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
|
const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@ -82,7 +88,7 @@ export function renderMessageGroup(
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-group ${roleClass}">
|
<div class="chat-group ${roleClass}">
|
||||||
${renderAvatar(group.role)}
|
${renderAvatar(roleKind)}
|
||||||
<div class="chat-group-messages">
|
<div class="chat-group-messages">
|
||||||
${group.messages.map((item, index) =>
|
${group.messages.map((item, index) =>
|
||||||
renderGroupedMessage(
|
renderGroupedMessage(
|
||||||
@ -105,9 +111,16 @@ export function renderMessageGroup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderAvatar(role: string) {
|
function renderAvatar(role: string) {
|
||||||
const normalized = normalizeRoleForGrouping(role);
|
const initial =
|
||||||
const initial = normalized === "user" ? "U" : normalized === "assistant" ? "A" : "?";
|
role === "user" ? "U" : role === "assistant" ? "A" : role === "tool" ? "⚙" : "?";
|
||||||
const className = normalized === "user" ? "user" : normalized === "assistant" ? "assistant" : "other";
|
const className =
|
||||||
|
role === "user"
|
||||||
|
? "user"
|
||||||
|
: role === "assistant"
|
||||||
|
? "assistant"
|
||||||
|
: role === "tool"
|
||||||
|
? "tool"
|
||||||
|
: "other";
|
||||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,27 +129,27 @@ function renderGroupedMessage(
|
|||||||
opts: { isStreaming: boolean; showReasoning: boolean },
|
opts: { isStreaming: boolean; showReasoning: boolean },
|
||||||
onOpenSidebar?: (content: string) => void,
|
onOpenSidebar?: (content: string) => void,
|
||||||
) {
|
) {
|
||||||
const m = message as Record<string, unknown>;
|
const classification = classifyMessage(message);
|
||||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
const roleLower = classification.roleRaw.toLowerCase();
|
||||||
const isToolResult =
|
const isToolResult =
|
||||||
isToolResultMessage(message) ||
|
classification.hasToolResults ||
|
||||||
role.toLowerCase() === "toolresult" ||
|
roleLower === "toolresult" ||
|
||||||
role.toLowerCase() === "tool_result" ||
|
roleLower === "tool_result" ||
|
||||||
typeof m.toolCallId === "string" ||
|
(classification.isToolLike && !classification.hasText);
|
||||||
typeof m.tool_call_id === "string";
|
|
||||||
|
|
||||||
const toolCards = extractToolCards(message);
|
const toolCards = extractToolCards(message);
|
||||||
const hasToolCards = toolCards.length > 0;
|
const hasToolCards = toolCards.length > 0;
|
||||||
|
|
||||||
const extractedText = extractText(message);
|
const extractedText = extractText(message);
|
||||||
const extractedThinking =
|
const extractedThinking =
|
||||||
opts.showReasoning && role === "assistant" ? extractThinking(message) : null;
|
opts.showReasoning && classification.roleKind === "assistant"
|
||||||
|
? extractThinking(message)
|
||||||
|
: null;
|
||||||
const markdownBase = extractedText?.trim() ? extractedText : null;
|
const markdownBase = extractedText?.trim() ? extractedText : null;
|
||||||
const markdown = extractedThinking
|
const reasoningMarkdown = extractedThinking
|
||||||
? [formatReasoningMarkdown(extractedThinking), markdownBase]
|
? formatReasoningMarkdown(extractedThinking)
|
||||||
.filter(Boolean)
|
: null;
|
||||||
.join("\n\n")
|
const markdown = markdownBase;
|
||||||
: markdownBase;
|
|
||||||
|
|
||||||
const bubbleClasses = [
|
const bubbleClasses = [
|
||||||
"chat-bubble",
|
"chat-bubble",
|
||||||
@ -156,6 +169,11 @@ function renderGroupedMessage(
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="${bubbleClasses}">
|
<div class="${bubbleClasses}">
|
||||||
|
${reasoningMarkdown
|
||||||
|
? html`<div class="chat-thinking">${unsafeHTML(
|
||||||
|
toSanitizedMarkdownHtml(reasoningMarkdown),
|
||||||
|
)}</div>`
|
||||||
|
: nothing}
|
||||||
${markdown
|
${markdown
|
||||||
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
@ -163,4 +181,3 @@ function renderGroupedMessage(
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,7 @@ import { html, nothing } from "lit";
|
|||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||||
|
|
||||||
import { toSanitizedMarkdownHtml } from "../markdown";
|
import { toSanitizedMarkdownHtml } from "../markdown";
|
||||||
import {
|
import { classifyMessage } from "./message-classifier";
|
||||||
isToolResultMessage,
|
|
||||||
normalizeRoleForGrouping,
|
|
||||||
} from "./message-normalizer";
|
|
||||||
import {
|
import {
|
||||||
extractText,
|
extractText,
|
||||||
extractThinking,
|
extractThinking,
|
||||||
@ -38,16 +35,19 @@ export function renderMessage(
|
|||||||
opts?: { streaming?: boolean; showReasoning?: boolean },
|
opts?: { streaming?: boolean; showReasoning?: boolean },
|
||||||
) {
|
) {
|
||||||
const m = message as Record<string, unknown>;
|
const m = message as Record<string, unknown>;
|
||||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
const classification = classifyMessage(message);
|
||||||
const toolCards = extractToolCards(message);
|
const toolCards = extractToolCards(message);
|
||||||
const hasToolCards = toolCards.length > 0;
|
const hasToolCards = toolCards.length > 0;
|
||||||
const isToolResult =
|
const isToolResult =
|
||||||
isToolResultMessage(message) ||
|
classification.hasToolResults ||
|
||||||
typeof m.toolCallId === "string" ||
|
classification.roleRaw.toLowerCase() === "toolresult" ||
|
||||||
typeof m.tool_call_id === "string";
|
classification.roleRaw.toLowerCase() === "tool_result" ||
|
||||||
|
(classification.isToolLike && !classification.hasText);
|
||||||
const extractedText = extractText(message);
|
const extractedText = extractText(message);
|
||||||
const extractedThinking =
|
const extractedThinking =
|
||||||
opts?.showReasoning && role === "assistant" ? extractThinking(message) : null;
|
opts?.showReasoning && classification.roleKind === "assistant"
|
||||||
|
? extractThinking(message)
|
||||||
|
: null;
|
||||||
const contentText = typeof m.content === "string" ? m.content : null;
|
const contentText = typeof m.content === "string" ? m.content : null;
|
||||||
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
|
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
|
||||||
|
|
||||||
@ -64,28 +64,33 @@ export function renderMessage(
|
|||||||
display?.kind === "json"
|
display?.kind === "json"
|
||||||
? ["```json", display.value, "```"].join("\n")
|
? ["```json", display.value, "```"].join("\n")
|
||||||
: (display?.value ?? null);
|
: (display?.value ?? null);
|
||||||
const markdown = extractedThinking
|
const reasoningMarkdown = extractedThinking
|
||||||
? [formatReasoningMarkdown(extractedThinking), markdownBase]
|
? formatReasoningMarkdown(extractedThinking)
|
||||||
.filter(Boolean)
|
: null;
|
||||||
.join("\n\n")
|
const markdown = markdownBase;
|
||||||
: markdownBase;
|
|
||||||
|
|
||||||
const timestamp =
|
const timestamp =
|
||||||
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
|
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
|
||||||
|
|
||||||
const normalizedRole = normalizeRoleForGrouping(role);
|
const roleKind = classification.roleKind;
|
||||||
const klass =
|
const klass =
|
||||||
normalizedRole === "assistant"
|
roleKind === "assistant"
|
||||||
? "assistant"
|
? "assistant"
|
||||||
: normalizedRole === "user"
|
: roleKind === "user"
|
||||||
? "user"
|
? "user"
|
||||||
: "other";
|
: roleKind === "tool"
|
||||||
|
? "tool"
|
||||||
|
: "other";
|
||||||
const who =
|
const who =
|
||||||
normalizedRole === "assistant"
|
roleKind === "assistant"
|
||||||
? "Assistant"
|
? "Assistant"
|
||||||
: normalizedRole === "user"
|
: roleKind === "user"
|
||||||
? "You"
|
? "You"
|
||||||
: normalizedRole;
|
: roleKind === "tool"
|
||||||
|
? "Tool"
|
||||||
|
: roleKind === "system"
|
||||||
|
? "System"
|
||||||
|
: classification.roleRaw;
|
||||||
|
|
||||||
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
|
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
|
||||||
const toolCardBase =
|
const toolCardBase =
|
||||||
@ -98,6 +103,11 @@ export function renderMessage(
|
|||||||
<div class="chat-line ${klass}">
|
<div class="chat-line ${klass}">
|
||||||
<div class="chat-msg">
|
<div class="chat-msg">
|
||||||
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
|
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
|
||||||
|
${reasoningMarkdown
|
||||||
|
? html`<div class="chat-thinking">${unsafeHTML(
|
||||||
|
toSanitizedMarkdownHtml(reasoningMarkdown),
|
||||||
|
)}</div>`
|
||||||
|
: nothing}
|
||||||
${markdown
|
${markdown
|
||||||
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
@ -118,4 +128,3 @@ export function renderMessage(
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
101
ui/src/ui/chat/message-classifier.ts
Normal file
101
ui/src/ui/chat/message-classifier.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { extractRawText, extractThinking } from "./message-extract";
|
||||||
|
|
||||||
|
export type RoleKind = "assistant" | "user" | "tool" | "system" | "other";
|
||||||
|
|
||||||
|
export type MessageClassification = {
|
||||||
|
roleRaw: string;
|
||||||
|
roleKind: RoleKind;
|
||||||
|
hasText: boolean;
|
||||||
|
hasThinking: boolean;
|
||||||
|
hasToolCalls: boolean;
|
||||||
|
hasToolResults: boolean;
|
||||||
|
isToolLike: boolean;
|
||||||
|
displayLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOOL_CALL_TYPES = new Set([
|
||||||
|
"toolcall",
|
||||||
|
"tool_call",
|
||||||
|
"tooluse",
|
||||||
|
"tool_use",
|
||||||
|
"toolcall",
|
||||||
|
"tooluse",
|
||||||
|
"functioncall",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TOOL_RESULT_TYPES = new Set(["toolresult", "tool_result"]);
|
||||||
|
|
||||||
|
export function classifyMessage(message: unknown): MessageClassification {
|
||||||
|
const m = message as Record<string, unknown>;
|
||||||
|
const roleRaw = typeof m.role === "string" ? m.role : "unknown";
|
||||||
|
const roleLower = roleRaw.toLowerCase();
|
||||||
|
|
||||||
|
const content = Array.isArray(m.content)
|
||||||
|
? (m.content as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const hasText = Boolean(extractRawText(message)?.trim());
|
||||||
|
const hasThinking = Boolean(extractThinking(message)?.trim());
|
||||||
|
const hasToolCalls = content.some((item) => {
|
||||||
|
const kind = String(item.type ?? "").toLowerCase();
|
||||||
|
if (TOOL_CALL_TYPES.has(kind)) return true;
|
||||||
|
return typeof item.name === "string" && (item.arguments ?? item.args ?? item.input) != null;
|
||||||
|
});
|
||||||
|
const hasToolResults = content.some((item) =>
|
||||||
|
TOOL_RESULT_TYPES.has(String(item.type ?? "").toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasToolId =
|
||||||
|
typeof m.toolCallId === "string" ||
|
||||||
|
typeof m.tool_call_id === "string" ||
|
||||||
|
typeof m.toolUseId === "string" ||
|
||||||
|
typeof m.tool_use_id === "string";
|
||||||
|
const hasToolName =
|
||||||
|
typeof m.toolName === "string" || typeof m.tool_name === "string";
|
||||||
|
|
||||||
|
const isRoleTool =
|
||||||
|
roleLower === "tool" ||
|
||||||
|
roleLower === "toolresult" ||
|
||||||
|
roleLower === "tool_result" ||
|
||||||
|
roleLower === "function";
|
||||||
|
|
||||||
|
const isToolLike =
|
||||||
|
isRoleTool || hasToolId || hasToolName || hasToolCalls || hasToolResults;
|
||||||
|
|
||||||
|
let roleKind: RoleKind;
|
||||||
|
if (roleLower === "user") {
|
||||||
|
roleKind = "user";
|
||||||
|
} else if (roleLower === "assistant") {
|
||||||
|
roleKind = isToolLike && !hasText ? "tool" : "assistant";
|
||||||
|
} else if (roleLower === "system") {
|
||||||
|
roleKind = "system";
|
||||||
|
} else if (isRoleTool) {
|
||||||
|
roleKind = "tool";
|
||||||
|
} else if (isToolLike && !hasText) {
|
||||||
|
roleKind = "tool";
|
||||||
|
} else {
|
||||||
|
roleKind = "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayLabel =
|
||||||
|
roleKind === "assistant"
|
||||||
|
? "Assistant"
|
||||||
|
: roleKind === "user"
|
||||||
|
? "You"
|
||||||
|
: roleKind === "tool"
|
||||||
|
? "Tool"
|
||||||
|
: roleKind === "system"
|
||||||
|
? "System"
|
||||||
|
: roleRaw;
|
||||||
|
|
||||||
|
return {
|
||||||
|
roleRaw,
|
||||||
|
roleKind,
|
||||||
|
hasText,
|
||||||
|
hasThinking,
|
||||||
|
hasToolCalls,
|
||||||
|
hasToolResults,
|
||||||
|
isToolLike,
|
||||||
|
displayLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,9 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import {
|
import { normalizeMessage } from "./message-normalizer";
|
||||||
normalizeMessage,
|
import { classifyMessage } from "./message-classifier";
|
||||||
normalizeRoleForGrouping,
|
|
||||||
isToolResultMessage,
|
|
||||||
} from "./message-normalizer";
|
|
||||||
|
|
||||||
describe("message-normalizer", () => {
|
describe("message-normalizer", () => {
|
||||||
describe("normalizeMessage", () => {
|
describe("normalizeMessage", () => {
|
||||||
@ -57,24 +54,24 @@ describe("message-normalizer", () => {
|
|||||||
expect(result.content).toEqual([{ type: "text", text: "Alternative format" }]);
|
expect(result.content).toEqual([{ type: "text", text: "Alternative format" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects tool result by toolCallId", () => {
|
it("preserves role when toolCallId is present", () => {
|
||||||
const result = normalizeMessage({
|
const result = normalizeMessage({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
toolCallId: "call-123",
|
toolCallId: "call-123",
|
||||||
content: "Tool output",
|
content: "Tool output",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.role).toBe("toolResult");
|
expect(result.role).toBe("assistant");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects tool result by tool_call_id (snake_case)", () => {
|
it("preserves role when tool_call_id is present", () => {
|
||||||
const result = normalizeMessage({
|
const result = normalizeMessage({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
tool_call_id: "call-456",
|
tool_call_id: "call-456",
|
||||||
content: "Tool output",
|
content: "Tool output",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.role).toBe("toolResult");
|
expect(result.role).toBe("assistant");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles missing role", () => {
|
it("handles missing role", () => {
|
||||||
@ -102,68 +99,54 @@ describe("message-normalizer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeRoleForGrouping", () => {
|
describe("classifyMessage", () => {
|
||||||
it("returns assistant for toolresult", () => {
|
it("keeps assistant role when text + tool blocks are mixed", () => {
|
||||||
expect(normalizeRoleForGrouping("toolresult")).toBe("assistant");
|
const result = classifyMessage({
|
||||||
expect(normalizeRoleForGrouping("toolResult")).toBe("assistant");
|
role: "assistant",
|
||||||
expect(normalizeRoleForGrouping("TOOLRESULT")).toBe("assistant");
|
content: [
|
||||||
|
{ type: "text", text: "before" },
|
||||||
|
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||||
|
{ type: "thinking", thinking: "after" },
|
||||||
|
{ type: "text", text: "after text" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.roleKind).toBe("assistant");
|
||||||
|
expect(result.hasText).toBe(true);
|
||||||
|
expect(result.hasToolCalls).toBe(true);
|
||||||
|
expect(result.hasThinking).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns assistant for tool_result", () => {
|
it("classifies tool-only assistant messages as tool", () => {
|
||||||
expect(normalizeRoleForGrouping("tool_result")).toBe("assistant");
|
const result = classifyMessage({
|
||||||
expect(normalizeRoleForGrouping("TOOL_RESULT")).toBe("assistant");
|
role: "assistant",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
content: [{ type: "toolCall", id: "call-1", name: "read" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.roleKind).toBe("tool");
|
||||||
|
expect(result.hasText).toBe(false);
|
||||||
|
expect(result.isToolLike).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns assistant for tool", () => {
|
it("classifies tool role messages as tool", () => {
|
||||||
expect(normalizeRoleForGrouping("tool")).toBe("assistant");
|
const result = classifyMessage({
|
||||||
expect(normalizeRoleForGrouping("Tool")).toBe("assistant");
|
role: "tool",
|
||||||
|
content: "Sunny, 70F.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.roleKind).toBe("tool");
|
||||||
|
expect(result.hasText).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns assistant for function", () => {
|
it("classifies toolResult role messages as tool", () => {
|
||||||
expect(normalizeRoleForGrouping("function")).toBe("assistant");
|
const result = classifyMessage({
|
||||||
expect(normalizeRoleForGrouping("Function")).toBe("assistant");
|
role: "toolResult",
|
||||||
});
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves user role", () => {
|
expect(result.roleKind).toBe("tool");
|
||||||
expect(normalizeRoleForGrouping("user")).toBe("user");
|
expect(result.isToolLike).toBe(true);
|
||||||
expect(normalizeRoleForGrouping("User")).toBe("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves assistant role", () => {
|
|
||||||
expect(normalizeRoleForGrouping("assistant")).toBe("assistant");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves system role", () => {
|
|
||||||
expect(normalizeRoleForGrouping("system")).toBe("system");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isToolResultMessage", () => {
|
|
||||||
it("returns true for toolresult role", () => {
|
|
||||||
expect(isToolResultMessage({ role: "toolresult" })).toBe(true);
|
|
||||||
expect(isToolResultMessage({ role: "toolResult" })).toBe(true);
|
|
||||||
expect(isToolResultMessage({ role: "TOOLRESULT" })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for tool_result role", () => {
|
|
||||||
expect(isToolResultMessage({ role: "tool_result" })).toBe(true);
|
|
||||||
expect(isToolResultMessage({ role: "TOOL_RESULT" })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for other roles", () => {
|
|
||||||
expect(isToolResultMessage({ role: "user" })).toBe(false);
|
|
||||||
expect(isToolResultMessage({ role: "assistant" })).toBe(false);
|
|
||||||
expect(isToolResultMessage({ role: "tool" })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for missing role", () => {
|
|
||||||
expect(isToolResultMessage({})).toBe(false);
|
|
||||||
expect(isToolResultMessage({ content: "test" })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for non-string role", () => {
|
|
||||||
expect(isToolResultMessage({ role: 123 })).toBe(false);
|
|
||||||
expect(isToolResultMessage({ role: null })).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,12 +12,7 @@ import type {
|
|||||||
*/
|
*/
|
||||||
export function normalizeMessage(message: unknown): NormalizedMessage {
|
export function normalizeMessage(message: unknown): NormalizedMessage {
|
||||||
const m = message as Record<string, unknown>;
|
const m = message as Record<string, unknown>;
|
||||||
let role = typeof m.role === "string" ? m.role : "unknown";
|
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||||
|
|
||||||
// Detect tool result messages by presence of toolCallId or tool_call_id
|
|
||||||
if (typeof m.toolCallId === "string" || typeof m.tool_call_id === "string") {
|
|
||||||
role = "toolResult";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract content
|
// Extract content
|
||||||
let content: MessageContentItem[] = [];
|
let content: MessageContentItem[] = [];
|
||||||
@ -40,30 +35,3 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
|
|||||||
|
|
||||||
return { role, content, timestamp, id };
|
return { role, content, timestamp, id };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize role for grouping purposes.
|
|
||||||
* Tool results should be grouped with assistant messages.
|
|
||||||
*/
|
|
||||||
export function normalizeRoleForGrouping(role: string): string {
|
|
||||||
const lower = role.toLowerCase();
|
|
||||||
// All tool-related roles should display as assistant
|
|
||||||
if (
|
|
||||||
lower === "toolresult" ||
|
|
||||||
lower === "tool_result" ||
|
|
||||||
lower === "tool" ||
|
|
||||||
lower === "function"
|
|
||||||
) {
|
|
||||||
return "assistant";
|
|
||||||
}
|
|
||||||
return role;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a message is a tool result message based on its role.
|
|
||||||
*/
|
|
||||||
export function isToolResultMessage(message: unknown): boolean {
|
|
||||||
const m = message as Record<string, unknown>;
|
|
||||||
const role = typeof m.role === "string" ? m.role.toLowerCase() : "";
|
|
||||||
return role === "toolresult" || role === "tool_result";
|
|
||||||
}
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
formatToolOutputForSidebar,
|
formatToolOutputForSidebar,
|
||||||
getTruncatedPreview,
|
getTruncatedPreview,
|
||||||
} from "./tool-helpers";
|
} from "./tool-helpers";
|
||||||
import { isToolResultMessage } from "./message-normalizer";
|
import { classifyMessage } from "./message-classifier";
|
||||||
import { extractText } from "./message-extract";
|
import { extractText } from "./message-extract";
|
||||||
|
|
||||||
export function extractToolCards(message: unknown): ToolCard[] {
|
export function extractToolCards(message: unknown): ToolCard[] {
|
||||||
@ -39,10 +39,13 @@ export function extractToolCards(message: unknown): ToolCard[] {
|
|||||||
cards.push({ kind: "result", name, text });
|
cards.push({ kind: "result", name, text });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const classification = classifyMessage(message);
|
||||||
isToolResultMessage(message) &&
|
const isToolResultMessage =
|
||||||
!cards.some((card) => card.kind === "result")
|
classification.hasToolResults ||
|
||||||
) {
|
classification.roleRaw.toLowerCase() === "toolresult" ||
|
||||||
|
classification.roleRaw.toLowerCase() === "tool_result";
|
||||||
|
|
||||||
|
if (isToolResultMessage && !cards.some((card) => card.kind === "result")) {
|
||||||
const name =
|
const name =
|
||||||
(typeof m.toolName === "string" && m.toolName) ||
|
(typeof m.toolName === "string" && m.toolName) ||
|
||||||
(typeof m.tool_name === "string" && m.tool_name) ||
|
(typeof m.tool_name === "string" && m.tool_name) ||
|
||||||
@ -197,4 +200,3 @@ function extractToolText(item: Record<string, unknown>): string | undefined {
|
|||||||
if (typeof item.content === "string") return item.content;
|
if (typeof item.content === "string") return item.content;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export type UiSettings = {
|
|||||||
lastActiveSessionKey: string;
|
lastActiveSessionKey: string;
|
||||||
theme: ThemeMode;
|
theme: ThemeMode;
|
||||||
chatFocusMode: boolean;
|
chatFocusMode: boolean;
|
||||||
|
chatShowThinking: boolean;
|
||||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||||
useNewChatLayout: boolean; // Slack-style grouped messages layout
|
useNewChatLayout: boolean; // Slack-style grouped messages layout
|
||||||
navCollapsed: boolean; // Collapsible sidebar state
|
navCollapsed: boolean; // Collapsible sidebar state
|
||||||
@ -28,6 +29,7 @@ export function loadSettings(): UiSettings {
|
|||||||
lastActiveSessionKey: "main",
|
lastActiveSessionKey: "main",
|
||||||
theme: "system",
|
theme: "system",
|
||||||
chatFocusMode: false,
|
chatFocusMode: false,
|
||||||
|
chatShowThinking: true,
|
||||||
splitRatio: 0.6,
|
splitRatio: 0.6,
|
||||||
useNewChatLayout: true, // Enabled by default
|
useNewChatLayout: true, // Enabled by default
|
||||||
navCollapsed: false,
|
navCollapsed: false,
|
||||||
@ -65,6 +67,10 @@ export function loadSettings(): UiSettings {
|
|||||||
typeof parsed.chatFocusMode === "boolean"
|
typeof parsed.chatFocusMode === "boolean"
|
||||||
? parsed.chatFocusMode
|
? parsed.chatFocusMode
|
||||||
: defaults.chatFocusMode,
|
: defaults.chatFocusMode,
|
||||||
|
chatShowThinking:
|
||||||
|
typeof parsed.chatShowThinking === "boolean"
|
||||||
|
? parsed.chatShowThinking
|
||||||
|
: defaults.chatShowThinking,
|
||||||
splitRatio:
|
splitRatio:
|
||||||
typeof parsed.splitRatio === "number" &&
|
typeof parsed.splitRatio === "number" &&
|
||||||
parsed.splitRatio >= 0.4 &&
|
parsed.splitRatio >= 0.4 &&
|
||||||
|
|||||||
@ -3,10 +3,8 @@ import { repeat } from "lit/directives/repeat.js";
|
|||||||
import type { SessionsListResult } from "../types";
|
import type { SessionsListResult } from "../types";
|
||||||
import type { ChatQueueItem } from "../ui-types";
|
import type { ChatQueueItem } from "../ui-types";
|
||||||
import type { ChatItem, MessageGroup } from "../types/chat-types";
|
import type { ChatItem, MessageGroup } from "../types/chat-types";
|
||||||
import {
|
import { normalizeMessage } from "../chat/message-normalizer";
|
||||||
normalizeMessage,
|
import { classifyMessage } from "../chat/message-classifier";
|
||||||
normalizeRoleForGrouping,
|
|
||||||
} from "../chat/message-normalizer";
|
|
||||||
import { extractText } from "../chat/message-extract";
|
import { extractText } from "../chat/message-extract";
|
||||||
import { renderMessage, renderReadingIndicator } from "../chat/legacy-render";
|
import { renderMessage, renderReadingIndicator } from "../chat/legacy-render";
|
||||||
import {
|
import {
|
||||||
@ -21,6 +19,7 @@ export type ChatProps = {
|
|||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
onSessionKeyChange: (next: string) => void;
|
onSessionKeyChange: (next: string) => void;
|
||||||
thinkingLevel: string | null;
|
thinkingLevel: string | null;
|
||||||
|
showThinking: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
canAbort?: boolean;
|
canAbort?: boolean;
|
||||||
@ -69,7 +68,7 @@ export function renderChat(props: ChatProps) {
|
|||||||
(row) => row.key === props.sessionKey,
|
(row) => row.key === props.sessionKey,
|
||||||
);
|
);
|
||||||
const reasoningLevel = activeSession?.reasoningLevel ?? "off";
|
const reasoningLevel = activeSession?.reasoningLevel ?? "off";
|
||||||
const showReasoning = reasoningLevel !== "off";
|
const showReasoning = props.showThinking && reasoningLevel !== "off";
|
||||||
|
|
||||||
const composePlaceholder = props.connected
|
const composePlaceholder = props.connected
|
||||||
? "Message (↩ to send, Shift+↩ for line breaks)"
|
? "Message (↩ to send, Shift+↩ for line breaks)"
|
||||||
@ -271,7 +270,8 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalized = normalizeMessage(item.message);
|
const normalized = normalizeMessage(item.message);
|
||||||
const role = normalizeRoleForGrouping(normalized.role);
|
const classification = classifyMessage(item.message);
|
||||||
|
const role = classification.roleKind;
|
||||||
const timestamp = normalized.timestamp || Date.now();
|
const timestamp = normalized.timestamp || Date.now();
|
||||||
|
|
||||||
if (!currentGroup || currentGroup.role !== role) {
|
if (!currentGroup || currentGroup.role !== role) {
|
||||||
@ -310,10 +310,11 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (let i = historyStart; i < history.length; i++) {
|
for (let i = historyStart; i < history.length; i++) {
|
||||||
|
const msg = history[i];
|
||||||
items.push({
|
items.push({
|
||||||
kind: "message",
|
kind: "message",
|
||||||
key: messageKey(history[i], i),
|
key: messageKey(msg, i),
|
||||||
message: history[i],
|
message: msg,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (let i = 0; i < tools.length; i++) {
|
for (let i = 0; i < tools.length; i++) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user