Kaynağa Gözat

feat(studio): extract safe chat and service polish from #192

Ma 6 ay önce
ebeveyn
işleme
a4a8d4e765

+ 83 - 32
packages/studio/src/pages/ChatPage.tsx

@@ -1,4 +1,4 @@
-import { useRef, useEffect, useMemo } from "react";
+import { useRef, useEffect, useMemo, useState } from "react";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import type { SSEMessage } from "../hooks/use-sse";
@@ -30,6 +30,7 @@ import {
   Message,
   MessageContent,
 } from "../components/ai-elements/message";
+import { filterModelGroups, shouldUseFreshBookCreateSession } from "./chat-page-state";
 
 // -- Types --
 
@@ -153,9 +154,14 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
     return () => { es.close(); };
   }, [bookCreating, setCreateProgress]);
 
-  // Load session messages on mount or when activeBookId changes
+  // Load session messages on mount or when activeBookId changes.
+  // Book-create mode should always start from a fresh chat.
   useEffect(() => {
-    useChatStore.getState().loadSession(activeBookId);
+    if (shouldUseFreshBookCreateSession(activeBookId)) {
+      useChatStore.setState({ currentSessionId: null, messages: [], input: "", _activeStream: null });
+    } else {
+      useChatStore.getState().loadSession(activeBookId);
+    }
   }, [activeBookId]);
 
   const onSend = (text: string) => {
@@ -398,35 +404,13 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
                       </span>
                       <ChevronDown size={14} className="text-muted-foreground" />
                     </DropdownMenuTrigger>
-                    <DropdownMenuContent side="top" align="start" className="w-60 max-h-72 overflow-y-auto">
-                      {groupedModels.map((group) => (
-                        <div key={group.service}>
-                          <div className="px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
-                            {group.label}
-                          </div>
-                          {group.models.map((m) => {
-                            const isSelected = selectedModel === m.id && selectedService === group.service;
-                            return (
-                              <DropdownMenuItem
-                                key={`${group.service}:${m.id}`}
-                                onClick={() => setSelectedModel(m.id, group.service)}
-                                className={isSelected ? "bg-muted/50" : ""}
-                              >
-                                <div className="flex flex-1 items-center justify-between">
-                                  <span className="text-sm">{m.name ?? m.id}</span>
-                                  {isSelected && <Check size={14} className="text-primary shrink-0" />}
-                                </div>
-                              </DropdownMenuItem>
-                            );
-                          })}
-                        </div>
-                      ))}
-                      <div className="border-t border-border/30 mt-1">
-                        <DropdownMenuItem onClick={() => nav.toServices()} className="text-primary">
-                          管理服务商
-                        </DropdownMenuItem>
-                      </div>
-                    </DropdownMenuContent>
+                    <ModelPickerContent
+                      groupedModels={groupedModels}
+                      selectedModel={selectedModel}
+                      selectedService={selectedService}
+                      onSelect={setSelectedModel}
+                      onManage={() => nav.toServices()}
+                    />
                   </DropdownMenu>
                 ) : (
                   <button
@@ -444,3 +428,70 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
     </div>
   );
 }
