feat: 提交 task-045 至 task-058 收口产物

- 收口 rust final closure checklist,推进页面/块系统/Mindmap/CLI/AI tools 到最终 cutover 状态

- 按 ai-frontend-simplification-plan-v1 接入 Hermes bridge,合并 AI 面板并清理旧前端编排残留

- 补充 harness 任务与进度记录,加入 CLI smoke 夹具/脚本,并修正文档页 bridge SSR 自请求回退逻辑
This commit is contained in:
lix-2026
2026-04-16 15:24:37 +08:00
parent 98db79b301
commit 2ff10fa86c
47 changed files with 6494 additions and 2674 deletions
@@ -7,19 +7,15 @@ import { useEditorBridgeStore } from "@/store/editor-bridge";
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { Button } from "@/components/ui/button";
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AiProvider = "online" | "local" | "ollama" | "codex";
type CodexMode = "chat" | "test" | "dev";
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
@@ -116,41 +112,6 @@ const normalizeSessions = (sessions: ChatSession[]) => {
.sort((a, b) => b.updatedAt - a.updatedAt);
};
const parseSseChunks = async (
res: Response,
onEvent: (event: string, dataText: string) => void,
) => {
if (!res.body) throw new Error("响应不支持流式读取");
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) {
const sep = buffer.indexOf("\n\n");
if (sep === -1) break;
const raw = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
// 注释/心跳:以 ":" 开头
if (raw.trimStart().startsWith(":")) continue;
const lines = raw.split(/\r?\n/);
let event = "message";
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message";
if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart());
}
onEvent(event, dataLines.join("\n"));
}
}
};
const safeJsonStringify = (value: unknown) => {
try {
return JSON.stringify(value);
@@ -177,7 +138,6 @@ export function DocumentAiAgentPanel({
const [loading, setLoading] = useState(false);
const [networkOn, setNetworkOn] = useState(() => !flightMode);
const [toolAuto, setToolAuto] = useState(true);
const [toolPickerOpen, setToolPickerOpen] = useState(false);
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
const [maxSteps, setMaxSteps] = useState<number>(10);
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
@@ -212,29 +172,18 @@ export function DocumentAiAgentPanel({
}, [setAvailable, setOpen]);
useEffect(() => {
try {
const stepsRaw = window.localStorage.getItem("doc_ai_max_steps") || "";
const p = (window.localStorage.getItem("doc_ai_provider") || "").trim();
const m = window.localStorage.getItem("doc_ai_model") || "";
const parsed = Number(stepsRaw);
if (Number.isFinite(parsed) && parsed >= MIN_AGENT_STEPS) {
setMaxSteps(clamp(Math.floor(parsed), MIN_AGENT_STEPS, MAX_AGENT_STEPS));
}
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
if (typeof m === "string") setAiModel(m);
} catch {
// ignore
}
const prefs = readAiPanelPrefs("doc_ai", { provider: "online", model: "", maxSteps: 10 });
setMaxSteps(clamp(prefs.maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS));
setAiProvider(prefs.provider);
setAiModel(prefs.model);
}, []);
useEffect(() => {
try {
window.localStorage.setItem("doc_ai_max_steps", String(maxSteps));
window.localStorage.setItem("doc_ai_provider", aiProvider);
window.localStorage.setItem("doc_ai_model", aiModel);
} catch {
// ignore
}
writeAiPanelPrefs("doc_ai", {
provider: aiProvider,
model: aiModel,
maxSteps: clamp(maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS),
});
}, [aiModel, aiProvider, maxSteps]);
useEffect(() => {
@@ -676,73 +625,89 @@ export function DocumentAiAgentPanel({
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" showCloseButton={false} className="p-0">
<SheetHeader className="border-b">
<SheetTitle className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2">
<SheetContent
side="right"
showCloseButton={false}
className="w-[min(1400px,calc(100vw-24px))] max-w-none border-l-0 bg-transparent p-3 shadow-none sm:max-w-none"
>
<SheetTitle className="sr-only"> AI</SheetTitle>
<AiBridgePanel
title={pageTitle}
subtitle="页面 AI"
status={loading ? "运行中" : "待命"}
onClose={() => setOpen(false)}
scrollBody={false}
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
secondaryActions={
<>
{page === "chat" ? (
<Sparkles className="h-4 w-4" />
<Sparkles className="h-4 w-4 text-white/75" />
) : (
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={() => setPage("chat")}
disabled={loading}
title="返回对话"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<ChevronLeft className="h-4 w-4" />
</Button>
)}
{pageTitle}
</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={startNewSession} disabled={loading} title="新建会话">
<Button
variant="ghost"
size="icon-sm"
onClick={startNewSession}
disabled={loading}
title="新建会话"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={() => setPage("tools")}
disabled={loading}
title="工具(代替 MCP"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Wrench className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={() => setPage("history")}
disabled={loading}
title="历史"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<History className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={() => setPage("account")}
disabled={loading}
title="账户/模型"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<User className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={() => setPage("settings")}
disabled={loading}
title="设置"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Settings className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setOpen(false)} title="关闭">
<X className="h-4 w-4" />
</Button>
</div>
</SheetTitle>
</SheetHeader>
<div className="flex flex-1 flex-col overflow-hidden">
</>
}
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className={page === "chat" ? "" : "hidden"}>
<div className="hidden">
<div className="flex items-center gap-2">
@@ -766,17 +731,6 @@ export function DocumentAiAgentPanel({
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
{!toolAuto && (
<button
type="button"
className="inline-flex items-center gap-1 rounded border bg-white px-2 py-1"
onClick={() => setToolPickerOpen((v) => !v)}
disabled={loading}
>
<Settings2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-2">
@@ -1446,6 +1400,7 @@ export function DocumentAiAgentPanel({
</div>
</DialogContent>
</Dialog>
</AiBridgePanel>
</SheetContent>
</Sheet>
);
@@ -3,9 +3,11 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkles, User, Wrench, X, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
type AgentAssetItem = {
kind: "media" | "local-mindmap" | "test-pdf";
@@ -18,7 +20,6 @@ type AgentAssetItem = {
};
type AgentMessage = { role: "user" | "assistant"; content: string };
type AiProvider = "online" | "local" | "ollama" | "codex";
type CodexMode = "chat" | "test" | "dev";
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
@@ -183,7 +184,6 @@ export function MindmapAiAgentPanel({
const [networkOn, setNetworkOn] = useState(() => !flightMode);
const [toolAuto, setToolAuto] = useState(true);
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
const [toolPickerOpen, setToolPickerOpen] = useState(false);
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
const [aiModel, setAiModel] = useState<string>("");
@@ -210,29 +210,18 @@ export function MindmapAiAgentPanel({
}, [flightMode]);
useEffect(() => {
try {
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
const m = window.localStorage.getItem("mindmap_ai_model") || "";
const stepsRaw = window.localStorage.getItem("mindmap_ai_max_steps") || "";
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
if (typeof m === "string") setAiModel(m);
const parsed = Number(stepsRaw);
if (Number.isFinite(parsed) && parsed >= 1) {
setMaxSteps(Math.max(1, Math.min(24, Math.floor(parsed))));
}
} catch {
// ignore
}
const prefs = readAiPanelPrefs("mindmap_ai", { provider: "online", model: "", maxSteps: 10 });
setAiProvider(prefs.provider);
setAiModel(prefs.model);
setMaxSteps(Math.max(1, Math.min(24, Math.floor(prefs.maxSteps))));
}, []);
useEffect(() => {
try {
window.localStorage.setItem("mindmap_ai_provider", aiProvider);
window.localStorage.setItem("mindmap_ai_model", aiModel);
window.localStorage.setItem("mindmap_ai_max_steps", String(maxSteps));
} catch {
// ignore
}
writeAiPanelPrefs("mindmap_ai", {
provider: aiProvider,
model: aiModel,
maxSteps: Math.max(1, Math.min(24, Math.floor(maxSteps))),
});
}, [aiProvider, aiModel, maxSteps]);
useEffect(() => {
@@ -369,38 +358,6 @@ export function MindmapAiAgentPanel({
return false;
};
const parseSseChunks = async (res: Response, onEvent: (event: string, dataText: string) => void) => {
if (!res.body) throw new Error("响应不支持流式读取");
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) {
const sep = buffer.indexOf("\n\n");
if (sep === -1) break;
const raw = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
// 注释/心跳:以 ":" 开头
if (raw.trimStart().startsWith(":")) continue;
const lines = raw.split(/\r?\n/);
let event = "message";
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message";
if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart());
}
onEvent(event, dataLines.join("\n"));
}
}
};
const selectedNodes = useMemo(() => {
const list = Array.isArray(activeNodes) ? activeNodes : [];
@@ -562,13 +519,6 @@ export function MindmapAiAgentPanel({
}, 0);
};
const toggleTool = (tool: ToolName) => {
setSelectedTools((prev) => {
if (prev.includes(tool)) return prev.filter((t) => t !== tool);
return [...prev, tool];
});
};
const uploadFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
if (!workspaceId || !documentId) {
@@ -979,42 +929,82 @@ export function MindmapAiAgentPanel({
};
return (
<div className="flex h-full flex-col">
<div className="border-b p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{page === "chat" ? (
<Sparkles className="h-4 w-4" />
) : (
<Button variant="ghost" size="icon" onClick={() => setPage("chat")} disabled={loading} title="返回对话">
<ChevronLeft className="h-4 w-4" />
</Button>
)}
<span className="text-sm font-medium">{pageTitle}</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={startNewSession} disabled={loading} title="新建会话">
<Plus className="h-4 w-4" />
<AiBridgePanel
title={pageTitle}
subtitle="思维导图 AI"
status={loading ? "运行中" : "待命"}
onClose={onClose}
scrollBody={false}
className="h-full min-h-0 rounded-[24px] border-white/10 shadow-none"
secondaryActions={
<>
{page === "chat" ? (
<Sparkles className="h-4 w-4 text-white/75" />
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("chat")}
disabled={loading}
title="返回对话"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("tools")} disabled={loading} title="工具(代替 MCP">
<Wrench className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("history")} disabled={loading} title="历史">
<History className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("account")} disabled={loading} title="账户/模型">
<User className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("settings")} disabled={loading} title="设置">
<Settings className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onClose?.()} disabled={loading} title="关闭">
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
<Button
variant="ghost"
size="icon-sm"
onClick={startNewSession}
disabled={loading}
title="新建会话"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("tools")}
disabled={loading}
title="工具(代替 MCP"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Wrench className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("history")}
disabled={loading}
title="历史"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<History className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("account")}
disabled={loading}
title="账户/模型"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<User className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("settings")}
disabled={loading}
title="设置"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Settings className="h-4 w-4" />
</Button>
</>
}
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{page === "chat" ? (
<>
@@ -1435,374 +1425,6 @@ export function MindmapAiAgentPanel({
) : null}
</div>
{false ? (
<div className="hidden">
<div className="flex items-center justify-between gap-2 pb-3">
<div
className="text-xs text-gray-500"
title={
selectedNodes.length
? selectedNodes
.map((n) => (n.text ? `${n.text}${n.uid}` : n.uid))
.slice(0, 3)
.join("")
: ""
}
>
{selectedNodes.length
? `选中节点:${selectedNodes[0]?.text || selectedNodes[0]?.uid}${selectedNodes.length > 1 ? `+${selectedNodes.length - 1}` : ""}`
: "未选中节点(将以整图为上下文)"}
</div>
<div className="flex items-center gap-1">
<button
type="button"
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${networkOn ? "border-blue-200 bg-blue-50 text-blue-700" : "border-gray-200 text-gray-500"}`}
title="联网检索(SearxNG"
onClick={() => setNetworkOn((v) => !v)}
>
<Network className="h-3 w-3" />
</button>
<button
type="button"
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${toolPickerOpen ? "border-gray-300 bg-gray-50 text-gray-700" : "border-gray-200 text-gray-600"}`}
title="选择允许使用的工具"
onClick={() => setToolPickerOpen((v) => !v)}
>
<Settings2 className="h-3 w-3" />
</button>
</div>
</div>
{toolPickerOpen && (
<div className="mb-3 rounded-md border border-gray-200 bg-white p-2">
<div className="mb-2 flex items-center justify-between">
<div className="text-xs font-medium text-gray-700"></div>
<button
type="button"
className={`rounded px-2 py-1 text-xs ${toolAuto ? "bg-blue-500 text-white" : "border border-gray-200 text-gray-600"}`}
onClick={() => setToolAuto((v) => !v)}
title="自动:AI 自行选择;手动:仅允许勾选工具"
>
{toolAuto ? "自动" : "手动"}
</button>
</div>
<div className="space-y-1">
{DEFAULT_TOOLS.map((t) => (
<label key={t} className={`flex cursor-pointer items-center gap-2 text-xs ${toolAuto ? "opacity-50" : ""}`}>
<input
type="checkbox"
disabled={toolAuto}
checked={selectedTools.includes(t)}
onChange={() => toggleTool(t)}
/>
<span className="text-gray-700">{TOOL_LABEL[t]}</span>
</label>
))}
</div>
<div className="mt-2 text-[11px] text-gray-400">
AI
</div>
</div>
)}
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-white p-2">
<div className="space-y-2">
{messages.map((m, idx) => (
<div
key={idx}
className={`whitespace-pre-wrap rounded-md px-2 py-2 text-sm ${m.role === "user" ? "bg-gray-50 text-gray-900" : "bg-white text-gray-800"}`}
>
<div className="mb-1 text-[11px] text-gray-400">{m.role === "user" ? "你" : "AI"}</div>
<div>{m.content}</div>
</div>
))}
</div>
</div>
{attachments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{attachments.map((a) => (
<span
key={a.id}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] text-gray-700"
title={a.fileUrl}
>
@{a.title}
<button
type="button"
className="text-gray-400 hover:text-gray-700"
onClick={() => setAttachments((prev) => prev.filter((x) => x.id !== a.id))}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="mt-2 rounded-md border border-gray-200 bg-white px-2 py-2 text-xs text-gray-700">
<div className="flex flex-wrap items-center gap-3">
<div className="text-gray-500">AI</div>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
data-testid="mindmap-ai-provider-online"
checked={aiProvider === "online"}
onChange={() => setAiProvider("online")}
/>
线
</label>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
data-testid="mindmap-ai-provider-local"
checked={aiProvider === "local"}
onChange={() => setAiProvider("local")}
/>
</label>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
checked={aiProvider === "ollama"}
onChange={() => setAiProvider("ollama")}
/>
Ollama
</label>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
checked={aiProvider === "codex"}
onChange={() => setAiProvider("codex")}
/>
Codex
</label>
<div className="flex items-center gap-2">
<div className="text-gray-500"></div>
{aiProvider === "codex" ? (
<div className="text-[11px] text-gray-500">
<code className="rounded bg-gray-100 px-1 py-0.5">#chat</code> /{" "}
<code className="rounded bg-gray-100 px-1 py-0.5">#test</code> /{" "}
<code className="rounded bg-gray-100 px-1 py-0.5">#dev</code> <code className="rounded bg-gray-100 px-1 py-0.5">#chat</code>
</div>
) : aiProvider === "online" ? (
<select
data-testid="mindmap-ai-model-select"
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
>
{ONLINE_MODELS.map((m) => (
<option key={m || "__default__"} value={m}>
{m ? m : "默认(ai.md"}
</option>
))}
</select>
) : aiProvider === "ollama" ? (
<select
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
>
<option value="">{OLLAMA_QWEN3_30B}</option>
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
</select>
) : (
<input
data-testid="mindmap-ai-model-input"
className="w-[220px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="默认(ai.local.md/环境变量)"
list="mindmap-local-model-suggestions-bottom"
/>
)}
<datalist id="mindmap-local-model-suggestions-bottom">
<option value={OLLAMA_QWEN3_30B} />
</datalist>
</div>
<div className="flex items-center gap-2">
<div className="text-gray-500"></div>
<input
className="w-[72px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
type="number"
min={1}
max={24}
step={1}
value={maxSteps}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isFinite(v)) return;
setMaxSteps(Math.max(1, Math.min(24, Math.floor(v))));
}}
title="工具调用/推理最大步数(上限 24)"
disabled={loading}
/>
</div>
</div>
{aiProvider === "local" ? (
<div className="mt-1 text-[11px] text-gray-400">
AI `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` `ai.local.md` / `ai-local.md`
</div>
) : null}
</div>
<div className="relative mt-2">
{mentionOpen && filteredAssets.length > 0 && (
<div className="absolute bottom-[calc(100%+8px)] left-0 right-0 z-30 max-h-56 overflow-auto rounded-md border border-gray-200 bg-white shadow">
{filteredAssets.map((a) => (
<button
key={a.id}
type="button"
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
onClick={() => insertMention(a)}
>
<span className="truncate text-gray-800">{a.title}</span>
<span className="shrink-0 text-[11px] text-gray-400">{a.kind}</span>
</button>
))}
</div>
)}
<textarea
ref={textareaRef}
data-testid="mindmap-ai-input"
className="w-full resize-none rounded-md border border-gray-200 bg-white p-2 text-sm outline-none focus:border-blue-300"
rows={4}
value={input}
placeholder="输入你的需求。使用 @ 选择文件(PDF/附件/本地导图),例如:总结 @卤化反应原理_1-9.pdf 并写入导图(章->节->要点)。"
onChange={(e) => {
const v = e.target.value;
setInput(v);
updateMentionState(v, e.target.selectionStart ?? v.length);
}}
onKeyDown={(e) => {
// 焦点在 AI 输入框时,不应触发导图 Enter/Tab 快捷键
e.stopPropagation();
if (e.key === "Escape") {
setMentionOpen(false);
setToolPickerOpen(false);
return;
}
if (e.key === "Enter") {
// Enter 发送;Shift+Enter 换行
const isComposing = Boolean((e.nativeEvent as unknown as { isComposing?: boolean })?.isComposing);
if (!e.shiftKey && !isComposing) {
e.preventDefault();
if (!loading) void send();
}
return;
}
}}
onClick={(e) => {
const el = e.currentTarget;
updateMentionState(el.value, el.selectionStart ?? el.value.length);
}}
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
onClick={() => fileInputRef.current?.click()}
title="上传文件到当前页面附件"
disabled={loading}
>
<Paperclip className="h-3 w-3" />
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
void uploadFiles(e.target.files);
}}
accept="*/*"
/>
<div className="text-[11px] text-gray-400">Enter · Shift+Enter </div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
disabled={!loading}
onClick={() => abortRef.current?.abort()}
title={aiProvider === "codex" ? "暂停本次执行(类似 ESC" : "停止本次执行"}
>
<X className="h-4 w-4" />
{aiProvider === "codex" ? "暂停" : "停止"}
</button>
<button
type="button"
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
disabled={loading || !input.trim()}
onClick={() => void send()}
>
<Send className="h-4 w-4" />
{loading ? "执行中..." : "发送"}
</button>
</div>
</div>
</div>
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
<summary className="cursor-pointer select-none"></summary>
<div className="mt-2 space-y-2">
{toolLogs.length === 0 ? <div className="text-gray-400"></div> : null}
{toolLogs.map((l, idx) => {
if (l.type === "error") {
return (
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
{l.message}
</div>
);
}
if (l.type === "info") {
return (
<div key={idx} className="rounded border bg-gray-50 p-2 text-gray-600">
{l.message}
</div>
);
}
if (l.type === "tool_call") {
return (
<div key={idx} className="rounded border p-2">
<div className="text-[11px] text-gray-400">tool_call · {l.id}</div>
<div className="font-medium text-gray-800">{l.tool}</div>
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap">{JSON.stringify(l.args, null, 2)}</pre>
</div>
);
}
return (
<div key={idx} className="rounded border p-2">
<div className="text-[11px] text-gray-400">
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
</div>
<div className="font-medium text-gray-800">{l.tool}</div>
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap">{JSON.stringify(l.result, null, 2)}</pre>
</div>
);
})}
</div>
</details>
{debug ? (
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
<summary className="cursor-pointer select-none">SSE </summary>
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
</details>
) : null}
</div>
) : null}
</div>
</AiBridgePanel>
);
}