Explorar o código

feat: add thinking/reasoning display, collapsible tool calls, workspace editor

- Add ThinkingBlock component for displaying LLM reasoning in collapsible bubbles
- Make ToolCallCard collapsible with expand/collapse functionality
- Add WorkspaceEdit page for editing workspace LLM settings
- Add helper system prompt editor with Monaco code editor
- Update proto to support thinking_delta in stream chunks
- Fix model selector to only show context length if > 0
- Fix tool call processing to properly await and iterate
- Stream thinking content from backend when available
fszontagh hai 7 meses
pai
achega
e8e40e9bfe

+ 6 - 0
proto/proto/llm.proto

@@ -64,9 +64,14 @@ message ContentPart {
         ImageContent image = 2;
         ToolUseContent tool_use = 3;
         ToolResultContent tool_result = 4;
+        ThinkingContent thinking = 5;     // Thinking/reasoning content
     }
 }
 
+message ThinkingContent {
+    string content = 1;                   // The thinking/reasoning text
+}
+
 message ImageContent {
     string url = 1;           // URL or base64 data URI
     string media_type = 2;    // e.g., "image/png", "image/jpeg"
@@ -200,6 +205,7 @@ message ChatStreamChunk {
         string content_delta = 2;         // Text content being generated
         ToolCall tool_call = 3;           // Complete tool call when ready
         StreamMetadata metadata = 4;      // Final metadata after stream ends
+        string thinking_delta = 5;        // Thinking/reasoning content being generated
     }
 }
 

+ 19 - 1
webserver/src/http_server.cpp

@@ -6216,6 +6216,18 @@ void HttpServer::HandleLlmStreamMessage(const httplib::Request& req, httplib::Re
             if (pc.contains("view_id")) ctx->set_view_id(pc["view_id"].get<std::string>());
         }
 
+        // Add available tools for this user
+        if (toolService_) {
+            auto tools = toolService_->GetAvailableTools(*auth_user, session.workspace_id());
+            for (const auto& tool : tools) {
+                auto* tool_def = request.add_tools();
+                tool_def->set_name(tool.name);
+                tool_def->set_description(tool.description);
+                tool_def->set_input_schema(tool.input_schema);
+            }
+            spdlog::debug("Added {} tools to chat request", tools.size());
+        }
+
         // Set up SSE response
         res.set_header("Content-Type", "text/event-stream");
         res.set_header("Cache-Control", "no-cache");
