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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user