Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf6386d209 | ||
|
|
0dea655f68 | ||
|
|
6ff8886524 | ||
|
|
7cd0c61181 | ||
|
|
f6d2e19397 | ||
|
|
181871e0cf | ||
|
|
8934844bd9 | ||
|
|
24cddc18a6 | ||
|
|
7f07f0449d | ||
|
|
757eea49de | ||
|
|
bb7311589c | ||
|
|
877b79ada8 |
@@ -24,7 +24,7 @@ npm run start
|
|||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for components, `camelCase` for variables/functions, and descriptive feature-oriented filenames. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
|
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for React component files and component names. Use `camelCase` for ordinary TypeScript modules, hooks, stores, providers, utilities, variables, and functions. Next.js route directories under `src/app` use `kebab-case`; route groups and dynamic segments keep the Next.js syntax such as `(main)` and `[...nextauth]`. Keep backend/Agent boundary fields and query parameters in the shape required by the API, typically `snake_case`, and do not translate third-party SDK fields. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Frontend Naming Audit
|
||||||
|
|
||||||
|
DOC-004 audit for the internal `TJWaterFrontend_Refine` application.
|
||||||
|
|
||||||
|
## Local frontend naming
|
||||||
|
|
||||||
|
- Next.js route directories under `src/app` are already `kebab-case`: `audit-logs`, `health-risk-analysis`, `hydraulic-simulation`, `monitoring-place-optimization`, `network-simulation`, `scada-data-cleaning`, and `system-admin`.
|
||||||
|
- Route groups and dynamic segments keep Next.js syntax: `(main)` and `[...nextauth]`.
|
||||||
|
- Local ordinary module filenames now use `camelCase`, including `src/utils/breaksClassification.ts`, `src/components/chat/globalChatboxUtils.ts`, and `src/components/chat/globalChatboxVoice.ts`.
|
||||||
|
- React component files remain `PascalCase.tsx`, including `src/app/RefineContext.tsx`, `src/components/chat/GlobalChatboxParts.tsx`, and domain directories under `src/components/olmap`.
|
||||||
|
|
||||||
|
## API boundary naming
|
||||||
|
|
||||||
|
Frontend request and response boundary fields intentionally keep backend/Agent wire names. Examples include `project_id`, `user_id`, `scheme_name`, `scheme_type`, `start_time`, `end_time`, `session_id`, `request_id`, and `keep_message_count`.
|
||||||
|
|
||||||
|
Current direct API calls mostly use new `kebab-case` URL paths, including:
|
||||||
|
|
||||||
|
- `/api/v1/admin/projects`
|
||||||
|
- `/api/v1/audit/logs`
|
||||||
|
- `/api/v1/projects/open`
|
||||||
|
- `/api/v1/project-info`
|
||||||
|
- `/api/v1/schemes`
|
||||||
|
- `/api/v1/sensor-placement-schemes`
|
||||||
|
- `/api/v1/burst-analysis`
|
||||||
|
- `/api/v1/valve-isolation-analysis`
|
||||||
|
- `/api/v1/flushing-analysis`
|
||||||
|
- `/api/v1/contaminant-simulation`
|
||||||
|
- `/api/v1/simulations/run-by-date`
|
||||||
|
- `/api/v1/burst-detection/detect`
|
||||||
|
- `/api/v1/burst-location/locate`
|
||||||
|
- `/api/v1/scada/by-ids-field-time-range`
|
||||||
|
- `/api/v1/composite/clean-scada`
|
||||||
|
- `/api/v1/agent/chat/render-ref/{render_ref}`
|
||||||
|
|
||||||
|
## Legacy URL inventory
|
||||||
|
|
||||||
|
The frontend no longer calls these active legacy URLs directly. The backend still exposes them as deprecated compatibility aliases:
|
||||||
|
|
||||||
|
| Current frontend URL | Files | Suggested target |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/api/v1/openproject/` | `src/contexts/ProjectContext.tsx` | `/api/v1/projects/open` or `/api/v1/project/open` |
|
||||||
|
| `/api/v1/project_info/` | `src/contexts/ProjectContext.tsx` | `/api/v1/project-info` |
|
||||||
|
| `/api/v1/getallschemes/` | burst, burst simulation, contaminant, flushing scheme query components | `/api/v1/schemes` |
|
||||||
|
| `/api/v1/getallsensorplacements/` | `src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx` | `/api/v1/sensor-placement-schemes` |
|
||||||
|
| `/api/v1/sensorplacementscheme/create` | `src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx` | `/api/v1/sensor-placement-schemes` |
|
||||||
|
| `/api/v1/burst_analysis/` | `src/components/olmap/BurstSimulation/AnalysisParameters.tsx` | `/api/v1/burst-analysis` |
|
||||||
|
| `/api/v1/valve_isolation_analysis/` | `src/components/olmap/BurstSimulation/ValveIsolation.tsx` | `/api/v1/valve-isolation-analysis` |
|
||||||
|
| `/api/v1/flushing_analysis/` | `src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx` | `/api/v1/flushing-analysis` |
|
||||||
|
| `/api/v1/contaminant_simulation/` | `src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx` | `/api/v1/contaminant-simulation` |
|
||||||
|
| `/api/v1/runsimulationmanuallybydate/` | `src/components/olmap/core/Controls/Timeline.tsx` | `/api/v1/simulations/run-by-date` |
|
||||||
|
|
||||||
|
Already migrated frontend code leaves comments showing older pre-`/api/v1` URLs in `src/components/olmap/core/Controls/Toolbar.tsx`; those comments are historical only and are not active calls.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
Continue tracking broad passive legacy backend routes under the shared legacy API compatibility strategy.
|
||||||
Generated
+51
-3382
File diff suppressed because it is too large
Load Diff
+4
-13
@@ -6,14 +6,13 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev",
|
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev",
|
||||||
"build": "refine build",
|
"build": "next build",
|
||||||
"start": "refine start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
"refine": "refine",
|
|
||||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -38,6 +37,7 @@
|
|||||||
"deck.gl": "^9.1.14",
|
"deck.gl": "^9.1.14",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
|
"edge-tts-ts": "^1.0.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
@@ -58,12 +58,6 @@
|
|||||||
"fast-xml-parser": "5.5.9"
|
"fast-xml-parser": "5.5.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@refinedev/cli": "^2.16.52",
|
|
||||||
"@refinedev/devtools": "^2.0.5",
|
|
||||||
"@refinedev/devtools-internal": "^2.0.2",
|
|
||||||
"@refinedev/devtools-server": "^2.0.2",
|
|
||||||
"@refinedev/devtools-shared": "^2.0.2",
|
|
||||||
"@refinedev/devtools-ui": "^2.0.3",
|
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -82,8 +76,5 @@
|
|||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
|
||||||
"refine": {
|
|
||||||
"projectId": "4LwOCL-BBaV29-qUYMAJ"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { AuditLogPanel } from "@/components/audit/AuditLogPanel";
|
||||||
|
|
||||||
|
export default function AuditLogsPage() {
|
||||||
|
return <AuditLogPanel />;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import authOptions from "@app/api/auth/[...nextauth]/options";
|
|||||||
import { Header } from "@components/header";
|
import { Header } from "@components/header";
|
||||||
import { Title } from "@components/title";
|
import { Title } from "@components/title";
|
||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||||
|
import { AppSider } from "@components/sider/AppSider";
|
||||||
import { ThemedLayout } from "@refinedev/mui";
|
import { ThemedLayout } from "@refinedev/mui";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -35,6 +36,7 @@ export default async function MainLayout({
|
|||||||
<ThemedLayout
|
<ThemedLayout
|
||||||
Header={Header}
|
Header={Header}
|
||||||
Title={Title}
|
Title={Title}
|
||||||
|
Sider={AppSider}
|
||||||
childrenBoxProps={{
|
childrenBoxProps={{
|
||||||
sx: { height: "100vh", p: 0 },
|
sx: { height: "100vh", p: 0 },
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SystemAdminPanel } from "@/components/admin/SystemAdminPanel";
|
||||||
|
|
||||||
|
export default function SystemAdminPage() {
|
||||||
|
return <SystemAdminPanel />;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@refinedev/mui";
|
} from "@refinedev/mui";
|
||||||
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
import routerProvider from "@refinedev/nextjs-router";
|
import routerProvider from "@refinedev/nextjs-router";
|
||||||
|
|
||||||
@@ -16,6 +16,8 @@ import { ColorModeContextProvider } from "@contexts/color-mode";
|
|||||||
import { dataProvider } from "@providers/data-provider";
|
import { dataProvider } from "@providers/data-provider";
|
||||||
import { ProjectProvider } from "@/contexts/ProjectContext";
|
import { ProjectProvider } from "@/contexts/ProjectContext";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { config } from "@config/config";
|
||||||
|
|
||||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||||
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
||||||
@@ -23,6 +25,8 @@ import { LuReplace } from "react-icons/lu";
|
|||||||
import { AiOutlineSecurityScan } from "react-icons/ai";
|
import { AiOutlineSecurityScan } from "react-icons/ai";
|
||||||
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
|
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
|
||||||
import {
|
import {
|
||||||
|
ManageAccounts as ManageAccountsIcon,
|
||||||
|
FactCheck as FactCheckIcon,
|
||||||
MyLocation as MyLocationIcon,
|
MyLocation as MyLocationIcon,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
@@ -51,11 +55,41 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
const { data, status } = useSession();
|
const { data, status } = useSession();
|
||||||
const to = usePathname();
|
const to = usePathname();
|
||||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||||
|
const [isMetadataAdmin, setIsMetadataAdmin] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
|
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
|
||||||
}, [data?.accessToken, setAccessToken]);
|
}, [data?.accessToken, setAccessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "authenticated") {
|
||||||
|
setIsMetadataAdmin(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
|
||||||
|
projectHeaderMode: "omit",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!response.ok) {
|
||||||
|
setIsMetadataAdmin(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin"));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setIsMetadataAdmin(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return <span>loading...</span>;
|
return <span>loading...</span>;
|
||||||
}
|
}
|
||||||
@@ -227,6 +261,26 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "管道冲洗",
|
label: "管道冲洗",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...(isMetadataAdmin
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: "系统管理",
|
||||||
|
list: "/system-admin",
|
||||||
|
meta: {
|
||||||
|
icon: <ManageAccountsIcon className="w-6 h-6" />,
|
||||||
|
label: "系统管理",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "审计日志",
|
||||||
|
list: "/audit-logs",
|
||||||
|
meta: {
|
||||||
|
icon: <FactCheckIcon className="w-6 h-6" />,
|
||||||
|
label: "审计日志",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
options={{
|
options={{
|
||||||
syncWithLocation: true,
|
syncWithLocation: true,
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment node
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { POST } from "./route";
|
||||||
|
|
||||||
|
const streamMock = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("edge-tts-ts", () => ({
|
||||||
|
Communicate: jest.fn().mockImplementation(() => ({
|
||||||
|
stream: streamMock,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("POST /api/tts/edge", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
streamMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns synthesized mp3 audio", async () => {
|
||||||
|
streamMock.mockImplementation(async function* () {
|
||||||
|
yield { type: "audio", data: new Uint8Array([1, 2]) };
|
||||||
|
yield { type: "SentenceBoundary", offset: 0, duration: 1, text: "测试" };
|
||||||
|
yield { type: "audio", data: new Uint8Array([3]) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://localhost/api/tts/edge", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ text: "测试文本" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("Content-Type")).toBe("audio/mpeg");
|
||||||
|
expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty text", async () => {
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://localhost/api/tts/edge", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ text: " " }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ error: "text is required" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { Communicate } from "edge-tts-ts";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const DEFAULT_VOICE = process.env.EDGE_TTS_VOICE || "zh-CN-XiaoxiaoNeural";
|
||||||
|
const MAX_TEXT_LENGTH = 12000;
|
||||||
|
|
||||||
|
type EdgeTtsRequest = {
|
||||||
|
text?: unknown;
|
||||||
|
voice?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonError = (message: string, status: number) =>
|
||||||
|
NextResponse.json({ error: message }, { status });
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let payload: EdgeTtsRequest;
|
||||||
|
try {
|
||||||
|
payload = (await request.json()) as EdgeTtsRequest;
|
||||||
|
} catch {
|
||||||
|
return jsonError("Invalid JSON body", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
||||||
|
if (!text) {
|
||||||
|
return jsonError("text is required", 400);
|
||||||
|
}
|
||||||
|
if (text.length > MAX_TEXT_LENGTH) {
|
||||||
|
return jsonError(`text must be ${MAX_TEXT_LENGTH} characters or fewer`, 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
const voice =
|
||||||
|
typeof payload.voice === "string" && payload.voice.trim()
|
||||||
|
? payload.voice.trim()
|
||||||
|
: DEFAULT_VOICE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const communicate = new Communicate(text, { voice });
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let byteLength = 0;
|
||||||
|
|
||||||
|
for await (const chunk of communicate.stream()) {
|
||||||
|
if (chunk.type !== "audio") continue;
|
||||||
|
chunks.push(chunk.data);
|
||||||
|
byteLength += chunk.data.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byteLength === 0) {
|
||||||
|
return jsonError("Edge TTS returned empty audio", 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = new Uint8Array(byteLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
audio.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBuffer = audio.buffer.slice(
|
||||||
|
audio.byteOffset,
|
||||||
|
audio.byteOffset + audio.byteLength,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(audioBuffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "audio/mpeg",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[EdgeTTS] Failed to synthesize speech:", error);
|
||||||
|
return jsonError("Failed to synthesize speech", 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import React, { Suspense } from "react";
|
import React, { Suspense } from "react";
|
||||||
import { RefineContext } from "./_refine_context";
|
import { RefineContext } from "./RefineContext";
|
||||||
import { META_DATA } from "@config/config";
|
import { META_DATA } from "@config/config";
|
||||||
|
|
||||||
export const metadata: Metadata = META_DATA;
|
export const metadata: Metadata = META_DATA;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import { AnimatePresence, motion } from "framer-motion";
|
|||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
CircularProgress,
|
||||||
IconButton,
|
IconButton,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -22,8 +23,12 @@ import {
|
|||||||
parseContentWithToolCalls,
|
parseContentWithToolCalls,
|
||||||
type ContentSegment,
|
type ContentSegment,
|
||||||
} from "./chatMessageSections";
|
} from "./chatMessageSections";
|
||||||
import type { Message, SpeechState } from "./GlobalChatbox.types";
|
import type {
|
||||||
import { stripMarkdown } from "./GlobalChatbox.utils";
|
Message,
|
||||||
|
SpeechState,
|
||||||
|
} from "./GlobalChatbox.types";
|
||||||
|
import { stripMarkdown } from "./globalChatboxUtils";
|
||||||
|
import { findSpeechSelectionStartOffset } from "./speechStartOptions";
|
||||||
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||||
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
|
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
|
||||||
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||||
@@ -40,7 +45,11 @@ type AgentTurnProps = {
|
|||||||
message: Message;
|
message: Message;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
messageSpeechState: SpeechState;
|
messageSpeechState: SpeechState;
|
||||||
onSpeak: (messageId: string, text: string) => void;
|
onSpeak: (
|
||||||
|
messageId: string,
|
||||||
|
text: string,
|
||||||
|
options?: { startOffset?: number },
|
||||||
|
) => void;
|
||||||
onPause: () => void;
|
onPause: () => void;
|
||||||
onResume: () => void;
|
onResume: () => void;
|
||||||
onStopSpeech: () => void;
|
onStopSpeech: () => void;
|
||||||
@@ -170,6 +179,11 @@ export const AgentTurn = React.memo(
|
|||||||
const isErrorMessage = Boolean(message.isError);
|
const isErrorMessage = Boolean(message.isError);
|
||||||
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
||||||
const [isHovered, setIsHovered] = React.useState(false);
|
const [isHovered, setIsHovered] = React.useState(false);
|
||||||
|
const answerContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const [selectedSpeechStart, setSelectedSpeechStart] = React.useState<{
|
||||||
|
offset: number;
|
||||||
|
preview: string;
|
||||||
|
} | null>(null);
|
||||||
const isProgressComplete = message.progress?.some(
|
const isProgressComplete = message.progress?.some(
|
||||||
(item) => item.phase === "complete" && item.status === "completed",
|
(item) => item.phase === "complete" && item.status === "completed",
|
||||||
) ?? false;
|
) ?? false;
|
||||||
@@ -185,6 +199,43 @@ export const AgentTurn = React.memo(
|
|||||||
[isErrorMessage, isUser, message.content],
|
[isErrorMessage, isUser, message.content],
|
||||||
);
|
);
|
||||||
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
||||||
|
const speechText = useMemo(
|
||||||
|
() => stripMarkdown(answerContent),
|
||||||
|
[answerContent],
|
||||||
|
);
|
||||||
|
const handleCaptureSpeechSelection = React.useCallback(() => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const container = answerContentRef.current;
|
||||||
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (!container.contains(range.commonAncestorContainer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedText = selection.toString();
|
||||||
|
const startOffset = findSpeechSelectionStartOffset(speechText, selectedText);
|
||||||
|
if (startOffset === null) {
|
||||||
|
setSelectedSpeechStart(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = selectedText.replace(/\s+/g, " ").trim().slice(0, 24);
|
||||||
|
setSelectedSpeechStart({
|
||||||
|
offset: startOffset,
|
||||||
|
preview: preview.length === 24 ? `${preview}...` : preview,
|
||||||
|
});
|
||||||
|
}, [speechText]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSelectedSpeechStart(null);
|
||||||
|
}, [message.id, speechText]);
|
||||||
|
const handleSpeakFromCurrentStart = () => {
|
||||||
|
onSpeak(message.id, speechText, {
|
||||||
|
startOffset: selectedSpeechStart?.offset ?? 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
const contentSegments: ContentSegment[] = useMemo(
|
const contentSegments: ContentSegment[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
!isUser && !isErrorMessage
|
!isUser && !isErrorMessage
|
||||||
@@ -333,6 +384,10 @@ export const AgentTurn = React.memo(
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
|
ref={answerContentRef}
|
||||||
|
onMouseUp={handleCaptureSpeechSelection}
|
||||||
|
onKeyUp={handleCaptureSpeechSelection}
|
||||||
|
onTouchEnd={handleCaptureSpeechSelection}
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
@@ -487,13 +542,28 @@ export const AgentTurn = React.memo(
|
|||||||
{messageSpeechState === "idle" ? (
|
{messageSpeechState === "idle" ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
|
onClick={handleSpeakFromCurrentStart}
|
||||||
aria-label="朗读消息"
|
aria-label={selectedSpeechStart ? "从选中位置朗读" : "朗读消息"}
|
||||||
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
||||||
>
|
>
|
||||||
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
) : null}
|
) : null}
|
||||||
|
{messageSpeechState === "loading" ? (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled
|
||||||
|
aria-label="正在生成语音"
|
||||||
|
sx={{ color: "primary.main", p: 0.5 }}
|
||||||
|
>
|
||||||
|
<CircularProgress size={16} thickness={5} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||||
|
<StopRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{messageSpeechState === "playing" ? (
|
{messageSpeechState === "playing" ? (
|
||||||
<>
|
<>
|
||||||
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ jest.mock("framer-motion", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("./GlobalChatbox.parts", () => ({
|
|
||||||
TypingIndicator: () => <div>typing</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("./AgentTurn", () => ({
|
jest.mock("./AgentTurn", () => ({
|
||||||
AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => {
|
AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
|
|||||||
import MapRounded from "@mui/icons-material/MapRounded";
|
import MapRounded from "@mui/icons-material/MapRounded";
|
||||||
|
|
||||||
import { AgentTurn } from "./AgentTurn";
|
import { AgentTurn } from "./AgentTurn";
|
||||||
import { TypingIndicator } from "./GlobalChatbox.parts";
|
|
||||||
import type { PermissionReply } from "@/lib/chatStream";
|
import type { PermissionReply } from "@/lib/chatStream";
|
||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
@@ -26,7 +25,11 @@ type AgentWorkspaceProps = {
|
|||||||
onScrollStateChange?: (isNearBottom: boolean) => void;
|
onScrollStateChange?: (isNearBottom: boolean) => void;
|
||||||
speakingMessageId: string | null;
|
speakingMessageId: string | null;
|
||||||
speechState: SpeechState;
|
speechState: SpeechState;
|
||||||
onSpeak: (messageId: string, text: string) => void;
|
onSpeak: (
|
||||||
|
messageId: string,
|
||||||
|
text: string,
|
||||||
|
options?: { startOffset?: number },
|
||||||
|
) => void;
|
||||||
onPauseSpeech: () => void;
|
onPauseSpeech: () => void;
|
||||||
onResumeSpeech: () => void;
|
onResumeSpeech: () => void;
|
||||||
onStopSpeech: () => void;
|
onStopSpeech: () => void;
|
||||||
@@ -39,11 +42,15 @@ type AgentWorkspaceProps = {
|
|||||||
|
|
||||||
type TurnListProps = {
|
type TurnListProps = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isStreaming: boolean;
|
isAssistantStreaming: boolean;
|
||||||
streamingMessageId: string | null;
|
streamingMessageId: string | null;
|
||||||
speakingMessageId: string | null;
|
speakingMessageId: string | null;
|
||||||
speechState: SpeechState;
|
speechState: SpeechState;
|
||||||
onSpeak: (messageId: string, text: string) => void;
|
onSpeak: (
|
||||||
|
messageId: string,
|
||||||
|
text: string,
|
||||||
|
options?: { startOffset?: number },
|
||||||
|
) => void;
|
||||||
onPauseSpeech: () => void;
|
onPauseSpeech: () => void;
|
||||||
onResumeSpeech: () => void;
|
onResumeSpeech: () => void;
|
||||||
onStopSpeech: () => void;
|
onStopSpeech: () => void;
|
||||||
@@ -65,7 +72,7 @@ const TurnItem = React.memo(AgentTurn);
|
|||||||
|
|
||||||
const TurnListInner = ({
|
const TurnListInner = ({
|
||||||
messages,
|
messages,
|
||||||
isStreaming,
|
isAssistantStreaming,
|
||||||
streamingMessageId,
|
streamingMessageId,
|
||||||
speakingMessageId,
|
speakingMessageId,
|
||||||
speechState,
|
speechState,
|
||||||
@@ -85,7 +92,7 @@ const TurnListInner = ({
|
|||||||
<TurnItem
|
<TurnItem
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
isStreaming={isStreaming && message.id === streamingMessageId}
|
isStreaming={isAssistantStreaming && message.id === streamingMessageId}
|
||||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||||
onSpeak={onSpeak}
|
onSpeak={onSpeak}
|
||||||
onPause={onPauseSpeech}
|
onPause={onPauseSpeech}
|
||||||
@@ -106,7 +113,7 @@ const TurnList = React.memo(
|
|||||||
TurnListInner,
|
TurnListInner,
|
||||||
(prevProps, nextProps) =>
|
(prevProps, nextProps) =>
|
||||||
sameMessages(prevProps.messages, nextProps.messages) &&
|
sameMessages(prevProps.messages, nextProps.messages) &&
|
||||||
prevProps.isStreaming === nextProps.isStreaming &&
|
prevProps.isAssistantStreaming === nextProps.isAssistantStreaming &&
|
||||||
prevProps.streamingMessageId === nextProps.streamingMessageId &&
|
prevProps.streamingMessageId === nextProps.streamingMessageId &&
|
||||||
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||||||
prevProps.speechState === nextProps.speechState &&
|
prevProps.speechState === nextProps.speechState &&
|
||||||
@@ -318,19 +325,10 @@ export const AgentWorkspace = ({
|
|||||||
onReplyQuestion,
|
onReplyQuestion,
|
||||||
onRejectQuestion,
|
onRejectQuestion,
|
||||||
}: AgentWorkspaceProps) => {
|
}: AgentWorkspaceProps) => {
|
||||||
const theme = useTheme();
|
const streamingMessageId =
|
||||||
const latestAssistant = [...messages]
|
|
||||||
.reverse()
|
|
||||||
.find((message) => message.role === "assistant");
|
|
||||||
const showTypingIndicator =
|
|
||||||
isStreaming &&
|
|
||||||
(!latestAssistant ||
|
|
||||||
(latestAssistant.content.trim().length === 0 &&
|
|
||||||
!(latestAssistant.artifacts?.length)));
|
|
||||||
const streamingMessage =
|
|
||||||
isStreaming && messages.at(-1)?.role === "assistant"
|
isStreaming && messages.at(-1)?.role === "assistant"
|
||||||
? messages.at(-1)
|
? messages.at(-1)?.id ?? null
|
||||||
: undefined;
|
: null;
|
||||||
const handleScroll = React.useCallback(
|
const handleScroll = React.useCallback(
|
||||||
(event: React.UIEvent<HTMLDivElement>) => {
|
(event: React.UIEvent<HTMLDivElement>) => {
|
||||||
if (!onScrollStateChange) return;
|
if (!onScrollStateChange) return;
|
||||||
@@ -372,8 +370,8 @@ export const AgentWorkspace = ({
|
|||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<TurnList
|
<TurnList
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isStreaming={isStreaming}
|
isAssistantStreaming={isStreaming}
|
||||||
streamingMessageId={streamingMessage?.id ?? null}
|
streamingMessageId={streamingMessageId}
|
||||||
speakingMessageId={speakingMessageId}
|
speakingMessageId={speakingMessageId}
|
||||||
speechState={speechState}
|
speechState={speechState}
|
||||||
onSpeak={onSpeak}
|
onSpeak={onSpeak}
|
||||||
@@ -391,27 +389,6 @@ export const AgentWorkspace = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoadingSession && showTypingIndicator ? (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10, scale: 0.94 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
transition={{ type: "spring", stiffness: 300 }}
|
|
||||||
style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 44 }}
|
|
||||||
>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 1.3,
|
|
||||||
borderRadius: 4,
|
|
||||||
bgcolor: alpha("#fff", 0.82),
|
|
||||||
boxShadow: `0 4px 12px ${alpha(theme.palette.common.black, 0.05)}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TypingIndicator />
|
|
||||||
</Paper>
|
|
||||||
</motion.div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={bottomRef}
|
ref={bottomRef}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
|
|||||||
import { AgentHeader } from "./AgentHeader";
|
import { AgentHeader } from "./AgentHeader";
|
||||||
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||||
import { AgentWorkspace } from "./AgentWorkspace";
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
import { Blob } from "./GlobalChatbox.parts";
|
import { Blob } from "./GlobalChatboxParts";
|
||||||
import type { Props } from "./GlobalChatbox.types";
|
import type { Props } from "./GlobalChatbox.types";
|
||||||
import { PRESET_PROMPTS } from "./GlobalChatbox.utils";
|
import { PRESET_PROMPTS } from "./globalChatboxUtils";
|
||||||
import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
|
import { useSpeechRecognition, useSpeechSynthesis } from "./globalChatboxVoice";
|
||||||
import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
||||||
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export type Props = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SpeechState = "idle" | "playing" | "paused";
|
export type SpeechState = "idle" | "loading" | "playing" | "paused";
|
||||||
|
|
||||||
export type ChatSessionSummary = {
|
export type ChatSessionSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,647 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import config from "@/config/config";
|
|
||||||
import type { SpeechState } from "./GlobalChatbox.types";
|
|
||||||
|
|
||||||
type AudioStreamStartResponse = {
|
|
||||||
stream_id?: string;
|
|
||||||
audio_url?: string;
|
|
||||||
status_url?: string;
|
|
||||||
result_url?: string;
|
|
||||||
sample_rate?: number;
|
|
||||||
channels?: number;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AudioStreamStatusResponse = {
|
|
||||||
state?: "starting" | "running" | "done" | "failed" | "closed";
|
|
||||||
ready?: boolean;
|
|
||||||
failed?: boolean;
|
|
||||||
closed?: boolean;
|
|
||||||
status_text?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AudioStreamResultResponse = {
|
|
||||||
run_status?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// WebKit Speech Recognition compatibility
|
|
||||||
interface SpeechRecognitionEvent extends Event {
|
|
||||||
readonly resultIndex: number;
|
|
||||||
readonly results: SpeechRecognitionResultList;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeechRecognition extends EventTarget {
|
|
||||||
lang: string;
|
|
||||||
continuous: boolean;
|
|
||||||
interimResults: boolean;
|
|
||||||
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
|
||||||
onerror: ((event: Event) => void) | null;
|
|
||||||
onend: (() => void) | null;
|
|
||||||
start(): void;
|
|
||||||
stop(): void;
|
|
||||||
abort(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
SpeechRecognition?: {
|
|
||||||
new (): SpeechRecognition;
|
|
||||||
prototype: SpeechRecognition;
|
|
||||||
};
|
|
||||||
webkitSpeechRecognition?: {
|
|
||||||
new (): SpeechRecognition;
|
|
||||||
prototype: SpeechRecognition;
|
|
||||||
};
|
|
||||||
webkitAudioContext?: typeof AudioContext;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSpeechSynthesis() {
|
|
||||||
const [speechState, setSpeechState] = useState<SpeechState>("idle");
|
|
||||||
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
|
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
|
||||||
const streamAbortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
const activeSourceNodesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
|
|
||||||
const streamIdRef = useRef<string | null>(null);
|
|
||||||
const closeUrlRef = useRef<string | null>(null);
|
|
||||||
const statusUrlRef = useRef<string | null>(null);
|
|
||||||
const resultUrlRef = useRef<string | null>(null);
|
|
||||||
const statusPollTimeoutRef = useRef<number | null>(null);
|
|
||||||
const playbackTokenRef = useRef(0);
|
|
||||||
|
|
||||||
const isSupported =
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
typeof window.FormData !== "undefined" &&
|
|
||||||
(typeof window.AudioContext !== "undefined" ||
|
|
||||||
typeof window.webkitAudioContext !== "undefined");
|
|
||||||
|
|
||||||
const trimTrailingSlash = useCallback((value: string) => value.replace(/\/+$/, ""), []);
|
|
||||||
|
|
||||||
const buildServiceUrl = useCallback(
|
|
||||||
(path: string) => `${trimTrailingSlash(config.AUDIO_SERVICE_URL)}${path.startsWith("/") ? path : `/${path}`}`,
|
|
||||||
[trimTrailingSlash],
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolveServiceUrl = useCallback(
|
|
||||||
(pathOrUrl: string) => {
|
|
||||||
if (/^https?:\/\//i.test(pathOrUrl)) {
|
|
||||||
return pathOrUrl;
|
|
||||||
}
|
|
||||||
return buildServiceUrl(pathOrUrl);
|
|
||||||
},
|
|
||||||
[buildServiceUrl],
|
|
||||||
);
|
|
||||||
|
|
||||||
const withQueryParams = useCallback(
|
|
||||||
(urlString: string, params: Record<string, string>) => {
|
|
||||||
const url = new URL(urlString);
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
url.searchParams.set(key, value);
|
|
||||||
});
|
|
||||||
return url.toString();
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const readErrorMessage = useCallback(async (response: Response, fallback: string) => {
|
|
||||||
try {
|
|
||||||
const payload = (await response.json()) as { error?: string; message?: string };
|
|
||||||
return payload.error || payload.message || fallback;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeStream = useCallback(async (closeUrl: string) => {
|
|
||||||
const response = await fetch(closeUrl, {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error("[GlobalChatbox] Failed to close audio stream:", closeUrl);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const stopStatusPolling = useCallback(() => {
|
|
||||||
if (statusPollTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(statusPollTimeoutRef.current);
|
|
||||||
statusPollTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchStreamResult = useCallback(
|
|
||||||
async (resultUrl: string) => {
|
|
||||||
const response = await fetch(resultUrl);
|
|
||||||
if (response.status === 202) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
await readErrorMessage(
|
|
||||||
response,
|
|
||||||
`Audio stream result failed with status ${response.status}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = (await response.json()) as AudioStreamResultResponse;
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[readErrorMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearAudio = useCallback(async () => {
|
|
||||||
const abortController = streamAbortControllerRef.current;
|
|
||||||
streamAbortControllerRef.current = null;
|
|
||||||
abortController?.abort();
|
|
||||||
|
|
||||||
activeSourceNodesRef.current.forEach((source) => {
|
|
||||||
try {
|
|
||||||
source.onended = null;
|
|
||||||
source.stop();
|
|
||||||
} catch {
|
|
||||||
// ignore stop errors when source already ended
|
|
||||||
}
|
|
||||||
source.disconnect();
|
|
||||||
});
|
|
||||||
activeSourceNodesRef.current.clear();
|
|
||||||
|
|
||||||
const audioContext = audioContextRef.current;
|
|
||||||
audioContextRef.current = null;
|
|
||||||
if (!audioContext) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await audioContext.close();
|
|
||||||
} catch {
|
|
||||||
// ignore close errors when context already closed
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const playPcmStream = useCallback(
|
|
||||||
async ({
|
|
||||||
audioUrl,
|
|
||||||
sampleRate,
|
|
||||||
channels,
|
|
||||||
playbackToken,
|
|
||||||
}: {
|
|
||||||
audioUrl: string;
|
|
||||||
sampleRate: number;
|
|
||||||
channels: number;
|
|
||||||
playbackToken: number;
|
|
||||||
}) => {
|
|
||||||
const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext;
|
|
||||||
if (!AudioContextCtor) {
|
|
||||||
throw new Error("WebAudio AudioContext is not available in this browser");
|
|
||||||
}
|
|
||||||
|
|
||||||
const abortController = new AbortController();
|
|
||||||
streamAbortControllerRef.current = abortController;
|
|
||||||
|
|
||||||
const response = await fetch(withQueryParams(audioUrl, { format: "pcm" }), {
|
|
||||||
signal: abortController.signal,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
await readErrorMessage(response, `Audio stream failed with status ${response.status}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error("Audio stream response body is missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioContext = new AudioContextCtor({
|
|
||||||
sampleRate,
|
|
||||||
});
|
|
||||||
audioContextRef.current = audioContext;
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const bytesPerFrame = Math.max(1, channels) * 2;
|
|
||||||
let bufferedRemainder = new Uint8Array(0);
|
|
||||||
let nextStartTime = audioContext.currentTime + 0.05;
|
|
||||||
let activeSources = 0;
|
|
||||||
let streamEnded = false;
|
|
||||||
let resolvePlaybackDrain: (() => void) | null = null;
|
|
||||||
const playbackDrainPromise = new Promise<void>((resolve) => {
|
|
||||||
resolvePlaybackDrain = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
const maybeResolvePlaybackDrain = () => {
|
|
||||||
if (streamEnded && activeSources === 0) {
|
|
||||||
resolvePlaybackDrain?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const schedulePcmChunk = (pcmBytes: Uint8Array) => {
|
|
||||||
const frameCount = pcmBytes.byteLength / bytesPerFrame;
|
|
||||||
if (frameCount <= 0) return;
|
|
||||||
|
|
||||||
const buffer = audioContext.createBuffer(Math.max(1, channels), frameCount, sampleRate);
|
|
||||||
const view = new DataView(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength);
|
|
||||||
for (let frame = 0; frame < frameCount; frame += 1) {
|
|
||||||
for (let channel = 0; channel < Math.max(1, channels); channel += 1) {
|
|
||||||
const sampleIndex = frame * Math.max(1, channels) + channel;
|
|
||||||
const pcm = view.getInt16(sampleIndex * 2, true);
|
|
||||||
buffer.getChannelData(channel)[frame] = pcm / 32768;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = audioContext.createBufferSource();
|
|
||||||
source.buffer = buffer;
|
|
||||||
source.connect(audioContext.destination);
|
|
||||||
const sourceStartTime = Math.max(nextStartTime, audioContext.currentTime + 0.01);
|
|
||||||
nextStartTime = sourceStartTime + buffer.duration;
|
|
||||||
|
|
||||||
activeSources += 1;
|
|
||||||
activeSourceNodesRef.current.add(source);
|
|
||||||
source.onended = () => {
|
|
||||||
activeSources -= 1;
|
|
||||||
activeSourceNodesRef.current.delete(source);
|
|
||||||
source.disconnect();
|
|
||||||
maybeResolvePlaybackDrain();
|
|
||||||
};
|
|
||||||
source.start(sourceStartTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
const concatUint8Arrays = (a: Uint8Array, b: Uint8Array) => {
|
|
||||||
if (a.byteLength === 0) return b;
|
|
||||||
if (b.byteLength === 0) return a;
|
|
||||||
const merged = new Uint8Array(a.byteLength + b.byteLength);
|
|
||||||
merged.set(a);
|
|
||||||
merged.set(b, a.byteLength);
|
|
||||||
return merged;
|
|
||||||
};
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
if (playbackToken !== playbackTokenRef.current) {
|
|
||||||
throw new DOMException("PCM stream playback cancelled", "AbortError");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
if (!value || value.byteLength === 0) continue;
|
|
||||||
|
|
||||||
const merged = concatUint8Arrays(bufferedRemainder, value);
|
|
||||||
const alignedByteLength = merged.byteLength - (merged.byteLength % bytesPerFrame);
|
|
||||||
if (alignedByteLength === 0) {
|
|
||||||
bufferedRemainder = new Uint8Array(merged);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const alignedChunk = merged.slice(0, alignedByteLength);
|
|
||||||
bufferedRemainder = new Uint8Array(merged.slice(alignedByteLength));
|
|
||||||
schedulePcmChunk(alignedChunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
streamEnded = true;
|
|
||||||
maybeResolvePlaybackDrain();
|
|
||||||
await playbackDrainPromise;
|
|
||||||
},
|
|
||||||
[readErrorMessage, withQueryParams],
|
|
||||||
);
|
|
||||||
|
|
||||||
const stopPlayback = useCallback(async () => {
|
|
||||||
await clearAudio();
|
|
||||||
stopStatusPolling();
|
|
||||||
|
|
||||||
const closeUrl = closeUrlRef.current;
|
|
||||||
streamIdRef.current = null;
|
|
||||||
closeUrlRef.current = null;
|
|
||||||
statusUrlRef.current = null;
|
|
||||||
resultUrlRef.current = null;
|
|
||||||
setSpeechState("idle");
|
|
||||||
setSpeakingMessageId(null);
|
|
||||||
|
|
||||||
if (closeUrl) {
|
|
||||||
try {
|
|
||||||
await closeStream(closeUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[GlobalChatbox] Failed to close audio stream:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [clearAudio, closeStream, stopStatusPolling]);
|
|
||||||
|
|
||||||
const pollStreamStatus = useCallback(
|
|
||||||
(playbackToken: number, statusUrl: string, resultUrl: string) => {
|
|
||||||
stopStatusPolling();
|
|
||||||
|
|
||||||
statusPollTimeoutRef.current = window.setTimeout(async () => {
|
|
||||||
if (
|
|
||||||
playbackToken !== playbackTokenRef.current ||
|
|
||||||
statusUrlRef.current !== statusUrl ||
|
|
||||||
resultUrlRef.current !== resultUrl
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(statusUrl);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
await readErrorMessage(
|
|
||||||
response,
|
|
||||||
`Audio stream status failed with status ${response.status}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = (await response.json()) as AudioStreamStatusResponse;
|
|
||||||
if (
|
|
||||||
playbackToken !== playbackTokenRef.current ||
|
|
||||||
statusUrlRef.current !== statusUrl ||
|
|
||||||
resultUrlRef.current !== resultUrl
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.failed || payload.state === "failed") {
|
|
||||||
console.error(
|
|
||||||
"[GlobalChatbox] Audio stream failed:",
|
|
||||||
payload.error || payload.status_text || statusUrl,
|
|
||||||
);
|
|
||||||
playbackTokenRef.current += 1;
|
|
||||||
void stopPlayback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.closed || payload.state === "closed") {
|
|
||||||
stopStatusPolling();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.ready || payload.state === "done") {
|
|
||||||
try {
|
|
||||||
const isResultReady = await fetchStreamResult(resultUrl);
|
|
||||||
if (isResultReady) {
|
|
||||||
stopStatusPolling();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[GlobalChatbox] Failed to fetch audio stream result:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pollStreamStatus(playbackToken, statusUrl, resultUrl);
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
playbackToken === playbackTokenRef.current &&
|
|
||||||
statusUrlRef.current === statusUrl &&
|
|
||||||
resultUrlRef.current === resultUrl
|
|
||||||
) {
|
|
||||||
console.error("[GlobalChatbox] Failed to poll audio stream status:", error);
|
|
||||||
pollStreamStatus(playbackToken, statusUrl, resultUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
},
|
|
||||||
[fetchStreamResult, readErrorMessage, stopPlayback, stopStatusPolling],
|
|
||||||
);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
playbackTokenRef.current += 1;
|
|
||||||
void stopPlayback();
|
|
||||||
}, [stopPlayback]);
|
|
||||||
|
|
||||||
const speak = useCallback(
|
|
||||||
async (messageId: string, text: string) => {
|
|
||||||
const normalizedText = text.trim();
|
|
||||||
if (!isSupported || !normalizedText) return;
|
|
||||||
|
|
||||||
const playbackToken = playbackTokenRef.current + 1;
|
|
||||||
playbackTokenRef.current = playbackToken;
|
|
||||||
await stopPlayback();
|
|
||||||
|
|
||||||
setSpeakingMessageId(messageId);
|
|
||||||
setSpeechState("playing");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("text", normalizedText);
|
|
||||||
formData.append("demo_id", "demo-1");
|
|
||||||
|
|
||||||
const response = await fetch(buildServiceUrl("/api/generate-stream/start"), {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
await readErrorMessage(
|
|
||||||
response,
|
|
||||||
`Audio stream start failed with status ${response.status}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = (await response.json()) as AudioStreamStartResponse;
|
|
||||||
const streamId = payload.stream_id;
|
|
||||||
const sampleRate =
|
|
||||||
typeof payload.sample_rate === "number" && payload.sample_rate > 0
|
|
||||||
? payload.sample_rate
|
|
||||||
: 24000;
|
|
||||||
const channels =
|
|
||||||
typeof payload.channels === "number" && payload.channels > 0
|
|
||||||
? payload.channels
|
|
||||||
: 1;
|
|
||||||
const audioUrl = payload.audio_url
|
|
||||||
? resolveServiceUrl(payload.audio_url)
|
|
||||||
: buildServiceUrl(
|
|
||||||
`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/audio?format=pcm`,
|
|
||||||
);
|
|
||||||
const rawStatusUrl = payload.status_url
|
|
||||||
? resolveServiceUrl(payload.status_url)
|
|
||||||
: buildServiceUrl(`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/status`);
|
|
||||||
const statusUrl = withQueryParams(rawStatusUrl, { compact: "1" });
|
|
||||||
const rawResultUrl = payload.result_url
|
|
||||||
? resolveServiceUrl(payload.result_url)
|
|
||||||
: buildServiceUrl(`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/result`);
|
|
||||||
const resultUrl = withQueryParams(rawResultUrl, {
|
|
||||||
compact: "1",
|
|
||||||
include_audio: "0",
|
|
||||||
});
|
|
||||||
const closeUrl = buildServiceUrl(
|
|
||||||
`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/close`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!streamId) {
|
|
||||||
throw new Error(payload.error || "Audio stream start response is missing stream_id");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playbackToken !== playbackTokenRef.current) {
|
|
||||||
await closeStream(closeUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
streamIdRef.current = streamId;
|
|
||||||
closeUrlRef.current = closeUrl;
|
|
||||||
statusUrlRef.current = statusUrl;
|
|
||||||
resultUrlRef.current = resultUrl;
|
|
||||||
|
|
||||||
pollStreamStatus(playbackToken, statusUrl, resultUrl);
|
|
||||||
await playPcmStream({
|
|
||||||
audioUrl,
|
|
||||||
sampleRate,
|
|
||||||
channels,
|
|
||||||
playbackToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (playbackToken !== playbackTokenRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await clearAudio();
|
|
||||||
if (streamIdRef.current === streamId) {
|
|
||||||
streamIdRef.current = null;
|
|
||||||
closeUrlRef.current = null;
|
|
||||||
statusUrlRef.current = null;
|
|
||||||
resultUrlRef.current = null;
|
|
||||||
setSpeechState("idle");
|
|
||||||
setSpeakingMessageId(null);
|
|
||||||
}
|
|
||||||
stopStatusPolling();
|
|
||||||
await fetchStreamResult(resultUrl).catch((error) => {
|
|
||||||
console.error("[GlobalChatbox] Failed to fetch audio stream result:", error);
|
|
||||||
});
|
|
||||||
await closeStream(closeUrl);
|
|
||||||
} catch (error) {
|
|
||||||
await clearAudio();
|
|
||||||
if (
|
|
||||||
error instanceof DOMException &&
|
|
||||||
error.name === "AbortError" &&
|
|
||||||
playbackToken !== playbackTokenRef.current
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const closeUrl = closeUrlRef.current;
|
|
||||||
streamIdRef.current = null;
|
|
||||||
closeUrlRef.current = null;
|
|
||||||
statusUrlRef.current = null;
|
|
||||||
resultUrlRef.current = null;
|
|
||||||
setSpeechState("idle");
|
|
||||||
setSpeakingMessageId(null);
|
|
||||||
if (closeUrl) {
|
|
||||||
try {
|
|
||||||
await closeStream(closeUrl);
|
|
||||||
} catch (closeError) {
|
|
||||||
console.error("[GlobalChatbox] Failed to close audio stream:", closeError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.error("[GlobalChatbox] Failed to play audio stream:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
buildServiceUrl,
|
|
||||||
clearAudio,
|
|
||||||
closeStream,
|
|
||||||
fetchStreamResult,
|
|
||||||
isSupported,
|
|
||||||
playPcmStream,
|
|
||||||
readErrorMessage,
|
|
||||||
resolveServiceUrl,
|
|
||||||
pollStreamStatus,
|
|
||||||
stopPlayback,
|
|
||||||
stopStatusPolling,
|
|
||||||
withQueryParams,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
if (!isSupported || !audioContextRef.current) return;
|
|
||||||
void audioContextRef.current.suspend().then(
|
|
||||||
() => {
|
|
||||||
setSpeechState("paused");
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
console.error("[GlobalChatbox] Failed to pause PCM playback:", error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [isSupported]);
|
|
||||||
|
|
||||||
const resume = useCallback(() => {
|
|
||||||
if (!isSupported || !audioContextRef.current) return;
|
|
||||||
void audioContextRef.current.resume().then(
|
|
||||||
() => {
|
|
||||||
setSpeechState("playing");
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
playbackTokenRef.current += 1;
|
|
||||||
void stopPlayback();
|
|
||||||
console.error("[GlobalChatbox] Failed to resume audio playback:", error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [isSupported, stopPlayback]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
playbackTokenRef.current += 1;
|
|
||||||
void stopPlayback();
|
|
||||||
};
|
|
||||||
}, [stopPlayback]);
|
|
||||||
|
|
||||||
return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSpeechRecognition(onResult: (text: string) => void) {
|
|
||||||
const [isListening, setIsListening] = useState(false);
|
|
||||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
|
||||||
const onResultRef = useRef(onResult);
|
|
||||||
useEffect(() => {
|
|
||||||
onResultRef.current = onResult;
|
|
||||||
}, [onResult]);
|
|
||||||
|
|
||||||
const isSupported =
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
("SpeechRecognition" in window || "webkitSpeechRecognition" in window);
|
|
||||||
|
|
||||||
const start = useCallback(() => {
|
|
||||||
if (!isSupported || recognitionRef.current) return;
|
|
||||||
const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
|
|
||||||
if (!Ctor) return;
|
|
||||||
|
|
||||||
const recognition = new Ctor();
|
|
||||||
recognition.lang = "zh-CN";
|
|
||||||
recognition.continuous = true;
|
|
||||||
recognition.interimResults = false;
|
|
||||||
|
|
||||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
|
||||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
||||||
if (event.results[i].isFinal) {
|
|
||||||
onResultRef.current(event.results[i][0].transcript);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
recognition.onerror = () => {
|
|
||||||
setIsListening(false);
|
|
||||||
recognitionRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
recognition.onend = () => {
|
|
||||||
setIsListening(false);
|
|
||||||
recognitionRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
recognitionRef.current = recognition;
|
|
||||||
recognition.start();
|
|
||||||
setIsListening(true);
|
|
||||||
}, [isSupported]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
recognitionRef.current?.stop();
|
|
||||||
recognitionRef.current = null;
|
|
||||||
setIsListening(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
recognitionRef.current?.stop();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { isListening, start, stop, isSupported };
|
|
||||||
}
|
|
||||||
-30
@@ -2,36 +2,6 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Box, Stack } from "@mui/material";
|
|
||||||
|
|
||||||
export const TypingIndicator = () => {
|
|
||||||
return (
|
|
||||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}>
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ y: 0 }}
|
|
||||||
animate={{ y: [-4, 4, -4] }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.6,
|
|
||||||
repeat: Infinity,
|
|
||||||
delay: i * 0.15,
|
|
||||||
ease: "easeInOut",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Blob = ({
|
export const Blob = ({
|
||||||
color,
|
color,
|
||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
LoadedChatState,
|
LoadedChatState,
|
||||||
Message,
|
Message,
|
||||||
} from "./GlobalChatbox.types";
|
} from "./GlobalChatbox.types";
|
||||||
import { cloneMessages } from "./GlobalChatbox.utils";
|
import { cloneMessages } from "./globalChatboxUtils";
|
||||||
|
|
||||||
type BackendSessionPayload = {
|
type BackendSessionPayload = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -49,7 +49,6 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
|||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -77,7 +76,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta
|
|||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -123,7 +121,6 @@ const updateBackendChatSessionTitle = async (
|
|||||||
is_title_manually_edited: isTitleManuallyEdited,
|
is_title_manually_edited: isTitleManuallyEdited,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -138,7 +135,6 @@ const deleteBackendChatSession = async (sessionId: string) => {
|
|||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { cloneMessage } from "./GlobalChatbox.utils";
|
import { cloneMessage } from "./globalChatboxUtils";
|
||||||
import type { Message } from "./GlobalChatbox.types";
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
describe("cloneMessage", () => {
|
describe("cloneMessage", () => {
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { SpeechState } from "./GlobalChatbox.types";
|
||||||
|
import { splitSpeechTextIntoChunks } from "./speechStartOptions";
|
||||||
|
|
||||||
|
type SpeakOptions = {
|
||||||
|
startOffset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SpeechRecognitionEvent extends Event {
|
||||||
|
readonly resultIndex: number;
|
||||||
|
readonly results: SpeechRecognitionResultList;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognition extends EventTarget {
|
||||||
|
lang: string;
|
||||||
|
continuous: boolean;
|
||||||
|
interimResults: boolean;
|
||||||
|
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
||||||
|
onerror: ((event: Event) => void) | null;
|
||||||
|
onend: (() => void) | null;
|
||||||
|
start(): void;
|
||||||
|
stop(): void;
|
||||||
|
abort(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
SpeechRecognition?: {
|
||||||
|
new (): SpeechRecognition;
|
||||||
|
prototype: SpeechRecognition;
|
||||||
|
};
|
||||||
|
webkitSpeechRecognition?: {
|
||||||
|
new (): SpeechRecognition;
|
||||||
|
prototype: SpeechRecognition;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpeechSynthesis() {
|
||||||
|
const [speechState, setSpeechState] = useState<SpeechState>("idle");
|
||||||
|
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const currentAudioUrlRef = useRef<string | null>(null);
|
||||||
|
const audioObjectUrlsRef = useRef<Set<string>>(new Set());
|
||||||
|
const fetchAbortControllersRef = useRef<Set<AbortController>>(new Set());
|
||||||
|
const chunkAudioUrlCacheRef = useRef<Map<number, string>>(new Map());
|
||||||
|
const chunkFetchPromisesRef = useRef<Map<number, Promise<string>>>(new Map());
|
||||||
|
const chunksRef = useRef<string[]>([]);
|
||||||
|
const currentChunkIndexRef = useRef(0);
|
||||||
|
const playChunkRef = useRef<(chunkIndex: number, playbackToken: number) => Promise<void>>(
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
|
const playbackTokenRef = useRef(0);
|
||||||
|
const activeMessageIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const isSupported =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof window.Audio !== "undefined" &&
|
||||||
|
typeof window.URL !== "undefined" &&
|
||||||
|
typeof window.fetch !== "undefined";
|
||||||
|
|
||||||
|
const detachCurrentAudio = useCallback((revokeCurrentUrl: boolean) => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
audioRef.current = null;
|
||||||
|
if (audio) {
|
||||||
|
audio.pause();
|
||||||
|
audio.onended = null;
|
||||||
|
audio.onerror = null;
|
||||||
|
audio.removeAttribute("src");
|
||||||
|
audio.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUrl = currentAudioUrlRef.current;
|
||||||
|
currentAudioUrlRef.current = null;
|
||||||
|
if (revokeCurrentUrl && currentUrl) {
|
||||||
|
URL.revokeObjectURL(currentUrl);
|
||||||
|
audioObjectUrlsRef.current.delete(currentUrl);
|
||||||
|
chunkAudioUrlCacheRef.current.delete(currentChunkIndexRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const releaseAudio = useCallback(() => {
|
||||||
|
fetchAbortControllersRef.current.forEach((controller) => controller.abort());
|
||||||
|
fetchAbortControllersRef.current.clear();
|
||||||
|
chunkFetchPromisesRef.current.clear();
|
||||||
|
detachCurrentAudio(false);
|
||||||
|
audioObjectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
audioObjectUrlsRef.current.clear();
|
||||||
|
chunkAudioUrlCacheRef.current.clear();
|
||||||
|
chunksRef.current = [];
|
||||||
|
currentChunkIndexRef.current = 0;
|
||||||
|
activeMessageIdRef.current = null;
|
||||||
|
}, [detachCurrentAudio]);
|
||||||
|
|
||||||
|
const readErrorMessage = useCallback(async (response: Response, fallback: string) => {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: string; message?: string };
|
||||||
|
return payload.error || payload.message || fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchChunkAudio = useCallback(
|
||||||
|
(chunkIndex: number, playbackToken: number) => {
|
||||||
|
const cachedUrl = chunkAudioUrlCacheRef.current.get(chunkIndex);
|
||||||
|
if (cachedUrl) return Promise.resolve(cachedUrl);
|
||||||
|
|
||||||
|
const existingPromise = chunkFetchPromisesRef.current.get(chunkIndex);
|
||||||
|
if (existingPromise) return existingPromise;
|
||||||
|
|
||||||
|
const chunkText = chunksRef.current[chunkIndex];
|
||||||
|
if (!chunkText) {
|
||||||
|
return Promise.reject(new Error("Speech chunk is missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
fetchAbortControllersRef.current.add(abortController);
|
||||||
|
|
||||||
|
const promise = fetch("/api/tts/edge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text: chunkText }),
|
||||||
|
signal: abortController.signal,
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readErrorMessage(response, `Edge TTS failed with status ${response.status}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const audioBlob = await response.blob();
|
||||||
|
if (!audioBlob.size) {
|
||||||
|
throw new Error("Edge TTS returned empty audio");
|
||||||
|
}
|
||||||
|
if (playbackToken !== playbackTokenRef.current) {
|
||||||
|
throw new DOMException("Edge TTS chunk cancelled", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(audioBlob);
|
||||||
|
audioObjectUrlsRef.current.add(objectUrl);
|
||||||
|
chunkAudioUrlCacheRef.current.set(chunkIndex, objectUrl);
|
||||||
|
return objectUrl;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
fetchAbortControllersRef.current.delete(abortController);
|
||||||
|
chunkFetchPromisesRef.current.delete(chunkIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
chunkFetchPromisesRef.current.set(chunkIndex, promise);
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
[readErrorMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const prefetchChunk = useCallback(
|
||||||
|
(chunkIndex: number, playbackToken: number) => {
|
||||||
|
if (chunkIndex >= chunksRef.current.length) return;
|
||||||
|
void fetchChunkAudio(chunkIndex, playbackToken).catch((error) => {
|
||||||
|
if (
|
||||||
|
playbackToken === playbackTokenRef.current &&
|
||||||
|
!(error instanceof DOMException && error.name === "AbortError")
|
||||||
|
) {
|
||||||
|
console.error("[GlobalChatbox] Failed to prefetch Edge TTS chunk:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[fetchChunkAudio],
|
||||||
|
);
|
||||||
|
|
||||||
|
const playChunk = useCallback(
|
||||||
|
async (chunkIndex: number, playbackToken: number) => {
|
||||||
|
setSpeechState("loading");
|
||||||
|
const objectUrl = await fetchChunkAudio(chunkIndex, playbackToken);
|
||||||
|
if (playbackToken !== playbackTokenRef.current) return;
|
||||||
|
|
||||||
|
detachCurrentAudio(true);
|
||||||
|
currentChunkIndexRef.current = chunkIndex;
|
||||||
|
const audio = new Audio(objectUrl);
|
||||||
|
audio.preload = "auto";
|
||||||
|
audioRef.current = audio;
|
||||||
|
currentAudioUrlRef.current = objectUrl;
|
||||||
|
|
||||||
|
audio.onended = () => {
|
||||||
|
if (playbackToken !== playbackTokenRef.current) return;
|
||||||
|
detachCurrentAudio(true);
|
||||||
|
const nextChunkIndex = chunkIndex + 1;
|
||||||
|
if (nextChunkIndex >= chunksRef.current.length) {
|
||||||
|
releaseAudio();
|
||||||
|
setSpeechState("idle");
|
||||||
|
setSpeakingMessageId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentChunkIndexRef.current = nextChunkIndex;
|
||||||
|
void playChunkRef.current(nextChunkIndex, playbackToken);
|
||||||
|
};
|
||||||
|
audio.onerror = () => {
|
||||||
|
if (playbackToken !== playbackTokenRef.current) return;
|
||||||
|
playbackTokenRef.current += 1;
|
||||||
|
releaseAudio();
|
||||||
|
setSpeechState("idle");
|
||||||
|
setSpeakingMessageId(null);
|
||||||
|
console.error("[GlobalChatbox] Edge TTS audio playback failed");
|
||||||
|
};
|
||||||
|
|
||||||
|
await audio.play();
|
||||||
|
if (playbackToken !== playbackTokenRef.current) return;
|
||||||
|
setSpeechState("playing");
|
||||||
|
prefetchChunk(chunkIndex + 1, playbackToken);
|
||||||
|
},
|
||||||
|
[detachCurrentAudio, fetchChunkAudio, prefetchChunk, releaseAudio],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
playChunkRef.current = playChunk;
|
||||||
|
}, [playChunk]);
|
||||||
|
|
||||||
|
const playExistingAudio = useCallback(async () => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return false;
|
||||||
|
|
||||||
|
setSpeakingMessageId(activeMessageIdRef.current);
|
||||||
|
setSpeechState("playing");
|
||||||
|
try {
|
||||||
|
await audio.play();
|
||||||
|
prefetchChunk(currentChunkIndexRef.current + 1, playbackTokenRef.current);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
playbackTokenRef.current += 1;
|
||||||
|
releaseAudio();
|
||||||
|
setSpeechState("idle");
|
||||||
|
setSpeakingMessageId(null);
|
||||||
|
console.error("[GlobalChatbox] Failed to resume Edge TTS playback:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [prefetchChunk, releaseAudio]);
|
||||||
|
|
||||||
|
const speak = useCallback(
|
||||||
|
async (messageId: string, text: string, options: SpeakOptions = {}) => {
|
||||||
|
const normalizedText = text.trim();
|
||||||
|
if (!isSupported || !normalizedText) return;
|
||||||
|
|
||||||
|
const startOffset = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(options.startOffset ?? 0, normalizedText.length),
|
||||||
|
);
|
||||||
|
const textToSpeak = normalizedText.slice(startOffset).trim();
|
||||||
|
const chunks = splitSpeechTextIntoChunks(textToSpeak);
|
||||||
|
if (!chunks.length) return;
|
||||||
|
|
||||||
|
const playbackToken = playbackTokenRef.current + 1;
|
||||||
|
playbackTokenRef.current = playbackToken;
|
||||||
|
releaseAudio();
|
||||||
|
|
||||||
|
chunksRef.current = chunks;
|
||||||
|
currentChunkIndexRef.current = 0;
|
||||||
|
activeMessageIdRef.current = messageId;
|
||||||
|
|
||||||
|
setSpeakingMessageId(messageId);
|
||||||
|
setSpeechState("loading");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await playChunk(0, playbackToken);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === "AbortError" &&
|
||||||
|
playbackToken !== playbackTokenRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseAudio();
|
||||||
|
setSpeechState("idle");
|
||||||
|
setSpeakingMessageId(null);
|
||||||
|
console.error("[GlobalChatbox] Failed to play Edge TTS audio:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isSupported, playChunk, releaseAudio],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!isSupported || !audio || speechState !== "playing") return;
|
||||||
|
audio.pause();
|
||||||
|
setSpeechState("paused");
|
||||||
|
}, [isSupported, speechState]);
|
||||||
|
|
||||||
|
const resume = useCallback(() => {
|
||||||
|
if (!isSupported) return;
|
||||||
|
void playExistingAudio();
|
||||||
|
}, [isSupported, playExistingAudio]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
playbackTokenRef.current += 1;
|
||||||
|
releaseAudio();
|
||||||
|
setSpeechState("idle");
|
||||||
|
setSpeakingMessageId(null);
|
||||||
|
}, [releaseAudio]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
playbackTokenRef.current += 1;
|
||||||
|
releaseAudio();
|
||||||
|
};
|
||||||
|
}, [releaseAudio]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
speechState,
|
||||||
|
speakingMessageId,
|
||||||
|
speak,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
isSupported,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpeechRecognition(onResult: (text: string) => void) {
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||||||
|
const onResultRef = useRef(onResult);
|
||||||
|
useEffect(() => {
|
||||||
|
onResultRef.current = onResult;
|
||||||
|
}, [onResult]);
|
||||||
|
|
||||||
|
const isSupported =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
("SpeechRecognition" in window || "webkitSpeechRecognition" in window);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
if (!isSupported || recognitionRef.current) return;
|
||||||
|
const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
|
||||||
|
if (!Ctor) return;
|
||||||
|
|
||||||
|
const recognition = new Ctor();
|
||||||
|
recognition.lang = "zh-CN";
|
||||||
|
recognition.continuous = true;
|
||||||
|
recognition.interimResults = false;
|
||||||
|
|
||||||
|
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
if (event.results[i].isFinal) {
|
||||||
|
onResultRef.current(event.results[i][0].transcript);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = () => {
|
||||||
|
setIsListening(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = () => {
|
||||||
|
setIsListening(false);
|
||||||
|
recognitionRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
recognitionRef.current = recognition;
|
||||||
|
setIsListening(true);
|
||||||
|
recognition.start();
|
||||||
|
}, [isSupported]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
recognitionRef.current?.stop();
|
||||||
|
recognitionRef.current = null;
|
||||||
|
setIsListening(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
recognitionRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isListening, start, stop, isSupported };
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
ChatProgress,
|
ChatProgress,
|
||||||
Message,
|
Message,
|
||||||
} from "../GlobalChatbox.types";
|
} from "../GlobalChatbox.types";
|
||||||
import { createId } from "../GlobalChatbox.utils";
|
import { createId } from "../globalChatboxUtils";
|
||||||
|
|
||||||
export const upsertProgress = (
|
export const upsertProgress = (
|
||||||
progress: ChatProgress[] | undefined,
|
progress: ChatProgress[] | undefined,
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
// Tests for useAgentChatSession are split by behavior boundary.
|
|
||||||
// See useAgentChatSession.lifecycle.test.tsx and useAgentChatSession.actions.test.tsx.
|
|
||||||
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
||||||
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||||||
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types";
|
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types";
|
||||||
import { cloneMessages } from "../GlobalChatbox.utils";
|
import { cloneMessages } from "../globalChatboxUtils";
|
||||||
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage";
|
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage";
|
||||||
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
||||||
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
||||||
@@ -456,6 +456,21 @@ export const useAgentChatSession = ({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
|
} else if (event.type === "auth_required") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
content: message.content || `⚠️ **${event.message}**`,
|
||||||
|
isError: true,
|
||||||
|
progress: completeRunningProgress(message.progress),
|
||||||
|
todos: cancelRunningTodos(message.todos),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
findSpeechSelectionStartOffset,
|
||||||
|
splitSpeechTextIntoChunks,
|
||||||
|
} from "./speechStartOptions";
|
||||||
|
|
||||||
|
describe("findSpeechSelectionStartOffset", () => {
|
||||||
|
it("finds the reading start from selected reply text", () => {
|
||||||
|
const text = "第一段内容。\n\n第二段 包含空格。\n第三段内容。";
|
||||||
|
|
||||||
|
expect(findSpeechSelectionStartOffset(text, "第二段 包含空格")).toBe(
|
||||||
|
text.indexOf("第二段"),
|
||||||
|
);
|
||||||
|
expect(findSpeechSelectionStartOffset(text, "第三段")).toBe(text.indexOf("第三段"));
|
||||||
|
expect(findSpeechSelectionStartOffset(text, "不存在")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitSpeechTextIntoChunks", () => {
|
||||||
|
it("splits long text into bounded chunks", () => {
|
||||||
|
const text = Array.from({ length: 80 }, (_, index) => `第${index}句内容足够长。`).join("");
|
||||||
|
const chunks = splitSpeechTextIntoChunks(text);
|
||||||
|
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
expect(chunks.every((chunk) => chunk.length <= 520)).toBe(true);
|
||||||
|
expect(chunks.join("")).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps short text as one chunk", () => {
|
||||||
|
expect(splitSpeechTextIntoChunks("短句。")).toEqual(["短句。"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
const compactWhitespace = (value: string) => value.replace(/\s+/g, " ").trim();
|
||||||
|
const MAX_SPEECH_CHUNK_LENGTH = 520;
|
||||||
|
const MIN_SPEECH_CHUNK_LENGTH = 180;
|
||||||
|
const SPEECH_SENTENCE_PATTERN = /[^。!?!?;;\n]+(?:[。!?!?;;]+|(?=\n|$))/g;
|
||||||
|
|
||||||
|
const normalizeWithOffsetMap = (value: string) => {
|
||||||
|
let normalized = "";
|
||||||
|
const offsetMap: number[] = [];
|
||||||
|
let isPreviousWhitespace = false;
|
||||||
|
|
||||||
|
Array.from(value).forEach((char, index) => {
|
||||||
|
if (/\s/u.test(char)) {
|
||||||
|
if (!isPreviousWhitespace && normalized.length > 0) {
|
||||||
|
normalized += " ";
|
||||||
|
offsetMap.push(index);
|
||||||
|
}
|
||||||
|
isPreviousWhitespace = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized += char;
|
||||||
|
offsetMap.push(index);
|
||||||
|
isPreviousWhitespace = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalized: normalized.trimEnd(),
|
||||||
|
offsetMap,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function findSpeechSelectionStartOffset(
|
||||||
|
text: string,
|
||||||
|
selectedText: string,
|
||||||
|
): number | null {
|
||||||
|
const needle = selectedText.trim();
|
||||||
|
if (!needle) return null;
|
||||||
|
|
||||||
|
const exactIndex = text.indexOf(needle);
|
||||||
|
if (exactIndex >= 0) return exactIndex;
|
||||||
|
|
||||||
|
const normalizedNeedle = compactWhitespace(needle);
|
||||||
|
if (!normalizedNeedle) return null;
|
||||||
|
|
||||||
|
const haystack = normalizeWithOffsetMap(text);
|
||||||
|
const normalizedIndex = haystack.normalized.indexOf(normalizedNeedle);
|
||||||
|
if (normalizedIndex < 0) return null;
|
||||||
|
|
||||||
|
return haystack.offsetMap[normalizedIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitSpeechTextIntoChunks(text: string): string[] {
|
||||||
|
const normalizedText = text.trim();
|
||||||
|
if (!normalizedText) return [];
|
||||||
|
|
||||||
|
const segments = Array.from(normalizedText.matchAll(SPEECH_SENTENCE_PATTERN), (match) =>
|
||||||
|
compactWhitespace(match[0]),
|
||||||
|
).filter(Boolean);
|
||||||
|
const sourceSegments = segments.length > 0 ? segments : [normalizedText];
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let currentChunk = "";
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (!currentChunk) return;
|
||||||
|
chunks.push(currentChunk);
|
||||||
|
currentChunk = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushLongSegment = (segment: string) => {
|
||||||
|
for (let offset = 0; offset < segment.length; offset += MAX_SPEECH_CHUNK_LENGTH) {
|
||||||
|
chunks.push(segment.slice(offset, offset + MAX_SPEECH_CHUNK_LENGTH));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sourceSegments.forEach((segment) => {
|
||||||
|
if (segment.length > MAX_SPEECH_CHUNK_LENGTH) {
|
||||||
|
flush();
|
||||||
|
pushLongSegment(segment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = currentChunk ? `${currentChunk}${segment}` : segment;
|
||||||
|
if (
|
||||||
|
currentChunk &&
|
||||||
|
candidate.length > MAX_SPEECH_CHUNK_LENGTH &&
|
||||||
|
currentChunk.length >= MIN_SPEECH_CHUNK_LENGTH
|
||||||
|
) {
|
||||||
|
flush();
|
||||||
|
currentChunk = segment;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentChunk = candidate;
|
||||||
|
});
|
||||||
|
|
||||||
|
flush();
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
|||||||
|
|
||||||
setSchemeLoading(true);
|
setSchemeLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/getallschemes/`, {
|
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||||
params: { network: NETWORK_NAME },
|
params: { network: NETWORK_NAME },
|
||||||
});
|
});
|
||||||
const burstSchemes = (response.data as SchemeItem[]).filter(
|
const burstSchemes = (response.data as SchemeItem[]).filter(
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
|||||||
|
|
||||||
setSchemeLoading(true);
|
setSchemeLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/getallschemes/`, {
|
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||||
params: { network: NETWORK_NAME },
|
params: { network: NETWORK_NAME },
|
||||||
});
|
});
|
||||||
const burstSchemes = (response.data as SchemeItem[]).filter(
|
const burstSchemes = (response.data as SchemeItem[]).filter(
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.get(`${config.BACKEND_URL}/api/v1/burst_analysis/`, {
|
await api.get(`${config.BACKEND_URL}/api/v1/burst-analysis`, {
|
||||||
params,
|
params,
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
|
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`,
|
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
|
||||||
);
|
);
|
||||||
let filteredResults = response.data;
|
let filteredResults = response.data;
|
||||||
|
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
params.disabled_valves = disabled;
|
params.disabled_valves = disabled;
|
||||||
}
|
}
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`${config.BACKEND_URL}/api/v1/valve_isolation_analysis/`,
|
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
|
||||||
{
|
{
|
||||||
params,
|
params,
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.get(`${config.BACKEND_URL}/api/v1/contaminant_simulation/`, {
|
await api.get(`${config.BACKEND_URL}/api/v1/contaminant-simulation`, {
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`,
|
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
|
||||||
);
|
);
|
||||||
let filteredResults = response.data;
|
let filteredResults = response.data;
|
||||||
|
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
// but axios usually handles array as valves[]=1&valves[]=2
|
// but axios usually handles array as valves[]=1&valves[]=2
|
||||||
// FastAPI default expects repeated query params.
|
// FastAPI default expects repeated query params.
|
||||||
|
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing_analysis/`, {
|
const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing-analysis`, {
|
||||||
params,
|
params,
|
||||||
// Ensure arrays are sent as repeated keys: valves=1&valves=2
|
// Ensure arrays are sent as repeated keys: valves=1&valves=2
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`,
|
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
let filteredResults = response.data;
|
let filteredResults = response.data;
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const OptimizationParameters: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
// 发送优化请求
|
// 发送优化请求
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`${config.BACKEND_URL}/api/v1/sensorplacementscheme/create`,
|
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes`,
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`${config.BACKEND_URL}/api/v1/getallsensorplacements/?network=${network}`,
|
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes?network=${network}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
let filteredResults = response.data;
|
let filteredResults = response.data;
|
||||||
|
|||||||
@@ -643,7 +643,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/runsimulationmanuallybydate/`,
|
`${config.BACKEND_URL}/api/v1/simulations/run-by-date`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FlatStyleLike } from "ol/style/flat";
|
import { FlatStyleLike } from "ol/style/flat";
|
||||||
|
|
||||||
import { calculateClassification } from "@utils/breaks_classification";
|
import { calculateClassification } from "@utils/breaksClassification";
|
||||||
import { parseColor } from "@utils/parseColor";
|
import { parseColor } from "@utils/parseColor";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
StyleEditorStateProps,
|
StyleEditorStateProps,
|
||||||
} from "./styleEditorTypes";
|
} from "./styleEditorTypes";
|
||||||
import { LegendStyleConfig } from "./StyleLegend";
|
import { LegendStyleConfig } from "./StyleLegend";
|
||||||
import { calculateClassification } from "@utils/breaks_classification";
|
import { calculateClassification } from "@utils/breaksClassification";
|
||||||
|
|
||||||
const UNIT_HEADLOSS_RANGE: [number, number] = [0, 5];
|
const UNIT_HEADLOSS_RANGE: [number, number] = [0, 5];
|
||||||
|
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ export const useToolbarChatActions = ({
|
|||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemedSider, type RefineThemedLayoutSiderProps } from "@refinedev/mui";
|
||||||
|
|
||||||
|
export const AppSider: React.FC<RefineThemedLayoutSiderProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<ThemedSider
|
||||||
|
{...props}
|
||||||
|
render={({ items, logout }) => (
|
||||||
|
<>
|
||||||
|
{items}
|
||||||
|
{logout}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -53,7 +53,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
try {
|
try {
|
||||||
// Open project backend (simulation model)
|
// Open project backend (simulation model)
|
||||||
const openResponse = await apiFetch(
|
const openResponse = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/openproject/?network=${net}`,
|
`${config.BACKEND_URL}/api/v1/projects/open?network=${net}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
},
|
||||||
@@ -64,7 +64,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
|
|
||||||
// Fetch project metadata
|
// Fetch project metadata
|
||||||
const infoResponse = await apiFetch(
|
const infoResponse = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/project_info/?network=${net}`,
|
`${config.BACKEND_URL}/api/v1/project-info?network=${net}`,
|
||||||
);
|
);
|
||||||
if (!infoResponse.ok) {
|
if (!infoResponse.ok) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
@@ -49,30 +49,3 @@ export const getAccessToken = async () => {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUserId = async () => {
|
|
||||||
const session = await getSession();
|
|
||||||
const sessionUserId = typeof session?.user?.id === "string" ? session.user.id : null;
|
|
||||||
if (sessionUserId) {
|
|
||||||
return sessionUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessToken = await getAccessToken();
|
|
||||||
if (!accessToken) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = decodeJwtPayload(accessToken);
|
|
||||||
if (!payload || typeof payload !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate =
|
|
||||||
typeof payload.sub === "string"
|
|
||||||
? payload.sub
|
|
||||||
: typeof payload.user_id === "string"
|
|
||||||
? payload.user_id
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return candidate;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ describe("fetchAgentModels", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export const fetchAgentModels = async (): Promise<AgentModelConfig> => {
|
|||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/models`, {
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/models`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
+14
-7
@@ -84,6 +84,12 @@ export type StreamEvent =
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
totalDurationMs?: number;
|
totalDurationMs?: number;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "auth_required";
|
||||||
|
sessionId?: string;
|
||||||
|
reason?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "tool_call";
|
type: "tool_call";
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -303,6 +309,7 @@ const emitParsedStreamEvent = (
|
|||||||
rejected?: boolean;
|
rejected?: boolean;
|
||||||
message_id?: string;
|
message_id?: string;
|
||||||
todos?: unknown;
|
todos?: unknown;
|
||||||
|
reason?: string;
|
||||||
};
|
};
|
||||||
if (event === "state") {
|
if (event === "state") {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -352,6 +359,13 @@ const emitParsedStreamEvent = (
|
|||||||
detail: parsed.detail,
|
detail: parsed.detail,
|
||||||
totalDurationMs: parsed.total_duration_ms,
|
totalDurationMs: parsed.total_duration_ms,
|
||||||
});
|
});
|
||||||
|
} else if (event === "auth_required") {
|
||||||
|
onEvent({
|
||||||
|
type: "auth_required",
|
||||||
|
sessionId: parsed.session_id,
|
||||||
|
reason: parsed.reason,
|
||||||
|
message: parsed.message ?? "登录态已过期,请刷新登录后重试",
|
||||||
|
});
|
||||||
} else if (event === "tool_call") {
|
} else if (event === "tool_call") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
@@ -478,7 +492,6 @@ export const streamAgentChat = async ({
|
|||||||
approval_mode: approvalMode,
|
approval_mode: approvalMode,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -530,7 +543,6 @@ export const resumeAgentChatStream = async ({
|
|||||||
Accept: "text/event-stream",
|
Accept: "text/event-stream",
|
||||||
},
|
},
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -573,7 +585,6 @@ export const abortAgentChat = async (sessionId?: string) => {
|
|||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -600,7 +611,6 @@ export const replyAgentPermission = async (
|
|||||||
reply,
|
reply,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -628,7 +638,6 @@ export const replyAgentQuestion = async (
|
|||||||
answers,
|
answers,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -654,7 +663,6 @@ export const rejectAgentQuestion = async (
|
|||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -676,7 +684,6 @@ export const forkAgentChat = async (sessionId: string | undefined, keepMessageCo
|
|||||||
keep_message_count: keepMessageCount,
|
keep_message_count: keepMessageCount,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { getAccessToken, getUserId } from "@/lib/authToken";
|
import { getAccessToken } from "@/lib/authToken";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
|
||||||
export type AuthHeaderMode = "include" | "omit";
|
export type AuthHeaderMode = "include" | "omit";
|
||||||
export type ProjectHeaderMode = "auto" | "include" | "omit";
|
export type ProjectHeaderMode = "auto" | "include" | "omit";
|
||||||
export type UserHeaderMode = "include" | "omit";
|
|
||||||
|
|
||||||
export interface AuthContextHeaderOptions {
|
export interface AuthContextHeaderOptions {
|
||||||
authHeaderMode?: AuthHeaderMode;
|
authHeaderMode?: AuthHeaderMode;
|
||||||
projectHeaderMode?: ProjectHeaderMode;
|
projectHeaderMode?: ProjectHeaderMode;
|
||||||
userHeaderMode?: UserHeaderMode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldIncludeProjectHeader = (
|
const shouldIncludeProjectHeader = (
|
||||||
@@ -36,13 +34,6 @@ export const applyAuthContextHeaders = async (
|
|||||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.userHeaderMode === "include") {
|
|
||||||
const userId = await getUserId();
|
|
||||||
if (userId) {
|
|
||||||
headers.set("X-User-Id", userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectId = useProjectStore.getState().currentProjectId;
|
const projectId = useProjectStore.getState().currentProjectId;
|
||||||
if (
|
if (
|
||||||
projectId &&
|
projectId &&
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
DevtoolsPanel,
|
|
||||||
DevtoolsProvider as DevtoolsProviderBase,
|
|
||||||
} from "@refinedev/devtools";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const DevtoolsProvider = (props: React.PropsWithChildren) => {
|
|
||||||
return (
|
|
||||||
<DevtoolsProviderBase>
|
|
||||||
{props.children}
|
|
||||||
<DevtoolsPanel />
|
|
||||||
</DevtoolsProviderBase>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -50,7 +50,7 @@ function variance(data: number[], start: number, end: number): number {
|
|||||||
return data.slice(start, end + 1).reduce((sum, val) => sum + (val - mean) ** 2, 0);
|
return data.slice(start, end + 1).reduce((sum, val) => sum + (val - mean) ** 2, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jenks_breaks_jenkspy(data: number[], nClasses: number): number[] {
|
function jenksBreaksJenkspy(data: number[], nClasses: number): number[] {
|
||||||
if (data.length === 0) return [];
|
if (data.length === 0) return [];
|
||||||
if (nClasses >= data.length) return data.slice().sort((a, b) => a - b);
|
if (nClasses >= data.length) return data.slice().sort((a, b) => a - b);
|
||||||
|
|
||||||
@@ -92,13 +92,13 @@ export function jenks_breaks_jenkspy(data: number[], nClasses: number): number[]
|
|||||||
return breaks;
|
return breaks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jenks_with_stratified_sampling(
|
function jenksWithStratifiedSampling(
|
||||||
data: number[],
|
data: number[],
|
||||||
nClasses: number,
|
nClasses: number,
|
||||||
sampleSize = 10000
|
sampleSize = 10000
|
||||||
): number[] {
|
): number[] {
|
||||||
if (data.length <= sampleSize) {
|
if (data.length <= sampleSize) {
|
||||||
return jenks_breaks_jenkspy(data, nClasses);
|
return jenksBreaksJenkspy(data, nClasses);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedData = data.slice().sort((a, b) => a - b);
|
const sortedData = data.slice().sort((a, b) => a - b);
|
||||||
@@ -112,7 +112,7 @@ export function jenks_with_stratified_sampling(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return jenks_breaks_jenkspy(sampledData, nClasses);
|
return jenksBreaksJenkspy(sampledData, nClasses);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateClassification(
|
export function calculateClassification(
|
||||||
@@ -129,7 +129,7 @@ export function calculateClassification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (classificationMethod === "jenks_optimized") {
|
if (classificationMethod === "jenks_optimized") {
|
||||||
return jenks_with_stratified_sampling(data, segments);
|
return jenksWithStratifiedSampling(data, segments);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
Reference in New Issue
Block a user