+
+function ModelPickerContent({
+  groupedModels,
+  selectedModel,
+  selectedService,
+  onSelect,
+  onManage,
+}: {
+  groupedModels: ReadonlyArray<{ service: string; label: string; models: ReadonlyArray<{ id: string; name?: string }> }>;
+  selectedModel: string | null;
+  selectedService: string | null;
+  onSelect: (model: string, service: string) => void;
+  onManage: () => void;
+}) {
+  const [search, setSearch] = useState("");
+  const filtered = useMemo(() => filterModelGroups(groupedModels, search), [groupedModels, search]);
+
+  return (
+    <DropdownMenuContent side="top" align="start" className="w-64 max-h-80 flex flex-col">
+      <div className="px-2 py-1.5 border-b border-border/30">
+        <input
+          type="text"
+          value={search}
+          onChange={(e) => setSearch(e.target.value)}
+          placeholder="搜索模型..."
+          className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
+          onClick={(e) => e.stopPropagation()}
+          onKeyDown={(e) => e.stopPropagation()}
+        />
+      </div>
+      <div className="overflow-y-auto flex-1">
+        {filtered.map((group) => (
+          <div key={group.service}>
+            <div className="px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
+              {group.label}
+            </div>
+            {group.models.map((m) => {
+              const isSelected = selectedModel === m.id && selectedService === group.service;
+              return (
+                <DropdownMenuItem
+                  key={`${group.service}:${m.id}`}
+                  onClick={() => onSelect(m.id, group.service)}
+                  className={isSelected ? "bg-muted/50" : ""}
+                >
+                  <div className="flex flex-1 items-center justify-between">
+                    <span className="text-sm">{m.name ?? m.id}</span>
+                    {isSelected && <Check size={14} className="text-primary shrink-0" />}
+                  </div>
+                </DropdownMenuItem>
+              );
+            })}
+          </div>
+        ))}
+        {filtered.length === 0 && (
+          <div className="px-3 py-4 text-xs text-muted-foreground/50 text-center italic">
+            无匹配模型
+          </div>
+        )}
+      </div>
+      <div className="border-t border-border/30">
+        <DropdownMenuItem onClick={onManage} className="text-primary">
+          管理服务商
+        </DropdownMenuItem>
+      </div>
+    </DropdownMenuContent>
+  );
+}

+ 27 - 8
packages/studio/src/pages/ServiceDetailPage.tsx

