Bläddra i källkod

fix: improve chat UI - scroll, tool call display, empty bubbles

- Fix scroll issue by adding flex container to ChatPage wrapper
- Show tool calls before content message (per LLM behavior)
- Hide empty content bubbles when message only has tool calls
- Filter out tool result messages (role: 'tool') from display
- Prevent duplicate tool call display by filtering already-shown calls
fszontagh 7 månader sedan
förälder
incheckning
782f1f5546

+ 70 - 63
webui/src/components/Chat/ChatMessage.tsx

@@ -60,6 +60,10 @@ export function ChatMessage({
 
   const hasThinking = Boolean(thinkingContent) || (isStreaming && streamingThinking !== undefined)
 
+  // Check if there's actual content to display (not just whitespace)
+  const hasContent = Boolean(content && content.trim())
+  const hasToolCalls = message.tool_calls && message.tool_calls.length > 0
+
   const formattedTime = useMemo(() => {
     const date = new Date(message.created_at * 1000)
     return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -93,69 +97,10 @@ export function ChatMessage({
           </div>
         )}
 
-        <div
-          className={`rounded-2xl px-4 py-2 ${
-            isUser
-              ? 'bg-primary-600 text-white'
-              : 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
-          }`}
-        >
-          {isUser ? (
-            <p className="whitespace-pre-wrap text-sm">{content}</p>
-          ) : (
-            <div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-pre:my-2">
-              <ReactMarkdown
-                remarkPlugins={[remarkGfm]}
-                components={{
-                  code({ className, children, ...props }) {
-                    const match = /language-(\w+)/.exec(className || '')
-                    const isInline = !match && !className
-                    return isInline ? (
-                      <code
-                        className="rounded bg-gray-200 px-1 py-0.5 text-sm dark:bg-gray-600"
-                        {...props}
-                      >
-                        {children}
-                      </code>
-                    ) : (
-                      <pre className="overflow-x-auto rounded-md bg-gray-800 p-3 text-gray-100">
-                        <code className={className} {...props}>
-                          {children}
-                        </code>
-                      </pre>
-                    )
-                  },
-                  a({ href, children }) {
-                    return (
-                      <a
-                        href={href}
-                        target="_blank"
-                        rel="noopener noreferrer"
-                        className="text-blue-600 hover:underline dark:text-blue-400"
-                      >
-                        {children}
-                      </a>
-                    )
-                  },
-                  pre({ children }) {
-                    // pre is handled by code component
-                    return <>{children}</>
-                  },
-                }}
-              >
-                {content}
-              </ReactMarkdown>
-            </div>
-          )}
-          {isStreaming && (
-            <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-current opacity-75" />
-          )}
-        </div>
-
-        {/* Tool calls */}
-        {message.tool_calls && message.tool_calls.length > 0 && (
-          <div className="mt-2 w-full max-w-md">
-            {message.tool_calls.map((toolCall) => {
+        {/* Tool calls - shown first for assistant messages */}
+        {!isUser && hasToolCalls && (
+          <div className="w-full max-w-md mb-2">
+            {message.tool_calls!.map((toolCall) => {
               const toolState = toolStates.find((t) => t.tool_call.id === toolCall.id) || {
                 tool_call: toolCall,
                 status: 'completed' as const,
@@ -171,6 +116,68 @@ export function ChatMessage({
           </div>
         )}
 
+        {/* Content bubble - only show if there's actual content */}
+        {(isUser || hasContent || isStreaming) && (
+          <div
+            className={`rounded-2xl px-4 py-2 ${
+              isUser
+                ? 'bg-primary-600 text-white'
+                : 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
+            }`}
+          >
+            {isUser ? (
+              <p className="whitespace-pre-wrap text-sm">{content}</p>
+            ) : (
+              <div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-pre:my-2">
+                <ReactMarkdown
+                  remarkPlugins={[remarkGfm]}
+                  components={{
+                    code({ className, children, ...props }) {
+                      const match = /language-(\w+)/.exec(className || '')
+                      const isInline = !match && !className
+                      return isInline ? (
+                        <code
+                          className="rounded bg-gray-200 px-1 py-0.5 text-sm dark:bg-gray-600"
+                          {...props}
+                        >
+                          {children}
+                        </code>
+                      ) : (
+                        <pre className="overflow-x-auto rounded-md bg-gray-800 p-3 text-gray-100">
+                          <code className={className} {...props}>
+                            {children}
+                          </code>
+                        </pre>
+                      )
+                    },
+                    a({ href, children }) {
+                      return (
+                        <a
+                          href={href}
+                          target="_blank"
+                          rel="noopener noreferrer"
+                          className="text-blue-600 hover:underline dark:text-blue-400"
+                        >
+                          {children}
+                        </a>
+                      )
+                    },
+                    pre({ children }) {
+                      // pre is handled by code component
+                      return <>{children}</>
+                    },
+                  }}
+                >
+                  {content}
+                </ReactMarkdown>
+              </div>
+            )}
+            {isStreaming && (
+              <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-current opacity-75" />
+            )}
+          </div>
+        )}
+
         {/* Timestamp and context info */}
         <div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
           <span>{formattedTime}</span>

+ 27 - 15
webui/src/components/Chat/ChatMessageList.tsx

@@ -37,13 +37,15 @@ export function ChatMessageList() {
   return (
     <div className="flex-1 overflow-y-auto p-4">
       <div className="flex flex-col gap-4">
-        {messages.map((message, index) => (
+        {messages
+          .filter((message) => message.role !== 'tool') // Hide tool result messages (they're shown inline with tool calls)
+          .map((message, index, filteredMessages) => (
           <ChatMessage
             key={message.id || index}
             message={message}
-            isStreaming={isStreaming && index === messages.length - 1 && message.role === 'assistant'}
-            streamingContent={index === messages.length - 1 ? streamingContent : undefined}
-            streamingThinking={index === messages.length - 1 ? streamingThinking : undefined}
+            isStreaming={isStreaming && index === filteredMessages.length - 1 && message.role === 'assistant'}
+            streamingContent={index === filteredMessages.length - 1 ? streamingContent : undefined}
+            streamingThinking={index === filteredMessages.length - 1 ? streamingThinking : undefined}
             toolStates={pendingToolCalls}
           />
         ))}
@@ -74,17 +76,27 @@ export function ChatMessageList() {
           </div>
         )}
 
-        {/* Show pending/executing tool calls */}
-        {pendingToolCalls.length > 0 && (
-          <div className="max-w-[80%] ml-11">
-            {pendingToolCalls.map((toolState) => (
-              <ToolCallCard
-                key={toolState.tool_call.id}
-                toolState={toolState}
-              />
-            ))}
-          </div>
-        )}
+        {/* Show pending/executing tool calls that aren't already in a message */}
+        {(() => {
+          // Get tool call IDs that are already shown in messages
+          const messageToolCallIds = new Set(
+            messages.flatMap(m => m.tool_calls?.map(tc => tc.id) || [])
+          )
+          // Filter out tool calls that are already displayed in messages
+          const standaloneToolCalls = pendingToolCalls.filter(
+            ts => !messageToolCallIds.has(ts.tool_call.id)
+          )
+          return standaloneToolCalls.length > 0 && (
+            <div className="max-w-[80%] ml-11">
+              {standaloneToolCalls.map((toolState) => (
+                <ToolCallCard
+                  key={toolState.tool_call.id}
+                  toolState={toolState}
+                />
+              ))}
+            </div>
+          )
+        })()}
 
         <div ref={messagesEndRef} />
       </div>

+ 1 - 1
webui/src/pages/ChatPage.tsx

@@ -221,7 +221,7 @@ export default function ChatPage() {
       {/* Main chat area */}
       <div className="flex flex-1 flex-col">
         <ChatHeader />
-        <div className="flex-1 overflow-hidden">
+        <div className="flex flex-1 flex-col overflow-hidden">
           <ChatMessageList />
         </div>
         <ChatInput />