chore: add sproutclaw git workflow and track local extensions
Some checks failed
CI / build-check-test (push) Has been cancelled
Some checks failed
CI / build-check-test (push) Has been cancelled
Document main/upstream-sync/feature branch strategy, add sync/push scripts, track .pi/agent extensions and webui in git, and disable startup changelog via showChangelogOnStartup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1
.pi/agent/extensions/webui/frontend/.gitignore
vendored
Normal file
1
.pi/agent/extensions/webui/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
29
.pi/agent/extensions/webui/frontend/index.html
Normal file
29
.pi/agent/extensions/webui/frontend/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f7f8fb" />
|
||||
<meta name="description" content="树萌芽智能 AI 助手 Web 客户端" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="萌小芽" />
|
||||
<title>萌小芽</title>
|
||||
<link rel="icon" href="/logo.png" type="image/png" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-regular.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-bold.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6817
.pi/agent/extensions/webui/frontend/package-lock.json
generated
Normal file
6817
.pi/agent/extensions/webui/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
.pi/agent/extensions/webui/frontend/package.json
Normal file
28
.pi/agent/extensions/webui/frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "sproutclaw-webui-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"generate-icons": "node scripts/generate-icons.mjs",
|
||||
"dev": "vite",
|
||||
"build": "npm run generate-icons && tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.12",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"sharp": "^0.34.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.0"
|
||||
}
|
||||
}
|
||||
BIN
.pi/agent/extensions/webui/frontend/public/logo.png
Normal file
BIN
.pi/agent/extensions/webui/frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 615 KiB |
BIN
.pi/agent/extensions/webui/frontend/public/logo192.png
Normal file
BIN
.pi/agent/extensions/webui/frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
.pi/agent/extensions/webui/frontend/public/logo512.png
Normal file
BIN
.pi/agent/extensions/webui/frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 KiB |
@@ -0,0 +1,22 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import sharp from "sharp";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const publicDir = join(__dirname, "..", "public");
|
||||
const source = join(publicDir, "logo.png");
|
||||
|
||||
if (!existsSync(source)) {
|
||||
console.warn("[generate-icons] logo.png not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const size of [192, 512]) {
|
||||
const out = join(publicDir, `logo${size}.png`);
|
||||
await sharp(source)
|
||||
.resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toFile(out);
|
||||
console.log(`[generate-icons] wrote ${out}`);
|
||||
}
|
||||
33
.pi/agent/extensions/webui/frontend/src/App.tsx
Normal file
33
.pi/agent/extensions/webui/frontend/src/App.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
|
||||
import { ChatLayout } from "./components/layout/ChatLayout";
|
||||
import { AvatarProvider } from "./context/AvatarContext";
|
||||
import { BootstrapProvider } from "./context/BootstrapContext";
|
||||
import { ChatProvider } from "./context/ChatContext";
|
||||
import { ChatPage } from "./routes/ChatPage";
|
||||
import { SettingsPage } from "./routes/SettingsPage";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<BootstrapProvider>
|
||||
<AvatarProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ChatProvider>
|
||||
<ChatLayout />
|
||||
</ChatProvider>
|
||||
}
|
||||
>
|
||||
<Route index element={<ChatPage />} />
|
||||
</Route>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
<PwaUpdatePrompt />
|
||||
</AvatarProvider>
|
||||
</BootstrapProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
30
.pi/agent/extensions/webui/frontend/src/api/chat.ts
Normal file
30
.pi/agent/extensions/webui/frontend/src/api/chat.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
import type { ImageContent, ModelInfo, SessionState } from "../types/message";
|
||||
|
||||
export function sendChat(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
|
||||
return apiPost("/api/chat", { message, images });
|
||||
}
|
||||
|
||||
export function abortChat(): Promise<{ ok: boolean }> {
|
||||
return apiPost("/api/abort");
|
||||
}
|
||||
|
||||
export function fetchSessionState(): Promise<SessionState> {
|
||||
return apiGet("/api/session-state");
|
||||
}
|
||||
|
||||
export function fetchModels(): Promise<{ models: ModelInfo[] }> {
|
||||
return apiGet("/api/models");
|
||||
}
|
||||
|
||||
export function setModel(provider: string, modelId: string): Promise<{ ok: boolean }> {
|
||||
return apiPost("/api/model", { provider, modelId });
|
||||
}
|
||||
|
||||
export function setThinkingLevel(level: string): Promise<{ ok: boolean }> {
|
||||
return apiPost("/api/thinking", { level });
|
||||
}
|
||||
|
||||
export function createNewSession(): Promise<{ sessionFile?: string }> {
|
||||
return apiPost("/api/new-session");
|
||||
}
|
||||
30
.pi/agent/extensions/webui/frontend/src/api/client.ts
Normal file
30
.pi/agent/extensions/webui/frontend/src/api/client.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
|
||||
if (!res.ok || (data && "error" in data && data.error)) {
|
||||
throw new ApiError((data as { error?: string }).error || res.statusText, res.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function apiPost<T>(url: string, body?: unknown): Promise<T> {
|
||||
return apiFetch<T>(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string): Promise<T> {
|
||||
return apiFetch<T>(url);
|
||||
}
|
||||
51
.pi/agent/extensions/webui/frontend/src/api/sessions.ts
Normal file
51
.pi/agent/extensions/webui/frontend/src/api/sessions.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
||||
|
||||
export function fetchSessions(): Promise<{ sessions: SessionSummary[] }> {
|
||||
return apiGet("/api/sessions");
|
||||
}
|
||||
|
||||
export function fetchSessionHistory(path: string): Promise<SessionHistoryPayload> {
|
||||
return apiPost("/api/sessions/history", { path });
|
||||
}
|
||||
|
||||
export async function activateSessionBackend(path: string): Promise<void> {
|
||||
const activateRes = await fetch("/api/sessions/activate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
const activateData = (await activateRes.json().catch(() => ({}))) as { error?: string };
|
||||
if (activateRes.ok && !activateData.error) return;
|
||||
|
||||
const activateError = activateData.error || activateRes.statusText || "Unknown error";
|
||||
if (activateRes.status !== 404 && !/not found/i.test(activateError)) {
|
||||
throw new Error(activateError);
|
||||
}
|
||||
|
||||
const fallbackRes = await fetch("/api/sessions/load", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
const fallbackData = (await fallbackRes.json().catch(() => ({}))) as { error?: string };
|
||||
if (!fallbackRes.ok || fallbackData.error) {
|
||||
throw new Error(fallbackData.error || fallbackRes.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSession(path: string): Promise<{ ok?: boolean; error?: string }> {
|
||||
return apiPost("/api/sessions/load", { path });
|
||||
}
|
||||
|
||||
export function deleteSession(path: string): Promise<{ ok?: boolean }> {
|
||||
return apiPost("/api/sessions/delete", { path });
|
||||
}
|
||||
|
||||
export function renameSession(path: string, name: string): Promise<{ ok?: boolean }> {
|
||||
return apiPost("/api/sessions/name", { path, name });
|
||||
}
|
||||
|
||||
export function setSessionPinned(path: string, pinned: boolean): Promise<{ ok?: boolean; pinned?: boolean; pinnedPaths?: string[] }> {
|
||||
return apiPost("/api/sessions/pin", { path, pinned });
|
||||
}
|
||||
22
.pi/agent/extensions/webui/frontend/src/api/settings.ts
Normal file
22
.pi/agent/extensions/webui/frontend/src/api/settings.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
import type { AvatarSettings, SettingsData } from "../types/events";
|
||||
|
||||
export function fetchSettings(): Promise<SettingsData> {
|
||||
return apiGet("/api/settings");
|
||||
}
|
||||
|
||||
export function fetchAvatars(): Promise<AvatarSettings> {
|
||||
return apiGet("/api/avatars");
|
||||
}
|
||||
|
||||
export function reloadSettings(): Promise<{ ok?: boolean }> {
|
||||
return apiPost("/api/settings/reload");
|
||||
}
|
||||
|
||||
export function saveSystemPrompt(systemPrompt: string): Promise<{ systemPromptPath?: string }> {
|
||||
return apiPost("/api/settings/system-prompt", { systemPrompt });
|
||||
}
|
||||
|
||||
export function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & { ok?: boolean }> {
|
||||
return apiPost("/api/settings/avatars", avatars);
|
||||
}
|
||||
25
.pi/agent/extensions/webui/frontend/src/api/webuiConfig.ts
Normal file
25
.pi/agent/extensions/webui/frontend/src/api/webuiConfig.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
|
||||
export interface WebuiConfigResponse {
|
||||
config?: Record<string, string>;
|
||||
dbPath?: string;
|
||||
key?: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
export function fetchWebuiConfig(key?: string): Promise<WebuiConfigResponse> {
|
||||
const query = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||
return apiGet(`/api/webui/config${query}`);
|
||||
}
|
||||
|
||||
export function saveWebuiConfig(key: string, value: string): Promise<{ ok?: boolean; key?: string; value?: string }> {
|
||||
return apiPost("/api/webui/config", { key, value });
|
||||
}
|
||||
|
||||
export function saveWebuiConfigMany(entries: Record<string, string>): Promise<{ ok?: boolean; config?: Record<string, string> }> {
|
||||
return apiPost("/api/webui/config", { entries });
|
||||
}
|
||||
|
||||
export function deleteWebuiConfig(key: string): Promise<{ ok?: boolean; deleted?: boolean; key?: string }> {
|
||||
return apiPost("/api/webui/config/delete", { key });
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
.inputArea {
|
||||
padding: 10px 16px 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.inputAreaDragOver {
|
||||
background: rgba(239, 246, 255, 0.96);
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.attachmentItem {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.attachmentThumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.attachmentRemove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
background: #f6f7fb;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 14px;
|
||||
padding: 4px 4px 4px 8px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.wrapper:focus-within {
|
||||
border-color: #2563eb;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #1a1a1a;
|
||||
padding: 8px 0;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
min-height: 24px;
|
||||
max-height: 120px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.attachBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.attachBtn:hover:not(:disabled) {
|
||||
background: #eef2ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.attachBtn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sendBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.sendBtn:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.sendBtn:disabled {
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.inputArea {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
padding: 8px 10px calc(10px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 2px 2px 2px 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useRef, useState, type ClipboardEvent, type DragEvent, type KeyboardEvent } from "react";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import type { ImageContent } from "../../types/message";
|
||||
import { isTouchLike } from "../../utils/format";
|
||||
import {
|
||||
clipboardItemsToImages,
|
||||
fileToImageContent,
|
||||
imageToDataUrl,
|
||||
MAX_CHAT_IMAGES,
|
||||
} from "../../utils/images";
|
||||
import styles from "./ChatInput.module.css";
|
||||
|
||||
export function ChatInput() {
|
||||
const { isStreaming, sendMessage, addSystemMessage } = useChatContext();
|
||||
const [value, setValue] = useState("");
|
||||
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const touchLike = isTouchLike();
|
||||
|
||||
const canSend = (value.trim().length > 0 || pendingImages.length > 0) && !isStreaming;
|
||||
|
||||
const addImages = async (files: FileList | File[]) => {
|
||||
const list = Array.from(files);
|
||||
if (!list.length) return;
|
||||
|
||||
const remaining = MAX_CHAT_IMAGES - pendingImages.length;
|
||||
if (remaining <= 0) {
|
||||
addSystemMessage(`最多只能附加 ${MAX_CHAT_IMAGES} 张图片`);
|
||||
return;
|
||||
}
|
||||
|
||||
const next: ImageContent[] = [];
|
||||
for (const file of list.slice(0, remaining)) {
|
||||
try {
|
||||
next.push(await fileToImageContent(file));
|
||||
} catch (err) {
|
||||
addSystemMessage(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
if (next.length) {
|
||||
setPendingImages((prev) => [...prev, ...next].slice(0, MAX_CHAT_IMAGES));
|
||||
}
|
||||
};
|
||||
|
||||
const removePendingImage = (index: number) => {
|
||||
setPendingImages((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = value.trim();
|
||||
if ((!text && pendingImages.length === 0) || isStreaming) return;
|
||||
|
||||
const images = pendingImages.length ? pendingImages : undefined;
|
||||
setValue("");
|
||||
setPendingImages([]);
|
||||
if (inputRef.current) inputRef.current.style.height = "auto";
|
||||
await sendMessage(text, images);
|
||||
if (touchLike) inputRef.current?.blur();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBeforeInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const native = e.nativeEvent as InputEvent;
|
||||
if (touchLike && native.inputType === "insertLineBreak") {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
|
||||
};
|
||||
|
||||
const handlePaste = (e: ClipboardEvent<HTMLElement>) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
const hasImage = Array.from(items).some((item) => item.type.startsWith("image/"));
|
||||
if (!hasImage) return;
|
||||
|
||||
e.preventDefault();
|
||||
void clipboardItemsToImages(items)
|
||||
.then((images) => {
|
||||
if (!images.length) return;
|
||||
setPendingImages((prev) => {
|
||||
const remaining = MAX_CHAT_IMAGES - prev.length;
|
||||
if (remaining <= 0) {
|
||||
addSystemMessage(`最多只能附加 ${MAX_CHAT_IMAGES} 张图片`);
|
||||
return prev;
|
||||
}
|
||||
return [...prev, ...images.slice(0, remaining)];
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
addSystemMessage(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files?.length) void addImages(files);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (files.length) void addImages(files);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.inputArea} ${dragOver ? styles.inputAreaDragOver : ""}`}
|
||||
onPaste={handlePaste}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{pendingImages.length > 0 ? (
|
||||
<div className={styles.attachments}>
|
||||
{pendingImages.map((img, index) => (
|
||||
<div key={`${img.mimeType}-${index}`} className={styles.attachmentItem}>
|
||||
<img
|
||||
className={styles.attachmentThumb}
|
||||
src={imageToDataUrl(img)}
|
||||
alt=""
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.attachmentRemove}
|
||||
onClick={() => removePendingImage(index)}
|
||||
aria-label="移除图片"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.wrapper}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
multiple
|
||||
className={styles.fileInput}
|
||||
onChange={handleFileChange}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.attachBtn}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isStreaming || pendingImages.length >= MAX_CHAT_IMAGES}
|
||||
title="上传图片"
|
||||
aria-label="上传图片"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
</button>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
placeholder="输入消息,Ctrl+V 粘贴图片..."
|
||||
aria-label="消息输入框"
|
||||
enterKeyHint="send"
|
||||
inputMode="text"
|
||||
/>
|
||||
<button
|
||||
className={styles.sendBtn}
|
||||
type="button"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!canSend}
|
||||
aria-label="发送"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 148px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.menuItemDesc {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.menu {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { fetchSessionHistory } from "../../api/sessions";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import type { ChatMessage } from "../../types/message";
|
||||
import { extractContent, formatToolCall, getToolCalls } from "../../utils/toolCall";
|
||||
import { nextMessageId } from "../../utils/format";
|
||||
import {
|
||||
downloadTextFile,
|
||||
messagesToMarkdown,
|
||||
messagesToMinimalHtml,
|
||||
safeExportFilename,
|
||||
} from "../../utils/exportConversation";
|
||||
import styles from "./ExportMenu.module.css";
|
||||
|
||||
function historyToExportMessages(
|
||||
messages: NonNullable<Awaited<ReturnType<typeof fetchSessionHistory>>["messages"]>,
|
||||
): ChatMessage[] {
|
||||
const result: ChatMessage[] = [];
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
result.push({ id: nextMessageId(), role: "user", content: extractContent(m.content) });
|
||||
} else if (m.role === "assistant") {
|
||||
const text = extractContent(m.content);
|
||||
if (text) result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||
for (const tc of getToolCalls(m.content)) {
|
||||
const tid = tc.toolCallId || tc.id;
|
||||
result.push({
|
||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||
role: "tool_call",
|
||||
content: formatToolCall(tc),
|
||||
toolCallId: tid ? String(tid) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ExportMenu() {
|
||||
const { headerTitle, messages, activeSessionPath, isStreaming, addSystemMessage } = useChatContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasMessages = messages.some((m) => !m.streaming && m.content.trim());
|
||||
const canExport = hasMessages && !isStreaming && !exporting;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const resolveExportMessages = async (): Promise<ChatMessage[]> => {
|
||||
if (activeSessionPath) {
|
||||
try {
|
||||
const payload = await fetchSessionHistory(activeSessionPath);
|
||||
if (payload.messages?.length) {
|
||||
return historyToExportMessages(payload.messages);
|
||||
}
|
||||
} catch {
|
||||
/* fallback to in-memory messages */
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
};
|
||||
|
||||
const handleExport = async (format: "markdown" | "html") => {
|
||||
if (!canExport) return;
|
||||
setExporting(true);
|
||||
setOpen(false);
|
||||
try {
|
||||
const exportMessages = await resolveExportMessages();
|
||||
const filtered = exportMessages.filter((m) => !m.streaming && m.content.trim());
|
||||
if (!filtered.length) {
|
||||
addSystemMessage("当前对话没有可导出的内容");
|
||||
return;
|
||||
}
|
||||
const title = headerTitle.trim() || "对话";
|
||||
const base = safeExportFilename(title);
|
||||
if (format === "markdown") {
|
||||
downloadTextFile(`${base}.md`, messagesToMarkdown(title, filtered), "text/markdown");
|
||||
} else {
|
||||
downloadTextFile(`${base}.html`, messagesToMinimalHtml(title, filtered), "text/html");
|
||||
}
|
||||
} catch (err) {
|
||||
addSystemMessage(`导出失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrap} ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btn}
|
||||
disabled={!canExport}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 3v12M7 8l5 5 5-5M5 21h14" />
|
||||
</svg>
|
||||
导出
|
||||
</button>
|
||||
{open ? (
|
||||
<div className={styles.menu} role="menu">
|
||||
<button type="button" className={styles.menuItem} role="menuitem" onClick={() => void handleExport("markdown")}>
|
||||
Markdown
|
||||
<span className={styles.menuItemDesc}>.md 格式,保留 Markdown 结构</span>
|
||||
</button>
|
||||
<button type="button" className={styles.menuItem} role="menuitem" onClick={() => void handleExport("html")}>
|
||||
HTML 超简洁文本
|
||||
<span className={styles.menuItemDesc}>纯文本风格 HTML,便于阅读与分享</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState } from "react";
|
||||
import type { ChatMessage } from "../../types/message";
|
||||
import { useAvatars } from "../../context/AvatarContext";
|
||||
import {
|
||||
downloadTextFile,
|
||||
messageMarkdownFilename,
|
||||
singleAssistantMarkdown,
|
||||
} from "../../utils/exportConversation";
|
||||
import { renderMarkdown } from "../../utils/markdown";
|
||||
import { imageToDataUrl } from "../../utils/images";
|
||||
import { ToolCallBlock } from "./ToolCallBlock";
|
||||
import styles from "./MessageList.module.css";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
showAgentAvatar?: boolean;
|
||||
agentAvatarSpacer?: boolean;
|
||||
}
|
||||
|
||||
function ChatAvatar({ src, className }: { src: string; className: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (!src || failed) return null;
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
src={src}
|
||||
width={32}
|
||||
height={32}
|
||||
alt=""
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantActions({ content }: { content: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const markdown = singleAssistantMarkdown(content);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
downloadTextFile(messageMarkdownFilename(content), markdown, "text/markdown");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.assistantToolbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.msgActionBtn} ${copied ? styles.msgActionBtnCopied : ""}`}
|
||||
onClick={() => void handleCopy()}
|
||||
title="复制 Markdown"
|
||||
>
|
||||
{copied ? "已复制" : "复制"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.msgActionBtn}
|
||||
onClick={handleDownload}
|
||||
title="下载 Markdown"
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
message,
|
||||
showAgentAvatar = false,
|
||||
agentAvatarSpacer = false,
|
||||
}: MessageBubbleProps) {
|
||||
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
|
||||
|
||||
if (message.role === "tool_call") {
|
||||
return (
|
||||
<div className={styles.assistantRow}>
|
||||
{showAgentAvatar ? (
|
||||
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
||||
) : agentAvatarSpacer ? (
|
||||
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
|
||||
) : null}
|
||||
<div className={`${styles.msg} ${styles.toolCall}`}>
|
||||
<ToolCallBlock content={message.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const classNames = [styles.msg];
|
||||
if (message.role === "user") classNames.push(styles.user);
|
||||
if (message.role === "assistant") classNames.push(styles.assistant);
|
||||
if (message.role === "system") classNames.push(styles.system);
|
||||
if (message.streaming) classNames.push(styles.streaming);
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const showActions = !message.streaming && message.content.trim().length > 0;
|
||||
return (
|
||||
<div className={styles.assistantRow}>
|
||||
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
||||
<div className={classNames.join(" ")}>
|
||||
{showActions ? <AssistantActions content={message.content} /> : null}
|
||||
<div
|
||||
className={styles.assistantBody}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className={styles.userRow}>
|
||||
<div className={classNames.join(" ")}>
|
||||
{message.content ? <div>{message.content}</div> : null}
|
||||
{message.images?.length ? (
|
||||
<div className={styles.userImages}>
|
||||
{message.images.map((img, index) => (
|
||||
<img
|
||||
key={`${img.mimeType}-${index}`}
|
||||
className={styles.userImage}
|
||||
src={imageToDataUrl(img)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ChatAvatar src={userAvatarUrl} className={styles.userAvatar} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={classNames.join(" ")}>{message.content}</div>;
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
.chat {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.chat::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat::-webkit-scrollbar-thumb {
|
||||
background: #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat::-webkit-scrollbar-thumb:hover {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: #bbb;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.msg {
|
||||
padding: 10px 12px;
|
||||
line-height: 1.4;
|
||||
font-size: 16px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.user {
|
||||
max-width: min(700px, 85%);
|
||||
background: linear-gradient(180deg, #f0f7ff 0%, #eaf2ff 100%);
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
margin: 0;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.userRow {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: min(700px, 85%);
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.userAvatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8px;
|
||||
object-fit: cover;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.userImages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.userImages:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.userImage {
|
||||
max-width: min(280px, 100%);
|
||||
max-height: 240px;
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
border: 1px solid #dbeafe;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.assistantRow {
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.assistantAvatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8px;
|
||||
object-fit: cover;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.assistantAvatarSpacer {
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.assistant {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8ecf0;
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
color: #1a1a1a;
|
||||
line-height: 1.46;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.assistantToolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin: -2px -2px 6px 0;
|
||||
min-height: 22px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.assistant:hover .assistantToolbar,
|
||||
.assistant:focus-within .assistantToolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.msgActionBtn {
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #6b7280;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.msgActionBtn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.msgActionBtnCopied {
|
||||
color: #15803d;
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.assistantBody {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.system {
|
||||
align-self: center;
|
||||
color: #aaa;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
color: #666;
|
||||
padding: 0;
|
||||
margin: 0 0 4px;
|
||||
background: #fafafa;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #eee;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.streaming::after {
|
||||
content: "▊";
|
||||
animation: blink 0.8s step-end infinite;
|
||||
color: #2563eb;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.assistantRow + .assistantRow {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.assistant :global(h1),
|
||||
.assistant :global(h2),
|
||||
.assistant :global(h3),
|
||||
.assistant :global(h4),
|
||||
.assistant :global(h5),
|
||||
.assistant :global(h6) {
|
||||
margin: 0.78em 0 0.3em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.assistant :global(h1) { font-size: 1.18em; }
|
||||
.assistant :global(h2) { font-size: 1.1em; }
|
||||
.assistant :global(h3) { font-size: 1.03em; }
|
||||
.assistant :global(h4) { font-size: 1em; }
|
||||
|
||||
.assistant > :global(:first-child) { margin-top: 0; }
|
||||
.assistant > :global(:last-child) { margin-bottom: 0; }
|
||||
|
||||
.assistant :global(p) {
|
||||
margin: 0 0 0.46em;
|
||||
}
|
||||
|
||||
.assistant :global(p + p) {
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
.assistant :global(strong) { font-weight: 600; }
|
||||
.assistant :global(em) { font-style: italic; }
|
||||
|
||||
.assistant :global(a) {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.assistant :global(a:hover) { text-decoration: underline; }
|
||||
|
||||
.assistant :global(ul),
|
||||
.assistant :global(ol) {
|
||||
margin: 0.32em 0 0.48em;
|
||||
padding-left: 1.18em;
|
||||
}
|
||||
|
||||
.assistant :global(li) {
|
||||
margin: 0.08em 0;
|
||||
padding-left: 0.04em;
|
||||
}
|
||||
|
||||
.assistant :global(li > p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.assistant :global(blockquote) {
|
||||
margin: 0.36em 0;
|
||||
padding: 3px 9px;
|
||||
border-left: 2px solid #e5e5e5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.assistant :global(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid #eee;
|
||||
margin: 0.58em 0;
|
||||
}
|
||||
|
||||
.assistant :global(table) {
|
||||
border-collapse: collapse;
|
||||
margin: 0.42em 0;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.assistant :global(th),
|
||||
.assistant :global(td) {
|
||||
border: 1px solid #e5e5e5;
|
||||
padding: 4px 7px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.assistant :global(th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant :global(code:not(.hljs)) {
|
||||
font-size: 0.9em;
|
||||
background: #f5f5f5;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
color: #d63384;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.assistant :global(pre.assistant-pre) {
|
||||
margin: 0.5em 0 0.6em;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid #e8edf2;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.assistant :global(pre.assistant-pre code.hljs) {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.48;
|
||||
word-break: normal;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.assistant :global(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
margin: 0.42em 0;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.assistantRow {
|
||||
max-width: 66.6667%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.assistantRow .toolCall {
|
||||
max-width: none;
|
||||
width: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat {
|
||||
padding: 10px 12px 8px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
padding: 11px 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.userRow {
|
||||
max-width: 88%;
|
||||
}
|
||||
|
||||
.user {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistantAvatarSpacer {
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.assistantAvatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.userAvatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.assistantToolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.userRow {
|
||||
max-width: 92%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import styles from "./MessageList.module.css";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD = 96;
|
||||
|
||||
function isNearBottom(el: HTMLElement): boolean {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_THRESHOLD;
|
||||
}
|
||||
|
||||
function scrollToBottom(el: HTMLElement): void {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
export function MessageList() {
|
||||
const { messages } = useChatContext();
|
||||
const chatRef = useRef<HTMLDivElement>(null);
|
||||
const stickToBottomRef = useRef(true);
|
||||
const prevMessageCountRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = chatRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onScroll = () => {
|
||||
stickToBottomRef.current = isNearBottom(el);
|
||||
};
|
||||
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = chatRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const prevCount = prevMessageCountRef.current;
|
||||
const messageCount = messages.length;
|
||||
prevMessageCountRef.current = messageCount;
|
||||
|
||||
const lastMessage = messages[messageCount - 1];
|
||||
const userJustSent = messageCount > prevCount && lastMessage?.role === "user";
|
||||
|
||||
if (userJustSent || prevCount === 0) {
|
||||
stickToBottomRef.current = true;
|
||||
}
|
||||
|
||||
if (!stickToBottomRef.current) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (chatRef.current) scrollToBottom(chatRef.current);
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className={styles.chat} ref={chatRef}>
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.emptyIcon}>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>发送一条消息开始对话</p>
|
||||
<p className={styles.emptyHint}>按 Enter 发送 · Shift+Enter 换行</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.chat} ref={chatRef}>
|
||||
{messages.map((msg, index) => {
|
||||
const prev = messages[index - 1];
|
||||
const showAgentAvatar =
|
||||
msg.role === "assistant" ||
|
||||
(msg.role === "tool_call" &&
|
||||
prev?.role !== "tool_call" &&
|
||||
prev?.role !== "assistant");
|
||||
const agentAvatarSpacer =
|
||||
msg.role === "tool_call" &&
|
||||
(prev?.role === "tool_call" || prev?.role === "assistant");
|
||||
|
||||
return (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
showAgentAvatar={showAgentAvatar}
|
||||
agentAvatarSpacer={agentAvatarSpacer}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.bar {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.42;
|
||||
color: #5c6578;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.left {
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-break: break-word;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: #8b95a8;
|
||||
}
|
||||
|
||||
.secondary:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctxWarn {
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ctxDanger {
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bar {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { formatFooterTokens } from "../../utils/format";
|
||||
import styles from "./SessionContextBar.module.css";
|
||||
|
||||
export function SessionContextBar() {
|
||||
const { sessionState, isStreaming } = useChatContext();
|
||||
const data = sessionState;
|
||||
|
||||
if (!data || data.error || !data.stats || !data.model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tok = data.stats.tokens || {};
|
||||
const parts: string[] = [];
|
||||
if (tok.input) parts.push(`↑${formatFooterTokens(tok.input)}`);
|
||||
if (tok.output) parts.push(`↓${formatFooterTokens(tok.output)}`);
|
||||
if (tok.cacheRead) parts.push(`R${formatFooterTokens(tok.cacheRead)}`);
|
||||
if (tok.cacheWrite) parts.push(`W${formatFooterTokens(tok.cacheWrite)}`);
|
||||
|
||||
const costNum = Number(data.stats.cost ?? 0);
|
||||
parts.push(`$${costNum.toFixed(3)}`);
|
||||
|
||||
const cx = data.stats.contextUsage;
|
||||
const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0;
|
||||
const pctRaw = cx?.percent;
|
||||
const pctStr = pctRaw != null ? Number(pctRaw).toFixed(1) : "?";
|
||||
const pctNum = pctRaw != null ? Number(pctRaw) : null;
|
||||
const autoInd = data.autoCompactionEnabled ? " (auto)" : "";
|
||||
const ctxTxt =
|
||||
pctStr === "?" && cw
|
||||
? `?/${formatFooterTokens(cw)}${autoInd}`
|
||||
: `${pctStr}%/${formatFooterTokens(cw)}${autoInd}`;
|
||||
|
||||
const liveStreaming = isStreaming || data.isStreaming;
|
||||
let sub = "";
|
||||
if (data.isCompacting) sub = "⚡ 正在整理上下文…";
|
||||
else if (liveStreaming) sub = "⋯ 回复中…";
|
||||
else if ((data.turnIndex ?? 0) > 0) sub = `✓ Turn ${data.turnIndex} complete`;
|
||||
|
||||
let ctxClass = "";
|
||||
if (pctNum != null) {
|
||||
if (pctNum > 90) ctxClass = styles.ctxDanger;
|
||||
else if (pctNum > 70) ctxClass = styles.ctxWarn;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.left}>
|
||||
{parts.join(" ")} <span className={ctxClass}>{ctxTxt}</span>
|
||||
</div>
|
||||
</div>
|
||||
{sub ? <div className={styles.secondary}>{sub}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
.collapsible {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary::before {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid #888;
|
||||
border-top: 3.5px solid transparent;
|
||||
border-bottom: 3.5px solid transparent;
|
||||
flex-shrink: 0;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.collapsible[open] > .summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 0;
|
||||
padding: 6px 10px 8px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.detailPre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { deriveToolCallSummary } from "../../utils/toolCall";
|
||||
import styles from "./ToolCallBlock.module.css";
|
||||
|
||||
interface ToolCallBlockProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ToolCallBlock({ content }: ToolCallBlockProps) {
|
||||
return (
|
||||
<details className={styles.collapsible}>
|
||||
<summary className={styles.summary}>
|
||||
<span className={styles.summaryText}>{deriveToolCallSummary(content)}</span>
|
||||
</summary>
|
||||
<div className={styles.detail}>
|
||||
<pre className={styles.detailPre}>{content}</pre>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.app {
|
||||
display: flex;
|
||||
height: var(--app-height);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.overlayOpen {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app {
|
||||
height: var(--app-height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import layoutStyles from "./AppLayout.module.css";
|
||||
|
||||
export function ChatLayout() {
|
||||
const { sidebarOpen, toggleSidebar } = useChatContext();
|
||||
|
||||
return (
|
||||
<div className={layoutStyles.app}>
|
||||
<Sidebar />
|
||||
<main className={layoutStyles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<div
|
||||
className={`${layoutStyles.overlay} ${sidebarOpen ? layoutStyles.overlayOpen : ""}`}
|
||||
onClick={toggleSidebar}
|
||||
role="presentation"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.trailing {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
max-width: min(68%, 680px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menuBtn {
|
||||
display: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menuBtn:hover {
|
||||
background: #f0f0f0;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info h1 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.abortBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.abortBtn:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.modelControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modelSelect,
|
||||
.thinkingSelect {
|
||||
height: 30px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modelSelect {
|
||||
width: min(260px, 28vw);
|
||||
}
|
||||
|
||||
.thinkingSelect {
|
||||
width: 86px;
|
||||
}
|
||||
|
||||
.modelSelect:focus,
|
||||
.thinkingSelect:focus {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.22);
|
||||
}
|
||||
|
||||
.modelSelect:disabled,
|
||||
.thinkingSelect:disabled {
|
||||
color: #9ca3af;
|
||||
background: #f9fafb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 10px 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.menuBtn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.trailing {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
margin-left: 0;
|
||||
justify-content: flex-start;
|
||||
padding-left: 44px;
|
||||
box-sizing: border-box;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modelControls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modelSelect {
|
||||
width: min(100%, 260px);
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { modelKey, modelLabel } from "../../utils/sessionTitle";
|
||||
import { ExportMenu } from "../chat/ExportMenu";
|
||||
import { SessionContextBar } from "../chat/SessionContextBar";
|
||||
import styles from "./Header.module.css";
|
||||
|
||||
export function Header() {
|
||||
const {
|
||||
connected,
|
||||
isStreaming,
|
||||
headerTitle,
|
||||
headerMeta,
|
||||
toggleSidebar,
|
||||
abortStream,
|
||||
availableModels,
|
||||
currentModelKey,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
isApplyingModelSettings,
|
||||
applyModelSelection,
|
||||
applyThinkingSelection,
|
||||
} = useChatContext();
|
||||
|
||||
const metaText =
|
||||
headerMeta ||
|
||||
(!connected ? "未连接" : isStreaming ? "回复中" : "");
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<button type="button" className={styles.menuBtn} onClick={toggleSidebar} aria-label="切换菜单">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className={styles.info}>
|
||||
<h1>{headerTitle}</h1>
|
||||
{metaText ? <span className={styles.meta}>{metaText}</span> : null}
|
||||
</div>
|
||||
<div className={styles.trailing}>
|
||||
<div className={styles.modelControls}>
|
||||
<select
|
||||
className={styles.modelSelect}
|
||||
aria-label="选择模型"
|
||||
title="选择模型"
|
||||
value={currentModelKey}
|
||||
disabled={isStreaming || isApplyingModelSettings}
|
||||
onChange={(e) => void applyModelSelection(e.target.value)}
|
||||
>
|
||||
{availableModels.length === 0 ? (
|
||||
<option value="">加载模型...</option>
|
||||
) : (
|
||||
availableModels.map((m) => (
|
||||
<option key={modelKey(m)} value={modelKey(m)}>
|
||||
{modelLabel(m)}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<select
|
||||
className={styles.thinkingSelect}
|
||||
aria-label="选择推理强度"
|
||||
title="推理强度"
|
||||
value={
|
||||
availableThinkingLevels.includes(thinkingLevel as typeof availableThinkingLevels[number])
|
||||
? thinkingLevel
|
||||
: availableThinkingLevels[0] || "off"
|
||||
}
|
||||
disabled={
|
||||
!availableThinkingLevels.length ||
|
||||
availableThinkingLevels.length === 1 ||
|
||||
isStreaming ||
|
||||
isApplyingModelSettings
|
||||
}
|
||||
onChange={(e) => void applyThinkingSelection(e.target.value)}
|
||||
>
|
||||
{availableThinkingLevels.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SessionContextBar />
|
||||
<ExportMenu />
|
||||
{isStreaming ? (
|
||||
<button type="button" className={styles.abortBtn} onClick={() => void abortStream()} title="中止回复">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
中止
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
.sidebar {
|
||||
width: 236px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(10px);
|
||||
border-right: 1px solid #e5e5e5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 18px 16px 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: -0.3px;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sectionRefresh {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #bbb;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sectionRefresh:hover {
|
||||
color: #666;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 8px 10px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.actionBtn:hover {
|
||||
background: #f0f0f0;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.actionBtn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connected {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.streaming {
|
||||
background: #f59e0b;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.disconnected {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.statusModel {
|
||||
font-size: 10px;
|
||||
color: #bbb;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { SessionList } from "../session/SessionList";
|
||||
import styles from "./Sidebar.module.css";
|
||||
|
||||
export function Sidebar() {
|
||||
const { connected, isStreaming, sidebarOpen, loadSessions, newSession } = useChatContext();
|
||||
|
||||
const statusDotClass = connected
|
||||
? isStreaming
|
||||
? `${styles.statusDot} ${styles.streaming}`
|
||||
: `${styles.statusDot} ${styles.connected}`
|
||||
: `${styles.statusDot} ${styles.disconnected}`;
|
||||
|
||||
const statusText = !connected ? "未连接" : isStreaming ? "输入中..." : "就绪";
|
||||
|
||||
return (
|
||||
<aside className={`${styles.sidebar} ${sidebarOpen ? styles.open : ""}`}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.brand}>
|
||||
<img className={styles.logo} src="/logo.png" width={36} height={36} alt="" decoding="async" />
|
||||
<h2 className={styles.title}>萌小芽</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<span>历史会话</span>
|
||||
<button type="button" className={styles.sectionRefresh} onClick={() => void loadSessions()} title="刷新">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 4v6h6M23 20v-6h-6" />
|
||||
<path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<SessionList />
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.actionBtn} onClick={newSession}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
新会话
|
||||
</button>
|
||||
<Link to="/settings" className={styles.actionBtn}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 00.3 1.9l.1.1a2 2 0 01-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.9-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 01-4 0v-.1a1.7 1.7 0 00-1-1.5 1.7 1.7 0 00-1.9.3l-.1.1A2 2 0 014.2 17l.1-.1A1.7 1.7 0 004.6 15a1.7 1.7 0 00-1.5-1H3a2 2 0 010-4h.1a1.7 1.7 0 001.5-1 1.7 1.7 0 00-.3-1.9L4.2 7A2 2 0 017 4.2l.1.1A1.7 1.7 0 009 4.6a1.7 1.7 0 001-1.5V3a2 2 0 014 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.9-.3l.1-.1A2 2 0 0119.8 7l-.1.1a1.7 1.7 0 00-.3 1.9 1.7 1.7 0 001.5 1h.1a2 2 0 010 4h-.1a1.7 1.7 0 00-1.5 1z" />
|
||||
</svg>
|
||||
设置
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.status}>
|
||||
<div className={styles.statusRow}>
|
||||
<span className={statusDotClass} />
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.bar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: min(92vw, 420px);
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.primary {
|
||||
color: #fff;
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
color: #4b5563;
|
||||
background: #fff;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.secondary:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useRegisterSW } from "virtual:pwa-register/react";
|
||||
import styles from "./PwaUpdatePrompt.module.css";
|
||||
|
||||
export function PwaUpdatePrompt() {
|
||||
const {
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
onRegistered(registration) {
|
||||
if (!registration) return;
|
||||
window.setInterval(() => {
|
||||
void registration.update();
|
||||
}, 60 * 60 * 1000);
|
||||
},
|
||||
onRegisterError(error) {
|
||||
console.error("[PWA] service worker registration failed:", error);
|
||||
},
|
||||
});
|
||||
|
||||
if (!needRefresh) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.bar} role="status" aria-live="polite">
|
||||
<div className={styles.text}>发现新版本,更新后即可使用最新功能。</div>
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.secondary} onClick={() => setNeedRefresh(false)}>
|
||||
稍后
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primary}
|
||||
onClick={() => void updateServiceWorker(true)}
|
||||
>
|
||||
立即更新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
.splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(37, 99, 235, 0.12), transparent 34%),
|
||||
radial-gradient(circle at 82% 88%, rgba(34, 197, 94, 0.08), transparent 32%),
|
||||
linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
|
||||
opacity: 1;
|
||||
transition: opacity 0.42s ease;
|
||||
}
|
||||
|
||||
.splashExiting {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bgPulse {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 45%, rgba(37, 99, 235, 0.14), transparent 42%),
|
||||
radial-gradient(circle at 50% 55%, rgba(34, 197, 94, 0.1), transparent 48%);
|
||||
animation: bgPulse 3.6s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bgGlow {
|
||||
position: absolute;
|
||||
width: min(72vw, 420px);
|
||||
height: min(72vw, 420px);
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(37, 99, 235, 0.12) 0%, transparent 68%);
|
||||
filter: blur(8px);
|
||||
animation: glowDrift 4.8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.logoWrap {
|
||||
position: relative;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(37, 99, 235, 0.28);
|
||||
opacity: 0;
|
||||
animation: ringExpand 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.ring2 {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
.ring3 {
|
||||
animation-delay: 1.6s;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
border-radius: 22px;
|
||||
box-shadow:
|
||||
0 10px 28px rgba(37, 99, 235, 0.18),
|
||||
0 2px 8px rgba(15, 23, 42, 0.08);
|
||||
animation: logoFloat 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.titleBlock {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(28px, 6vw, 36px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.4px;
|
||||
color: #111827;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
animation: dotPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot2 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.dot3 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bgPulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.72; }
|
||||
50% { transform: scale(1.06); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes glowDrift {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(-10px) scale(1.04); }
|
||||
}
|
||||
|
||||
@keyframes logoFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
@keyframes ringExpand {
|
||||
0% {
|
||||
transform: scale(0.72);
|
||||
opacity: 0.65;
|
||||
}
|
||||
70% {
|
||||
opacity: 0.12;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.45);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.72);
|
||||
opacity: 0.45;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bgPulse,
|
||||
.bgGlow,
|
||||
.logo,
|
||||
.ring,
|
||||
.dot {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.ring {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import styles from "./SplashScreen.module.css";
|
||||
|
||||
interface SplashScreenProps {
|
||||
exiting?: boolean;
|
||||
}
|
||||
|
||||
export function SplashScreen({ exiting = false }: SplashScreenProps) {
|
||||
return (
|
||||
<div className={`${styles.splash} ${exiting ? styles.splashExiting : ""}`} aria-hidden={exiting}>
|
||||
<div className={styles.bgPulse} />
|
||||
<div className={styles.bgGlow} />
|
||||
<div className={styles.content}>
|
||||
<div className={styles.logoWrap}>
|
||||
<span className={styles.ring} />
|
||||
<span className={`${styles.ring} ${styles.ring2}`} />
|
||||
<span className={`${styles.ring} ${styles.ring3}`} />
|
||||
<img className={styles.logo} src="/logo192.png" width={96} height={96} alt="" decoding="async" />
|
||||
</div>
|
||||
<div className={styles.titleBlock}>
|
||||
<h1 className={styles.title}>萌小芽</h1>
|
||||
<p className={styles.subtitle}>加载中</p>
|
||||
</div>
|
||||
<div className={styles.dots} aria-label="加载中">
|
||||
<span className={styles.dot} />
|
||||
<span className={`${styles.dot} ${styles.dot2}`} />
|
||||
<span className={`${styles.dot} ${styles.dot3}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 6px;
|
||||
}
|
||||
|
||||
.list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.list::-webkit-scrollbar-thumb {
|
||||
background: #ddd;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 10px;
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.pinned {
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.pinned.active {
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding: 8px 4px 8px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pinnedBadge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ca8a04;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.pinnedBadge:hover {
|
||||
opacity: 1;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.titleRow .name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 10px;
|
||||
color: #bbb;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-right: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.item:hover .actions,
|
||||
.active .actions,
|
||||
.actions:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pinBtn,
|
||||
.renameBtn,
|
||||
.deleteBtn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #bbb;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.pinBtn:hover {
|
||||
color: #ca8a04;
|
||||
background: #fef9c3;
|
||||
}
|
||||
|
||||
.renameBtn:hover {
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.deleteBtn:hover {
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { formatTimeAgo } from "../../utils/format";
|
||||
import { formatSessionTitle } from "../../utils/sessionTitle";
|
||||
import styles from "./SessionList.module.css";
|
||||
|
||||
export function SessionList() {
|
||||
const { sessions, activeSessionPath, loadSession, deleteSession, renameSession, toggleSessionPin } =
|
||||
useChatContext();
|
||||
|
||||
if (!sessions.length) {
|
||||
return <div className={styles.empty}>暂无历史会话</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{sessions.map((s) => {
|
||||
const isActive = activeSessionPath === s.path;
|
||||
const isPinned = Boolean(s.pinned);
|
||||
const title = formatSessionTitle(s.name);
|
||||
return (
|
||||
<div
|
||||
key={s.path}
|
||||
className={`${styles.item} ${isActive ? styles.active : ""} ${isPinned ? styles.pinned : ""}`}
|
||||
>
|
||||
<button type="button" className={styles.main} onClick={() => void loadSession(s.path)}>
|
||||
<div className={styles.titleRow}>
|
||||
{isPinned ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pinnedBadge}
|
||||
title="取消置顶"
|
||||
aria-label={`取消置顶 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleSessionPin(s.path, false);
|
||||
}}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M16 3H5a1 1 0 00-.8 1.6l4.6 6.12-4.6 6.12A1 1 0 005 18h11l4 3V3l-4 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<div className={styles.name}>{title}</div>
|
||||
</div>
|
||||
<div className={styles.meta}>
|
||||
{s.messageCount ?? 0} 条 · {formatTimeAgo(s.modified || s.created)}
|
||||
</div>
|
||||
</button>
|
||||
<div className={styles.actions}>
|
||||
{!isPinned ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pinBtn}
|
||||
title="置顶会话"
|
||||
aria-label={`置顶 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleSessionPin(s.path, true);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 3h6l1 7h4l-5 6v4H9v-4L4 10h4l1-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.renameBtn}
|
||||
title="重命名会话"
|
||||
aria-label={`重命名 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void renameSession(s.path, title);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.deleteBtn}
|
||||
title="删除会话"
|
||||
aria-label={`删除 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void deleteSession(s.path);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import * as settingsApi from "../api/settings";
|
||||
import type { AvatarSettings } from "../types/events";
|
||||
|
||||
interface AvatarContextValue extends AvatarSettings {
|
||||
loaded: boolean;
|
||||
refreshAvatars: () => Promise<void>;
|
||||
updateAvatars: (next: AvatarSettings) => void;
|
||||
}
|
||||
|
||||
const AvatarContext = createContext<AvatarContextValue | null>(null);
|
||||
|
||||
export function AvatarProvider({ children }: { children: ReactNode }) {
|
||||
const [userAvatarUrl, setUserAvatarUrl] = useState("");
|
||||
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const refreshAvatars = useCallback(async () => {
|
||||
try {
|
||||
const data = await settingsApi.fetchAvatars();
|
||||
setUserAvatarUrl(data.userAvatarUrl || "");
|
||||
setAgentAvatarUrl(data.agentAvatarUrl || "");
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateAvatars = useCallback((next: AvatarSettings) => {
|
||||
setUserAvatarUrl(next.userAvatarUrl || "");
|
||||
setAgentAvatarUrl(next.agentAvatarUrl || "");
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAvatars();
|
||||
}, [refreshAvatars]);
|
||||
|
||||
const value = useMemo(
|
||||
(): AvatarContextValue => ({
|
||||
userAvatarUrl,
|
||||
agentAvatarUrl,
|
||||
loaded,
|
||||
refreshAvatars,
|
||||
updateAvatars,
|
||||
}),
|
||||
[userAvatarUrl, agentAvatarUrl, loaded, refreshAvatars, updateAvatars],
|
||||
);
|
||||
|
||||
return <AvatarContext.Provider value={value}>{children}</AvatarContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAvatars(): AvatarContextValue {
|
||||
const ctx = useContext(AvatarContext);
|
||||
if (!ctx) throw new Error("useAvatars must be used within AvatarProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { SplashScreen } from "../components/pwa/SplashScreen";
|
||||
|
||||
const MIN_SPLASH_MS = 400;
|
||||
const SKIP_SPLASH_KEY = "sproutclaw-warm-boot";
|
||||
|
||||
type BootstrapRoute = "chat" | "settings";
|
||||
|
||||
interface BootstrapContextValue {
|
||||
markRouteReady: (route: BootstrapRoute) => void;
|
||||
}
|
||||
|
||||
const BootstrapContext = createContext<BootstrapContextValue | null>(null);
|
||||
|
||||
export function BootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const route: BootstrapRoute = location.pathname.startsWith("/settings") ? "settings" : "chat";
|
||||
const [readyRoutes, setReadyRoutes] = useState<Record<BootstrapRoute, boolean>>({
|
||||
chat: false,
|
||||
settings: false,
|
||||
});
|
||||
const [splashVisible, setSplashVisible] = useState(true);
|
||||
const [splashExiting, setSplashExiting] = useState(false);
|
||||
const mountTime = useRef(Date.now());
|
||||
|
||||
const markRouteReady = useCallback((readyRoute: BootstrapRoute) => {
|
||||
setReadyRoutes((prev) => (prev[readyRoute] ? prev : { ...prev, [readyRoute]: true }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setReadyRoutes((prev) => ({ ...prev, [route]: false }));
|
||||
setSplashVisible(true);
|
||||
setSplashExiting(false);
|
||||
mountTime.current = Date.now();
|
||||
}, [route]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!readyRoutes[route]) return;
|
||||
|
||||
let warmBoot = false;
|
||||
try {
|
||||
warmBoot = sessionStorage.getItem(SKIP_SPLASH_KEY) === "1";
|
||||
sessionStorage.setItem(SKIP_SPLASH_KEY, "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (warmBoot) {
|
||||
setSplashVisible(false);
|
||||
setSplashExiting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - mountTime.current;
|
||||
const remaining = Math.max(0, MIN_SPLASH_MS - elapsed);
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setSplashExiting(true);
|
||||
window.setTimeout(() => setSplashVisible(false), 420);
|
||||
}, remaining);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [readyRoutes, route]);
|
||||
|
||||
const value = useMemo(() => ({ markRouteReady }), [markRouteReady]);
|
||||
|
||||
return (
|
||||
<BootstrapContext.Provider value={value}>
|
||||
{children}
|
||||
{splashVisible ? <SplashScreen exiting={splashExiting} /> : null}
|
||||
</BootstrapContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBootstrap(): BootstrapContextValue {
|
||||
const ctx = useContext(BootstrapContext);
|
||||
if (!ctx) throw new Error("useBootstrap must be used within BootstrapProvider");
|
||||
return ctx;
|
||||
}
|
||||
937
.pi/agent/extensions/webui/frontend/src/context/ChatContext.tsx
Normal file
937
.pi/agent/extensions/webui/frontend/src/context/ChatContext.tsx
Normal file
@@ -0,0 +1,937 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import * as chatApi from "../api/chat";
|
||||
import * as sessionsApi from "../api/sessions";
|
||||
import { useBootstrap } from "./BootstrapContext";
|
||||
import type { SseEvent } from "../types/events";
|
||||
import type { ChatMessage, ImageContent, ModelInfo, SessionState, ThinkingLevel } from "../types/message";
|
||||
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
||||
import { nextMessageId, syncViewportHeight } from "../utils/format";
|
||||
import {
|
||||
extractContent,
|
||||
extractImages,
|
||||
formatToolCall,
|
||||
getToolCalls,
|
||||
buildToolCallUpdateText,
|
||||
} from "../utils/toolCall";
|
||||
import {
|
||||
formatSessionTitle,
|
||||
isMachineSessionLabel,
|
||||
modelKey,
|
||||
titleFromFirstUserMessage,
|
||||
} from "../utils/sessionTitle";
|
||||
|
||||
interface ChatContextValue {
|
||||
connected: boolean;
|
||||
isStreaming: boolean;
|
||||
messages: ChatMessage[];
|
||||
sessions: SessionSummary[];
|
||||
activeSessionPath: string | null;
|
||||
headerTitle: string;
|
||||
headerMeta: string;
|
||||
sidebarOpen: boolean;
|
||||
sessionState: SessionState | null;
|
||||
availableModels: ModelInfo[];
|
||||
currentModelKey: string;
|
||||
thinkingLevel: string;
|
||||
availableThinkingLevels: ThinkingLevel[];
|
||||
isApplyingModelSettings: boolean;
|
||||
toggleSidebar: () => void;
|
||||
closeSidebar: () => void;
|
||||
loadSessions: () => Promise<void>;
|
||||
loadSession: (path: string) => Promise<void>;
|
||||
deleteSession: (path: string) => Promise<void>;
|
||||
renameSession: (path: string, currentTitle: string) => Promise<void>;
|
||||
toggleSessionPin: (path: string, pinned: boolean) => Promise<void>;
|
||||
newSession: () => void;
|
||||
sendMessage: (text: string, images?: ImageContent[]) => Promise<void>;
|
||||
abortStream: () => Promise<void>;
|
||||
applyModelSelection: (key: string) => Promise<void>;
|
||||
applyThinkingSelection: (level: string) => Promise<void>;
|
||||
addSystemMessage: (text: string) => void;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextValue | null>(null);
|
||||
|
||||
const THINKING_LEVELS_LIST: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||
|
||||
function getAvailableThinkingLevels(model: ModelInfo | undefined): ThinkingLevel[] {
|
||||
if (!model?.reasoning) return ["off"];
|
||||
return THINKING_LEVELS_LIST.filter((level) => {
|
||||
const mapped = model.thinkingLevelMap?.[level];
|
||||
if (mapped === null) return false;
|
||||
if (level === "xhigh") return mapped !== undefined;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function historyToMessages(payload: SessionHistoryPayload): ChatMessage[] {
|
||||
const result: ChatMessage[] = [];
|
||||
for (const m of payload.messages || []) {
|
||||
if (m.role === "user") {
|
||||
const text = extractContent(m.content);
|
||||
const images = extractImages(m.content);
|
||||
if (text || images.length) {
|
||||
result.push({
|
||||
id: nextMessageId(),
|
||||
role: "user",
|
||||
content: text,
|
||||
...(images.length ? { images } : {}),
|
||||
});
|
||||
}
|
||||
} else if (m.role === "assistant") {
|
||||
const text = extractContent(m.content);
|
||||
const toolCalls = getToolCalls(m.content);
|
||||
if (text) {
|
||||
result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||
}
|
||||
for (const tc of toolCalls) {
|
||||
const tid = tc.toolCallId || tc.id;
|
||||
result.push({
|
||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||
role: "tool_call",
|
||||
content: formatToolCall(tc),
|
||||
toolCallId: tid ? String(tid) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findStreamingAssistantId(messages: ChatMessage[]): string | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === "assistant" && msg.streaming) {
|
||||
return msg.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function finalizeAssistantInList(
|
||||
prev: ChatMessage[],
|
||||
targetId: string,
|
||||
assistantText: string,
|
||||
): ChatMessage[] {
|
||||
let next = prev.map((msg) =>
|
||||
msg.id === targetId ? { ...msg, content: assistantText, streaming: false } : msg,
|
||||
);
|
||||
if (!assistantText.trim()) {
|
||||
next = next.filter((msg) => msg.id !== targetId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const { markRouteReady } = useBootstrap();
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||
const [activeSessionPath, setActiveSessionPath] = useState<string | null>(null);
|
||||
const [backendSessionPath, setBackendSessionPath] = useState<string | null>(null);
|
||||
const [headerTitle, setHeaderTitle] = useState("聊天");
|
||||
const [headerMeta, setHeaderMeta] = useState("");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sessionState, setSessionState] = useState<SessionState | null>(null);
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [currentModelKey, setCurrentModelKey] = useState("");
|
||||
const [thinkingLevel, setThinkingLevel] = useState("off");
|
||||
const [isApplyingModelSettings, setIsApplyingModelSettings] = useState(false);
|
||||
|
||||
const sessionCache = useRef(new Map<string, SessionHistoryPayload>());
|
||||
const sessionActivationPromise = useRef<Promise<void> | null>(null);
|
||||
const newSessionPromise = useRef<Promise<void> | null>(null);
|
||||
const freshSessionBootRef = useRef<Promise<void> | null>(null);
|
||||
const streamingAssistantId = useRef<string | null>(null);
|
||||
const optimisticUserIdRef = useRef<string | null>(null);
|
||||
const sessionDashboardRaf = useRef<number | null>(null);
|
||||
const backendSessionPathRef = useRef(backendSessionPath);
|
||||
const activeSessionPathRef = useRef(activeSessionPath);
|
||||
const isStreamingRef = useRef(isStreaming);
|
||||
|
||||
useEffect(() => {
|
||||
backendSessionPathRef.current = backendSessionPath;
|
||||
}, [backendSessionPath]);
|
||||
useEffect(() => {
|
||||
activeSessionPathRef.current = activeSessionPath;
|
||||
}, [activeSessionPath]);
|
||||
useEffect(() => {
|
||||
isStreamingRef.current = isStreaming;
|
||||
}, [isStreaming]);
|
||||
|
||||
const availableThinkingLevels = useMemo(
|
||||
() => getAvailableThinkingLevels(sessionState?.model),
|
||||
[sessionState?.model],
|
||||
);
|
||||
|
||||
const addSystemMessage = useCallback((text: string) => {
|
||||
setMessages((prev) => [...prev, { id: nextMessageId(), role: "system", content: text }]);
|
||||
}, []);
|
||||
|
||||
const scheduleSessionDashboardRefresh = useCallback(() => {
|
||||
if (sessionDashboardRaf.current != null) return;
|
||||
sessionDashboardRaf.current = requestAnimationFrame(() => {
|
||||
sessionDashboardRaf.current = null;
|
||||
void chatApi.fetchSessionState().then((data) => {
|
||||
if (data.error) return;
|
||||
setSessionState(data);
|
||||
if (data.model) setCurrentModelKey(modelKey(data.model));
|
||||
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
|
||||
|
||||
if (!activeSessionPathRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (backendSessionPathRef.current !== activeSessionPathRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sid = data.sessionId != null ? String(data.sessionId) : "";
|
||||
if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) {
|
||||
setHeaderTitle(formatSessionTitle(data.sessionName));
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchSessionStateFull = useCallback(async () => {
|
||||
try {
|
||||
const data = await chatApi.fetchSessionState();
|
||||
if (data.error) return;
|
||||
setSessionState(data);
|
||||
if (data.model) setCurrentModelKey(modelKey(data.model));
|
||||
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
|
||||
|
||||
if (!activeSessionPathRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (backendSessionPathRef.current !== activeSessionPathRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sid = data.sessionId != null ? String(data.sessionId) : "";
|
||||
const pathKey = activeSessionPathRef.current;
|
||||
setSessions((currentSessions) => {
|
||||
const row = pathKey ? currentSessions.find((s) => s.path === pathKey) : undefined;
|
||||
if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) {
|
||||
setHeaderTitle(formatSessionTitle(data.sessionName));
|
||||
} else if (row) {
|
||||
if (row.name && !isMachineSessionLabel(row.name, sid)) {
|
||||
setHeaderTitle(formatSessionTitle(row.name));
|
||||
} else if (row.firstMessage && row.firstMessage !== "(空)") {
|
||||
setHeaderTitle(formatSessionTitle(titleFromFirstUserMessage(row.firstMessage)));
|
||||
}
|
||||
}
|
||||
return currentSessions;
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const activateSessionBackend = useCallback(async (path: string) => {
|
||||
await sessionsApi.activateSessionBackend(path);
|
||||
setBackendSessionPath(path);
|
||||
}, []);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const data = await sessionsApi.fetchSessions();
|
||||
setSessions(data.sessions || []);
|
||||
} catch (err) {
|
||||
addSystemMessage(`会话列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [addSystemMessage]);
|
||||
|
||||
const clearChat = useCallback((title = "聊天") => {
|
||||
setMessages([]);
|
||||
streamingAssistantId.current = null;
|
||||
setActiveSessionPath(null);
|
||||
setHeaderTitle(title);
|
||||
setSessionState(null);
|
||||
}, []);
|
||||
|
||||
const ensureFreshSessionOnBoot = useCallback(() => {
|
||||
if (!freshSessionBootRef.current) {
|
||||
freshSessionBootRef.current = (async () => {
|
||||
const data = await chatApi.createNewSession();
|
||||
setBackendSessionPath(data.sessionFile || null);
|
||||
setActiveSessionPath(null);
|
||||
setMessages([]);
|
||||
streamingAssistantId.current = null;
|
||||
setHeaderTitle("聊天");
|
||||
sessionActivationPromise.current = null;
|
||||
})().catch((err) => {
|
||||
freshSessionBootRef.current = null;
|
||||
addSystemMessage(
|
||||
`初始化新会话失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return freshSessionBootRef.current;
|
||||
}, [addSystemMessage]);
|
||||
|
||||
const loadSession = useCallback(
|
||||
async (path: string) => {
|
||||
setSidebarOpen(false);
|
||||
setActiveSessionPath(path);
|
||||
setHeaderMeta("加载中...");
|
||||
|
||||
try {
|
||||
let payload = sessionCache.current.get(path);
|
||||
const poisonName = (p: SessionHistoryPayload) =>
|
||||
typeof p?.session?.name === "string" &&
|
||||
p.session.name.trim() &&
|
||||
isMachineSessionLabel(p.session.name.trim(), String(p.session?.id ?? ""));
|
||||
if (payload && poisonName(payload)) {
|
||||
sessionCache.current.delete(path);
|
||||
payload = undefined;
|
||||
}
|
||||
if (!payload) {
|
||||
payload = await sessionsApi.fetchSessionHistory(path);
|
||||
if (payload.error) throw new Error(payload.error);
|
||||
sessionCache.current.set(path, payload);
|
||||
}
|
||||
|
||||
const sid = payload.session?.id != null ? String(payload.session.id) : "";
|
||||
let headerLabel = "";
|
||||
if (
|
||||
typeof payload.session?.name === "string" &&
|
||||
payload.session.name.trim() &&
|
||||
!isMachineSessionLabel(payload.session.name.trim(), sid)
|
||||
) {
|
||||
headerLabel = payload.session.name.trim();
|
||||
}
|
||||
if (!headerLabel) {
|
||||
for (const m of payload.messages || []) {
|
||||
if (m.role === "user") {
|
||||
const fb = titleFromFirstUserMessage(extractContent(m.content));
|
||||
if (fb) {
|
||||
headerLabel = fb;
|
||||
break;
|
||||
}
|
||||
if (extractImages(m.content).length) {
|
||||
headerLabel = `[${extractImages(m.content).length} 张图片]`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setHeaderTitle(formatSessionTitle(headerLabel));
|
||||
setMessages(historyToMessages(payload));
|
||||
setHeaderMeta("");
|
||||
scheduleSessionDashboardRefresh();
|
||||
} catch (err) {
|
||||
setHeaderMeta("");
|
||||
addSystemMessage(`加载会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
[addSystemMessage, scheduleSessionDashboardRefresh],
|
||||
);
|
||||
|
||||
const deleteSessionHandler = useCallback(
|
||||
async (path: string) => {
|
||||
if (isStreamingRef.current) {
|
||||
addSystemMessage("正在回复中,暂不能删除会话");
|
||||
return;
|
||||
}
|
||||
const row = sessions.find((s) => s.path === path);
|
||||
const title = formatSessionTitle(row?.name || row?.firstMessage || "该会话");
|
||||
if (!confirm(`删除会话「${title}」?此操作不可恢复。`)) return;
|
||||
|
||||
try {
|
||||
await sessionsApi.deleteSession(path);
|
||||
sessionCache.current.delete(path);
|
||||
setSessions((prev) => prev.filter((s) => s.path !== path));
|
||||
if (activeSessionPathRef.current === path) {
|
||||
clearChat();
|
||||
setBackendSessionPath(null);
|
||||
sessionActivationPromise.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
addSystemMessage(`删除会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
[sessions, addSystemMessage, clearChat],
|
||||
);
|
||||
|
||||
const renameSessionHandler = useCallback(
|
||||
async (path: string, currentTitle: string) => {
|
||||
if (isStreamingRef.current) {
|
||||
addSystemMessage("正在回复中,暂不能重命名会话");
|
||||
return;
|
||||
}
|
||||
const row = sessions.find((s) => s.path === path);
|
||||
const fallback = formatSessionTitle(row?.name || row?.firstMessage || currentTitle);
|
||||
const next = window.prompt("重命名会话", fallback);
|
||||
if (next === null) return;
|
||||
const trimmed = next.trim();
|
||||
if (!trimmed) {
|
||||
addSystemMessage("会话名称不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sessionsApi.renameSession(path, trimmed);
|
||||
sessionCache.current.delete(path);
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => (s.path === path ? { ...s, name: trimmed } : s)),
|
||||
);
|
||||
if (activeSessionPathRef.current === path) {
|
||||
setHeaderTitle(formatSessionTitle(trimmed));
|
||||
}
|
||||
} catch (err) {
|
||||
addSystemMessage(`重命名失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
[sessions, addSystemMessage],
|
||||
);
|
||||
|
||||
const toggleSessionPinHandler = useCallback(
|
||||
async (path: string, pinned: boolean) => {
|
||||
if (isStreamingRef.current) {
|
||||
addSystemMessage("正在回复中,暂不能修改置顶");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sessionsApi.setSessionPinned(path, pinned);
|
||||
await loadSessions();
|
||||
} catch (err) {
|
||||
addSystemMessage(`${pinned ? "置顶" : "取消置顶"}失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
[addSystemMessage, loadSessions],
|
||||
);
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
if (isStreamingRef.current) {
|
||||
addSystemMessage("正在回复中,暂不能创建新会话");
|
||||
return;
|
||||
}
|
||||
if (newSessionPromise.current) return;
|
||||
|
||||
setBackendSessionPath(null);
|
||||
setActiveSessionPath(null);
|
||||
sessionActivationPromise.current = null;
|
||||
setHeaderMeta("");
|
||||
clearChat("新会话");
|
||||
|
||||
const promise = (async () => {
|
||||
const data = await chatApi.createNewSession();
|
||||
setBackendSessionPath(data.sessionFile || null);
|
||||
await fetchSessionStateFull();
|
||||
})();
|
||||
|
||||
newSessionPromise.current = promise;
|
||||
void promise
|
||||
.catch((err) => {
|
||||
setHeaderMeta("");
|
||||
addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
newSessionPromise.current = null;
|
||||
});
|
||||
}, [addSystemMessage, clearChat, fetchSessionStateFull]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (text: string, images?: ImageContent[]) => {
|
||||
const trimmed = text.trim();
|
||||
const hasImages = Array.isArray(images) && images.length > 0;
|
||||
if ((!trimmed && !hasImages) || isStreamingRef.current) return;
|
||||
|
||||
if (newSessionPromise.current) {
|
||||
try {
|
||||
await newSessionPromise.current;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (freshSessionBootRef.current) {
|
||||
try {
|
||||
await freshSessionBootRef.current;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!backendSessionPathRef.current && !activeSessionPathRef.current) {
|
||||
try {
|
||||
const data = await chatApi.createNewSession();
|
||||
setBackendSessionPath(data.sessionFile || null);
|
||||
} catch (err) {
|
||||
addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSessionPathRef.current && backendSessionPathRef.current !== activeSessionPathRef.current) {
|
||||
setHeaderMeta("正在切换会话...");
|
||||
if (sessionActivationPromise.current) {
|
||||
try {
|
||||
await sessionActivationPromise.current;
|
||||
} catch {
|
||||
setHeaderMeta("");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const activationPromise = activateSessionBackend(activeSessionPathRef.current);
|
||||
sessionActivationPromise.current = activationPromise;
|
||||
await activationPromise;
|
||||
} catch (err) {
|
||||
setHeaderMeta("");
|
||||
addSystemMessage(`切换会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
} finally {
|
||||
sessionActivationPromise.current = null;
|
||||
}
|
||||
}
|
||||
setHeaderMeta("");
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = nextMessageId();
|
||||
optimisticUserIdRef.current = userId;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: userId,
|
||||
role: "user",
|
||||
content: trimmed,
|
||||
...(hasImages ? { images } : {}),
|
||||
},
|
||||
]);
|
||||
await chatApi.sendChat(trimmed, hasImages ? images : undefined);
|
||||
} catch (err) {
|
||||
const failedId = optimisticUserIdRef.current;
|
||||
optimisticUserIdRef.current = null;
|
||||
if (failedId) {
|
||||
setMessages((prev) => prev.filter((msg) => msg.id !== failedId));
|
||||
}
|
||||
addSystemMessage(`发送失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
[activateSessionBackend, addSystemMessage],
|
||||
);
|
||||
|
||||
const abortStream = useCallback(async () => {
|
||||
if (!isStreamingRef.current) return;
|
||||
try {
|
||||
await chatApi.abortChat();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyModelSelection = useCallback(
|
||||
async (key: string) => {
|
||||
if (!key || isApplyingModelSettings) return;
|
||||
const [provider, ...idParts] = key.split("/");
|
||||
const modelId = idParts.join("/");
|
||||
if (!provider || !modelId) return;
|
||||
|
||||
setIsApplyingModelSettings(true);
|
||||
setHeaderMeta("正在切换模型");
|
||||
try {
|
||||
await chatApi.setModel(provider, modelId);
|
||||
await fetchSessionStateFull();
|
||||
setHeaderMeta("模型已切换");
|
||||
setTimeout(() => setHeaderMeta(""), 1000);
|
||||
} catch (err) {
|
||||
setHeaderMeta("");
|
||||
addSystemMessage(`切换模型失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
await fetchSessionStateFull();
|
||||
} finally {
|
||||
setIsApplyingModelSettings(false);
|
||||
await fetchSessionStateFull();
|
||||
}
|
||||
},
|
||||
[isApplyingModelSettings, addSystemMessage, fetchSessionStateFull],
|
||||
);
|
||||
|
||||
const applyThinkingSelection = useCallback(
|
||||
async (level: string) => {
|
||||
if (isApplyingModelSettings) return;
|
||||
setIsApplyingModelSettings(true);
|
||||
setHeaderMeta("正在设置推理强度");
|
||||
try {
|
||||
await chatApi.setThinkingLevel(level);
|
||||
await fetchSessionStateFull();
|
||||
setHeaderMeta("推理强度已设置");
|
||||
setTimeout(() => setHeaderMeta(""), 1000);
|
||||
} catch (err) {
|
||||
setHeaderMeta("");
|
||||
addSystemMessage(`设置推理强度失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
await fetchSessionStateFull();
|
||||
} finally {
|
||||
setIsApplyingModelSettings(false);
|
||||
await fetchSessionStateFull();
|
||||
}
|
||||
},
|
||||
[isApplyingModelSettings, addSystemMessage, fetchSessionStateFull],
|
||||
);
|
||||
|
||||
const handleSseEvent = useCallback(
|
||||
(ev: SseEvent) => {
|
||||
switch (ev.type) {
|
||||
case "connected":
|
||||
setConnected(true);
|
||||
scheduleSessionDashboardRefresh();
|
||||
break;
|
||||
|
||||
case "agent_start":
|
||||
setIsStreaming(true);
|
||||
setConnected(true);
|
||||
streamingAssistantId.current = null;
|
||||
scheduleSessionDashboardRefresh();
|
||||
break;
|
||||
|
||||
case "agent_end":
|
||||
setIsStreaming(false);
|
||||
setConnected(true);
|
||||
setMessages((prev) => {
|
||||
const streamId =
|
||||
streamingAssistantId.current ?? findStreamingAssistantId(prev);
|
||||
if (!streamId) return prev;
|
||||
return prev.map((msg) =>
|
||||
msg.id === streamId && msg.streaming ? { ...msg, streaming: false } : msg,
|
||||
);
|
||||
});
|
||||
if (backendSessionPathRef.current) {
|
||||
sessionCache.current.delete(backendSessionPathRef.current);
|
||||
}
|
||||
setTimeout(() => void loadSessions(), 1000);
|
||||
void fetchSessionStateFull();
|
||||
break;
|
||||
|
||||
case "message_start": {
|
||||
const m = ev.message;
|
||||
if (m.role === "user") {
|
||||
const userText = extractContent(m.content);
|
||||
const userImages = extractImages(m.content);
|
||||
if (optimisticUserIdRef.current) {
|
||||
const optId = optimisticUserIdRef.current;
|
||||
optimisticUserIdRef.current = null;
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === optId
|
||||
? {
|
||||
...msg,
|
||||
content: userText || msg.content,
|
||||
images: userImages.length ? userImages : msg.images,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nextMessageId(),
|
||||
role: "user",
|
||||
content: userText,
|
||||
...(userImages.length ? { images: userImages } : {}),
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_update": {
|
||||
const m = ev.message;
|
||||
if (m.role === "assistant") {
|
||||
const assistantText = extractContent(m.content);
|
||||
if (!streamingAssistantId.current) {
|
||||
streamingAssistantId.current = nextMessageId();
|
||||
}
|
||||
const streamId = streamingAssistantId.current;
|
||||
setMessages((prev) => {
|
||||
if (prev.some((msg) => msg.id === streamId)) {
|
||||
return prev.map((msg) =>
|
||||
msg.id === streamId
|
||||
? { ...msg, content: assistantText, streaming: true }
|
||||
: msg,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ id: streamId, role: "assistant", content: assistantText, streaming: true },
|
||||
];
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_end": {
|
||||
const m = ev.message;
|
||||
if (m.role === "assistant") {
|
||||
const assistantText = extractContent(m.content);
|
||||
const toolCalls = getToolCalls(m.content);
|
||||
setMessages((prev) => {
|
||||
const streamId =
|
||||
streamingAssistantId.current ?? findStreamingAssistantId(prev);
|
||||
let next = prev;
|
||||
|
||||
if (streamId) {
|
||||
next = finalizeAssistantInList(prev, streamId, assistantText);
|
||||
} else if (assistantText.trim()) {
|
||||
let lastAssistantIdx = -1;
|
||||
for (let i = prev.length - 1; i >= 0; i--) {
|
||||
if (prev[i].role === "assistant") {
|
||||
lastAssistantIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastAssistantIdx >= 0) {
|
||||
const last = prev[lastAssistantIdx];
|
||||
if (last.content.trim() === assistantText.trim()) {
|
||||
next = prev;
|
||||
} else {
|
||||
next = prev.map((msg, i) =>
|
||||
i === lastAssistantIdx
|
||||
? { ...msg, content: assistantText, streaming: false }
|
||||
: msg,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
next = [
|
||||
...prev,
|
||||
{ id: nextMessageId(), role: "assistant", content: assistantText },
|
||||
];
|
||||
}
|
||||
}
|
||||
for (const tc of toolCalls) {
|
||||
const tid = tc.toolCallId || tc.id;
|
||||
const tidStr = tid ? String(tid) : undefined;
|
||||
const text = formatToolCall(tc);
|
||||
const existingIdx = tidStr
|
||||
? next.findIndex((msg) => msg.toolCallId === tidStr)
|
||||
: -1;
|
||||
if (existingIdx >= 0) {
|
||||
const cur = next[existingIdx].content.trimStart();
|
||||
const running =
|
||||
cur.startsWith("🔧") || cur.startsWith("✅") || cur.startsWith("❌");
|
||||
if (!running) {
|
||||
next = next.map((msg, i) =>
|
||||
i === existingIdx ? { ...msg, content: text } : msg,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
next = [
|
||||
...next,
|
||||
{
|
||||
id: tidStr ? `tool-${tidStr}` : nextMessageId(),
|
||||
role: "tool_call" as const,
|
||||
content: text,
|
||||
toolCallId: tidStr,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
streamingAssistantId.current = null;
|
||||
scheduleSessionDashboardRefresh();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_execution_start": {
|
||||
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
|
||||
const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`;
|
||||
setMessages((prev) => {
|
||||
const existingIdx = tid
|
||||
? prev.findIndex((msg) => msg.toolCallId === tid)
|
||||
: -1;
|
||||
if (existingIdx >= 0) {
|
||||
return prev.map((msg, i) =>
|
||||
i === existingIdx ? { ...msg, content: label } : msg,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||
role: "tool_call" as const,
|
||||
content: label,
|
||||
toolCallId: tid,
|
||||
},
|
||||
];
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_execution_update":
|
||||
case "tool_execution_end": {
|
||||
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
|
||||
const isError = ev.type === "tool_execution_end" ? ev.isError : undefined;
|
||||
const full = buildToolCallUpdateText(ev, isError);
|
||||
setMessages((prev) => {
|
||||
let idx = tid ? prev.findIndex((msg) => msg.toolCallId === tid) : -1;
|
||||
if (idx < 0) {
|
||||
const toolCalls = prev.filter((m) => m.role === "tool_call");
|
||||
if (toolCalls.length > 0) {
|
||||
idx = prev.findIndex((m) => m.id === toolCalls[toolCalls.length - 1].id);
|
||||
}
|
||||
}
|
||||
if (idx < 0) return prev;
|
||||
return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg));
|
||||
});
|
||||
if (ev.type === "tool_execution_end" && ev.isError) {
|
||||
addSystemMessage(`工具 ${ev.toolName} 执行出错`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[scheduleSessionDashboardRefresh, loadSessions, fetchSessionStateFull, addSystemMessage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
syncViewportHeight();
|
||||
const onResize = () => {
|
||||
syncViewportHeight();
|
||||
if (window.innerWidth > 768) setSidebarOpen(false);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
window.visualViewport?.addEventListener("resize", syncViewportHeight);
|
||||
window.visualViewport?.addEventListener("scroll", syncViewportHeight);
|
||||
window.addEventListener("orientationchange", syncViewportHeight);
|
||||
|
||||
const bootstrap = {
|
||||
sessions: false,
|
||||
models: false,
|
||||
sessionState: false,
|
||||
sse: false,
|
||||
};
|
||||
|
||||
const tryMarkBootstrapReady = () => {
|
||||
if (bootstrap.sessions && bootstrap.models && bootstrap.sessionState && bootstrap.sse) {
|
||||
markRouteReady("chat");
|
||||
}
|
||||
};
|
||||
|
||||
void loadSessions()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
bootstrap.sessions = true;
|
||||
tryMarkBootstrapReady();
|
||||
});
|
||||
|
||||
void chatApi
|
||||
.fetchModels()
|
||||
.then((data) => setAvailableModels(data.models || []))
|
||||
.catch((err) => {
|
||||
addSystemMessage(`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
bootstrap.models = true;
|
||||
tryMarkBootstrapReady();
|
||||
});
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const connect = () => {
|
||||
if (eventSource) eventSource.close();
|
||||
eventSource = new EventSource("/api/events");
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true);
|
||||
if (!bootstrap.sse) {
|
||||
bootstrap.sse = true;
|
||||
tryMarkBootstrapReady();
|
||||
}
|
||||
};
|
||||
eventSource.onmessage = (e) => {
|
||||
try {
|
||||
handleSseEvent(JSON.parse(e.data) as SseEvent);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
eventSource.onerror = () => {
|
||||
setConnected(false);
|
||||
setIsStreaming(false);
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connect, 2000);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
void fetchSessionStateFull()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
bootstrap.sessionState = true;
|
||||
tryMarkBootstrapReady();
|
||||
});
|
||||
|
||||
void ensureFreshSessionOnBoot()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
void fetchSessionStateFull();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
window.visualViewport?.removeEventListener("resize", syncViewportHeight);
|
||||
window.visualViewport?.removeEventListener("scroll", syncViewportHeight);
|
||||
window.removeEventListener("orientationchange", syncViewportHeight);
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
eventSource?.close();
|
||||
};
|
||||
}, [loadSessions, fetchSessionStateFull, handleSseEvent, addSystemMessage, markRouteReady, ensureFreshSessionOnBoot]);
|
||||
|
||||
const value: ChatContextValue = {
|
||||
connected,
|
||||
isStreaming,
|
||||
messages,
|
||||
sessions,
|
||||
activeSessionPath,
|
||||
headerTitle,
|
||||
headerMeta,
|
||||
sidebarOpen,
|
||||
sessionState,
|
||||
availableModels,
|
||||
currentModelKey,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
isApplyingModelSettings,
|
||||
toggleSidebar: () => setSidebarOpen((o) => !o),
|
||||
closeSidebar: () => setSidebarOpen(false),
|
||||
loadSessions,
|
||||
loadSession,
|
||||
deleteSession: deleteSessionHandler,
|
||||
renameSession: renameSessionHandler,
|
||||
toggleSessionPin: toggleSessionPinHandler,
|
||||
newSession,
|
||||
sendMessage,
|
||||
abortStream,
|
||||
applyModelSelection,
|
||||
applyThinkingSelection,
|
||||
addSystemMessage,
|
||||
};
|
||||
|
||||
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
||||
}
|
||||
|
||||
export function useChatContext(): ChatContextValue {
|
||||
const ctx = useContext(ChatContext);
|
||||
if (!ctx) throw new Error("useChatContext must be used within ChatProvider");
|
||||
return ctx;
|
||||
}
|
||||
10
.pi/agent/extensions/webui/frontend/src/main.tsx
Normal file
10
.pi/agent/extensions/webui/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles/global.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
13
.pi/agent/extensions/webui/frontend/src/routes/ChatPage.tsx
Normal file
13
.pi/agent/extensions/webui/frontend/src/routes/ChatPage.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ChatInput } from "../components/chat/ChatInput";
|
||||
import { MessageList } from "../components/chat/MessageList";
|
||||
import { Header } from "../components/layout/Header";
|
||||
|
||||
export function ChatPage() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<MessageList />
|
||||
<ChatInput />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: var(--app-height);
|
||||
height: var(--app-height);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: max(14px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) 14px max(18px, env(safe-area-inset-left));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pageTitle h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pageTitle p {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #8b95a8;
|
||||
}
|
||||
|
||||
.shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 12px;
|
||||
padding-left: max(12px, env(safe-area-inset-left));
|
||||
background: #f4f6f9;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.navBtn:hover {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.navBtnActive {
|
||||
background: #fff;
|
||||
color: #2563eb;
|
||||
border-color: #e5e7eb;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.navBtn:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) 14px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.paneActive {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.panelHeader h2 {
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.panelHeader p {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: #8b95a8;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn,
|
||||
.linkBtn {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.linkBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: #374151;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.linkBtn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
color: #fff;
|
||||
background: #2563eb;
|
||||
border: 1px solid #2563eb;
|
||||
}
|
||||
|
||||
.primaryBtn:hover {
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.primaryBtn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
color: #374151;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 14px;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
color: #111827;
|
||||
background: #fff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.formBody {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.textInput {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.textInput:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.fieldHint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #8b95a8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.avatarPreviewRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatarPreview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.previewHint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex-shrink: 0;
|
||||
min-height: 30px;
|
||||
padding: 7px 14px 9px;
|
||||
border-top: 1px solid #eef0f3;
|
||||
color: #8b95a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statusOk {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.statusError {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 20px 10px;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyCompact {
|
||||
padding: 10px 4px 2px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.skillItem,
|
||||
.mcpServerItem,
|
||||
.extensionItem {
|
||||
padding: 10px 10px 9px;
|
||||
border: 1px solid #edf0f4;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.skillItem + .skillItem,
|
||||
.mcpServerItem + .mcpServerItem,
|
||||
.extensionItem + .extensionItem {
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
min-width: 0;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
flex-shrink: 0;
|
||||
color: #9ca3af;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.itemDesc {
|
||||
margin-top: 5px;
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.itemPath {
|
||||
margin-top: 6px;
|
||||
color: #9ca3af;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mcpToolList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mcpServerHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mcpServerMain {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mcpServerSub {
|
||||
margin-top: 3px;
|
||||
color: #9ca3af;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mcpServerBadges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 19px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mcpToolItem {
|
||||
min-width: 0;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid #f1f3f6;
|
||||
border-radius: 7px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.mcpToolItem[open] {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mcpToolSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mcpToolSummary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mcpToolName {
|
||||
min-width: 0;
|
||||
color: #111827;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mcpToolDesc {
|
||||
margin-top: 6px;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mcpParamList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.mcpParamList code {
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
border: 1px solid #edf0f4;
|
||||
color: #374151;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.extensionCounts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.extensionCounts span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.extensionDetails {
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #f1f3f6;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
.extensionDetails > summary {
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.extensionDetails > summary:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.nameList {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
font-size: 11px;
|
||||
color: #8b95a8;
|
||||
}
|
||||
|
||||
.nameList > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nameList code {
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #edf0f4;
|
||||
color: #374151;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pageHeader {
|
||||
align-items: flex-start;
|
||||
padding-top: max(12px, env(safe-area-inset-top));
|
||||
padding-bottom: 12px;
|
||||
padding-left: max(12px, env(safe-area-inset-left));
|
||||
padding-right: max(12px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 10px max(12px, env(safe-area-inset-left)) 10px max(12px, env(safe-area-inset-right));
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar {
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 12px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px max(10px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.textarea {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
535
.pi/agent/extensions/webui/frontend/src/routes/SettingsPage.tsx
Normal file
535
.pi/agent/extensions/webui/frontend/src/routes/SettingsPage.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as settingsApi from "../api/settings";
|
||||
import { useAvatars } from "../context/AvatarContext";
|
||||
import { useBootstrap } from "../context/BootstrapContext";
|
||||
import type { ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
|
||||
import type { McpToolInfo } from "../types/events";
|
||||
import styles from "./SettingsPage.module.css";
|
||||
|
||||
const PANE_IDS: SettingsPaneId[] = ["prompt", "skills", "mcp", "extensions", "other"];
|
||||
const SETTINGS_PANE_KEY = "sproutclaw-settings-pane";
|
||||
|
||||
function renderNameList(label: string, items: string[] | undefined) {
|
||||
if (!Array.isArray(items) || !items.length) return null;
|
||||
return (
|
||||
<div className={styles.nameList}>
|
||||
<span>{label}</span>
|
||||
<div>
|
||||
{items.map((item) => (
|
||||
<code key={item}>{item}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function McpToolItem({ tool }: { tool: McpToolInfo }) {
|
||||
const params = Array.isArray(tool.parameters) ? tool.parameters : [];
|
||||
const required = new Set(Array.isArray(tool.required) ? tool.required : []);
|
||||
const description = String(tool.description || "").replace(/\s+/g, " ").trim();
|
||||
const shortDescription =
|
||||
description.length > 86 ? `${description.slice(0, 86).trimEnd()}...` : description;
|
||||
const title = [tool.name, description, params.length ? `参数: ${params.join(", ")}` : ""]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<details className={styles.mcpToolItem} title={title}>
|
||||
<summary className={styles.mcpToolSummary}>
|
||||
<span className={styles.mcpToolName}>{tool.name || "未命名工具"}</span>
|
||||
{params.length ? <span className={styles.badge}>{params.length} 参数</span> : null}
|
||||
</summary>
|
||||
{shortDescription ? <div className={styles.mcpToolDesc}>{shortDescription}</div> : null}
|
||||
{params.length ? (
|
||||
<div className={styles.mcpParamList}>
|
||||
{params.map((param) => (
|
||||
<code key={param}>
|
||||
{param}
|
||||
{required.has(param) ? "*" : ""}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const { markRouteReady } = useBootstrap();
|
||||
const { updateAvatars } = useAvatars();
|
||||
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
|
||||
const [systemPrompt, setSystemPrompt] = useState("");
|
||||
const [systemPromptPath, setSystemPromptPath] = useState("");
|
||||
const [userAvatarUrl, setUserAvatarUrl] = useState("");
|
||||
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
|
||||
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||
const [mcpTools, setMcpTools] = useState<McpServerInfo[]>([]);
|
||||
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
|
||||
const [extensionsPath, setExtensionsPath] = useState("");
|
||||
const [statusText, setStatusText] = useState("");
|
||||
const [statusKind, setStatusKind] = useState<"" | "ok" | "error">("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingAvatars, setSavingAvatars] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let initial: SettingsPaneId = "prompt";
|
||||
try {
|
||||
const saved = sessionStorage.getItem(SETTINGS_PANE_KEY);
|
||||
if (saved && PANE_IDS.includes(saved as SettingsPaneId)) {
|
||||
initial = saved as SettingsPaneId;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setActivePane(initial);
|
||||
}, []);
|
||||
|
||||
const setStatus = (text: string, kind: "" | "ok" | "error" = "") => {
|
||||
setStatusText(text);
|
||||
setStatusKind(kind);
|
||||
};
|
||||
|
||||
const showPane = (paneId: SettingsPaneId) => {
|
||||
setActivePane(paneId);
|
||||
try {
|
||||
sessionStorage.setItem(SETTINGS_PANE_KEY, paneId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setStatus("");
|
||||
try {
|
||||
const data = await settingsApi.fetchSettings();
|
||||
setSystemPrompt(data.systemPrompt || "");
|
||||
setSystemPromptPath(data.systemPromptPath || "");
|
||||
setUserAvatarUrl(data.userAvatarUrl || "");
|
||||
setAgentAvatarUrl(data.agentAvatarUrl || "");
|
||||
updateAvatars({
|
||||
userAvatarUrl: data.userAvatarUrl || "",
|
||||
agentAvatarUrl: data.agentAvatarUrl || "",
|
||||
});
|
||||
setSkills(data.skills || []);
|
||||
setMcpTools(data.mcpTools || []);
|
||||
setExtensions(data.extensions || []);
|
||||
setExtensionsPath(data.extensionsPath || "");
|
||||
} catch (err) {
|
||||
setStatus(`加载设置失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
markRouteReady("settings");
|
||||
}
|
||||
}, [markRouteReady, updateAvatars]);
|
||||
|
||||
const reloadSettings = async () => {
|
||||
setReloading(true);
|
||||
setStatus("正在重新加载 Agent 资源...");
|
||||
try {
|
||||
await settingsApi.reloadSettings();
|
||||
await loadSettings();
|
||||
setStatus("已重新加载并刷新设置", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`重新加载失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSystemPrompt = async () => {
|
||||
setSaving(true);
|
||||
setStatus("保存中...");
|
||||
try {
|
||||
const data = await settingsApi.saveSystemPrompt(systemPrompt);
|
||||
if (data.systemPromptPath) setSystemPromptPath(data.systemPromptPath);
|
||||
setStatus("已保存,Agent 已重新加载", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAvatars = async () => {
|
||||
setSavingAvatars(true);
|
||||
setStatus("保存中...");
|
||||
try {
|
||||
const data = await settingsApi.saveAvatars({ userAvatarUrl, agentAvatarUrl });
|
||||
const next = {
|
||||
userAvatarUrl: data.userAvatarUrl || "",
|
||||
agentAvatarUrl: data.agentAvatarUrl || "",
|
||||
};
|
||||
setUserAvatarUrl(next.userAvatarUrl);
|
||||
setAgentAvatarUrl(next.agentAvatarUrl);
|
||||
updateAvatars(next);
|
||||
setStatus("头像设置已保存", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setSavingAvatars(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const toolTotal = mcpTools.reduce((sum, server) => sum + (server.toolCount || 0), 0);
|
||||
const statusClass =
|
||||
statusKind === "ok" ? styles.statusOk : statusKind === "error" ? styles.statusError : "";
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<main className={styles.main}>
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageTitle}>
|
||||
<img className={styles.logo} src="/logo.png" width={34} height={34} alt="" decoding="async" />
|
||||
<div>
|
||||
<h1>设置</h1>
|
||||
<p>萌小芽 Agent</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/" className={`${styles.secondaryBtn} ${styles.linkBtn}`}>
|
||||
返回聊天
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className={styles.shell}>
|
||||
<div className={styles.layout}>
|
||||
<nav className={styles.sidebar} role="tablist" aria-label="设置分区">
|
||||
{(
|
||||
[
|
||||
["prompt", "系统提示词"],
|
||||
["skills", "Skills"],
|
||||
["mcp", "MCP 工具"],
|
||||
["extensions", "插件扩展"],
|
||||
["other", "其他设置"],
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activePane === id}
|
||||
className={`${styles.navBtn} ${activePane === id ? styles.navBtnActive : ""}`}
|
||||
onClick={() => showPane(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={styles.panes}>
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "prompt" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "prompt"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>系统提示词</h2>
|
||||
<p>{systemPromptPath}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
disabled={saving}
|
||||
onClick={() => void saveSystemPrompt()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
spellCheck={false}
|
||||
aria-label="系统提示词"
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
/>
|
||||
{statusText && activePane === "prompt" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "skills" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "skills"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>Skills</h2>
|
||||
<p>{skills.length} 个已加载</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
disabled={reloading}
|
||||
onClick={() => void reloadSettings()}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
{loading ? (
|
||||
<div className={styles.empty}>加载中...</div>
|
||||
) : !skills.length ? (
|
||||
<div className={styles.empty}>暂无已加载 skills</div>
|
||||
) : (
|
||||
skills.map((skill) => {
|
||||
const meta = [skill.scope, skill.source].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<div key={skill.path || skill.name || skill.command} className={styles.skillItem}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.itemName}>
|
||||
{skill.name || skill.command || "未命名"}
|
||||
</div>
|
||||
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className={styles.itemDesc}>{skill.description}</div>
|
||||
) : null}
|
||||
{skill.path ? <div className={styles.itemPath}>{skill.path}</div> : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "mcp" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "mcp"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>MCP 工具</h2>
|
||||
<p>
|
||||
{mcpTools.length} 个 Server · {toolTotal} 个工具
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
disabled={reloading}
|
||||
onClick={() => void reloadSettings()}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
{loading ? (
|
||||
<div className={styles.empty}>加载中...</div>
|
||||
) : !mcpTools.length ? (
|
||||
<div className={styles.empty}>暂无 MCP server 配置</div>
|
||||
) : (
|
||||
mcpTools.map((server) => {
|
||||
const tools = Array.isArray(server.tools) ? server.tools : [];
|
||||
const status = server.cached ? "已缓存" : "未连接";
|
||||
const cachedAt = server.cachedAt
|
||||
? new Date(server.cachedAt).toLocaleString()
|
||||
: "";
|
||||
return (
|
||||
<div key={server.name} className={styles.mcpServerItem}>
|
||||
<div className={styles.mcpServerHead}>
|
||||
<div className={styles.mcpServerMain}>
|
||||
<div className={styles.itemName}>{server.name || "未命名 Server"}</div>
|
||||
{cachedAt ? (
|
||||
<div className={styles.mcpServerSub}>更新 {cachedAt}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.mcpServerBadges}>
|
||||
<span className={styles.badge}>{status}</span>
|
||||
<span className={styles.badge}>{server.toolCount || 0} 工具</span>
|
||||
{server.resourceCount ? (
|
||||
<span className={styles.badge}>{server.resourceCount} 资源</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{tools.length ? (
|
||||
<div className={styles.mcpToolList}>
|
||||
{tools.map((tool) => (
|
||||
<McpToolItem key={tool.name} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.empty} ${styles.emptyCompact}`}>
|
||||
暂无已缓存工具,运行 /mcp reconnect 后刷新。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "extensions" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "extensions"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>插件扩展</h2>
|
||||
<p>
|
||||
{extensionsPath
|
||||
? `${extensions.length} 个已加载 · ${extensionsPath}`
|
||||
: `${extensions.length} 个已加载`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
disabled={reloading}
|
||||
onClick={() => void reloadSettings()}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
{loading ? (
|
||||
<div className={styles.empty}>加载中...</div>
|
||||
) : !extensions.length ? (
|
||||
<div className={styles.empty}>暂无已加载插件扩展</div>
|
||||
) : (
|
||||
extensions.map((extension) => {
|
||||
const meta =
|
||||
extension.kind ||
|
||||
[extension.scope, extension.source].filter(Boolean).join(" · ");
|
||||
const counts = (
|
||||
[
|
||||
["命令", extension.commands?.length || 0],
|
||||
["工具", extension.tools?.length || 0],
|
||||
["事件", extension.handlers?.length || 0],
|
||||
["Flags", extension.flags?.length || 0],
|
||||
["快捷键", extension.shortcuts?.length || 0],
|
||||
] as const
|
||||
).filter(([, count]) => count > 0);
|
||||
const hasDetails = ["commands", "tools", "handlers", "flags", "shortcuts"].some(
|
||||
(key) => (extension[key as keyof ExtensionInfo] as string[] | undefined)?.length,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={extension.location || extension.name || extension.path}
|
||||
className={styles.extensionItem}
|
||||
>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.itemName}>
|
||||
{extension.name || extension.path || "未命名"}
|
||||
</div>
|
||||
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
|
||||
</div>
|
||||
{counts.length ? (
|
||||
<div className={styles.extensionCounts}>
|
||||
{counts.map(([label, count]) => (
|
||||
<span key={label}>
|
||||
{label} {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{hasDetails ? (
|
||||
<details className={styles.extensionDetails}>
|
||||
<summary>查看注册项</summary>
|
||||
{renderNameList("命令", extension.commands)}
|
||||
{renderNameList("工具", extension.tools)}
|
||||
{renderNameList("事件", extension.handlers)}
|
||||
{renderNameList("Flags", extension.flags)}
|
||||
{renderNameList("快捷键", extension.shortcuts)}
|
||||
</details>
|
||||
) : null}
|
||||
{extension.location ? (
|
||||
<div className={styles.itemPath}>{extension.location}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "other" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "other"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>其他设置</h2>
|
||||
<p>聊天头像使用网页图片链接</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
disabled={savingAvatars}
|
||||
onClick={() => void saveAvatars()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.formBody}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.fieldLabel}>用户头像链接</span>
|
||||
<input
|
||||
className={styles.textInput}
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="https://example.com/user-avatar.png"
|
||||
value={userAvatarUrl}
|
||||
onChange={(e) => setUserAvatarUrl(e.target.value)}
|
||||
/>
|
||||
{userAvatarUrl.trim() ? (
|
||||
<div className={styles.avatarPreviewRow}>
|
||||
<img
|
||||
className={styles.avatarPreview}
|
||||
src={userAvatarUrl.trim()}
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
<span className={styles.previewHint}>预览</span>
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Agent 头像链接</span>
|
||||
<input
|
||||
className={styles.textInput}
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="https://example.com/agent-avatar.png"
|
||||
value={agentAvatarUrl}
|
||||
onChange={(e) => setAgentAvatarUrl(e.target.value)}
|
||||
/>
|
||||
{agentAvatarUrl.trim() ? (
|
||||
<div className={styles.avatarPreviewRow}>
|
||||
<img
|
||||
className={styles.avatarPreview}
|
||||
src={agentAvatarUrl.trim()}
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
<span className={styles.previewHint}>预览</span>
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
<p className={styles.fieldHint}>
|
||||
留空则不显示对应头像。仅支持 http / https 图片链接。
|
||||
</p>
|
||||
</div>
|
||||
{statusText && activePane === "other" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
.pi/agent/extensions/webui/frontend/src/styles/global.css
Normal file
55
.pi/agent/extensions/webui/frontend/src/styles/global.css
Normal file
@@ -0,0 +1,55 @@
|
||||
@import "./variables.css";
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: inherit;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(37, 99, 235, 0.06), transparent 28%),
|
||||
linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
|
||||
color: #1a1a1a;
|
||||
height: var(--app-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
optgroup {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.hljs,
|
||||
pre code.hljs,
|
||||
code.hljs {
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
15
.pi/agent/extensions/webui/frontend/src/styles/variables.css
Normal file
15
.pi/agent/extensions/webui/frontend/src/styles/variables.css
Normal file
@@ -0,0 +1,15 @@
|
||||
:root {
|
||||
--app-height: 100vh;
|
||||
--font-family: "LXGW WenKai Mono", "霞鹜文楷等宽", ui-monospace, monospace;
|
||||
--font-ui: var(--font-family);
|
||||
--font-mono: var(--font-family);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
122
.pi/agent/extensions/webui/frontend/src/types/events.ts
Normal file
122
.pi/agent/extensions/webui/frontend/src/types/events.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { RpcMessage } from "./message";
|
||||
|
||||
export interface ConnectedEvent {
|
||||
type: "connected";
|
||||
}
|
||||
|
||||
export interface AgentStartEvent {
|
||||
type: "agent_start";
|
||||
}
|
||||
|
||||
export interface AgentEndEvent {
|
||||
type: "agent_end";
|
||||
}
|
||||
|
||||
export interface MessageStartEvent {
|
||||
type: "message_start";
|
||||
message: RpcMessage;
|
||||
}
|
||||
|
||||
export interface MessageUpdateEvent {
|
||||
type: "message_update";
|
||||
message: RpcMessage;
|
||||
}
|
||||
|
||||
export interface MessageEndEvent {
|
||||
type: "message_end";
|
||||
message: RpcMessage;
|
||||
}
|
||||
|
||||
export interface ToolExecutionStartEvent {
|
||||
type: "tool_execution_start";
|
||||
toolCallId?: string;
|
||||
toolName: string;
|
||||
args?: unknown;
|
||||
}
|
||||
|
||||
export interface ToolExecutionUpdateEvent {
|
||||
type: "tool_execution_update";
|
||||
toolCallId?: string;
|
||||
toolName: string;
|
||||
partialResult?: { content?: unknown };
|
||||
}
|
||||
|
||||
export interface ToolExecutionEndEvent {
|
||||
type: "tool_execution_end";
|
||||
toolCallId?: string;
|
||||
toolName: string;
|
||||
isError?: boolean;
|
||||
result?: { content?: unknown };
|
||||
}
|
||||
|
||||
export type SseEvent =
|
||||
| ConnectedEvent
|
||||
| AgentStartEvent
|
||||
| AgentEndEvent
|
||||
| MessageStartEvent
|
||||
| MessageUpdateEvent
|
||||
| MessageEndEvent
|
||||
| ToolExecutionStartEvent
|
||||
| ToolExecutionUpdateEvent
|
||||
| ToolExecutionEndEvent;
|
||||
|
||||
export interface SkillInfo {
|
||||
name?: string;
|
||||
command?: string;
|
||||
description?: string;
|
||||
path?: string;
|
||||
scope?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface McpToolInfo {
|
||||
name?: string;
|
||||
description?: string;
|
||||
parameters?: string[];
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
export interface McpServerInfo {
|
||||
name?: string;
|
||||
configured?: boolean;
|
||||
cached?: boolean;
|
||||
toolCount?: number;
|
||||
resourceCount?: number;
|
||||
cachedAt?: string;
|
||||
tools?: McpToolInfo[];
|
||||
}
|
||||
|
||||
export interface ExtensionInfo {
|
||||
name?: string;
|
||||
path?: string;
|
||||
kind?: string;
|
||||
scope?: string;
|
||||
source?: string;
|
||||
location?: string;
|
||||
commands?: string[];
|
||||
tools?: string[];
|
||||
handlers?: string[];
|
||||
flags?: string[];
|
||||
shortcuts?: string[];
|
||||
}
|
||||
|
||||
export interface SettingsData {
|
||||
systemPrompt?: string;
|
||||
systemPromptPath?: string;
|
||||
userAvatarUrl?: string;
|
||||
agentAvatarUrl?: string;
|
||||
skills?: SkillInfo[];
|
||||
mcpTools?: McpServerInfo[];
|
||||
mcpConfigPath?: string;
|
||||
mcpCachePath?: string;
|
||||
extensions?: ExtensionInfo[];
|
||||
extensionsPath?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AvatarSettings {
|
||||
userAvatarUrl: string;
|
||||
agentAvatarUrl: string;
|
||||
}
|
||||
|
||||
export type SettingsPaneId = "prompt" | "skills" | "mcp" | "extensions" | "other";
|
||||
95
.pi/agent/extensions/webui/frontend/src/types/message.ts
Normal file
95
.pi/agent/extensions/webui/frontend/src/types/message.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export type MessageRole = "user" | "assistant" | "system" | "tool_call";
|
||||
|
||||
export interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
toolName?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
arguments?: unknown;
|
||||
args?: unknown;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
export interface ImageContent {
|
||||
type: "image";
|
||||
data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface RpcMessage {
|
||||
role: string;
|
||||
content?: string | ContentBlock[];
|
||||
}
|
||||
|
||||
export interface ToolCallBlock {
|
||||
type: "toolCall";
|
||||
toolName?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
arguments?: unknown;
|
||||
args?: unknown;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
images?: ImageContent[];
|
||||
toolCallId?: string;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
provider: string;
|
||||
id: string;
|
||||
reasoning?: boolean;
|
||||
contextWindow?: number;
|
||||
thinkingLevelMap?: Record<string, string | null>;
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
path: string;
|
||||
name?: string;
|
||||
firstMessage?: string;
|
||||
messageCount?: number;
|
||||
modified?: string;
|
||||
created?: string;
|
||||
}
|
||||
|
||||
export interface SessionHistoryPayload {
|
||||
session?: { id?: string | number; name?: string };
|
||||
messages?: RpcMessage[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
error?: string;
|
||||
sessionId?: string | number;
|
||||
sessionName?: string;
|
||||
model?: ModelInfo;
|
||||
thinkingLevel?: string;
|
||||
stats?: {
|
||||
tokens?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
};
|
||||
cost?: number;
|
||||
contextUsage?: { percent?: number; contextWindow?: number };
|
||||
};
|
||||
autoCompactionEnabled?: boolean;
|
||||
isStreaming?: boolean;
|
||||
isCompacting?: boolean;
|
||||
turnIndex?: number;
|
||||
}
|
||||
|
||||
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||
22
.pi/agent/extensions/webui/frontend/src/types/session.ts
Normal file
22
.pi/agent/extensions/webui/frontend/src/types/session.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { RpcMessage } from "./message";
|
||||
|
||||
export interface SessionSummary {
|
||||
path: string;
|
||||
name?: string;
|
||||
firstMessage?: string;
|
||||
messageCount?: number;
|
||||
modified?: string;
|
||||
created?: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionHistoryPayload {
|
||||
session?: { id?: string | number; name?: string };
|
||||
messages?: RpcMessage[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface NewSessionResponse {
|
||||
sessionFile?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ChatMessage } from "../types/message";
|
||||
import { escHtml } from "./format";
|
||||
|
||||
function roleLabel(role: ChatMessage["role"]): string {
|
||||
switch (role) {
|
||||
case "user":
|
||||
return "用户";
|
||||
case "assistant":
|
||||
return "助手";
|
||||
case "tool_call":
|
||||
return "工具";
|
||||
case "system":
|
||||
return "系统";
|
||||
}
|
||||
}
|
||||
|
||||
function exportableMessages(messages: ChatMessage[]): ChatMessage[] {
|
||||
return messages.filter((m) => !m.streaming && m.content.trim());
|
||||
}
|
||||
|
||||
export function messagesToMarkdown(title: string, messages: ChatMessage[]): string {
|
||||
const lines = [`# ${title}`, ""];
|
||||
for (const m of exportableMessages(messages)) {
|
||||
switch (m.role) {
|
||||
case "user":
|
||||
lines.push("## 用户", "", m.content, "");
|
||||
break;
|
||||
case "assistant":
|
||||
lines.push("## 助手", "", m.content, "");
|
||||
break;
|
||||
case "tool_call":
|
||||
lines.push("## 工具", "", "```", m.content, "```", "");
|
||||
break;
|
||||
case "system":
|
||||
lines.push(`> ${m.content}`, "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
|
||||
export function messagesToMinimalHtml(title: string, messages: ChatMessage[]): string {
|
||||
const sections = exportableMessages(messages)
|
||||
.map((m) => {
|
||||
const label = roleLabel(m.role);
|
||||
return `<section class="block"><h2>${escHtml(label)}</h2><pre>${escHtml(m.content)}</pre></section>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escHtml(title)}</title>
|
||||
<style>
|
||||
body{font-family:"LXGW WenKai Mono","霞鹜文楷等宽",ui-monospace,monospace;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#111;background:#fff}
|
||||
h1{font-size:1.25rem;font-weight:600;margin:0 0 1.25rem}
|
||||
.block{margin:0 0 1rem}
|
||||
h2{font-size:.75rem;font-weight:600;color:#6b7280;margin:0 0 .35rem;text-transform:uppercase;letter-spacing:.04em}
|
||||
pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:.9375rem;font-family:inherit}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escHtml(title)}</h1>
|
||||
${sections}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export function safeExportFilename(title: string): string {
|
||||
const cleaned = title.replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||
return cleaned || "conversation";
|
||||
}
|
||||
|
||||
export function downloadTextFile(filename: string, content: string, mime: string): void {
|
||||
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function singleAssistantMarkdown(content: string): string {
|
||||
return content.trimEnd() + (content.endsWith("\n") ? "" : "\n");
|
||||
}
|
||||
|
||||
export function messageMarkdownFilename(content: string): string {
|
||||
const snippet =
|
||||
content
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 24)
|
||||
.replace(/[\\/:*?"<>|]/g, "_") || "message";
|
||||
return `${snippet}.md`;
|
||||
}
|
||||
47
.pi/agent/extensions/webui/frontend/src/utils/format.ts
Normal file
47
.pi/agent/extensions/webui/frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export function formatFooterTokens(count: number | undefined): string {
|
||||
const n = Number(count) || 0;
|
||||
if (n <= 0) return "0";
|
||||
if (n < 1000) return String(Math.round(n));
|
||||
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
||||
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
||||
if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
return `${Math.round(n / 1_000_000)}M`;
|
||||
}
|
||||
|
||||
export function formatTimeAgo(isoStr: string | undefined): string {
|
||||
if (!isoStr) return "";
|
||||
const d = new Date(isoStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
if (mins < 1) return "刚刚";
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days} 天前`;
|
||||
return d.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function syncViewportHeight(): void {
|
||||
const viewportHeight = window.visualViewport?.height || window.innerHeight;
|
||||
document.documentElement.style.setProperty("--app-height", `${viewportHeight}px`);
|
||||
}
|
||||
|
||||
export function isTouchLike(): boolean {
|
||||
return window.matchMedia("(max-width: 768px)").matches || navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
let messageIdCounter = 0;
|
||||
export function nextMessageId(): string {
|
||||
messageIdCounter += 1;
|
||||
return `msg-${Date.now()}-${messageIdCounter}`;
|
||||
}
|
||||
58
.pi/agent/extensions/webui/frontend/src/utils/images.ts
Normal file
58
.pi/agent/extensions/webui/frontend/src/utils/images.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export interface ImageContent {
|
||||
type: "image";
|
||||
data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export const MAX_CHAT_IMAGES = 8;
|
||||
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
export const ALLOWED_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||
|
||||
export function imageToDataUrl(image: ImageContent): string {
|
||||
return `data:${image.mimeType};base64,${image.data}`;
|
||||
}
|
||||
|
||||
export async function fileToImageContent(file: File): Promise<ImageContent> {
|
||||
if (!ALLOWED_IMAGE_MIME.has(file.type)) {
|
||||
throw new Error(`不支持的图片类型: ${file.type || "unknown"}`);
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
throw new Error(`图片过大(最大 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`);
|
||||
}
|
||||
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!match) throw new Error("无效图片数据");
|
||||
return { type: "image", mimeType: match[1], data: match[2] };
|
||||
}
|
||||
|
||||
export async function clipboardItemsToImages(items: DataTransferItemList): Promise<ImageContent[]> {
|
||||
const images: ImageContent[] = [];
|
||||
for (const item of items) {
|
||||
if (!item.type.startsWith("image/")) continue;
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
images.push(await fileToImageContent(file));
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
export function validateImagePayload(image: ImageContent): ImageContent {
|
||||
if (image.type !== "image") throw new Error("无效图片对象");
|
||||
if (!ALLOWED_IMAGE_MIME.has(image.mimeType)) {
|
||||
throw new Error(`不支持的图片类型: ${image.mimeType}`);
|
||||
}
|
||||
if (!image.data) throw new Error("图片数据为空");
|
||||
const padding = image.data.endsWith("==") ? 2 : image.data.endsWith("=") ? 1 : 0;
|
||||
const size = Math.floor((image.data.length * 3) / 4) - padding;
|
||||
if (size > MAX_IMAGE_BYTES) {
|
||||
throw new Error(`图片过大(最大 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`);
|
||||
}
|
||||
return { type: "image", mimeType: image.mimeType, data: image.data };
|
||||
}
|
||||
67
.pi/agent/extensions/webui/frontend/src/utils/markdown.ts
Normal file
67
.pi/agent/extensions/webui/frontend/src/utils/markdown.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import javascript from "highlight.js/lib/languages/javascript";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
import python from "highlight.js/lib/languages/python";
|
||||
import bash from "highlight.js/lib/languages/bash";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
import markdown from "highlight.js/lib/languages/markdown";
|
||||
import css from "highlight.js/lib/languages/css";
|
||||
import xml from "highlight.js/lib/languages/xml";
|
||||
import { Marked, type Tokens } from "marked";
|
||||
import "highlight.js/styles/github.min.css";
|
||||
|
||||
hljs.registerLanguage("javascript", javascript);
|
||||
hljs.registerLanguage("typescript", typescript);
|
||||
hljs.registerLanguage("python", python);
|
||||
hljs.registerLanguage("bash", bash);
|
||||
hljs.registerLanguage("json", json);
|
||||
hljs.registerLanguage("markdown", markdown);
|
||||
hljs.registerLanguage("css", css);
|
||||
hljs.registerLanguage("xml", xml);
|
||||
hljs.registerLanguage("html", xml);
|
||||
|
||||
function escapeMarkdownCode(raw: string): string {
|
||||
return String(raw)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderAssistantCodeBlock(token: Tokens.Code): string {
|
||||
const langSlug = (((token.lang || "").match(/^\S+/) || [""])[0]).trim().toLowerCase();
|
||||
const codeRaw = String(token.text ?? "").replace(/\n+$/, "");
|
||||
const skipHl = !!token.escaped;
|
||||
|
||||
let innerHtml: string;
|
||||
if (skipHl) {
|
||||
innerHtml = codeRaw;
|
||||
} else {
|
||||
try {
|
||||
if (langSlug && hljs.getLanguage(langSlug)) {
|
||||
innerHtml = hljs.highlight(codeRaw, { language: langSlug }).value;
|
||||
} else {
|
||||
innerHtml = hljs.highlightAuto(codeRaw).value;
|
||||
}
|
||||
} catch {
|
||||
innerHtml = escapeMarkdownCode(codeRaw);
|
||||
}
|
||||
}
|
||||
|
||||
const safeLangSlug = /^[a-z][a-z0-9_-]*$/i.test(langSlug) ? langSlug : "";
|
||||
const codeClass = safeLangSlug ? `hljs language-${safeLangSlug}` : "hljs";
|
||||
return `<pre class="assistant-pre"><code class="${codeClass}">${innerHtml}</code></pre>\n`;
|
||||
}
|
||||
|
||||
const marked = new Marked({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
renderer: {
|
||||
code: renderAssistantCodeBlock,
|
||||
},
|
||||
});
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
if (!text) return "";
|
||||
return marked.parse(text) as string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export function formatSessionTitle(title: string | undefined): string {
|
||||
const text = typeof title === "string" ? title.trim() : "";
|
||||
return text || "未命名";
|
||||
}
|
||||
|
||||
/** 与服务端一致的机器标签(会话 id、纯 hex 段等),不写进界面标题 */
|
||||
export function isMachineSessionLabel(text: string | undefined, headerId: string): boolean {
|
||||
const t = (text ?? "").trim();
|
||||
if (!t) return true;
|
||||
if (headerId && t === headerId) return true;
|
||||
if (/^[0-9a-f]{8,}$/i.test(t)) return true;
|
||||
if (/^[0-9]{10,}$/.test(t)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 首句截取(侧边栏兜底与顶栏回填,需与 server.ts 保持一致逻辑) */
|
||||
export function titleFromFirstUserMessage(text: string | undefined, maxChars = 56): string {
|
||||
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!cleaned) return "";
|
||||
const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/);
|
||||
let candidate = sentenceMatch?.[1] ? sentenceMatch[1].trim() : cleaned;
|
||||
if (candidate.length > maxChars) {
|
||||
candidate = `${candidate.slice(0, maxChars).trimEnd()}…`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function modelKey(model: { provider: string; id: string } | undefined): string {
|
||||
return model ? `${model.provider}/${model.id}` : "";
|
||||
}
|
||||
|
||||
export function modelLabel(model: { provider: string; id: string } | undefined): string {
|
||||
if (!model) return "";
|
||||
return `${model.provider}/${model.id}`;
|
||||
}
|
||||
82
.pi/agent/extensions/webui/frontend/src/utils/toolCall.ts
Normal file
82
.pi/agent/extensions/webui/frontend/src/utils/toolCall.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ContentBlock, ImageContent, ToolCallBlock } from "../types/message";
|
||||
|
||||
export function extractContent(content: string | ContentBlock[] | undefined): string {
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
return String(content);
|
||||
}
|
||||
|
||||
export function extractImages(content: string | ContentBlock[] | undefined): ImageContent[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content
|
||||
.filter((c): c is ImageContent => c.type === "image" && typeof c.data === "string" && !!c.mimeType)
|
||||
.map((c) => ({ type: "image", data: c.data, mimeType: c.mimeType }));
|
||||
}
|
||||
|
||||
export function getToolCalls(content: string | ContentBlock[] | undefined): ToolCallBlock[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.filter((c): c is ToolCallBlock => c.type === "toolCall");
|
||||
}
|
||||
|
||||
export function formatToolCall(tc: ToolCallBlock): string {
|
||||
const name = tc.toolName || tc.name || "tool";
|
||||
const args = tc.arguments ?? tc.args ?? tc.input ?? {};
|
||||
let text = `${name}(${JSON.stringify(args)})`;
|
||||
const shortId = tc.toolCallId || tc.id;
|
||||
if (shortId) text = `# ${String(shortId).slice(0, 8)}\n${text}`;
|
||||
return text;
|
||||
}
|
||||
|
||||
export function formatToolResult(tr: { content?: unknown }): string {
|
||||
let text = "";
|
||||
const content = tr.content;
|
||||
if (typeof content === "string") text = content;
|
||||
else if (Array.isArray(content)) {
|
||||
text = content.map((c) => (c && typeof c === "object" && "text" in c ? String(c.text) : JSON.stringify(c))).join("\n");
|
||||
} else if (content && typeof content === "object" && "text" in content) {
|
||||
text = String((content as { text: string }).text);
|
||||
} else {
|
||||
text = JSON.stringify(content || tr, null, 2);
|
||||
}
|
||||
return text.length > 800 ? `${text.slice(0, 800)}\n… (已截断)` : text;
|
||||
}
|
||||
|
||||
export function deriveToolCallSummary(fullText: string): string {
|
||||
const raw = String(fullText || "").trim();
|
||||
if (!raw) return "tool";
|
||||
let icon = "";
|
||||
if (raw.startsWith("🔧")) icon = "🔧";
|
||||
else if (raw.startsWith("✅")) icon = "✅";
|
||||
else if (raw.startsWith("❌")) icon = "❌";
|
||||
const rest = icon ? raw.slice(icon.length).trimStart() : raw;
|
||||
const idLine = rest.split("\n").find((l) => l.trimStart().startsWith("# "));
|
||||
const idPart = idLine ? idLine.replace(/^\s*#\s*/, "").trim().split(/\s/)[0] : "";
|
||||
const oneLine = rest.replace(/\s+/g, " ");
|
||||
const nameMatch = oneLine.match(/(\w+)\s*\(/);
|
||||
const name = nameMatch ? nameMatch[1] : "tool";
|
||||
let label = idPart ? `${name} · ${idPart}` : name;
|
||||
if (icon) label = `${icon} ${label}`;
|
||||
return label;
|
||||
}
|
||||
|
||||
export function buildToolCallUpdateText(
|
||||
ev: { toolName: string; partialResult?: { content?: unknown }; result?: { content?: unknown } },
|
||||
isError?: boolean,
|
||||
): string {
|
||||
const icon = isError === undefined ? "🔧" : isError ? "❌" : "✅";
|
||||
let detail = "";
|
||||
if (ev.partialResult?.content) {
|
||||
detail = `\n${formatToolResult(ev.partialResult)}`;
|
||||
}
|
||||
if (isError !== undefined) {
|
||||
const resultText = ev.result?.content ? `\n${formatToolResult(ev.result)}` : "";
|
||||
return `${icon} ${ev.toolName}${resultText || detail}`;
|
||||
}
|
||||
return `${icon} ${ev.toolName}${detail}`;
|
||||
}
|
||||
2
.pi/agent/extensions/webui/frontend/src/vite-env.d.ts
vendored
Normal file
2
.pi/agent/extensions/webui/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
22
.pi/agent/extensions/webui/frontend/tsconfig.app.json
Normal file
22
.pi/agent/extensions/webui/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
.pi/agent/extensions/webui/frontend/tsconfig.json
Normal file
7
.pi/agent/extensions/webui/frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
20
.pi/agent/extensions/webui/frontend/tsconfig.node.json
Normal file
20
.pi/agent/extensions/webui/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
65
.pi/agent/extensions/webui/frontend/vite.config.ts
Normal file
65
.pi/agent/extensions/webui/frontend/vite.config.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
includeAssets: ["logo.png", "logo192.png", "logo512.png"],
|
||||
manifest: {
|
||||
name: "萌小芽",
|
||||
short_name: "萌小芽",
|
||||
description: "树萌芽智能 AI 助手 Web 客户端",
|
||||
theme_color: "#f7f8fb",
|
||||
background_color: "#f7f8fb",
|
||||
display: "standalone",
|
||||
orientation: "portrait-primary",
|
||||
scope: "/",
|
||||
start_url: "/",
|
||||
lang: "zh-CN",
|
||||
icons: [
|
||||
{
|
||||
src: "logo192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: "index.html",
|
||||
globPatterns: ["**/*.{js,css,html,png,ico,woff2}"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "../dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:19133",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user