Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4f0ffcd32 | |||
| 9dc8549f31 | |||
| 6b447eb398 | |||
| 54fbf15be8 | |||
| 4bf99e8069 | |||
| e4d45300b1 | |||
| 477350a2a1 | |||
| 424555aae2 |
Generated
-7
@@ -30,7 +30,6 @@
|
|||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"idb": "^8.0.3",
|
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
@@ -15844,12 +15843,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/idb": {
|
|
||||||
"version": "8.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
|
||||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"idb": "^8.0.3",
|
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
import { AgentHeader } from "./AgentHeader";
|
|
||||||
|
|
||||||
jest.mock("next/image", () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: (props: React.ComponentProps<"img">) => <img {...props} alt={props.alt ?? ""} />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const renderWithTheme = (ui: React.ReactElement) =>
|
|
||||||
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
|
|
||||||
|
|
||||||
describe("AgentHeader", () => {
|
|
||||||
it("submits a renamed active session title", () => {
|
|
||||||
const onRenameSessionTitle = jest.fn();
|
|
||||||
|
|
||||||
renderWithTheme(
|
|
||||||
<AgentHeader
|
|
||||||
sessionTitle="原始标题"
|
|
||||||
canRenameSessionTitle
|
|
||||||
isStreaming={false}
|
|
||||||
isHistoryOpen={false}
|
|
||||||
onHistoryToggle={jest.fn()}
|
|
||||||
onRenameSessionTitle={onRenameSessionTitle}
|
|
||||||
onNewConversation={jest.fn()}
|
|
||||||
onClose={jest.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "修改对话标题" }));
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), {
|
|
||||||
target: { value: "更新后的标题" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByLabelText("确认"));
|
|
||||||
|
|
||||||
expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -44,7 +44,7 @@ export const AgentHeader = ({
|
|||||||
onClose,
|
onClose,
|
||||||
}: AgentHeaderProps) => {
|
}: AgentHeaderProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const displayTitle = sessionTitle?.trim() || "TJWater Agent";
|
const displayTitle = sessionTitle?.trim() || "新对话";
|
||||||
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||||
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
|
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import React, {
|
|||||||
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
||||||
|
|
||||||
import type { AgentModel } from "@/lib/chatStream";
|
import type { AgentModel } from "@/lib/chatStream";
|
||||||
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { AgentComposer } from "./AgentComposer";
|
import { AgentComposer } from "./AgentComposer";
|
||||||
import { AgentHeader } from "./AgentHeader";
|
import { AgentHeader } from "./AgentHeader";
|
||||||
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||||
@@ -31,7 +32,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const hasResetForOpenRef = useRef(false);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
speechState,
|
speechState,
|
||||||
@@ -74,6 +77,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
removeSession,
|
removeSession,
|
||||||
switchSession,
|
switchSession,
|
||||||
} = useAgentChatSession({
|
} = useAgentChatSession({
|
||||||
|
projectId: currentProjectId,
|
||||||
onToolCall: handleToolCall,
|
onToolCall: handleToolCall,
|
||||||
onBeforeSend: stopListening,
|
onBeforeSend: stopListening,
|
||||||
getModel: () => selectedModel,
|
getModel: () => selectedModel,
|
||||||
@@ -84,13 +88,22 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
}, [messages, isStreaming]);
|
}, [messages, isStreaming]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) {
|
||||||
|
hasResetForOpenRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasResetForOpenRef.current || isHydrating) return;
|
||||||
|
hasResetForOpenRef.current = true;
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
|
createSession();
|
||||||
|
setInput("");
|
||||||
|
setIsHistoryOpen(false);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [open]);
|
}, [createSession, isHydrating, open]);
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
const prompt = input.trim();
|
const prompt = input.trim();
|
||||||
@@ -109,7 +122,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const handleNewConversation = useCallback(() => {
|
const handleNewConversation = useCallback(() => {
|
||||||
handleStopSpeech();
|
handleStopSpeech();
|
||||||
stopListening();
|
stopListening();
|
||||||
void createSession();
|
createSession();
|
||||||
setInput("");
|
setInput("");
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
|
|||||||
@@ -1,142 +1,101 @@
|
|||||||
import type { ChatSessionRecord } from "./GlobalChatbox.types";
|
|
||||||
import {
|
import {
|
||||||
createEmptyChatSession,
|
loadActiveChatState,
|
||||||
loadChatSessionById,
|
|
||||||
saveActiveChatState,
|
saveActiveChatState,
|
||||||
updateChatSessionTitle,
|
|
||||||
} from "./chatStorage";
|
} from "./chatStorage";
|
||||||
|
|
||||||
type StoreName = "sessions" | "meta";
|
const apiFetch = jest.fn();
|
||||||
|
|
||||||
const stores: Record<StoreName, Map<string, any>> = {
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
sessions: new Map(),
|
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||||
meta: new Map(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockDb = {
|
|
||||||
get: jest.fn(async (storeName: StoreName, key: string) => stores[storeName].get(key)),
|
|
||||||
getAll: jest.fn(async (storeName: StoreName) => Array.from(stores[storeName].values())),
|
|
||||||
put: jest.fn(async (storeName: StoreName, value: { id?: string; key?: string }) => {
|
|
||||||
const key = storeName === "sessions" ? value.id : value.key;
|
|
||||||
if (!key) {
|
|
||||||
throw new Error(`Missing key for store ${storeName}`);
|
|
||||||
}
|
|
||||||
stores[storeName].set(key, value);
|
|
||||||
return key;
|
|
||||||
}),
|
|
||||||
delete: jest.fn(async (storeName: StoreName, key: string) => {
|
|
||||||
stores[storeName].delete(key);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.mock("idb", () => ({
|
|
||||||
openDB: jest.fn(async () => mockDb),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("chatStorage timestamp semantics", () => {
|
describe("chatStorage backend-only persistence", () => {
|
||||||
let now = new Date("2026-05-19T09:00:00+08:00").getTime();
|
|
||||||
let dateNowSpy: jest.SpyInstance<number, []>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
stores.sessions.clear();
|
|
||||||
stores.meta.clear();
|
|
||||||
mockDb.get.mockClear();
|
|
||||||
mockDb.getAll.mockClear();
|
|
||||||
mockDb.put.mockClear();
|
|
||||||
mockDb.delete.mockClear();
|
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
now = new Date("2026-05-19T09:00:00+08:00").getTime();
|
apiFetch.mockReset();
|
||||||
dateNowSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
it("starts from an empty conversation instead of restoring a stored active id", async () => {
|
||||||
dateNowSpy.mockRestore();
|
window.localStorage.setItem("tjwater_agent_active_session_id_v2", "chat-active-1");
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps anchor and content timestamps when reopening an old session", async () => {
|
const loaded = await loadActiveChatState();
|
||||||
const record: ChatSessionRecord = {
|
|
||||||
id: "old-session",
|
expect(loaded).toMatchObject({
|
||||||
title: "很久之前的会话",
|
storageSessionId: undefined,
|
||||||
isTitleManuallyEdited: false,
|
title: undefined,
|
||||||
createdAt: new Date("2026-04-01T10:00:00+08:00").getTime(),
|
messages: [],
|
||||||
updatedAt: new Date("2026-04-01T10:30:00+08:00").getTime(),
|
sessionId: undefined,
|
||||||
sessionId: "remote-1",
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: "message-1",
|
|
||||||
role: "user",
|
|
||||||
content: "老问题",
|
|
||||||
branchRootId: "message-1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
branchGroups: [],
|
branchGroups: [],
|
||||||
};
|
|
||||||
stores.sessions.set(record.id, record);
|
|
||||||
|
|
||||||
const loadedState = await loadChatSessionById(record.id);
|
|
||||||
now = new Date("2026-05-19T09:30:00+08:00").getTime();
|
|
||||||
await saveActiveChatState(loadedState);
|
|
||||||
|
|
||||||
expect(stores.sessions.get(record.id)).toMatchObject({
|
|
||||||
createdAt: record.createdAt,
|
|
||||||
updatedAt: record.updatedAt,
|
|
||||||
});
|
});
|
||||||
|
expect(apiFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not change timestamps when renaming a session", async () => {
|
it("starts from an empty conversation when a project has a stored active id", async () => {
|
||||||
const record: ChatSessionRecord = {
|
window.localStorage.setItem(
|
||||||
id: "rename-session",
|
"tjwater_agent_active_session_id_v2:project-a",
|
||||||
title: "旧标题",
|
"chat-project-a",
|
||||||
|
);
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"tjwater_agent_active_session_id_v2:project-b",
|
||||||
|
"chat-project-b",
|
||||||
|
);
|
||||||
|
|
||||||
|
const loaded = await loadActiveChatState("project-b");
|
||||||
|
|
||||||
|
expect(loaded.storageSessionId).toBeUndefined();
|
||||||
|
expect(loaded.title).toBeUndefined();
|
||||||
|
expect(loaded.messages).toEqual([]);
|
||||||
|
expect(apiFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a backend conversation when saving the first non-empty state", async () => {
|
||||||
|
apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||||
|
if (url.endsWith("/api/v1/agent/chat/session")) {
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ session_id: "chat-new-1" }),
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/api/v1/agent/chat/session/chat-new-1")) {
|
||||||
|
expect(init?.method).toBe("PUT");
|
||||||
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||||
|
title: "新对话",
|
||||||
|
is_title_manually_edited: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: "chat-new-1", session_id: "chat-new-1" }),
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected request ${url}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedSessionId = await saveActiveChatState(
|
||||||
|
{
|
||||||
|
storageSessionId: undefined,
|
||||||
|
title: "新对话",
|
||||||
isTitleManuallyEdited: false,
|
isTitleManuallyEdited: false,
|
||||||
createdAt: new Date("2026-04-10T08:00:00+08:00").getTime(),
|
|
||||||
updatedAt: new Date("2026-04-10T08:05:00+08:00").getTime(),
|
|
||||||
sessionId: "remote-2",
|
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
id: "message-2",
|
id: "message-2",
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "保留时间",
|
content: "第一条消息",
|
||||||
branchRootId: "message-2",
|
branchRootId: "message-2",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
sessionId: undefined,
|
||||||
branchGroups: [],
|
branchGroups: [],
|
||||||
};
|
|
||||||
stores.sessions.set(record.id, record);
|
|
||||||
|
|
||||||
now = new Date("2026-05-19T11:00:00+08:00").getTime();
|
|
||||||
await updateChatSessionTitle(record.id, "新标题", {
|
|
||||||
isTitleManuallyEdited: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(stores.sessions.get(record.id)).toMatchObject({
|
|
||||||
title: "新标题",
|
|
||||||
isTitleManuallyEdited: true,
|
|
||||||
createdAt: record.createdAt,
|
|
||||||
updatedAt: record.updatedAt,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("anchors createdAt to the first real message time for a new empty session", async () => {
|
|
||||||
const emptyState = await createEmptyChatSession();
|
|
||||||
const storageSessionId = emptyState.storageSessionId;
|
|
||||||
|
|
||||||
now = new Date("2026-05-19T09:05:00+08:00").getTime();
|
|
||||||
await saveActiveChatState({
|
|
||||||
...emptyState,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: "message-3",
|
|
||||||
role: "user",
|
|
||||||
content: "第一条消息",
|
|
||||||
branchRootId: "message-3",
|
|
||||||
},
|
},
|
||||||
],
|
"project-a",
|
||||||
sessionId: "remote-3",
|
);
|
||||||
|
|
||||||
|
expect(savedSessionId).toBe("chat-new-1");
|
||||||
|
expect(
|
||||||
|
window.localStorage.getItem("tjwater_agent_active_session_id_v2:project-a"),
|
||||||
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(stores.sessions.get(storageSessionId!)).toMatchObject({
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+190
-314
@@ -1,39 +1,19 @@
|
|||||||
import { openDB, type DBSchema } from "idb";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { config } from "@config/config";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BranchGroup,
|
BranchGroup,
|
||||||
ChatSessionRecord,
|
|
||||||
ChatSessionSummary,
|
ChatSessionSummary,
|
||||||
ChatStorageMeta,
|
|
||||||
LegacyPersistedChatState,
|
|
||||||
LoadedChatState,
|
LoadedChatState,
|
||||||
Message,
|
Message,
|
||||||
} from "./GlobalChatbox.types";
|
} from "./GlobalChatbox.types";
|
||||||
import {
|
import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils";
|
||||||
cloneBranchGroups,
|
|
||||||
cloneMessages,
|
|
||||||
createId,
|
|
||||||
} from "./GlobalChatbox.utils";
|
|
||||||
|
|
||||||
const CHAT_DB_NAME = "tjwater-agent-chat";
|
type RemoteSessionPayload = {
|
||||||
const CHAT_DB_VERSION = 1;
|
id?: string;
|
||||||
const SESSION_STORE = "sessions";
|
title?: string;
|
||||||
const META_STORE = "meta";
|
created_at?: string | number;
|
||||||
const META_KEY = "chat-meta" as const;
|
updated_at?: string | number;
|
||||||
const LEGACY_CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1";
|
|
||||||
|
|
||||||
type ChatDB = DBSchema & {
|
|
||||||
sessions: {
|
|
||||||
key: string;
|
|
||||||
value: ChatSessionRecord;
|
|
||||||
indexes: {
|
|
||||||
"by-updatedAt": number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
meta: {
|
|
||||||
key: string;
|
|
||||||
value: ChatStorageMeta;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyLoadedChatState = (): LoadedChatState => ({
|
const emptyLoadedChatState = (): LoadedChatState => ({
|
||||||
@@ -51,17 +31,6 @@ const sanitizeMessages = (messages: Message[] | undefined) =>
|
|||||||
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
|
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
|
||||||
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
|
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
|
||||||
|
|
||||||
const serializeConversationState = (state: {
|
|
||||||
messages: Message[];
|
|
||||||
branchGroups: BranchGroup[];
|
|
||||||
sessionId?: string;
|
|
||||||
}) =>
|
|
||||||
JSON.stringify({
|
|
||||||
messages: sanitizeMessages(state.messages),
|
|
||||||
branchGroups: sanitizeBranchGroups(state.branchGroups),
|
|
||||||
sessionId: state.sessionId ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasChatContent = (state: {
|
const hasChatContent = (state: {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
branchGroups: BranchGroup[];
|
branchGroups: BranchGroup[];
|
||||||
@@ -72,8 +41,8 @@ const hasChatContent = (state: {
|
|||||||
Boolean(state.sessionId);
|
Boolean(state.sessionId);
|
||||||
|
|
||||||
const compareSessionsByAnchorTime = (
|
const compareSessionsByAnchorTime = (
|
||||||
left: Pick<ChatSessionRecord, "id" | "createdAt" | "updatedAt">,
|
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||||
right: Pick<ChatSessionRecord, "id" | "createdAt" | "updatedAt">,
|
right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||||
) => {
|
) => {
|
||||||
const createdAtDiff = right.createdAt - left.createdAt;
|
const createdAtDiff = right.createdAt - left.createdAt;
|
||||||
if (createdAtDiff !== 0) return createdAtDiff;
|
if (createdAtDiff !== 0) return createdAtDiff;
|
||||||
@@ -84,236 +53,201 @@ const compareSessionsByAnchorTime = (
|
|||||||
return right.id.localeCompare(left.id);
|
return right.id.localeCompare(left.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toLoadedChatState = (
|
const toMillis = (value: string | number | undefined) =>
|
||||||
session: ChatSessionRecord | undefined,
|
typeof value === "number" ? value : value ? new Date(value).getTime() : Date.now();
|
||||||
): LoadedChatState => {
|
|
||||||
if (!session) return emptyLoadedChatState();
|
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
|
||||||
return {
|
|
||||||
storageSessionId: session.id,
|
const fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||||
title: session.title,
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
|
||||||
isTitleManuallyEdited: session.isTitleManuallyEdited ?? false,
|
method: "GET",
|
||||||
messages: sanitizeMessages(session.messages),
|
projectHeaderMode: "include",
|
||||||
sessionId: session.sessionId,
|
userHeaderMode: "include",
|
||||||
branchGroups: sanitizeBranchGroups(session.branchGroups),
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
sessions?: RemoteSessionPayload[];
|
||||||
};
|
};
|
||||||
|
return (payload.sessions ?? [])
|
||||||
|
.map((session) => ({
|
||||||
|
id: session.id ?? "",
|
||||||
|
title: normalizeTitle(session.title),
|
||||||
|
createdAt: toMillis(session.created_at),
|
||||||
|
updatedAt: toMillis(session.updated_at),
|
||||||
|
}))
|
||||||
|
.filter((session) => Boolean(session.id))
|
||||||
|
.sort(compareSessionsByAnchorTime);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toSessionSummary = (session: ChatSessionRecord): ChatSessionSummary => ({
|
const fetchRemoteChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
||||||
id: session.id,
|
const response = await apiFetch(
|
||||||
title: session.title,
|
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||||
createdAt: session.createdAt,
|
{
|
||||||
updatedAt: session.updatedAt,
|
method: "GET",
|
||||||
});
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
const getDb = () =>
|
skipAuthRedirect: true,
|
||||||
openDB<ChatDB>(CHAT_DB_NAME, CHAT_DB_VERSION, {
|
|
||||||
upgrade(db) {
|
|
||||||
if (!db.objectStoreNames.contains(SESSION_STORE)) {
|
|
||||||
const sessionStore = db.createObjectStore(SESSION_STORE, {
|
|
||||||
keyPath: "id",
|
|
||||||
});
|
|
||||||
sessionStore.createIndex("by-updatedAt", "updatedAt");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(META_STORE)) {
|
|
||||||
db.createObjectStore(META_STORE, { keyPath: "key" });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
if (!response.ok) {
|
||||||
const readLegacyChatState = (): LegacyPersistedChatState | null => {
|
if (response.status === 404) {
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storedRaw = window.localStorage.getItem(LEGACY_CHAT_STORAGE_KEY);
|
|
||||||
if (!storedRaw) return null;
|
|
||||||
|
|
||||||
const parsed = JSON.parse(storedRaw) as LegacyPersistedChatState;
|
|
||||||
if (!Array.isArray(parsed.messages)) {
|
|
||||||
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messages: sanitizeMessages(parsed.messages),
|
|
||||||
sessionId: parsed.sessionId,
|
|
||||||
branchGroups: sanitizeBranchGroups(parsed.branchGroups),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[GlobalChatbox] Failed to read legacy chat state:", error);
|
|
||||||
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearLegacyChatState = () => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMeta = async () => {
|
|
||||||
const db = await getDb();
|
|
||||||
return db.get(META_STORE, META_KEY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setMeta = async (meta: Omit<ChatStorageMeta, "key">) => {
|
|
||||||
const db = await getDb();
|
|
||||||
await db.put(META_STORE, {
|
|
||||||
key: META_KEY,
|
|
||||||
...meta,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLatestSession = async () => {
|
|
||||||
const db = await getDb();
|
|
||||||
const sessions = await db.getAll(SESSION_STORE);
|
|
||||||
if (sessions.length === 0) return undefined;
|
|
||||||
return sessions.sort(compareSessionsByAnchorTime)[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
const migrateLegacyLocalStorage = async () => {
|
|
||||||
const meta = await getMeta();
|
|
||||||
if (meta?.migratedFromLocalStorage) return;
|
|
||||||
|
|
||||||
const legacyState = readLegacyChatState();
|
|
||||||
if (!legacyState) {
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: meta?.activeSessionId,
|
|
||||||
migratedFromLocalStorage: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasContent =
|
|
||||||
legacyState.messages.length > 0 ||
|
|
||||||
(legacyState.branchGroups?.length ?? 0) > 0 ||
|
|
||||||
Boolean(legacyState.sessionId);
|
|
||||||
|
|
||||||
if (!hasContent) {
|
|
||||||
clearLegacyChatState();
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: undefined,
|
|
||||||
migratedFromLocalStorage: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const sessionRecord: ChatSessionRecord = {
|
|
||||||
id: createId(),
|
|
||||||
title: "新对话",
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
sessionId: legacyState.sessionId,
|
|
||||||
messages: sanitizeMessages(legacyState.messages),
|
|
||||||
branchGroups: sanitizeBranchGroups(legacyState.branchGroups),
|
|
||||||
};
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
await db.put(SESSION_STORE, sessionRecord);
|
|
||||||
clearLegacyChatState();
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: sessionRecord.id,
|
|
||||||
migratedFromLocalStorage: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadActiveChatState = async (): Promise<LoadedChatState> => {
|
|
||||||
if (typeof window === "undefined") return emptyLoadedChatState();
|
|
||||||
|
|
||||||
await migrateLegacyLocalStorage();
|
|
||||||
|
|
||||||
const meta = await getMeta();
|
|
||||||
const db = await getDb();
|
|
||||||
|
|
||||||
if (meta?.activeSessionId) {
|
|
||||||
const activeSession = await db.get(SESSION_STORE, meta.activeSessionId);
|
|
||||||
if (activeSession) {
|
|
||||||
return toLoadedChatState(activeSession);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestSession = await getLatestSession();
|
|
||||||
if (!latestSession) {
|
|
||||||
return emptyLoadedChatState();
|
return emptyLoadedChatState();
|
||||||
}
|
}
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
is_title_manually_edited?: boolean;
|
||||||
|
session_id?: string;
|
||||||
|
messages?: Message[];
|
||||||
|
branch_groups?: BranchGroup[];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
storageSessionId: payload.id,
|
||||||
|
title: normalizeTitle(payload.title),
|
||||||
|
isTitleManuallyEdited: payload.is_title_manually_edited ?? false,
|
||||||
|
messages: sanitizeMessages(payload.messages),
|
||||||
|
sessionId: payload.session_id,
|
||||||
|
branchGroups: sanitizeBranchGroups(payload.branch_groups),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
await setMeta({
|
const createRemoteChatSession = async (payload?: {
|
||||||
activeSessionId: latestSession.id,
|
sessionId?: string;
|
||||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
parentSessionId?: string;
|
||||||
|
}) => {
|
||||||
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/session`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: payload?.sessionId,
|
||||||
|
parent_session_id: payload?.parentSessionId,
|
||||||
|
}),
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
session_id?: string;
|
||||||
|
};
|
||||||
|
const sessionId = body.session_id?.trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
throw new Error("backend did not return session_id");
|
||||||
|
}
|
||||||
|
return sessionId;
|
||||||
|
};
|
||||||
|
|
||||||
return toLoadedChatState(latestSession);
|
const saveRemoteChatState = async (
|
||||||
|
sessionId: string,
|
||||||
|
state: LoadedChatState,
|
||||||
|
): Promise<string> => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: normalizeTitle(state.title),
|
||||||
|
is_title_manually_edited: state.isTitleManuallyEdited ?? false,
|
||||||
|
messages: sanitizeMessages(state.messages),
|
||||||
|
branch_groups: sanitizeBranchGroups(state.branchGroups),
|
||||||
|
}),
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as { id?: string; session_id?: string };
|
||||||
|
return payload.id ?? payload.session_id ?? sessionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRemoteChatSessionTitle = async (
|
||||||
|
sessionId: string,
|
||||||
|
title: string,
|
||||||
|
isTitleManuallyEdited?: boolean,
|
||||||
|
) => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
is_title_manually_edited: isTitleManuallyEdited,
|
||||||
|
}),
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRemoteChatSession = async (sessionId: string) => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadActiveChatState = async (
|
||||||
|
_projectId?: string | null,
|
||||||
|
): Promise<LoadedChatState> => {
|
||||||
|
return emptyLoadedChatState();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveActiveChatState = async (
|
export const saveActiveChatState = async (
|
||||||
state: LoadedChatState,
|
state: LoadedChatState,
|
||||||
|
_projectId?: string | null,
|
||||||
): Promise<string | undefined> => {
|
): Promise<string | undefined> => {
|
||||||
if (typeof window === "undefined") return state.storageSessionId;
|
if (typeof window === "undefined") return state.storageSessionId;
|
||||||
|
|
||||||
const hasContent = hasChatContent(state);
|
if (!hasChatContent(state)) {
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const existingSession = state.storageSessionId
|
|
||||||
? await db.get(SESSION_STORE, state.storageSessionId)
|
|
||||||
: undefined;
|
|
||||||
const meta = await getMeta();
|
|
||||||
|
|
||||||
if (!hasContent) {
|
|
||||||
if (state.storageSessionId) {
|
|
||||||
await db.delete(SESSION_STORE, state.storageSessionId);
|
|
||||||
}
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: undefined,
|
|
||||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
|
||||||
});
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
let remoteSessionId = state.sessionId ?? state.storageSessionId;
|
||||||
const storageSessionId = state.storageSessionId ?? createId();
|
if (!remoteSessionId) {
|
||||||
const preferredTitle = state.title?.trim();
|
remoteSessionId = await createRemoteChatSession();
|
||||||
const finalTitle = preferredTitle || existingSession?.title || "新对话";
|
}
|
||||||
const hasContentChanged =
|
|
||||||
!existingSession ||
|
|
||||||
(existingSession && serializeConversationState(existingSession)) !==
|
|
||||||
serializeConversationState(state);
|
|
||||||
const shouldAnchorCreatedAtToFirstMessage =
|
|
||||||
existingSession && !hasChatContent(existingSession) && hasContent;
|
|
||||||
const nextRecord: ChatSessionRecord = {
|
|
||||||
id: storageSessionId,
|
|
||||||
title: finalTitle,
|
|
||||||
isTitleManuallyEdited:
|
|
||||||
state.isTitleManuallyEdited ??
|
|
||||||
existingSession?.isTitleManuallyEdited ??
|
|
||||||
false,
|
|
||||||
createdAt: shouldAnchorCreatedAtToFirstMessage
|
|
||||||
? now
|
|
||||||
: existingSession?.createdAt ?? now,
|
|
||||||
updatedAt: hasContentChanged ? now : existingSession?.updatedAt ?? now,
|
|
||||||
sessionId: state.sessionId,
|
|
||||||
messages: sanitizeMessages(state.messages),
|
|
||||||
branchGroups: sanitizeBranchGroups(state.branchGroups),
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.put(SESSION_STORE, nextRecord);
|
const savedSessionId = await saveRemoteChatState(remoteSessionId, {
|
||||||
await setMeta({
|
...state,
|
||||||
activeSessionId: storageSessionId,
|
storageSessionId: remoteSessionId,
|
||||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
sessionId: remoteSessionId,
|
||||||
});
|
});
|
||||||
|
return savedSessionId;
|
||||||
return storageSessionId;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||||
if (typeof window === "undefined") return [];
|
if (typeof window === "undefined") return [];
|
||||||
|
return await fetchRemoteChatSessions();
|
||||||
await migrateLegacyLocalStorage();
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const sessions = await db.getAll(SESSION_STORE);
|
|
||||||
return sessions.sort(compareSessionsByAnchorTime).map(toSessionSummary);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateChatSessionTitle = async (
|
export const updateChatSessionTitle = async (
|
||||||
@@ -327,87 +261,29 @@ export const updateChatSessionTitle = async (
|
|||||||
|
|
||||||
const normalizedTitle = title.trim();
|
const normalizedTitle = title.trim();
|
||||||
if (!normalizedTitle) return;
|
if (!normalizedTitle) return;
|
||||||
|
await updateRemoteChatSessionTitle(
|
||||||
const db = await getDb();
|
storageSessionId,
|
||||||
const session = await db.get(SESSION_STORE, storageSessionId);
|
normalizedTitle,
|
||||||
if (!session) return;
|
options?.isTitleManuallyEdited,
|
||||||
|
);
|
||||||
await db.put(SESSION_STORE, {
|
|
||||||
...session,
|
|
||||||
title: normalizedTitle,
|
|
||||||
isTitleManuallyEdited:
|
|
||||||
options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createEmptyChatSession = async (): Promise<LoadedChatState> => {
|
|
||||||
if (typeof window === "undefined") return emptyLoadedChatState();
|
|
||||||
|
|
||||||
await migrateLegacyLocalStorage();
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const session: ChatSessionRecord = {
|
|
||||||
id: createId(),
|
|
||||||
title: "新对话",
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
sessionId: undefined,
|
|
||||||
messages: [],
|
|
||||||
branchGroups: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
await db.put(SESSION_STORE, session);
|
|
||||||
const meta = await getMeta();
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: session.id,
|
|
||||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return toLoadedChatState(session);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadChatSessionById = async (
|
export const loadChatSessionById = async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
_projectId?: string | null,
|
||||||
): Promise<LoadedChatState> => {
|
): Promise<LoadedChatState> => {
|
||||||
if (typeof window === "undefined") return emptyLoadedChatState();
|
if (typeof window === "undefined") return emptyLoadedChatState();
|
||||||
|
|
||||||
await migrateLegacyLocalStorage();
|
return await fetchRemoteChatSession(sessionId);
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
const session = await db.get(SESSION_STORE, sessionId);
|
|
||||||
if (!session) {
|
|
||||||
return emptyLoadedChatState();
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = await getMeta();
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: session.id,
|
|
||||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return toLoadedChatState(session);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteChatSession = async (
|
export const deleteChatSession = async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
_projectId?: string | null,
|
||||||
): Promise<string | undefined> => {
|
): Promise<string | undefined> => {
|
||||||
if (typeof window === "undefined") return undefined;
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
const db = await getDb();
|
await deleteRemoteChatSession(sessionId);
|
||||||
await db.delete(SESSION_STORE, sessionId);
|
const nextActiveSession = (await listChatSessions())[0];
|
||||||
|
|
||||||
const remainingSessions = await db.getAll(SESSION_STORE);
|
|
||||||
const nextActiveSession = remainingSessions.sort(
|
|
||||||
compareSessionsByAnchorTime,
|
|
||||||
)[0];
|
|
||||||
const meta = await getMeta();
|
|
||||||
|
|
||||||
await setMeta({
|
|
||||||
activeSessionId: nextActiveSession?.id,
|
|
||||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return nextActiveSession?.id;
|
return nextActiveSession?.id;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { useAgentChatSession } from "./useAgentChatSession";
|
||||||
|
import { streamAgentChat } from "@/lib/chatStream";
|
||||||
|
import type { StreamEvent } from "@/lib/chatStream";
|
||||||
|
|
||||||
|
jest.mock("@/lib/chatStream", () => ({
|
||||||
|
abortAgentChat: jest.fn(async () => undefined),
|
||||||
|
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||||
|
streamAgentChat: jest.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const loadActiveChatState = jest.fn();
|
||||||
|
const listChatSessions = jest.fn();
|
||||||
|
const saveActiveChatState = jest.fn();
|
||||||
|
const updateChatSessionTitle = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("../chatStorage", () => ({
|
||||||
|
deleteChatSession: jest.fn(async () => undefined),
|
||||||
|
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
|
||||||
|
loadActiveChatState: (...args: unknown[]) => loadActiveChatState(...args),
|
||||||
|
loadChatSessionById: jest.fn(async () => ({
|
||||||
|
storageSessionId: "session-loaded",
|
||||||
|
title: "已存在会话",
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
})),
|
||||||
|
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
|
||||||
|
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useAgentChatSession", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
loadActiveChatState.mockReset();
|
||||||
|
listChatSessions.mockReset();
|
||||||
|
saveActiveChatState.mockReset();
|
||||||
|
updateChatSessionTitle.mockReset();
|
||||||
|
jest.mocked(streamAgentChat).mockReset();
|
||||||
|
saveActiveChatState.mockImplementation(async (state) => state.storageSessionId);
|
||||||
|
|
||||||
|
loadActiveChatState.mockResolvedValue({
|
||||||
|
storageSessionId: undefined,
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add a new empty session to history until there is actual chat content", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current.createSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.sessionTitle).toBe("新对话"));
|
||||||
|
expect(result.current.chatSessions).toEqual([]);
|
||||||
|
expect(result.current.activeStorageSessionId).toBeUndefined();
|
||||||
|
expect(result.current.messages).toEqual([]);
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
expect(listChatSessions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps existing history entries when creating a blank new session", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
title: "已有会话",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current.createSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.chatSessions).toEqual([
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
title: "已有会话",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the stream session id before persisting a new streaming conversation", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
emitStreamEvent = onEvent;
|
||||||
|
await new Promise<void>(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
void result.current.sendPrompt("第一条消息");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveActiveChatState).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitStreamEvent?.({
|
||||||
|
type: "token",
|
||||||
|
sessionId: "chat-stream-1",
|
||||||
|
content: "收到",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveActiveChatState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
|
||||||
|
sessionId: "chat-stream-1",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores generated session titles after the title was edited manually", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
loadActiveChatState.mockResolvedValue({
|
||||||
|
storageSessionId: "session-1",
|
||||||
|
title: "手动标题",
|
||||||
|
isTitleManuallyEdited: true,
|
||||||
|
messages: [],
|
||||||
|
sessionId: "session-1",
|
||||||
|
branchGroups: [],
|
||||||
|
});
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "session_title",
|
||||||
|
sessionId: "session-1",
|
||||||
|
title: "自动标题",
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "done",
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("帮我分析一下");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sessionTitle).toBe("手动标题");
|
||||||
|
expect(updateChatSessionTitle).not.toHaveBeenCalledWith(
|
||||||
|
"session-1",
|
||||||
|
"自动标题",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
createId,
|
createId,
|
||||||
} from "../GlobalChatbox.utils";
|
} from "../GlobalChatbox.utils";
|
||||||
import {
|
import {
|
||||||
createEmptyChatSession,
|
|
||||||
deleteChatSession,
|
deleteChatSession,
|
||||||
listChatSessions,
|
listChatSessions,
|
||||||
loadActiveChatState,
|
loadActiveChatState,
|
||||||
@@ -29,6 +28,7 @@ import {
|
|||||||
} from "../chatStorage";
|
} from "../chatStorage";
|
||||||
|
|
||||||
type UseAgentChatSessionOptions = {
|
type UseAgentChatSessionOptions = {
|
||||||
|
projectId?: string | null;
|
||||||
onToolCall: (
|
onToolCall: (
|
||||||
event: StreamEvent & { type: "tool_call" },
|
event: StreamEvent & { type: "tool_call" },
|
||||||
options: {
|
options: {
|
||||||
@@ -146,6 +146,7 @@ const messagesEqual = (left: Message[], right: Message[]) =>
|
|||||||
JSON.stringify(left) === JSON.stringify(right);
|
JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
|
||||||
export const useAgentChatSession = ({
|
export const useAgentChatSession = ({
|
||||||
|
projectId,
|
||||||
onToolCall,
|
onToolCall,
|
||||||
onBeforeSend,
|
onBeforeSend,
|
||||||
getModel,
|
getModel,
|
||||||
@@ -191,9 +192,37 @@ export const useAgentChatSession = ({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const hydrate = async () => {
|
const hydrate = async () => {
|
||||||
|
setIsHydrating(true);
|
||||||
|
hydrationCompletedRef.current = false;
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
storageSessionIdRef.current = undefined;
|
||||||
|
sessionIdRef.current = undefined;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||||
|
storageSessionId: undefined,
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
});
|
||||||
|
hydrationCompletedRef.current = true;
|
||||||
|
hydrationNonceRef.current += 1;
|
||||||
|
titleUpdateNonceRef.current += 1;
|
||||||
|
setBranchTransition(null);
|
||||||
|
setMessages([]);
|
||||||
|
setSessionTitle(undefined);
|
||||||
|
setIsSessionTitleManuallyEdited(false);
|
||||||
|
setSessionId(undefined);
|
||||||
|
setBranchGroups([]);
|
||||||
|
setChatSessions([]);
|
||||||
|
setIsHydrating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [loadedState, sessions] = await Promise.all([
|
const [loadedState, sessions] = await Promise.all([
|
||||||
loadActiveChatState(),
|
loadActiveChatState(projectId),
|
||||||
listChatSessions(),
|
listChatSessions(),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -225,10 +254,10 @@ export const useAgentChatSession = ({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isHydrating || !hydrationCompletedRef.current) return;
|
if (!projectId || isHydrating || !hydrationCompletedRef.current) return;
|
||||||
|
|
||||||
const currentHydrationNonce = hydrationNonceRef.current;
|
const currentHydrationNonce = hydrationNonceRef.current;
|
||||||
const persistTimer = window.setTimeout(() => {
|
const persistTimer = window.setTimeout(() => {
|
||||||
@@ -240,12 +269,21 @@ export const useAgentChatSession = ({
|
|||||||
sessionId,
|
sessionId,
|
||||||
branchGroups,
|
branchGroups,
|
||||||
};
|
};
|
||||||
|
if (
|
||||||
|
isStreaming &&
|
||||||
|
!state.storageSessionId &&
|
||||||
|
!state.sessionId &&
|
||||||
|
state.messages.length > 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentStateKey = createPersistedStateKey(state);
|
const currentStateKey = createPersistedStateKey(state);
|
||||||
if (currentStateKey === lastPersistedStateKeyRef.current) {
|
if (currentStateKey === lastPersistedStateKeyRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void saveActiveChatState(state)
|
void saveActiveChatState(state, projectId)
|
||||||
.then((storageSessionId) => {
|
.then((storageSessionId) => {
|
||||||
if (hydrationNonceRef.current !== currentHydrationNonce) return;
|
if (hydrationNonceRef.current !== currentHydrationNonce) return;
|
||||||
storageSessionIdRef.current = storageSessionId;
|
storageSessionIdRef.current = storageSessionId;
|
||||||
@@ -267,7 +305,7 @@ export const useAgentChatSession = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(persistTimer);
|
window.clearTimeout(persistTimer);
|
||||||
};
|
};
|
||||||
}, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, sessionId, sessionTitle]);
|
}, [branchGroups, isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBranchGroups((prev) => {
|
setBranchGroups((prev) => {
|
||||||
@@ -509,62 +547,29 @@ export const useAgentChatSession = ({
|
|||||||
cancelPromiseRef.current = trackedCancelPromise;
|
cancelPromiseRef.current = trackedCancelPromise;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const createSession = useCallback(() => {
|
||||||
const controller = abortRef.current;
|
|
||||||
controller?.abort();
|
|
||||||
const activeSessionId = sessionIdRef.current;
|
|
||||||
if (activeSessionId) {
|
|
||||||
const cancelPromise = abortAgentChat(activeSessionId).catch((error) => {
|
|
||||||
console.error("[GlobalChatbox] Failed to abort agent session during reset:", error);
|
|
||||||
});
|
|
||||||
const trackedCancelPromise = cancelPromise.finally(() => {
|
|
||||||
if (cancelPromiseRef.current === trackedCancelPromise) {
|
|
||||||
cancelPromiseRef.current = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
cancelPromiseRef.current = trackedCancelPromise;
|
|
||||||
}
|
|
||||||
setMessages([]);
|
|
||||||
setSessionTitle(undefined);
|
|
||||||
setIsSessionTitleManuallyEdited(false);
|
|
||||||
setBranchGroups([]);
|
|
||||||
setBranchTransition(null);
|
|
||||||
setSessionId(undefined);
|
|
||||||
sessionIdRef.current = undefined;
|
|
||||||
storageSessionIdRef.current = undefined;
|
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
storageSessionId: undefined,
|
|
||||||
title: undefined,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: [],
|
|
||||||
sessionId: undefined,
|
|
||||||
branchGroups: [],
|
|
||||||
});
|
|
||||||
titleUpdateNonceRef.current += 1;
|
|
||||||
setIsStreaming(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createSession = useCallback(async () => {
|
|
||||||
if (isHydrating || isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
const controller = abortRef.current;
|
const controller = abortRef.current;
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
setBranchTransition(null);
|
setBranchTransition(null);
|
||||||
|
|
||||||
const newState = await createEmptyChatSession();
|
|
||||||
const sessions = await listChatSessions();
|
|
||||||
|
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
storageSessionIdRef.current = newState.storageSessionId;
|
storageSessionIdRef.current = undefined;
|
||||||
sessionIdRef.current = newState.sessionId;
|
sessionIdRef.current = undefined;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey(newState);
|
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||||
setMessages(newState.messages);
|
storageSessionId: undefined,
|
||||||
setSessionTitle(newState.title);
|
title: "新对话",
|
||||||
setIsSessionTitleManuallyEdited(newState.isTitleManuallyEdited ?? false);
|
isTitleManuallyEdited: false,
|
||||||
setSessionId(newState.sessionId);
|
messages: [],
|
||||||
setBranchGroups(newState.branchGroups);
|
sessionId: undefined,
|
||||||
setChatSessions(sessions);
|
branchGroups: [],
|
||||||
|
});
|
||||||
|
setMessages([]);
|
||||||
|
setSessionTitle("新对话");
|
||||||
|
setIsSessionTitleManuallyEdited(false);
|
||||||
|
setSessionId(undefined);
|
||||||
|
setBranchGroups([]);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}, [isHydrating, isStreaming]);
|
}, [isHydrating, isStreaming]);
|
||||||
|
|
||||||
@@ -577,7 +582,7 @@ export const useAgentChatSession = ({
|
|||||||
setIsHydrating(true);
|
setIsHydrating(true);
|
||||||
try {
|
try {
|
||||||
const [nextState, sessions] = await Promise.all([
|
const [nextState, sessions] = await Promise.all([
|
||||||
loadChatSessionById(nextStorageSessionId),
|
loadChatSessionById(nextStorageSessionId, projectId),
|
||||||
listChatSessions(),
|
listChatSessions(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -599,7 +604,7 @@ export const useAgentChatSession = ({
|
|||||||
setIsHydrating(false);
|
setIsHydrating(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isHydrating, isStreaming],
|
[isHydrating, isStreaming, projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeSession = useCallback(
|
const removeSession = useCallback(
|
||||||
@@ -607,7 +612,10 @@ export const useAgentChatSession = ({
|
|||||||
if (isHydrating || isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextActiveSessionId = await deleteChatSession(targetStorageSessionId);
|
const nextActiveSessionId = await deleteChatSession(
|
||||||
|
targetStorageSessionId,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
const sessions = await listChatSessions();
|
const sessions = await listChatSessions();
|
||||||
setChatSessions(sessions);
|
setChatSessions(sessions);
|
||||||
|
|
||||||
@@ -639,7 +647,7 @@ export const useAgentChatSession = ({
|
|||||||
|
|
||||||
setIsHydrating(true);
|
setIsHydrating(true);
|
||||||
const [nextState, sessionsAfterDelete] = await Promise.all([
|
const [nextState, sessionsAfterDelete] = await Promise.all([
|
||||||
loadChatSessionById(nextActiveSessionId),
|
loadChatSessionById(nextActiveSessionId, projectId),
|
||||||
listChatSessions(),
|
listChatSessions(),
|
||||||
]);
|
]);
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
@@ -660,7 +668,7 @@ export const useAgentChatSession = ({
|
|||||||
setIsHydrating(false);
|
setIsHydrating(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isHydrating, isStreaming],
|
[isHydrating, isStreaming, projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendPrompt = useCallback(
|
const sendPrompt = useCallback(
|
||||||
@@ -869,7 +877,6 @@ export const useAgentChatSession = ({
|
|||||||
cycleBranch,
|
cycleBranch,
|
||||||
abort,
|
abort,
|
||||||
createSession,
|
createSession,
|
||||||
reset,
|
|
||||||
renameSession,
|
renameSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
switchSession,
|
switchSession,
|
||||||
|
|||||||
@@ -59,6 +59,23 @@ interface TimelineProps {
|
|||||||
schemeName?: string;
|
schemeName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timelineIconButtonSx = {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
"&:hover": {
|
||||||
|
borderRadius: "50%",
|
||||||
|
},
|
||||||
|
"&.Mui-focusVisible": {
|
||||||
|
borderRadius: "50%",
|
||||||
|
},
|
||||||
|
"& .MuiTouchRipple-root": {
|
||||||
|
borderRadius: "50%",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
const Timeline: React.FC<TimelineProps> = ({
|
const Timeline: React.FC<TimelineProps> = ({
|
||||||
disableDateSelection = false,
|
disableDateSelection = false,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -445,7 +462,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[920px] opacity-90 hover:opacity-100 transition-opacity duration-300">
|
<div className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100">
|
||||||
<LocalizationProvider
|
<LocalizationProvider
|
||||||
dateAdapter={AdapterDayjs}
|
dateAdapter={AdapterDayjs}
|
||||||
adapterLocale="zh-cn"
|
adapterLocale="zh-cn"
|
||||||
@@ -481,6 +498,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
onClick={handleDayStepBackward}
|
onClick={handleDayStepBackward}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={disableDateSelection}
|
disabled={disableDateSelection}
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<FiSkipBack />
|
<FiSkipBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -517,6 +535,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
selectedDateTime.toDateString() ===
|
selectedDateTime.toDateString() ===
|
||||||
new Date().toDateString()
|
new Date().toDateString()
|
||||||
}
|
}
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<FiSkipForward />
|
<FiSkipForward />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -545,6 +564,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleStepBackward}
|
onClick={handleStepBackward}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<TbArrowBackUp />
|
<TbArrowBackUp />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -555,6 +575,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={isPlaying ? handlePause : handlePlay}
|
onClick={isPlaying ? handlePause : handlePlay}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause /> : <PlayArrow />}
|
{isPlaying ? <Pause /> : <PlayArrow />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -565,6 +586,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleStepForward}
|
onClick={handleStepForward}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<TbArrowForwardUp />
|
<TbArrowForwardUp />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -575,6 +597,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<Stop />
|
<Stop />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const LayerControl: React.FC = () => {
|
|||||||
const deckLayers = data?.deckLayers ?? (deckLayer ? [deckLayer] : []);
|
const deckLayers = data?.deckLayers ?? (deckLayer ? [deckLayer] : []);
|
||||||
const isContourLayerAvailable = data?.isContourLayerAvailable;
|
const isContourLayerAvailable = data?.isContourLayerAvailable;
|
||||||
const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable;
|
const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable;
|
||||||
|
const showContourLayer = data?.showContourLayer;
|
||||||
|
const showWaterflowLayer = data?.showWaterflowLayer;
|
||||||
const setShowWaterflowLayer = data?.setShowWaterflowLayer;
|
const setShowWaterflowLayer = data?.setShowWaterflowLayer;
|
||||||
const setShowContourLayer = data?.setShowContourLayer;
|
const setShowContourLayer = data?.setShowContourLayer;
|
||||||
|
|
||||||
@@ -46,6 +48,14 @@ const LayerControl: React.FC = () => {
|
|||||||
if (!map || !data) return [];
|
if (!map || !data) return [];
|
||||||
|
|
||||||
const items: LayerItem[] = [];
|
const items: LayerItem[] = [];
|
||||||
|
const upsertLayerItem = (nextItem: LayerItem) => {
|
||||||
|
const index = items.findIndex((item) => item.id === nextItem.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
items[index] = nextItem;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.push(nextItem);
|
||||||
|
};
|
||||||
|
|
||||||
map.getLayers().getArray().forEach((layer) => {
|
map.getLayers().getArray().forEach((layer) => {
|
||||||
if (
|
if (
|
||||||
@@ -56,7 +66,7 @@ const LayerControl: React.FC = () => {
|
|||||||
const value = layer.get("value");
|
const value = layer.get("value");
|
||||||
const name = layer.get("name");
|
const name = layer.get("name");
|
||||||
if (value) {
|
if (value) {
|
||||||
items.push({
|
upsertLayerItem({
|
||||||
id: value,
|
id: value,
|
||||||
name: name || value,
|
name: name || value,
|
||||||
visible: layer.getVisible(),
|
visible: layer.getVisible(),
|
||||||
@@ -80,7 +90,7 @@ const LayerControl: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push({
|
upsertLayerItem({
|
||||||
id: layer.props.id,
|
id: layer.props.id,
|
||||||
name: layer.props.name,
|
name: layer.props.name,
|
||||||
visible:
|
visible:
|
||||||
@@ -91,6 +101,30 @@ const LayerControl: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isWaterflowLayerAvailable) {
|
||||||
|
upsertLayerItem({
|
||||||
|
id: "waterflowLayer",
|
||||||
|
name: "水流",
|
||||||
|
visible:
|
||||||
|
deckLayer?.getDeckLayerVisible("waterflowLayer") ?? showWaterflowLayer ?? false,
|
||||||
|
type: "deck",
|
||||||
|
layerRef: deckLayer?.getDeckLayerById("waterflowLayer") ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isContourLayerAvailable) {
|
||||||
|
upsertLayerItem({
|
||||||
|
id: "junctionContourLayer",
|
||||||
|
name: "等值线",
|
||||||
|
visible:
|
||||||
|
deckLayer?.getDeckLayerVisible("junctionContourLayer") ??
|
||||||
|
showContourLayer ??
|
||||||
|
false,
|
||||||
|
type: "deck",
|
||||||
|
layerRef: deckLayer?.getDeckLayerById("junctionContourLayer") ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
.filter((item) => LAYER_ORDER.includes(item.id))
|
.filter((item) => LAYER_ORDER.includes(item.id))
|
||||||
.sort((a, b) => LAYER_ORDER.indexOf(a.id) - LAYER_ORDER.indexOf(b.id));
|
.sort((a, b) => LAYER_ORDER.indexOf(a.id) - LAYER_ORDER.indexOf(b.id));
|
||||||
@@ -100,6 +134,8 @@ const LayerControl: React.FC = () => {
|
|||||||
deckLayer,
|
deckLayer,
|
||||||
isContourLayerAvailable,
|
isContourLayerAvailable,
|
||||||
isWaterflowLayerAvailable,
|
isWaterflowLayerAvailable,
|
||||||
|
showContourLayer,
|
||||||
|
showWaterflowLayer,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -126,7 +162,7 @@ const LayerControl: React.FC = () => {
|
|||||||
.filter((layer) => layer.get("value") === item.id)
|
.filter((layer) => layer.get("value") === item.id)
|
||||||
.forEach((layer) => layer.setVisible(checked));
|
.forEach((layer) => layer.setVisible(checked));
|
||||||
});
|
});
|
||||||
} else if (item.type === "deck" && deckLayers.length > 0) {
|
} else if (item.type === "deck") {
|
||||||
deckLayers.forEach((targetDeckLayer) => {
|
deckLayers.forEach((targetDeckLayer) => {
|
||||||
targetDeckLayer.setDeckLayerVisible(item.id, checked);
|
targetDeckLayer.setDeckLayerVisible(item.id, checked);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ interface TimelineProps {
|
|||||||
schemeType?: string;
|
schemeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timelineIconButtonSx = {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
"&:hover": {
|
||||||
|
borderRadius: "50%",
|
||||||
|
},
|
||||||
|
"&.Mui-focusVisible": {
|
||||||
|
borderRadius: "50%",
|
||||||
|
},
|
||||||
|
"& .MuiTouchRipple-root": {
|
||||||
|
borderRadius: "50%",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
const NOOP_SET_CURRENT_TIME = (_: any) => undefined;
|
const NOOP_SET_CURRENT_TIME = (_: any) => undefined;
|
||||||
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
|
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
|
||||||
|
|
||||||
@@ -665,7 +682,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
<Draggable nodeRef={draggableRef} handle=".drag-handle">
|
<Draggable nodeRef={draggableRef} handle=".drag-handle">
|
||||||
<div
|
<div
|
||||||
ref={draggableRef}
|
ref={draggableRef}
|
||||||
className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[920px] opacity-90 hover:opacity-100 transition-opacity duration-300"
|
className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100"
|
||||||
>
|
>
|
||||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
||||||
<Paper
|
<Paper
|
||||||
@@ -723,6 +740,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
onClick={handleDayStepBackward}
|
onClick={handleDayStepBackward}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={disableDateSelection}
|
disabled={disableDateSelection}
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<FiSkipBack />
|
<FiSkipBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -757,6 +775,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
selectedDate.toDateString() ===
|
selectedDate.toDateString() ===
|
||||||
new Date().toDateString()
|
new Date().toDateString()
|
||||||
}
|
}
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<FiSkipForward />
|
<FiSkipForward />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -785,6 +804,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleStepBackward}
|
onClick={handleStepBackward}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<TbRewindBackward15 />
|
<TbRewindBackward15 />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -795,6 +815,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={isPlaying ? handlePause : handlePlay}
|
onClick={isPlaying ? handlePause : handlePlay}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause /> : <PlayArrow />}
|
{isPlaying ? <Pause /> : <PlayArrow />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -805,6 +826,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleStepForward}
|
onClick={handleStepForward}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<TbRewindForward15 />
|
<TbRewindForward15 />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -815,6 +837,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={timelineIconButtonSx}
|
||||||
>
|
>
|
||||||
<Stop />
|
<Stop />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ interface DataContextType {
|
|||||||
setShowPipeTextLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowPipeTextLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setShowJunctionId?: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowJunctionId?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setShowPipeId?: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowPipeId?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
showContourLayer?: boolean;
|
||||||
setShowContourLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowContourLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
isContourLayerAvailable?: boolean;
|
isContourLayerAvailable?: boolean;
|
||||||
|
showWaterflowLayer?: boolean;
|
||||||
setShowWaterflowLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowWaterflowLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setContourLayerAvailable?: React.Dispatch<React.SetStateAction<boolean>>;
|
setContourLayerAvailable?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
isWaterflowLayerAvailable?: boolean;
|
isWaterflowLayerAvailable?: boolean;
|
||||||
@@ -1504,8 +1506,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
setShowPipeId,
|
setShowPipeId,
|
||||||
showJunctionId,
|
showJunctionId,
|
||||||
showPipeId,
|
showPipeId,
|
||||||
|
showContourLayer,
|
||||||
setShowContourLayer,
|
setShowContourLayer,
|
||||||
isContourLayerAvailable,
|
isContourLayerAvailable,
|
||||||
|
showWaterflowLayer,
|
||||||
setContourLayerAvailable,
|
setContourLayerAvailable,
|
||||||
isWaterflowLayerAvailable,
|
isWaterflowLayerAvailable,
|
||||||
setWaterflowLayerAvailable,
|
setWaterflowLayerAvailable,
|
||||||
|
|||||||
Reference in New Issue
Block a user