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:
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bot, Settings, Wrench, X } from "lucide-react";
|
||||
import { Bot, Settings, Wrench } 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 { clamp } from "@/lib/constants";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
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 ToolLog =
|
||||
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||
@@ -44,37 +45,6 @@ const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
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 blobToDataUrl = (blob: Blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -133,27 +103,18 @@ export function OnlyOfficeAiAgentPanel({
|
||||
}, [flightMode]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stepsRaw = window.localStorage.getItem("onlyoffice_ai_max_steps") || "";
|
||||
const providerRaw = (window.localStorage.getItem("onlyoffice_ai_provider") || "").trim();
|
||||
const modelRaw = window.localStorage.getItem("onlyoffice_ai_model") || "";
|
||||
const parsed = Number(stepsRaw);
|
||||
if (providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama" || providerRaw === "codex") setAiProvider(providerRaw);
|
||||
if (typeof modelRaw === "string") setAiModel(modelRaw);
|
||||
if (Number.isFinite(parsed) && parsed >= 1) setMaxSteps(clamp(Math.floor(parsed), 1, 24));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const prefs = readAiPanelPrefs("onlyoffice_ai", { provider: "online", model: "", maxSteps: 10 });
|
||||
setAiProvider(prefs.provider);
|
||||
setAiModel(prefs.model);
|
||||
setMaxSteps(clamp(Math.floor(prefs.maxSteps), 1, 24));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem("onlyoffice_ai_provider", aiProvider);
|
||||
window.localStorage.setItem("onlyoffice_ai_model", aiModel);
|
||||
window.localStorage.setItem("onlyoffice_ai_max_steps", String(maxSteps));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
writeAiPanelPrefs("onlyoffice_ai", {
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
maxSteps: clamp(Math.floor(maxSteps), 1, 24),
|
||||
});
|
||||
}, [aiProvider, aiModel, maxSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -458,41 +419,43 @@ export function OnlyOfficeAiAgentPanel({
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="fixed right-0 top-0 z-[70] h-screen w-[420px] border-l bg-background shadow-xl">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="text-sm font-semibold">OnlyOffice AI</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => setOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b px-2 py-2">
|
||||
<Button
|
||||
variant={page === "chat" ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setPage("chat")}
|
||||
>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
对话
|
||||
</Button>
|
||||
<Button
|
||||
variant={page === "tools" ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setPage("tools")}
|
||||
>
|
||||
<Wrench className="mr-2 h-4 w-4" />
|
||||
工具
|
||||
</Button>
|
||||
<Button
|
||||
variant={page === "settings" ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setPage("settings")}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
设置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-y-0 right-0 z-[70] w-[min(960px,calc(100vw-24px))] p-3">
|
||||
<AiBridgePanel
|
||||
title={page === "chat" ? "OnlyOffice AI" : page === "tools" ? "OnlyOffice 工具" : "OnlyOffice 设置"}
|
||||
subtitle="OnlyOffice AI"
|
||||
status={loading ? "运行中" : "待命"}
|
||||
onClose={() => setOpen(false)}
|
||||
scrollBody={false}
|
||||
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
|
||||
secondaryActions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("chat")}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("tools")}
|
||||
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("settings")}
|
||||
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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{page === "tools" ? (
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -687,6 +650,7 @@ export function OnlyOfficeAiAgentPanel({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AiBridgePanel>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user