@@ -223,16 +223,35 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
       });
       if (trimmedKey) {
         try {
-          const data = await fetchJson<{ models: ModelInfo[] }>(`/services/${encodeURIComponent(effectiveServiceId)}/models`);
-          const m = data.models ?? [];
-          if (m.length > 0) {
-            setStoreModels(effectiveServiceId, m);
-            setStatus({ state: "connected", models: m });
+          const result = await fetchJson<{
+            ok: boolean;
+            models?: ModelInfo[];
+            selectedModel?: string;
+            detected?: DetectedConfig;
+            error?: string;
+          }>(`/services/${encodeURIComponent(effectiveServiceId)}/test`, {
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify({
+              apiKey: trimmedKey,
+              apiFormat,
+              stream,
+              ...(isCustom ? { baseUrl: baseUrl.trim() } : {}),
+            }),
+          });
+          if (result.ok && result.models) {
+            if (result.detected?.apiFormat) setApiFormat(result.detected.apiFormat);
+            if (typeof result.detected?.stream === "boolean") setStream(result.detected.stream);
+            if (isCustom && result.detected?.baseUrl) setBaseUrl(result.detected.baseUrl);
+            setDetectedModel(result.selectedModel ?? detectedModel);
+            setDetectedConfig(result.detected ?? detectedConfig);
+            setStoreModels(effectiveServiceId, result.models);
+            setStatus({ state: "connected", models: result.models });
           } else {
-            setStatus({ state: "saved" });
+            setStatus({ state: "error", message: result.error ?? "连接失败" });
           }
-        } catch {
-          setStatus({ state: "saved" });
+        } catch (e) {
+          setStatus({ state: "error", message: e instanceof Error ? e.message : "连接失败" });
         }
       } else {
         clearStoreModels(effectiveServiceId);

+ 61 - 0
packages/studio/src/pages/chat-page-state.test.ts

@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+import { filterModelGroups, shouldUseFreshBookCreateSession } from "./chat-page-state";
+
+describe("shouldUseFreshBookCreateSession", () => {
+  it("uses a fresh session when there is no active book", () => {
+    expect(shouldUseFreshBookCreateSession(undefined)).toBe(true);
+    expect(shouldUseFreshBookCreateSession("")).toBe(true);
+  });
+
+  it("reuses the persisted session when a book is active", () => {
+    expect(shouldUseFreshBookCreateSession("demo-book")).toBe(false);
+  });
+});
+
+describe("filterModelGroups", () => {
+  const grouped = [
+    {
+      service: "openai",
+      label: "OpenAI",
+      models: [
+        { id: "gpt-5.4", name: "gpt-5.4" },
+        { id: "gpt-4o", name: "gpt-4o" },
+      ],
+    },
+    {
+      service: "custom:gemma",
+      label: "LM Studio",
+      models: [
+        { id: "google/gemma-4-27b-it", name: "google/gemma-4-27b-it" },
+      ],
+    },
+  ] as const;
+
+  it("returns all groups when search is blank", () => {
+    expect(filterModelGroups(grouped, "")).toEqual(grouped);
+    expect(filterModelGroups(grouped, "   ")).toEqual(grouped);
+  });
+
+  it("filters by model name and preserves only matching groups", () => {
+    expect(filterModelGroups(grouped, "gemma")).toEqual([
+      {
+        service: "custom:gemma",
+        label: "LM Studio",
+        models: [{ id: "google/gemma-4-27b-it", name: "google/gemma-4-27b-it" }],
+      },
+    ]);
+  });
+
+  it("filters by service label", () => {
+    expect(filterModelGroups(grouped, "openai")).toEqual([
+      {
+        service: "openai",
+        label: "OpenAI",
+        models: [
+          { id: "gpt-5.4", name: "gpt-5.4" },
+          { id: "gpt-4o", name: "gpt-4o" },
+        ],
+      },
+    ]);
+  });
+});

+ 32 - 0
packages/studio/src/pages/chat-page-state.ts

@@ -0,0 +1,32 @@
+export interface ChatPageModelInfo {
+  readonly id: string;
+  readonly name?: string;
+}
+
+export interface ChatPageModelGroup {
+  readonly service: string;
+  readonly label: string;
+  readonly models: ReadonlyArray<ChatPageModelInfo>;
+}
+
+export function shouldUseFreshBookCreateSession(activeBookId?: string): boolean {
+  return !activeBookId;
+}
+
+export function filterModelGroups(
+  groupedModels: ReadonlyArray<ChatPageModelGroup>,
+  search: string,
+): ReadonlyArray<ChatPageModelGroup> {
+  const query = search.trim().toLowerCase();
+  if (!query) return groupedModels;
+
+  return groupedModels
+    .map((group) => ({
+      ...group,
+      models: group.models.filter((model) =>
+        (model.name ?? model.id).toLowerCase().includes(query)
+        || group.label.toLowerCase().includes(query),
+      ),
+    }))
+    .filter((group) => group.models.length > 0);
+}

+ 16 - 0
packages/studio/src/store/chat/message-policy.test.ts

@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+import { shouldRefreshSidebarForTool } from "./message-policy";
+
+describe("shouldRefreshSidebarForTool", () => {
+  it("does not refresh for read-only tools", () => {
+    expect(shouldRefreshSidebarForTool("read")).toBe(false);
+    expect(shouldRefreshSidebarForTool("grep")).toBe(false);
+    expect(shouldRefreshSidebarForTool("ls")).toBe(false);
+  });
+
+  it("refreshes for mutating and unknown tools", () => {
+    expect(shouldRefreshSidebarForTool("edit")).toBe(true);
+    expect(shouldRefreshSidebarForTool("sub_agent")).toBe(true);
+    expect(shouldRefreshSidebarForTool("some_future_tool")).toBe(true);
+  });
+});

+ 5 - 0
packages/studio/src/store/chat/message-policy.ts

@@ -0,0 +1,5 @@
+const READ_ONLY_TOOLS = new Set(["read", "grep", "ls"]);
+
+export function shouldRefreshSidebarForTool(toolName: string): boolean {
+  return !READ_ONLY_TOOLS.has(toolName);
+}

+ 4 - 1
packages/studio/src/store/chat/slices/message/action.ts

@@ -1,6 +1,7 @@
 import type { StateCreator } from "zustand";
 import type { ChatStore, MessageActions, AgentResponse, SessionMessage, Message, MessagePart, ToolExecution, PipelineStage } from "../../types";
 import { fetchJson } from "../../../../hooks/use-api";
+import { shouldRefreshSidebarForTool } from "../../message-policy";
 
 function extractErrorMessage(error: string | { code?: string; message?: string }): string {
   if (typeof error === "string") return error;
@@ -347,7 +348,9 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
           return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
         });
 
-        get().bumpBookDataVersion();
+        if (shouldRefreshSidebarForTool(d.tool)) {
+          get().bumpBookDataVersion();
+        }
       } catch { /* ignore */ }
     });