@@ -6228,10 +6240,13 @@ void HttpServer::HandleLlmStreamMessage(const httplib::Request& req, httplib::Re
                 nlohmann::json event;
                 event["session_id"] = chunk.session_id();
 
-                // ChatStreamChunk uses oneof for content_delta, tool_call, or metadata
+                // ChatStreamChunk uses oneof for content_delta, tool_call, thinking_delta, or metadata
                 if (chunk.has_content_delta()) {
                     event["delta"] = chunk.content_delta();
                 }
+                if (chunk.has_thinking_delta()) {
+                    event["thinking_delta"] = chunk.thinking_delta();
+                }
                 if (chunk.has_tool_call()) {
                     event["tool_call"] = {
                         {"id", chunk.tool_call().id()},
@@ -7487,6 +7502,9 @@ void HttpServer::HandleLlmSubmitToolResults(const httplib::Request& req, httplib
                 if (chunk.has_content_delta()) {
                     event["delta"] = chunk.content_delta();
                 }
+                if (chunk.has_thinking_delta()) {
+                    event["thinking_delta"] = chunk.thinking_delta();
+                }
                 if (chunk.has_tool_call()) {
                     event["tool_call"] = {
                         {"id", chunk.tool_call().id()},

+ 69 - 0
webui/package-lock.json

@@ -9,6 +9,7 @@
       "version": "0.1.0",
       "dependencies": {
         "@headlessui/react": "^2.2.0",
+        "@monaco-editor/react": "^4.7.0",
         "@uiw/react-md-editor": "^4.0.11",
         "react": "^19.0.0",
         "react-dom": "^19.0.0",
@@ -1116,6 +1117,29 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
+    "node_modules/@monaco-editor/loader": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
+      "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
+      "license": "MIT",
+      "dependencies": {
+        "state-local": "^1.0.6"
+      }
+    },
+    "node_modules/@monaco-editor/react": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
+      "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
+      "license": "MIT",
+      "dependencies": {
+        "@monaco-editor/loader": "^1.5.0"
+      },
+      "peerDependencies": {
+        "monaco-editor": ">= 0.25.0 < 1",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/@nodelib/fs.scandir": {
       "version": "2.1.5",
       "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1797,6 +1821,13 @@
         "@types/react": "^19.2.0"
       }
     },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/@types/unist": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -2798,6 +2829,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/dompurify": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
+      "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
+      "license": "(MPL-2.0 OR Apache-2.0)",
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
+      }
+    },
     "node_modules/electron-to-chromium": {
       "version": "1.5.283",
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz",
@@ -3938,6 +3978,18 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/marked": {
+      "version": "14.0.0",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
+      "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+      "license": "MIT",
+      "bin": {
+        "marked": "bin/marked.js"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
     "node_modules/mdast-util-find-and-replace": {
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
@@ -4820,6 +4872,17 @@
         "node": "*"
       }
     },
+    "node_modules/monaco-editor": {
+      "version": "0.55.1",
+      "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
+      "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "dompurify": "3.2.7",
+        "marked": "14.0.0"
+      }
+    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6029,6 +6092,12 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/state-local": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
+      "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
+      "license": "MIT"
+    },
     "node_modules/stringify-entities": {
       "version": "4.0.4",
       "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",

+ 1 - 0
webui/package.json

@@ -15,6 +15,7 @@
   },
   "dependencies": {
     "@headlessui/react": "^2.2.0",
+    "@monaco-editor/react": "^4.7.0",
     "@uiw/react-md-editor": "^4.0.11",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",

+ 2 - 0
webui/src/App.tsx

@@ -13,6 +13,7 @@ import Users from '@/pages/Users'
 import Groups from '@/pages/Groups'
 import ApiKeys from '@/pages/ApiKeys'
 import Workspaces from '@/pages/Workspaces'
+import WorkspaceEdit from '@/pages/WorkspaceEdit'
 import LlmProviders from '@/pages/LlmProviders'
 import ChatPage from '@/pages/ChatPage'
 import ProtectedRoute from '@/components/ProtectedRoute'
@@ -105,6 +106,7 @@ function App() {
         <Route path="/groups" element={<Groups />} />
         <Route path="/api-keys" element={<ApiKeys />} />
         <Route path="/workspaces" element={<Workspaces />} />
+        <Route path="/workspaces/:workspaceId/edit" element={<WorkspaceEdit />} />
         <Route path="/llm-providers" element={<LlmProviders />} />
         <Route path="/chat" element={<ChatPage />} />
       </Route>

+ 1 - 1
webui/src/api/chat.ts

@@ -56,7 +56,7 @@ export async function sendMessage(sessionId: string, request: SendMessageRequest
 export async function streamMessage(
   sessionId: string,
   request: SendMessageRequest,
-  onChunk: (chunk: { delta?: string; finish_reason?: string | number; message_id?: string; session_id: string; tool_call?: { id: string; name: string; arguments: string } }) => void,
+  onChunk: (chunk: { delta?: string; thinking_delta?: string; finish_reason?: string | number; message_id?: string; session_id: string; tool_call?: { id: string; name: string; arguments: string } }) => void,
   onError?: (error: Error) => void
 ): Promise<void> {
   const accessToken = apiClient.getAccessToken()

+ 1 - 0
webui/src/api/tools.ts

@@ -67,6 +67,7 @@ export async function streamWithToolResults(
   results: ToolResult[],
   onChunk: (chunk: {
     delta?: string
+    thinking_delta?: string
     finish_reason?: string
     message_id?: string
     session_id: string

+ 24 - 0
webui/src/components/Chat/ChatMessage.tsx

@@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm'
 import type { ChatMessage as ChatMessageType } from '@/types/chat'
 import type { ToolExecutionState } from '@/types/tools'
 import { ToolCallCard } from './ToolCallCard'
+import { ThinkingBlock } from './ThinkingBlock'
 
 function UserIcon({ className = 'h-4 w-4' }: { className?: string }) {
   return (
@@ -27,6 +28,7 @@ interface ChatMessageProps {
   message: ChatMessageType
   isStreaming?: boolean
   streamingContent?: string
+  streamingThinking?: string
   toolStates?: ToolExecutionState[]
   onExecuteTool?: (toolCallId: string) => void
 }
@@ -35,6 +37,7 @@ export function ChatMessage({
   message,
   isStreaming,
   streamingContent,
+  streamingThinking,
   toolStates = [],
   onExecuteTool,
 }: ChatMessageProps) {
@@ -47,6 +50,16 @@ export function ChatMessage({
     return message.content
   }, [isStreaming, streamingContent, message.content])
 
+  // Get thinking content (from streaming or stored in message)
+  const thinkingContent = useMemo(() => {
+    if (isStreaming && streamingThinking) {
+      return streamingThinking
+    }
+    return message.thinking
+  }, [isStreaming, streamingThinking, message.thinking])
+
+  const hasThinking = Boolean(thinkingContent) || (isStreaming && streamingThinking !== undefined)
+
   const formattedTime = useMemo(() => {
     const date = new Date(message.created_at * 1000)
     return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -69,6 +82,17 @@ export function ChatMessage({
 
       {/* Message content */}
       <div className={`flex max-w-[80%] flex-col ${isUser ? 'items-end' : 'items-start'}`}>
+        {/* Thinking block - shown before content for assistant messages */}
+        {!isUser && hasThinking && (
+          <div className="w-full mb-2">
+            <ThinkingBlock
+              content={thinkingContent || ''}
+              isStreaming={isStreaming && Boolean(streamingThinking)}
+              defaultExpanded={isStreaming}
+            />
+          </div>
+        )}
+
         <div
           className={`rounded-2xl px-4 py-2 ${
             isUser

+ 38 - 4
webui/src/components/Chat/ChatMessageList.tsx

@@ -3,6 +3,8 @@
 import { useRef, useEffect } from 'react'
 import { useChat } from '@/contexts/ChatContext'
 import { ChatMessage } from './ChatMessage'
+import { ToolCallCard } from './ToolCallCard'
+import { ThinkingBlock } from './ThinkingBlock'
 
 function Loader2Icon({ className = 'h-4 w-4' }: { className?: string }) {
   return (
@@ -13,7 +15,7 @@ function Loader2Icon({ className = 'h-4 w-4' }: { className?: string }) {
 }
 
 export function ChatMessageList() {
-  const { messages, isStreaming, streamingContent } = useChat()
+  const { messages, isStreaming, streamingContent, streamingThinking, pendingToolCalls } = useChat()
   const messagesEndRef = useRef<HTMLDivElement>(null)
 
   // Auto-scroll to bottom when new messages arrive or during streaming
@@ -41,14 +43,46 @@ export function ChatMessageList() {
             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}
+            toolStates={pendingToolCalls}
           />
         ))}
 
         {/* Streaming indicator when no assistant message yet */}
         {isStreaming && (messages.length === 0 || messages[messages.length - 1]?.role === 'user') && (
-          <div className="flex items-center gap-2 text-gray-500">
-            <Loader2Icon className="h-4 w-4 animate-spin" />
-            <span className="text-sm">Thinking...</span>
+          <div className="flex flex-col gap-2 max-w-[80%] ml-11">
+            {/* Show thinking block if thinking content is being streamed */}
+            {streamingThinking && (
+              <ThinkingBlock
+                content={streamingThinking}
+                isStreaming={true}
+                defaultExpanded={true}
+              />
+            )}
+            {/* Show streaming content or thinking indicator */}
+            {streamingContent ? (
+              <div className="bg-gray-100 dark:bg-gray-700 rounded-2xl px-4 py-2 text-gray-900 dark:text-gray-100">
+                <p className="text-sm whitespace-pre-wrap">{streamingContent}</p>
+                <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-gray-500 ml-1" />
+              </div>
+            ) : !streamingThinking && (
+              <div className="flex items-center gap-2 text-gray-500">
+                <Loader2Icon className="h-4 w-4 animate-spin" />
+                <span className="text-sm">Thinking...</span>
+              </div>
+            )}
+          </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>
         )}
 

+ 120 - 0
webui/src/components/Chat/ThinkingBlock.tsx

@@ -0,0 +1,120 @@
+// Thinking/Reasoning block component for displaying LLM thinking process
+
+import { useState, useMemo } from 'react'
+
+interface ThinkingBlockProps {
+  content: string
+  isStreaming?: boolean
+  defaultExpanded?: boolean
+}
+
+function BrainIcon({ className = 'h-4 w-4' }: { className?: string }) {
+  return (
+    <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        strokeWidth={2}
+        d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
+      />
+    </svg>
+  )
+}
+
+function ChevronIcon({ className = 'h-4 w-4', expanded = false }: { className?: string; expanded?: boolean }) {
+  return (
+    <svg
+      className={`${className} transition-transform ${expanded ? 'rotate-180' : ''}`}
+      fill="none"
+      viewBox="0 0 24 24"
+      stroke="currentColor"
+    >
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+    </svg>
+  )
+}
+
+function SpinnerIcon({ className = 'h-4 w-4' }: { className?: string }) {
+  return (
+    <svg className={`${className} animate-spin`} fill="none" viewBox="0 0 24 24">
+      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+      <path
+        className="opacity-75"
+        fill="currentColor"
+        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+      />
+    </svg>
+  )
+}
+
+export function ThinkingBlock({ content, isStreaming = false, defaultExpanded = false }: ThinkingBlockProps) {
+  const [isExpanded, setIsExpanded] = useState(defaultExpanded || isStreaming)
+
+  // Truncate content for preview
+  const previewContent = useMemo(() => {
+    if (!content) return ''
+    const firstLine = content.split('\n')[0]
+    if (firstLine.length > 60) {
+      return firstLine.slice(0, 60) + '...'
+    }
+    return firstLine
+  }, [content])
+
+  // Format content for display - preserve line breaks
+  const formattedContent = useMemo(() => {
+    return content?.trim() || ''
+  }, [content])
+
+  const wordCount = useMemo(() => {
+    return content?.split(/\s+/).filter(Boolean).length || 0
+  }, [content])
+
+  if (!content && !isStreaming) {
+    return null
+  }
+
+  return (
+    <div className="rounded-lg border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20 my-2 overflow-hidden">
+      {/* Header - clickable to expand/collapse */}
+      <button
+        type="button"
+        onClick={() => setIsExpanded(!isExpanded)}
+        className="w-full flex items-center justify-between p-3 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
+      >
+        <div className="flex items-center gap-2">
+          {isStreaming ? (
+            <SpinnerIcon className="h-4 w-4 text-purple-500" />
+          ) : (
+            <BrainIcon className="h-4 w-4 text-purple-500" />
+          )}
+          <span className="font-medium text-sm text-gray-900 dark:text-gray-100">
+            {isStreaming ? 'Thinking...' : 'Reasoning'}
+          </span>
+          {!isExpanded && previewContent && (
+            <span className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
+              {previewContent}
+            </span>
+          )}
+        </div>
+        <div className="flex items-center gap-2">
+          {!isStreaming && wordCount > 0 && (
+            <span className="text-xs text-purple-500">{wordCount} words</span>
+          )}
+          <ChevronIcon className="h-4 w-4 text-gray-400" expanded={isExpanded} />
+        </div>
+      </button>
+
+      {/* Collapsible content */}
+      {isExpanded && (
+        <div className="px-3 pb-3 border-t border-purple-200 dark:border-purple-700 pt-2">
+          <div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap max-h-80 overflow-y-auto font-mono bg-purple-100/50 dark:bg-purple-900/30 rounded p-2">
+            {formattedContent || (isStreaming ? '...' : '')}
+            {isStreaming && (
+              <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-purple-500 ml-1" />
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 77 - 47
webui/src/components/Chat/ToolCallCard.tsx

@@ -1,11 +1,12 @@
 // Tool call card component for displaying tool execution status
 
-import { useMemo } from 'react'
+import { useState, useMemo } from 'react'
 import type { ToolExecutionState } from '@/types/tools'
 
 interface ToolCallCardProps {
   toolState: ToolExecutionState
   onExecute?: () => void
+  defaultExpanded?: boolean
 }
 
 function ToolIcon({ className = 'h-4 w-4' }: { className?: string }) {
@@ -64,8 +65,22 @@ function NavigationIcon({ className = 'h-4 w-4' }: { className?: string }) {
   )
 }
 
-export function ToolCallCard({ toolState, onExecute }: ToolCallCardProps) {
+function ChevronIcon({ className = 'h-4 w-4', expanded = false }: { className?: string; expanded?: boolean }) {
+  return (
+    <svg
+      className={`${className} transition-transform ${expanded ? 'rotate-180' : ''}`}
+      fill="none"
+      viewBox="0 0 24 24"
+      stroke="currentColor"
+    >
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+    </svg>
+  )
+}
+
+export function ToolCallCard({ toolState, onExecute, defaultExpanded = false }: ToolCallCardProps) {
   const { tool_call, status, result } = toolState
+  const [isExpanded, setIsExpanded] = useState(defaultExpanded || status === 'executing' || status === 'error')
 
   const statusConfig = useMemo(() => {
     switch (status) {
@@ -133,58 +148,73 @@ export function ToolCallCard({ toolState, onExecute }: ToolCallCardProps) {
   }, [tool_call.name])
 
   return (
-    <div className={`rounded-lg border ${statusConfig.borderColor} ${statusConfig.bgColor} p-3 my-2`}>
-      {/* Header */}
-      <div className="flex items-center justify-between">
+    <div className={`rounded-lg border ${statusConfig.borderColor} ${statusConfig.bgColor} my-2 overflow-hidden`}>
+      {/* Header - clickable to expand/collapse */}
+      <button
+        type="button"
+        onClick={() => setIsExpanded(!isExpanded)}
+        className="w-full flex items-center justify-between p-3 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
+      >
         <div className="flex items-center gap-2">
           {statusConfig.icon}
           <span className="font-medium text-sm text-gray-900 dark:text-gray-100">{toolDisplayName}</span>
         </div>
-        <span className={`text-xs ${statusConfig.statusColor}`}>{statusConfig.statusText}</span>
-      </div>
-
-      {/* Arguments */}
-      {parsedArgs && Object.keys(parsedArgs).length > 0 && (
-        <div className="mt-2">
-          <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Arguments:</div>
-          <div className="bg-gray-100 dark:bg-gray-900 rounded p-2 text-xs font-mono overflow-x-auto">
-            {Object.entries(parsedArgs).map(([key, value]) => (
-              <div key={key} className="text-gray-700 dark:text-gray-300">
-                <span className="text-gray-500">{key}:</span>{' '}
-                <span>{typeof value === 'object' ? JSON.stringify(value) : String(value)}</span>
-              </div>
-            ))}
-          </div>
+        <div className="flex items-center gap-2">
+          <span className={`text-xs ${statusConfig.statusColor}`}>{statusConfig.statusText}</span>
+          <ChevronIcon className="h-4 w-4 text-gray-400" expanded={isExpanded} />
         </div>
-      )}
+      </button>
 
-      {/* Result */}
-      {result && (
-        <div className="mt-2">
-          <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
-            {result.is_error ? 'Error:' : 'Result:'}
-          </div>
-          <div
-            className={`text-xs p-2 rounded ${
-              result.is_error
-                ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
-                : 'bg-gray-100 dark:bg-gray-900 text-gray-700 dark:text-gray-300'
-            }`}
-          >
-            {result.content}
-          </div>
-        </div>
-      )}
+      {/* Collapsible content */}
+      {isExpanded && (
+        <div className="px-3 pb-3 border-t border-gray-200 dark:border-gray-700 pt-2">
+          {/* Arguments */}
+          {parsedArgs && Object.keys(parsedArgs).length > 0 && (
+            <div className="mb-2">
+              <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Arguments:</div>
+              <div className="bg-gray-100 dark:bg-gray-900 rounded p-2 text-xs font-mono overflow-x-auto">
+                {Object.entries(parsedArgs).map(([key, value]) => (
+                  <div key={key} className="text-gray-700 dark:text-gray-300">
+                    <span className="text-gray-500">{key}:</span>{' '}
+                    <span>{typeof value === 'object' ? JSON.stringify(value) : String(value)}</span>
+                  </div>
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Result */}
+          {result && (
+            <div>
+              <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
+                {result.is_error ? 'Error:' : 'Result:'}
+              </div>
+              <div
+                className={`text-xs p-2 rounded max-h-40 overflow-y-auto ${
+                  result.is_error
+                    ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
+                    : 'bg-gray-100 dark:bg-gray-900 text-gray-700 dark:text-gray-300'
+                }`}
+              >
+                {result.content}
+              </div>
+            </div>
+          )}
 
-      {/* Execute button for pending tools (if manual mode) */}
-      {status === 'pending' && onExecute && (
-        <div className="mt-2">
-          <button
-            onClick={onExecute}
-            className="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
-          >
-            Execute
-          </button>
+          {/* Execute button for pending tools (if manual mode) */}
+          {status === 'pending' && onExecute && (
+            <div className="mt-2">
+              <button
+                onClick={(e) => {
+                  e.stopPropagation()
+                  onExecute()
+                }}
+                className="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
+              >
+                Execute
+              </button>
+            </div>
+          )}
         </div>
       )}
     </div>

+ 3 - 1
webui/src/components/ModelSelector.tsx

@@ -192,7 +192,9 @@ export function ModelSelector({
                               {model.name || model.id}
                             </div>
                             <div className="flex items-center gap-2 text-xs text-gray-500 mt-0.5">
-                              <span>Context: {formatContextLength(model.context_length)} tokens</span>
+                              {model.context_length > 0 && (
+                                <span>Context: {formatContextLength(model.context_length)} tokens</span>
+                              )}
                               {model.supports_vision && (
                                 <span className="inline-flex items-center rounded bg-blue-100 px-1.5 py-0.5 text-blue-700">
                                   <svg className="mr-0.5 h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">

+ 100 - 31
webui/src/contexts/ChatContext.tsx

@@ -16,6 +16,8 @@ interface WorkspaceSettings {
   llm_helper_provider_id?: string
   llm_helper_model_id?: string
   llm_context_compaction_threshold?: number
+  llm_system_prompt?: string
+  llm_helper_system_prompt?: string
   [key: string]: unknown
 }
 
@@ -41,6 +43,7 @@ interface ChatContextValue {
   messages: ChatMessage[]
   isStreaming: boolean
   streamingContent: string
+  streamingThinking: string  // Thinking content being streamed
   sendMessage: (content: string) => Promise<void>
   clearMessages: () => Promise<void>
 
@@ -67,7 +70,7 @@ interface ChatProviderProps {
 }
 
 export function ChatProvider({ children }: ChatProviderProps) {
-  const { isAuthenticated, isLoading: isAuthLoading } = useAuth()
+  const { isAuthenticated, isLoading: isAuthLoading, user } = useAuth()
   const { currentWorkspace } = useWorkspace()
   const navigate = useNavigate()
 
@@ -75,6 +78,48 @@ export function ChatProvider({ children }: ChatProviderProps) {
   const workspaceSettings = currentWorkspace?.settings as WorkspaceSettings | undefined
   const workspaceModelId = workspaceSettings?.llm_model_id
   const compactionThreshold = workspaceSettings?.llm_context_compaction_threshold ?? 80
+  const systemPromptTemplate = workspaceSettings?.llm_system_prompt
+
+  // Generate system prompt from JavaScript template
+  const generateSystemPrompt = useCallback(async (): Promise<string | undefined> => {
+    if (!systemPromptTemplate) return undefined
+
+    try {
+      // Create context for the system prompt function
+      const context = {
+        user: {
+          id: user?.id || '',
+          name: user?.name || '',
+          email: user?.email || '',
+        },
+        workspace: {
+          id: currentWorkspace?.id || '',
+          name: currentWorkspace?.name || '',
+          settings: currentWorkspace?.settings || {},
+        },
+        storage: {
+          // TODO: Implement actual storage queries via API
+          query: async (collection: string, options: Record<string, unknown>) => {
+            console.log(`Storage query (not implemented): ${collection}`, options)
+            return { documents: [], total: 0 }
+          },
+        },
+      }
+
+      // Execute the JavaScript template
+      const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor
+      const fn = new AsyncFunction('context', `
+        ${systemPromptTemplate}
+        return generateSystemPrompt(context);
+      `)
+
+      const result = await fn(context)
+      return typeof result === 'string' ? result : undefined
+    } catch (err) {
+      console.error('Failed to generate system prompt:', err)
+      return undefined
+    }
+  }, [systemPromptTemplate, user, currentWorkspace])
 
   // UI State
   const [isOpen, setIsOpen] = useState(false)
@@ -89,6 +134,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
   const [messages, setMessages] = useState<ChatMessage[]>([])
   const [isStreaming, setIsStreaming] = useState(false)
   const [streamingContent, setStreamingContent] = useState('')
+  const [streamingThinking, setStreamingThinking] = useState('')
 
   // Tool Calling State
   const [pendingToolCalls, setPendingToolCalls] = useState<ToolExecutionState[]>([])
@@ -146,19 +192,23 @@ export function ChatProvider({ children }: ChatProviderProps) {
   }, [isAuthenticated, isAuthLoading, currentWorkspace?.id])
 
   const createSession = useCallback(async (workspaceId?: string): Promise<ChatSession> => {
-    // Use workspace's configured model
+    // Use workspace's configured model and system prompt
     const wsId = workspaceId || currentWorkspace?.id
 
+    // Generate system prompt from template
+    const systemPrompt = await generateSystemPrompt()
+
     const session = await chatApi.createSession({
       workspace_id: wsId,
       model_id: workspaceModelId || undefined,
+      system_prompt: systemPrompt,
     })
     setSessions(prev => [session, ...prev])
     setCurrentSession(session)
     setMessages([])
     setUserMessageCount(0) // Reset count for new session
     return session
-  }, [workspaceModelId, currentWorkspace?.id])
+  }, [workspaceModelId, currentWorkspace?.id, generateSystemPrompt])
 
   const switchSession = useCallback(async (sessionId: string) => {
     try {
@@ -331,18 +381,18 @@ export function ChatProvider({ children }: ChatProviderProps) {
     }
   }, [handleNavigation])
 
-  // Process tool calls from stream
+  // Process tool calls from stream - returns a promise that resolves when all iterations are done
   const processToolCalls = useCallback(async (
     sessionId: string,
     toolCalls: ToolCall[],
     workspaceId: string
-  ) => {
+  ): Promise<void> => {
     // Add pending tool calls
     const newToolStates: ToolExecutionState[] = toolCalls.map(tc => ({
       tool_call: tc,
       status: 'pending' as const,
     }))
-    setPendingToolCalls(newToolStates)
+    setPendingToolCalls(prev => [...prev, ...newToolStates])
 
     // Execute tools based on mode
     if (toolExecutionMode === 'auto') {
@@ -355,6 +405,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
 
       // Submit results to continue conversation
       if (results.length > 0) {
+        // Collect new tool calls from the continuation
+        const newToolCalls: ToolCall[] = []
+        let hasMoreToolCalls = false
+
         try {
           await toolsApi.streamWithToolResults(
             sessionId,
@@ -363,16 +417,29 @@ export function ChatProvider({ children }: ChatProviderProps) {
               if (chunk.delta) {
                 setStreamingContent(prev => prev + chunk.delta)
               }
-              // Handle nested tool calls
+              // Collect nested tool calls
               if (chunk.tool_call) {
-                // Recursively process new tool calls
-                processToolCalls(sessionId, [chunk.tool_call as ToolCall], workspaceId)
+                newToolCalls.push(chunk.tool_call as ToolCall)
+              }
+              // Check if this response also needs tool use
+              if (chunk.finish_reason) {
+                const finishReason = typeof chunk.finish_reason === 'string'
+                  ? parseInt(chunk.finish_reason, 10)
+                  : chunk.finish_reason
+                if (finishReason === 2 && newToolCalls.length > 0) {
+                  hasMoreToolCalls = true
+                }
               }
             },
             (error) => {
               console.error('Tool results stream error:', error)
             }
           )
+
+          // Recursively process any new tool calls
+          if (hasMoreToolCalls && newToolCalls.length > 0) {
+            await processToolCalls(sessionId, newToolCalls, workspaceId)
+          }
         } catch (error) {
           console.error('Failed to submit tool results:', error)
         }
@@ -404,11 +471,16 @@ export function ChatProvider({ children }: ChatProviderProps) {
     // Start streaming
     setIsStreaming(true)
     setStreamingContent('')
+    setStreamingThinking('')
 
     // Collect tool calls during streaming
     const collectedToolCalls: ToolCall[] = []
     const workspaceId = currentWorkspace?.id || ''
 
+    // Track if we need to process tool calls
+    let needsToolProcessing = false
+    let finishReasonValue = 0
+
     try {
       await chatApi.streamMessage(
         sessionId,
@@ -420,32 +492,22 @@ export function ChatProvider({ children }: ChatProviderProps) {
           if (chunk.delta) {
             setStreamingContent(prev => prev + chunk.delta)
           }
+          // Handle thinking/reasoning content
+          if (chunk.thinking_delta) {
+            setStreamingThinking(prev => prev + chunk.thinking_delta)
+          }
           // Collect tool calls
           if (chunk.tool_call) {
             collectedToolCalls.push(chunk.tool_call)
           }
-          if (chunk.finish_reason && chunk.message_id) {
-            // Stream complete, add the full message
-            const assistantMessage: ChatMessage = {
-              id: chunk.message_id,
-              role: 'assistant',
-              content: '', // Will be set below
-              created_at: Date.now() / 1000,
-              tool_calls: collectedToolCalls.length > 0 ? collectedToolCalls : undefined,
-            }
-            setMessages(prev => {
-              // Get current streaming content
-              const fullContent = prev.length > 0 ? streamingContent : ''
-              return [...prev, { ...assistantMessage, content: fullContent }]
-            })
-
-            // Process tool calls if finish_reason indicates tool use
-            // finish_reason 2 = FINISH_REASON_TOOL_USE in proto enum
-            const finishReason = typeof chunk.finish_reason === 'string'
+          if (chunk.finish_reason) {
+            // Parse finish reason
+            finishReasonValue = typeof chunk.finish_reason === 'string'
               ? parseInt(chunk.finish_reason, 10)
               : chunk.finish_reason
-            if (finishReason === 2 && collectedToolCalls.length > 0) {
-              processToolCalls(sessionId!, collectedToolCalls, workspaceId)
+            // Check if tool use is needed (finish_reason 2 = FINISH_REASON_TOOL_USE)
+            if (finishReasonValue === 2 && collectedToolCalls.length > 0) {
+              needsToolProcessing = true
             }
           }
         },
@@ -454,7 +516,12 @@ export function ChatProvider({ children }: ChatProviderProps) {
         }
       )
 
-      // After streaming completes, refresh the session to get proper message IDs
+      // Process tool calls if needed - WAIT for this to complete
+      if (needsToolProcessing) {
+        await processToolCalls(sessionId!, collectedToolCalls, workspaceId)
+      }
+
+      // After streaming and tool processing completes, refresh the session
       const updatedSession = await chatApi.getSession(sessionId)
       setMessages(updatedSession.messages || [])
       setCurrentSession(updatedSession)
@@ -492,8 +559,9 @@ export function ChatProvider({ children }: ChatProviderProps) {
     } finally {
       setIsStreaming(false)
       setStreamingContent('')
+      setStreamingThinking('')
     }
-  }, [currentSession?.id, currentPageContext, currentWorkspace?.id, createSession, streamingContent, userMessageCount, generateTitle, compactionThreshold, compressConversation, processToolCalls])
+  }, [currentSession?.id, currentPageContext, currentWorkspace?.id, createSession, userMessageCount, generateTitle, compactionThreshold, compressConversation, processToolCalls])
 
   const clearMessages = useCallback(async () => {
     if (!currentSession) return
@@ -560,6 +628,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
     messages,
     isStreaming,
     streamingContent,
+    streamingThinking,
     sendMessage,
     clearMessages,
 

+ 659 - 0
webui/src/pages/WorkspaceEdit.tsx

@@ -0,0 +1,659 @@
+// Dedicated workspace edit page with system prompt editor
+
+import { useState, useEffect, useCallback } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import Editor from '@monaco-editor/react'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import { useAuth } from '@/contexts/AuthContext'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
+import ModelSelector from '@/components/ModelSelector'
+import type { Workspace } from '@/types'
+import type { LlmProvider, LlmModel } from '@/types/chat'
+import * as chatApi from '@/api/chat'
+
+interface WorkspaceSettings {
+  llm_provider_id?: string
+  llm_model_id?: string
+  llm_helper_provider_id?: string
+  llm_helper_model_id?: string
+  llm_context_compaction_threshold?: number
+  llm_system_prompt?: string
+  llm_helper_system_prompt?: string
+  [key: string]: unknown
+}
+
+// Default system prompt template with access to storage and user
+const DEFAULT_SYSTEM_PROMPT = `// System prompt generator function
+// This function is called before each LLM request
+// Available context:
+//   - user: { id, name, email }
+//   - workspace: { id, name, settings }
+//   - storage: async function to query collections
+//
+// Example storage query:
+//   const customers = await storage.query('customers', { limit: 10 })
+//
+// Return a string with the system prompt
+
+async function generateSystemPrompt(context) {
+  const { user, workspace } = context;
+
+  return \`You are a helpful AI assistant for \${workspace.name}.
+You are helping \${user.name} (\${user.email}).
+
+Be concise and helpful. Focus on the user's needs.
+\`;
+}
+`
+
+// Default helper system prompt for title generation and compression
+const DEFAULT_HELPER_SYSTEM_PROMPT = `// Helper model system prompt generator
+// Used for: conversation title generation, context compression
+// This should be optimized for fast, concise responses
+//
+// Available context:
+//   - user: { id, name, email }
+//   - workspace: { id, name, settings }
+//   - task: 'title_generation' | 'compression'
+
+async function generateSystemPrompt(context) {
+  const { task } = context;
+
+  if (task === 'title_generation') {
+    return \`Generate a short, descriptive title (max 6 words) for the conversation.
+Return ONLY the title, no quotes or explanation.\`;
+  }
+
+  if (task === 'compression') {
+    return \`Summarize the conversation concisely while preserving key information.
+Focus on: decisions made, questions asked, important context.
+Be brief but complete.\`;
+  }
+
+  return 'You are a helpful assistant.';
+}
+`
+
+function WorkspaceEdit() {
+  const { workspaceId } = useParams<{ workspaceId: string }>()
+  const navigate = useNavigate()
+  const { fetchWorkspaces: refreshWorkspaces } = useWorkspace()
+  const { user } = useAuth()
+
+  const [workspace, setWorkspace] = useState<Workspace | null>(null)
+  const [isLoading, setIsLoading] = useState(true)
+  const [isSaving, setIsSaving] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const [successMessage, setSuccessMessage] = useState<string | null>(null)
+
+  // Form state
+  const [name, setName] = useState('')
+  const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT)
+  const [helperSystemPrompt, setHelperSystemPrompt] = useState(DEFAULT_HELPER_SYSTEM_PROMPT)
+
+  // LLM Provider and Model state
+  const [providers, setProviders] = useState<LlmProvider[]>([])
+  const [models, setModels] = useState<LlmModel[]>([])
+  const [isLoadingProviders, setIsLoadingProviders] = useState(true)
+  const [isLoadingModels, setIsLoadingModels] = useState(false)
+  const [selectedProviderId, setSelectedProviderId] = useState<string>('')
+  const [selectedModelId, setSelectedModelId] = useState<string>('')
+
+  // Helper model state
+  const [helperProviderId, setHelperProviderId] = useState<string>('')
+  const [helperModelId, setHelperModelId] = useState<string>('')
+  const [helperModels, setHelperModels] = useState<LlmModel[]>([])
+  const [isLoadingHelperModels, setIsLoadingHelperModels] = useState(false)
+  const [compactionThreshold, setCompactionThreshold] = useState<number>(80)
+
+  // Track if initial load is done to prevent clearing models
+  const [initialLoadDone, setInitialLoadDone] = useState(false)
+
+  // Test prompt result modal
+  const [testPromptResult, setTestPromptResult] = useState<{ success: boolean; content: string } | null>(null)
+
+  // Load workspace data
+  const loadWorkspace = useCallback(async () => {
+    if (!workspaceId) return
+
+    setIsLoading(true)
+    setError(null)
+    try {
+      const ws = await apiClient.get<Workspace>(`/workspaces/${workspaceId}`)
+      setWorkspace(ws)
+
+      // Initialize form state from workspace
+      setName(ws.name)
+      const settings = ws.settings as WorkspaceSettings
+      setSelectedProviderId(settings?.llm_provider_id || '')
+      setSelectedModelId(settings?.llm_model_id || '')
+      setHelperProviderId(settings?.llm_helper_provider_id || '')
+      setHelperModelId(settings?.llm_helper_model_id || '')
+      setCompactionThreshold(settings?.llm_context_compaction_threshold ?? 80)
+      setSystemPrompt(settings?.llm_system_prompt || DEFAULT_SYSTEM_PROMPT)
+      setHelperSystemPrompt(settings?.llm_helper_system_prompt || DEFAULT_HELPER_SYSTEM_PROMPT)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to load workspace')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [workspaceId])
+
+  // Load providers
+  useEffect(() => {
+    const loadProviders = async () => {
+      try {
+        const response = await chatApi.listProviders()
+        setProviders(response.providers || [])
+      } catch (err) {
+        console.error('Failed to load providers:', err)
+      } finally {
+        setIsLoadingProviders(false)
+      }
+    }
+    loadProviders()
+  }, [])
+
+  // Load workspace after providers are loaded
+  useEffect(() => {
+    if (!isLoadingProviders) {
+      loadWorkspace()
+    }
+  }, [isLoadingProviders, loadWorkspace])
+
+  // Load models when provider changes
+  useEffect(() => {
+    if (!selectedProviderId) {
+      setModels([])
+      if (initialLoadDone) {
+        setSelectedModelId('')
+      }
+      return
+    }
+
+    const loadModels = async () => {
+      setIsLoadingModels(true)
+      try {
+        const response = await chatApi.listModels(selectedProviderId)
+        setModels(response.models || [])
+      } catch (err) {
+        console.error('Failed to load models:', err)
+        setModels([])
+      } finally {
+        setIsLoadingModels(false)
+      }
+    }
+    loadModels()
+  }, [selectedProviderId, initialLoadDone])
+
+  // Load helper models when helper provider changes
+  // Note: When helperProviderId is empty, we use the primary provider's models
+  useEffect(() => {
+    if (!helperProviderId) {
+      // Use primary provider's models for helper when no separate provider selected
+      setHelperModels([])
+      return
+    }
+
+    const loadHelperModels = async () => {
+      setIsLoadingHelperModels(true)
+      try {
+        const response = await chatApi.listModels(helperProviderId)
+        setHelperModels(response.models || [])
+      } catch (err) {
+        console.error('Failed to load helper models:', err)
+        setHelperModels([])
+      } finally {
+        setIsLoadingHelperModels(false)
+      }
+    }
+    loadHelperModels()
+  }, [helperProviderId])
+
+  // Mark initial load as done after workspace is loaded
+  useEffect(() => {
+    if (workspace && !isLoading) {
+      // Small delay to ensure models are loaded before allowing changes
+      const timer = setTimeout(() => setInitialLoadDone(true), 500)
+      return () => clearTimeout(timer)
+    }
+  }, [workspace, isLoading])
+
+  const handleSave = async () => {
+    if (!workspaceId) return
+
+    setIsSaving(true)
+    setError(null)
+    setSuccessMessage(null)
+
+    try {
+      const settings: WorkspaceSettings = {
+        ...(workspace?.settings as WorkspaceSettings),
+        llm_provider_id: selectedProviderId || undefined,
+        llm_model_id: selectedModelId || undefined,
+        llm_helper_provider_id: helperProviderId || undefined,
+        llm_helper_model_id: helperModelId || undefined,
+        llm_context_compaction_threshold: compactionThreshold,
+        llm_system_prompt: systemPrompt,
+        llm_helper_system_prompt: helperSystemPrompt,
+      }
+
+      // Remove undefined values
+      Object.keys(settings).forEach(key => {
+        if (settings[key] === undefined) {
+          delete settings[key]
+        }
+      })
+
+      await apiClient.patch(`/workspaces/${workspaceId}`, {
+        name,
+        settings,
+      })
+
+      setSuccessMessage('Workspace saved successfully')
+      refreshWorkspaces()
+
+      // Clear success message after 3 seconds
+      setTimeout(() => setSuccessMessage(null), 3000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to save workspace')
+    } finally {
+      setIsSaving(false)
+    }
+  }
+
+  const handleTestPrompt = async () => {
+    // Test the system prompt by executing it with mock context
+    try {
+      // Create a function from the code
+      const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor
+      const fn = new AsyncFunction('context', `
+        ${systemPrompt}
+        return generateSystemPrompt(context);
+      `)
+
+      const mockContext = {
+        user: {
+          id: user?.id || 'test-user',
+          name: user?.name || 'Test User',
+          email: user?.email || 'test@example.com',
+        },
+        workspace: {
+          id: workspace?.id || 'test-workspace',
+          name: workspace?.name || 'Test Workspace',
+          settings: workspace?.settings || {},
+        },
+        storage: {
+          query: async (collection: string, options: Record<string, unknown>) => {
+            console.log(`Mock storage query: ${collection}`, options)
+            return { documents: [], total: 0 }
+          },
+        },
+      }
+
+      const result = await fn(mockContext)
+      setTestPromptResult({ success: true, content: result })
+    } catch (err) {
+      setTestPromptResult({ success: false, content: err instanceof Error ? err.message : 'Unknown error' })
+    }
+  }
+
+  const handleTestHelperPrompt = async () => {
+    // Test the helper system prompt with different task contexts
+    try {
+      const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor
+      const fn = new AsyncFunction('context', `
+        ${helperSystemPrompt}
+        return generateSystemPrompt(context);
+      `)
+
+      const results: string[] = []
+
+      // Test with title_generation task
+      const titleContext = {
+        user: {
+          id: user?.id || 'test-user',
+          name: user?.name || 'Test User',
+          email: user?.email || 'test@example.com',
+        },
+        workspace: {
+          id: workspace?.id || 'test-workspace',
+          name: workspace?.name || 'Test Workspace',
+          settings: workspace?.settings || {},
+        },
+        task: 'title_generation',
+      }
+      const titleResult = await fn(titleContext)
+      results.push(`=== Task: title_generation ===\n${titleResult}`)
+
+      // Test with compression task
+      const compressionContext = { ...titleContext, task: 'compression' }
+      const compressionResult = await fn(compressionContext)
+      results.push(`\n=== Task: compression ===\n${compressionResult}`)
+
+      setTestPromptResult({ success: true, content: results.join('\n') })
+    } catch (err) {
+      setTestPromptResult({ success: false, content: err instanceof Error ? err.message : 'Unknown error' })
+    }
+  }
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center py-12">
+        <svg
+          className="h-8 w-8 animate-spin text-primary-600"
+          xmlns="http://www.w3.org/2000/svg"
+          fill="none"
+          viewBox="0 0 24 24"
+        >
+          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+          <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
+        </svg>
+      </div>
+    )
+  }
+
+  if (!workspace) {
+    return (
+      <div className="text-center py-12">
+        <p className="text-gray-500">Workspace not found</p>
+        <Button className="mt-4" onClick={() => navigate('/workspaces')}>
+          Back to Workspaces
+        </Button>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Header */}
+      <div className="flex items-center justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Edit Workspace</h1>
+          <p className="mt-1 text-gray-600">Configure workspace settings and AI assistant</p>
+        </div>
+        <div className="flex gap-3">
+          <Button variant="secondary" onClick={() => navigate('/workspaces')}>
+            Cancel
+          </Button>
+          <Button onClick={handleSave} isLoading={isSaving}>
+            Save Changes
+          </Button>
+        </div>
+      </div>
+
+      {/* Messages */}
+      {error && (
+        <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+      )}
+      {successMessage && (
+        <div className="rounded-lg bg-green-50 px-4 py-3 text-sm text-green-700">{successMessage}</div>
+      )}
+
+      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+        {/* Left Column - Basic Settings */}
+        <div className="space-y-6">
+          {/* Basic Info */}
+          <div className="rounded-lg bg-white p-6 shadow">
+            <h2 className="text-lg font-semibold text-gray-900 mb-4">Basic Information</h2>
+            <Input
+              label="Workspace Name"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              placeholder="Enter workspace name"
+              required
+            />
+          </div>
+
+          {/* LLM Settings */}
+          <div className="rounded-lg bg-white p-6 shadow">
+            <h2 className="text-lg font-semibold text-gray-900 mb-4">AI Assistant Settings</h2>
+
+            {/* Primary Provider */}
+            <div className="mb-4">
+              <label className="block text-sm font-medium text-gray-700 mb-1">
+                LLM Provider
+              </label>
+              <select
+                value={selectedProviderId}
+                onChange={(e) => {
+                  setSelectedProviderId(e.target.value)
+                  if (initialLoadDone) {
+                    setSelectedModelId('')
+                  }
+                }}
+                disabled={isLoadingProviders}
+                className="w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100"
+              >
+                <option value="">
+                  {isLoadingProviders ? 'Loading providers...' : 'Select a provider'}
+                </option>
+                {providers.filter(p => p.enabled).map((provider) => (
+                  <option key={provider.id} value={provider.id}>
+                    {provider.name} ({provider.type})
+                  </option>
+                ))}
+              </select>
+            </div>
+
+            {/* Primary Model */}
+            {selectedProviderId && (
+              <div className="mb-4">
+                <label className="block text-sm font-medium text-gray-700 mb-1">
+                  Primary Model
+                </label>
+                <ModelSelector
+                  models={models}
+                  providers={providers}
+                  selectedModelId={selectedModelId}
+                  onChange={setSelectedModelId}
+                  isLoading={isLoadingModels}
+                  placeholder="Select a model"
+                />
+              </div>
+            )}
+
+            {/* Helper Model Section */}
+            {selectedProviderId && selectedModelId && (
+              <div className="border-t border-gray-200 pt-4 mt-4">
+                <h3 className="text-sm font-medium text-gray-900 mb-3">Helper Model</h3>
+                <p className="text-xs text-gray-500 mb-3">
+                  A smaller, faster model for conversation compression and title generation.
+                </p>
+
+                {/* Helper Provider */}
+                <div className="mb-3">
+                  <label className="block text-sm font-medium text-gray-700 mb-1">
+                    Helper Provider
+                  </label>
+                  <select
+                    value={helperProviderId}
+                    onChange={(e) => {
+                      const newProviderId = e.target.value
+                      setHelperProviderId(newProviderId)
+                      // Only clear model when switching TO a different provider (not when going back to "same as primary")
+                      if (initialLoadDone && newProviderId && newProviderId !== helperProviderId) {
+                        setHelperModelId('')
+                      }
+                    }}
+                    className="w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
+                  >
+                    <option value="">Same as primary provider</option>
+                    {providers.filter(p => p.enabled).map((provider) => (
+                      <option key={provider.id} value={provider.id}>
+                        {provider.name} ({provider.type})
+                      </option>
+                    ))}
+                  </select>
+                </div>
+
+                {/* Helper Model */}
+                <div className="mb-3">
+                  <label className="block text-sm font-medium text-gray-700 mb-1">
+                    Helper Model
+                  </label>
+                  <ModelSelector
+                    models={helperProviderId ? helperModels : models}
+                    providers={providers}
+                    selectedModelId={helperModelId}
+                    onChange={setHelperModelId}
+                    isLoading={helperProviderId ? isLoadingHelperModels : isLoadingModels}
+                    placeholder="Select a smaller model"
+                  />
+                </div>
+
+                {/* Compaction Threshold */}
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 mb-1">
+                    Auto-compress at {compactionThreshold}% context usage
+                  </label>
+                  <input
+                    type="range"
+                    min="50"
+                    max="95"
+                    step="5"
+                    value={compactionThreshold}
+                    onChange={(e) => setCompactionThreshold(Number(e.target.value))}
+                    className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
+                  />
+                  <div className="flex justify-between text-xs text-gray-500 mt-1">
+                    <span>50%</span>
+                    <span>95%</span>
+                  </div>
+                </div>
+              </div>
+            )}
+          </div>
+        </div>
+
+        {/* Right Column - System Prompt Editors */}
+        <div className="space-y-6">
+          {/* Primary System Prompt Editor */}
+          <div className="rounded-lg bg-white p-6 shadow">
+            <div className="flex items-center justify-between mb-4">
+              <div>
+                <h2 className="text-lg font-semibold text-gray-900">Primary Model System Prompt</h2>
+                <p className="text-xs text-gray-500 mt-1">
+                  JavaScript function that generates the system prompt for the main AI assistant
+                </p>
+              </div>
+              <Button variant="secondary" size="sm" onClick={handleTestPrompt}>
+                Test Prompt
+              </Button>
+            </div>
+
+            <div className="border border-gray-300 rounded-lg overflow-hidden">
+              <Editor
+                height="400px"
+                defaultLanguage="javascript"
+                value={systemPrompt}
+                onChange={(value) => setSystemPrompt(value || '')}
+                theme="vs-light"
+                options={{
+                  minimap: { enabled: false },
+                  fontSize: 13,
+                  lineNumbers: 'on',
+                  scrollBeyondLastLine: false,
+                  wordWrap: 'on',
+                  tabSize: 2,
+                  automaticLayout: true,
+                }}
+              />
+            </div>
+
+            <div className="mt-4 p-4 bg-gray-50 rounded-lg">
+              <h3 className="text-sm font-medium text-gray-900 mb-2">Available Context</h3>
+              <div className="text-xs text-gray-600 space-y-1 font-mono">
+                <p><span className="text-blue-600">context.user</span> - Current user (id, name, email)</p>
+                <p><span className="text-blue-600">context.workspace</span> - Workspace info (id, name, settings)</p>
+                <p><span className="text-blue-600">context.storage.query(collection, options)</span> - Query documents</p>
+              </div>
+            </div>
+          </div>
+
+          {/* Helper Model System Prompt Editor */}
+          <div className="rounded-lg bg-white p-6 shadow">
+            <div className="flex items-center justify-between mb-4">
+              <div>
+                <h2 className="text-lg font-semibold text-gray-900">Helper Model System Prompt</h2>
+                <p className="text-xs text-gray-500 mt-1">
+                  Used for title generation and conversation compression (uses smaller/faster model)
+                </p>
+              </div>
+              <Button variant="secondary" size="sm" onClick={handleTestHelperPrompt}>
+                Test Prompt
+              </Button>
+            </div>
+
+            <div className="border border-gray-300 rounded-lg overflow-hidden">
+              <Editor
+                height="300px"
+                defaultLanguage="javascript"
+                value={helperSystemPrompt}
+                onChange={(value) => setHelperSystemPrompt(value || '')}
+                theme="vs-light"
+                options={{
+                  minimap: { enabled: false },
+                  fontSize: 13,
+                  lineNumbers: 'on',
+                  scrollBeyondLastLine: false,
+                  wordWrap: 'on',
+                  tabSize: 2,
+                  automaticLayout: true,
+                }}
+              />
+            </div>
+
+            <div className="mt-4 p-4 bg-gray-50 rounded-lg">
+              <h3 className="text-sm font-medium text-gray-900 mb-2">Available Context</h3>
+              <div className="text-xs text-gray-600 space-y-1 font-mono">
+                <p><span className="text-blue-600">context.user</span> - Current user (id, name, email)</p>
+                <p><span className="text-blue-600">context.workspace</span> - Workspace info (id, name, settings)</p>
+                <p><span className="text-blue-600">context.task</span> - 'title_generation' | 'compression'</p>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      {/* Test Prompt Result Modal */}
+      {testPromptResult && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+          <div className="w-full max-w-2xl max-h-[80vh] overflow-hidden rounded-lg bg-white shadow-xl flex flex-col">
+            <div className={`px-6 py-4 border-b ${testPromptResult.success ? 'bg-green-50' : 'bg-red-50'}`}>
+              <div className="flex items-center justify-between">
+                <h3 className={`text-lg font-semibold ${testPromptResult.success ? 'text-green-900' : 'text-red-900'}`}>
+                  {testPromptResult.success ? 'Generated System Prompt' : 'Error in System Prompt'}
+                </h3>
+                <button
+                  onClick={() => setTestPromptResult(null)}
+                  className="text-gray-400 hover:text-gray-600"
+                >
+                  <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+                  </svg>
+                </button>
+              </div>
+            </div>
+            <div className="flex-1 overflow-y-auto p-6">
+              <pre className={`whitespace-pre-wrap text-sm font-mono p-4 rounded-lg ${
+                testPromptResult.success ? 'bg-gray-50 text-gray-800' : 'bg-red-50 text-red-800'
+              }`}>
+                {testPromptResult.content}
+              </pre>
+            </div>
+            <div className="px-6 py-4 border-t bg-gray-50 flex justify-end">
+              <Button variant="secondary" onClick={() => setTestPromptResult(null)}>
+                Close
+              </Button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default WorkspaceEdit

+ 24 - 309
webui/src/pages/Workspaces.tsx

@@ -1,14 +1,12 @@
 // Workspaces management page (US-029)
 
-import { useState, useEffect, useCallback, useRef } from 'react'
+import { useState, useEffect, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
 import apiClient from '@/api/client'
 import { useWorkspace } from '@/contexts/WorkspaceContext'
 import Button from '@/components/Button'
 import Input from '@/components/Input'
-import ModelSelector from '@/components/ModelSelector'
 import type { Workspace } from '@/types'
-import type { LlmProvider, LlmModel } from '@/types/chat'
-import * as chatApi from '@/api/chat'
 import { formatDateTime } from '@/utils/format'
 
 interface WorkspaceFormData {
@@ -23,200 +21,36 @@ interface WorkspaceFormData {
   }
 }
 
-function WorkspaceModal({
-  workspace,
+// Simple modal for creating a new workspace (name only)
+function CreateWorkspaceModal({
   onClose,
   onSave,
 }: {
-  workspace: Workspace | null
   onClose: () => void
-  onSave: () => void
+  onSave: (workspaceId: string) => void
 }) {
-  const [formData, setFormData] = useState<WorkspaceFormData>({
-    name: workspace?.name || '',
-    settings: (workspace?.settings as WorkspaceFormData['settings']) || {},
-  })
+  const [name, setName] = useState('')
   const [isLoading, setIsLoading] = useState(false)
   const [error, setError] = useState<string | null>(null)
 
-  // LLM Provider and Model state
-  const [providers, setProviders] = useState<LlmProvider[]>([])
-  const [models, setModels] = useState<LlmModel[]>([])
-  const [isLoadingProviders, setIsLoadingProviders] = useState(true)
-  const [isLoadingModels, setIsLoadingModels] = useState(false)
-  const [selectedProviderId, setSelectedProviderId] = useState<string>(
-    (workspace?.settings as WorkspaceFormData['settings'])?.llm_provider_id || ''
-  )
-  const [selectedModelId, setSelectedModelId] = useState<string>(
-    (workspace?.settings as WorkspaceFormData['settings'])?.llm_model_id || ''
-  )
-
-  // Helper model state (for compression and title generation)
-  const [helperProviderId, setHelperProviderId] = useState<string>(
-    (workspace?.settings as WorkspaceFormData['settings'])?.llm_helper_provider_id || ''
-  )
-  const [helperModelId, setHelperModelId] = useState<string>(
-    (workspace?.settings as WorkspaceFormData['settings'])?.llm_helper_model_id || ''
-  )
-  const [helperModels, setHelperModels] = useState<LlmModel[]>([])
-  const [isLoadingHelperModels, setIsLoadingHelperModels] = useState(false)
-  const [compactionThreshold, setCompactionThreshold] = useState<number>(
-    (workspace?.settings as WorkspaceFormData['settings'])?.llm_context_compaction_threshold ?? 80
-  )
-
-  // Track initial model values to prevent clearing on first load
-  const initialModelIdRef = useRef<string | null>(
-    (workspace?.settings as WorkspaceFormData['settings'])?.llm_model_id || null
-  )
-  const initialHelperModelIdRef = useRef<string | null>(
-    (workspace?.settings as WorkspaceFormData['settings'])?.llm_helper_model_id || null
-  )
-
-  // Load providers on mount
-  useEffect(() => {
-    const loadProviders = async () => {
-      try {
-        const response = await chatApi.listProviders()
-        setProviders(response.providers || [])
-      } catch (err) {
-        console.error('Failed to load providers:', err)
-      } finally {
-        setIsLoadingProviders(false)
-      }
-    }
-    loadProviders()
-  }, [])
-
-  // Load models when provider changes
-  useEffect(() => {
-    if (!selectedProviderId) {
-      setModels([])
-      setSelectedModelId('')
-      return
-    }
-
-    const loadModels = async () => {
-      setIsLoadingModels(true)
-      try {
-        const response = await chatApi.listModels(selectedProviderId)
-        const modelList = response.models || []
-        setModels(modelList)
-
-        // Debug logging
-        console.log('Loaded models for provider:', selectedProviderId, modelList.map(m => m.id))
-        console.log('Current selectedModelId:', selectedModelId)
-        console.log('Initial modelId from settings:', initialModelIdRef.current)
-
-        // Only clear the model if:
-        // 1. There's a selected model AND
-        // 2. It's not in the new list AND
-        // 3. It's not the initial model from settings (preserve on first load)
-        if (selectedModelId && !modelList.some(m => m.id === selectedModelId)) {
-          // If this is the initial model from settings, keep it (might be a valid model not in list)
-          if (selectedModelId === initialModelIdRef.current) {
-            console.log('Keeping initial model from settings:', selectedModelId)
-          } else {
-            console.log('Clearing model not in list:', selectedModelId)
-            setSelectedModelId('')
-          }
-        }
-        // Clear the ref after first check so subsequent provider changes clear properly
-        if (initialModelIdRef.current) {
-          initialModelIdRef.current = null
-        }
-      } catch (err) {
-        console.error('Failed to load models:', err)
-        setModels([])
-      } finally {
-        setIsLoadingModels(false)
-      }
-    }
-    loadModels()
-  }, [selectedProviderId])
-
-  // Load helper models when helper provider changes
-  useEffect(() => {
-    if (!helperProviderId) {
-      setHelperModels([])
-      setHelperModelId('')
-      return
-    }
-
-    const loadHelperModels = async () => {
-      setIsLoadingHelperModels(true)
-      try {
-        const response = await chatApi.listModels(helperProviderId)
-        const modelList = response.models || []
-        setHelperModels(modelList)
-
-        // Debug logging
-        console.log('Loaded helper models for provider:', helperProviderId, modelList.map(m => m.id))
-        console.log('Current helperModelId:', helperModelId)
-        console.log('Initial helperModelId from settings:', initialHelperModelIdRef.current)
-
-        // Only clear if not the initial model from settings
-        if (helperModelId && !modelList.some(m => m.id === helperModelId)) {
-          if (helperModelId === initialHelperModelIdRef.current) {
-            console.log('Keeping initial helper model from settings:', helperModelId)
-          } else {
-            console.log('Clearing helper model not in list:', helperModelId)
-            setHelperModelId('')
-          }
-        }
-        // Clear the ref after first check
-        if (initialHelperModelIdRef.current) {
-          initialHelperModelIdRef.current = null
-        }
-      } catch (err) {
-        console.error('Failed to load helper models:', err)
-        setHelperModels([])
-      } finally {
-        setIsLoadingHelperModels(false)
-      }
-    }
-    loadHelperModels()
-  }, [helperProviderId])
-
-  // Update settings when provider/model changes
-  useEffect(() => {
-    setFormData(prev => ({
-      ...prev,
-      settings: {
-        ...prev.settings,
-        llm_provider_id: selectedProviderId || undefined,
-        llm_model_id: selectedModelId || undefined,
-        llm_helper_provider_id: helperProviderId || undefined,
-        llm_helper_model_id: helperModelId || undefined,
-        llm_context_compaction_threshold: compactionThreshold,
-      },
-    }))
-  }, [selectedProviderId, selectedModelId, helperProviderId, helperModelId, compactionThreshold])
-
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault()
     setIsLoading(true)
     setError(null)
 
     try {
-      if (workspace) {
-        await apiClient.patch(`/workspaces/${workspace.id}`, formData)
-      } else {
-        await apiClient.post('/workspaces', formData)
-      }
-      onSave()
+      const workspace = await apiClient.post<Workspace>('/workspaces', { name })
+      onSave(workspace.id)
     } catch (err) {
-      setError(err instanceof Error ? err.message : 'Failed to save workspace')
-    } finally {
+      setError(err instanceof Error ? err.message : 'Failed to create workspace')
       setIsLoading(false)
     }
   }
 
   return (
     <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
-      <div className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-xl">
-        <h2 className="text-xl font-semibold text-gray-900">
-          {workspace ? 'Edit Workspace' : 'Create Workspace'}
-        </h2>
+      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">Create Workspace</h2>
 
         <form onSubmit={handleSubmit} className="mt-4 space-y-4">
           {error && (
@@ -226,135 +60,23 @@ function WorkspaceModal({
           <Input
             label="Workspace Name"
             name="name"
-            value={formData.name}
-            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
+            value={name}
+            onChange={(e) => setName(e.target.value)}
             placeholder="Enter workspace name"
             required
             autoFocus
           />
 
-          {/* LLM Provider Selection */}
-          <div>
-            <label className="block text-sm font-medium text-gray-700 mb-1">
-              LLM Provider
-            </label>
-            <select
-              value={selectedProviderId}
-              onChange={(e) => setSelectedProviderId(e.target.value)}
-              disabled={isLoadingProviders}
-              className="w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100"
-            >
-              <option value="">
-                {isLoadingProviders ? 'Loading providers...' : 'Select a provider (optional)'}
-              </option>
-              {providers.filter(p => p.enabled).map((provider) => (
-                <option key={provider.id} value={provider.id}>
-                  {provider.name} ({provider.type})
-                </option>
-              ))}
-            </select>
-            <p className="mt-1 text-xs text-gray-500">
-              Select an LLM provider to enable AI assistant for this workspace
-            </p>
-          </div>
-
-          {/* LLM Model Selection */}
-          {selectedProviderId && (
-            <div>
-              <label className="block text-sm font-medium text-gray-700 mb-1">
-                LLM Model (Primary)
-              </label>
-              <ModelSelector
-                models={models}
-                providers={providers}
-                selectedModelId={selectedModelId}
-                onChange={setSelectedModelId}
-                isLoading={isLoadingModels}
-                placeholder="Select a model"
-              />
-              {models.length === 0 && !isLoadingModels && (
-                <p className="mt-1 text-xs text-amber-600">
-                  No models available. Try refreshing models in LLM Providers settings.
-                </p>
-              )}
-            </div>
-          )}
-
-          {/* Helper Model Section - for compression and title generation */}
-          {selectedProviderId && selectedModelId && (
-            <div className="border-t border-gray-200 pt-4 mt-4">
-              <h3 className="text-sm font-medium text-gray-900 mb-3">Helper Model Settings</h3>
-              <p className="text-xs text-gray-500 mb-3">
-                A smaller, faster model for conversation compression and title generation.
-              </p>
-
-              {/* Helper Provider */}
-              <div className="mb-3">
-                <label className="block text-sm font-medium text-gray-700 mb-1">
-                  Helper Provider
-                </label>
-                <select
-                  value={helperProviderId}
-                  onChange={(e) => setHelperProviderId(e.target.value)}
-                  disabled={isLoadingProviders}
-                  className="w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100"
-                >
-                  <option value="">Same as primary provider</option>
-                  {providers.filter(p => p.enabled).map((provider) => (
-                    <option key={provider.id} value={provider.id}>
-                      {provider.name} ({provider.type})
-                    </option>
-                  ))}
-                </select>
-              </div>
-
-              {/* Helper Model */}
-              <div className="mb-3">
-                <label className="block text-sm font-medium text-gray-700 mb-1">
-                  Helper Model
-                </label>
-                <ModelSelector
-                  models={helperProviderId ? helperModels : models}
-                  providers={providers}
-                  selectedModelId={helperModelId}
-                  onChange={setHelperModelId}
-                  isLoading={helperProviderId ? isLoadingHelperModels : isLoadingModels}
-                  placeholder="Select a smaller model (optional)"
-                />
-                <p className="mt-1 text-xs text-gray-500">
-                  Used for summarizing conversations and generating titles
-                </p>
-              </div>
-
-              {/* Context Compaction Threshold */}
-              <div>
-                <label className="block text-sm font-medium text-gray-700 mb-1">
-                  Auto-compress at {compactionThreshold}% context usage
-                </label>
-                <input
-                  type="range"
-                  min="50"
-                  max="95"
-                  step="5"
-                  value={compactionThreshold}
-                  onChange={(e) => setCompactionThreshold(Number(e.target.value))}
-                  className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
-                />
-                <div className="flex justify-between text-xs text-gray-500 mt-1">
-                  <span>50%</span>
-                  <span>Auto-compress when context reaches {compactionThreshold}%</span>
-                  <span>95%</span>
-                </div>
-              </div>
-            </div>
-          )}
+          <p className="text-sm text-gray-500">
+            After creating the workspace, you can configure LLM settings and system prompts on the edit page.
+          </p>
 
           <div className="flex justify-end gap-3 pt-4">
             <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
               Cancel
             </Button>
             <Button type="submit" isLoading={isLoading}>
-              {workspace ? 'Save Changes' : 'Create Workspace'}
+              Create Workspace
             </Button>
           </div>
         </form>
@@ -415,6 +137,7 @@ function DeleteConfirmModal({
 }
 
 function Workspaces() {
+  const navigate = useNavigate()
   const { fetchWorkspaces: refreshWorkspaces } = useWorkspace()
   const [workspaces, setWorkspaces] = useState<Workspace[]>([])
   const [filteredWorkspaces, setFilteredWorkspaces] = useState<Workspace[]>([])
@@ -422,7 +145,6 @@ function Workspaces() {
   const [error, setError] = useState<string | null>(null)
   const [searchQuery, setSearchQuery] = useState('')
   const [showCreateModal, setShowCreateModal] = useState(false)
-  const [editingWorkspace, setEditingWorkspace] = useState<Workspace | null>(null)
   const [deletingWorkspace, setDeletingWorkspace] = useState<Workspace | null>(null)
 
   const fetchWorkspaces = useCallback(async () => {
@@ -451,11 +173,12 @@ function Workspaces() {
     )
   }, [workspaces, searchQuery])
 
-  const handleSave = () => {
+  const handleCreate = (workspaceId: string) => {
     setShowCreateModal(false)
-    setEditingWorkspace(null)
     fetchWorkspaces()
     refreshWorkspaces()
+    // Navigate to the edit page to configure the new workspace
+    navigate(`/workspaces/${workspaceId}/edit`)
   }
 
   const handleDelete = () => {
@@ -589,7 +312,7 @@ function Workspaces() {
                       <td className="whitespace-nowrap px-6 py-4 text-right">
                         <div className="flex justify-end gap-2">
                           <button
-                            onClick={() => setEditingWorkspace(workspace)}
+                            onClick={() => navigate(`/workspaces/${workspace.id}/edit`)}
                             className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
                           >
                             Edit
@@ -613,17 +336,9 @@ function Workspaces() {
 
       {/* Modals */}
       {showCreateModal && (
-        <WorkspaceModal
-          workspace={null}
+        <CreateWorkspaceModal
           onClose={() => setShowCreateModal(false)}
-          onSave={handleSave}
-        />
-      )}
-      {editingWorkspace && (
-        <WorkspaceModal
-          workspace={editingWorkspace}
-          onClose={() => setEditingWorkspace(null)}
-          onSave={handleSave}
+          onSave={handleCreate}
         />
       )}
       {deletingWorkspace && (

+ 2 - 0
webui/src/types/chat.ts

@@ -12,6 +12,7 @@ export interface ChatMessage {
   id: string
   role: 'user' | 'assistant' | 'system' | 'tool'
   content: string
+  thinking?: string  // Thinking/reasoning content from extended thinking models
   created_at: number
   page_context?: PageContext
   tool_calls?: ToolCall[]
@@ -63,6 +64,7 @@ export interface ChatResponse {
 export interface ChatStreamChunk {
   session_id: string
   delta?: string
+  thinking_delta?: string  // Thinking/reasoning content delta from extended thinking
   finish_reason?: string | number
   message_id?: string
   tool_call?: ToolCall