diff --git a/docs/development/frontend.md b/docs/development/frontend.md
index e8fcde0..4c07c92 100644
--- a/docs/development/frontend.md
+++ b/docs/development/frontend.md
@@ -52,17 +52,18 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
### 共享 Hooks
-| Hook | 路径 | 说明 |
-| ----------------------- | --------------------------------------- | ----------------------------------------- |
-| `use-meta.ts` | `shared/hooks/use-meta.ts` | `/api/meta`(30s 轮询,5s staleTime) |
-| `use-providers.ts` | `shared/hooks/use-providers.ts` | 供应商 CRUD + test connection |
-| `use-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection |
-| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
-| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks) |
-| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook(组件内使用) |
-| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 |
-| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
-| `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 |
+| Hook | 路径 | 说明 |
+| ----------------------- | --------------------------------------- | ---------------------------------------------------------- |
+| `use-meta.ts` | `shared/hooks/use-meta.ts` | `/api/meta`(30s 轮询,5s staleTime) |
+| `use-providers.ts` | `shared/hooks/use-providers.ts` | 供应商 CRUD + test connection |
+| `use-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection |
+| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
+| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks) |
+| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook(组件内使用) |
+| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 |
+| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
+| `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 |
+| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext(需在 ProjectProvider 内) |
### 共享工具函数
diff --git a/skills-lock.json b/skills-lock.json
index 89040ed..9d401b1 100644
--- a/skills-lock.json
+++ b/skills-lock.json
@@ -11,27 +11,51 @@
"source": "ant-design/antd-skill",
"sourceType": "github",
"skillPath": "skills/ant-design/SKILL.md",
- "computedHash": "4d0447d48fced080b2825ecc0fb4d7ca836c8015882899c643acca0b864d5179"
+ "computedHash": "096d4ac9513e43030f960aab49b50168a3d5eb35be86926ac6e96e5998ea9466"
},
"antd": {
"source": "ant-design/antd-skill",
"sourceType": "github",
"skillPath": "skills/antd/SKILL.md",
- "computedHash": "4295010f09f85855cab9e9de9ec7f96c14541474b4f3f9d6ef89006430931b94"
+ "computedHash": "5e26c8042060bb811118927b5daf637af7929a00fa973dd8f5f804f3ba6e2bf2"
+ },
+ "react-router-data-mode": {
+ "source": "remix-run/agent-skills",
+ "sourceType": "github",
+ "skillPath": "skills/react-router-data-mode/SKILL.md",
+ "computedHash": "76e3e0f70ff47b743bd90999e676515221e25fd7ee89cd9e5b340417b1a601e2"
+ },
+ "react-router-declarative-mode": {
+ "source": "remix-run/agent-skills",
+ "sourceType": "github",
+ "skillPath": "skills/react-router-declarative-mode/SKILL.md",
+ "computedHash": "d7ebbf1ede90809618f02cb3b3d37b9871cdd6c88a81cf338e63de50a0df6a42"
+ },
+ "react-router-framework-mode": {
+ "source": "remix-run/agent-skills",
+ "sourceType": "github",
+ "skillPath": "skills/react-router-framework-mode/SKILL.md",
+ "computedHash": "26c5bdac2f686c47eb4c4b48b6cb52401cde1dc833e6d26408ddfb22ea83c5ca"
+ },
+ "vercel-react-best-practices": {
+ "source": "vercel-labs/agent-skills",
+ "sourceType": "github",
+ "skillPath": "skills/react-best-practices/SKILL.md",
+ "computedHash": "ca7b0c0c6e5f2750043f7f0cd72d16ac4e2abc48f9b5500d047a4b77a2506212"
},
"x-components": {
"source": "ant-design/x",
"ref": "main",
"sourceType": "github",
"skillPath": "packages/x-skill/skills/x-components/SKILL.md",
- "computedHash": "ebc195a3a5020b6d4f4533adf2e0af33253919f0c704947e727f877aba23a4c2"
+ "computedHash": "efb7661cadf8a35fae32ce9a6b261b82ee8c8a2bb76303b333ff166163c0a729"
},
"x-markdown": {
"source": "ant-design/x",
"ref": "main",
"sourceType": "github",
"skillPath": "packages/x-skill/skills/x-markdown/SKILL.md",
- "computedHash": "2d26b8eda1692929e99a8b6163ef8b206f1f096a4a84507b50dbe836a7ec041e"
+ "computedHash": "441c281e8537e4aebbc6db5dce0b12c170df916f81782f33f3c8f66dd3f17b17"
}
}
}
diff --git a/src/web/features/chat/ChatPage.tsx b/src/web/features/chat/ChatPage.tsx
index c302c99..bcae78f 100644
--- a/src/web/features/chat/ChatPage.tsx
+++ b/src/web/features/chat/ChatPage.tsx
@@ -2,12 +2,12 @@ import { DeleteOutlined, MoreOutlined } from "@ant-design/icons";
import { Conversations } from "@ant-design/x";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Spin } from "antd";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import type { Conversation } from "../../../shared/api";
-import { useCurrentProject } from "../../layouts/workbench-layout/useCurrentProject";
import { createConversation, deleteConversation, fetchConversations } from "../../shared/hooks/use-conversations";
+import { useCurrentProject } from "../../shared/hooks/use-current-project";
import { useModelList } from "../../shared/hooks/use-models";
import { ChatPanel } from "./ChatPanel";
@@ -25,8 +25,13 @@ export function ChatPage() {
});
const { data: modelsData } = useModelList({ pageSize: 200 });
- const textModels = (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text"));
- const defaultModelId = textModels[0]?.id;
+
+ const textModels = useMemo(
+ () => (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text")),
+ [modelsData],
+ );
+
+ const defaultModelId = textModels[0]?.id ?? null;
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteConversation(project.id, id),
@@ -39,10 +44,10 @@ export function ChatPage() {
},
});
- const conversations = (data?.items ?? []).map((c: Conversation) => ({
- key: c.id,
- label: c.title,
- }));
+ const conversations = useMemo(
+ () => (data?.items ?? []).map((c: Conversation) => ({ key: c.id, label: c.title })),
+ [data],
+ );
return (
@@ -54,7 +59,7 @@ export function ChatPage() {
activeKey={activeConversationId ?? ""}
creation={{
onClick: () => {
- void createConversation(project.id, defaultModelId)
+ void createConversation(project.id, defaultModelId ?? undefined)
.then((conv) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
setActiveConversationId(conv.id);
@@ -85,8 +90,10 @@ export function ChatPage() {
);
diff --git a/src/web/features/chat/ChatPanel.tsx b/src/web/features/chat/ChatPanel.tsx
index c620977..8293297 100644
--- a/src/web/features/chat/ChatPanel.tsx
+++ b/src/web/features/chat/ChatPanel.tsx
@@ -4,7 +4,7 @@ import { Sender } from "@ant-design/x";
import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport, type UIMessage } from "ai";
import { App, Button, Card, Divider, Flex, Input, Select, Spin, Typography } from "antd";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
createConversation,
@@ -13,7 +13,6 @@ import {
updateConversation,
} from "../../shared/hooks/use-conversations";
import { useLogger } from "../../shared/hooks/use-logger";
-import { useModelList } from "../../shared/hooks/use-models";
import { ChatScrollArea } from "./ChatScrollArea";
import { ReasoningPart } from "./parts/ReasoningPart";
import { TextPart } from "./parts/TextPart";
@@ -21,11 +20,19 @@ import { ToolPart } from "./parts/ToolPart";
interface ChatPanelProps {
conversationId: null | string;
+ defaultModelId: null | string;
onConversationCreated: (id: string) => void;
projectId: string;
+ textModels: Array<{ id: string; name: string }>;
}
-export function ChatPanel({ conversationId, onConversationCreated, projectId }: ChatPanelProps) {
+export function ChatPanel({
+ conversationId,
+ defaultModelId: _defaultModelId,
+ onConversationCreated,
+ projectId,
+ textModels,
+}: ChatPanelProps) {
const { message } = App.useApp();
const logger = useLogger({ component: "ChatPanel", page: "workbench" });
const queryClient = useQueryClient();
@@ -37,12 +44,6 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
const fetchRef = useRef(fetchMessages);
const skipHistoryLoadRef = useRef(null);
- const { data: modelsData } = useModelList({ pageSize: 200 });
- const textModels = useMemo(
- () => (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text")),
- [modelsData],
- );
-
const modelOptions = useMemo(() => textModels.map((m) => ({ label: m.name, value: m.id })), [textModels]);
const { messages, regenerate, sendMessage, setMessages, status, stop } = useChat({
@@ -178,6 +179,33 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
[sendMessage, conversationId, projectId, onConversationCreated, message, queryClient, displayModelId, logger],
);
+ const renderSenderFooter = useCallback(
+ (actionNode: ReactNode) => (
+
+
+
+
+ {actionNode}
+
+
+ ),
+ [isLoading, handleModelChange, modelOptions, displayModelId],
+ );
+
+ const handleStop = useCallback(() => {
+ void stop().catch((err: unknown) => {
+ const msg = err instanceof Error ? err.message : String(err);
+ logger.warn("停止聊天失败", { error: msg });
+ });
+ }, [stop, logger]);
+
const extractText = useCallback((msg: UIMessage) => {
return msg.parts
.filter((p) => p.type === "text")
@@ -304,29 +332,9 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
(
-
-
-
-
- {actionNode}
-
-
- )}
+ footer={renderSenderFooter}
loading={isLoading}
- onCancel={() => {
- void stop().catch((err: unknown) => {
- const msg = err instanceof Error ? err.message : String(err);
- logger.warn("停止聊天失败", { error: msg });
- });
- }}
+ onCancel={handleStop}
onChange={setInput}
onSubmit={handleSenderSubmit}
placeholder="输入消息..."
@@ -397,29 +405,9 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
(
-
-
-
-
- {actionNode}
-
-
- )}
+ footer={renderSenderFooter}
loading={isLoading}
- onCancel={() => {
- void stop().catch((err: unknown) => {
- const msg = err instanceof Error ? err.message : String(err);
- logger.warn("停止聊天失败", { error: msg });
- });
- }}
+ onCancel={handleStop}
onChange={setInput}
onSubmit={handleSenderSubmit}
placeholder="输入消息..."
diff --git a/src/web/features/models/components/ModelTable.tsx b/src/web/features/models/components/ModelTable.tsx
index 2464561..28045ea 100644
--- a/src/web/features/models/components/ModelTable.tsx
+++ b/src/web/features/models/components/ModelTable.tsx
@@ -1,7 +1,8 @@
-import type { ColumnsType } from "antd/es/table";
+import type { TableColumnsType } from "antd";
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
+import { useCallback, useMemo } from "react";
import type { Model, ModelListResponse, ProviderOption } from "../../../../shared/api";
@@ -31,7 +32,7 @@ function getProviderName(providerId: string, providers: ProviderOption[]): strin
return providers.find((p) => p.id === providerId)?.name ?? providerId;
}
-const COLUMNS: ColumnsType = [
+const COLUMNS: TableColumnsType = [
{ dataIndex: "name", ellipsis: true, title: "名称", width: 180 },
{
dataIndex: "providerId",
@@ -65,49 +66,61 @@ export function ModelTable({
}: ModelTableProps) {
const { message } = AntApp.useApp();
- const handleDelete = async (id: string) => {
- try {
- await onDelete(id);
- message.success("模型已删除");
- } catch (err: unknown) {
- message.error((err as Error).message);
- }
- };
-
- const columnsWithProvider: ColumnsType = COLUMNS.map((col) =>
- "dataIndex" in col && col.dataIndex === "providerId"
- ? {
- ...col,
- render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
- }
- : col,
+ const handleDelete = useCallback(
+ async (id: string) => {
+ try {
+ await onDelete(id);
+ message.success("模型已删除");
+ } catch (err: unknown) {
+ message.error((err as Error).message);
+ }
+ },
+ [onDelete, message],
);
- const operationColumn: ColumnsType[number] = {
- dataIndex: "op",
- render: (_value: unknown, record: Model) => (
-
- } onClick={() => onEdit(record)} size="small" type="link">
- 编辑
-
- void handleDelete(record.id)}
- title="确认删除此模型?"
- >
- } size="small" type="link">
- 删除
+ const columnsWithProvider = useMemo>(
+ () =>
+ COLUMNS.map((col) =>
+ "dataIndex" in col && col.dataIndex === "providerId"
+ ? {
+ ...col,
+ render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
+ }
+ : col,
+ ),
+ [providers],
+ );
+
+ const operationColumn = useMemo[number]>(
+ () => ({
+ dataIndex: "op",
+ render: (_value: unknown, record: Model) => (
+
+ } onClick={() => onEdit(record)} size="small" type="link">
+ 编辑
-
-
- ),
- title: "操作",
- width: 180,
- };
+ void handleDelete(record.id)}
+ title="确认删除此模型?"
+ >
+ } size="small" type="link">
+ 删除
+
+
+
+ ),
+ title: "操作",
+ width: 180,
+ }),
+ [onEdit, handleDelete],
+ );
+
+ const columns = useMemo(() => [...columnsWithProvider, operationColumn], [columnsWithProvider, operationColumn]);
return (
= {
"openai-compatible": "OpenAI 兼容",
};
-const COLUMNS: ColumnsType = [
+const COLUMNS: TableColumnsType = [
{ dataIndex: "name", ellipsis: true, title: "名称", width: 180 },
{
dataIndex: "type",
@@ -35,40 +36,48 @@ const COLUMNS: ColumnsType = [
export function ProviderTable({ data, loading, onDelete, onEdit, onPageChange, page, pageSize }: ProviderTableProps) {
const { message } = AntApp.useApp();
- const handleDelete = async (id: string) => {
- try {
- await onDelete(id);
- message.success("供应商已删除");
- } catch (err: unknown) {
- message.error((err as Error).message);
- }
- };
+ const handleDelete = useCallback(
+ async (id: string) => {
+ try {
+ await onDelete(id);
+ message.success("供应商已删除");
+ } catch (err: unknown) {
+ message.error((err as Error).message);
+ }
+ },
+ [onDelete, message],
+ );
- const operationColumn: ColumnsType[number] = {
- dataIndex: "op",
- render: (_value: unknown, record: Provider) => (
-
- } onClick={() => onEdit(record)} size="small" type="link">
- 编辑
-
- void handleDelete(record.id)}
- title="确认删除此供应商?"
- >
- } size="small" type="link">
- 删除
+ const operationColumn = useMemo[number]>(
+ () => ({
+ dataIndex: "op",
+ render: (_value: unknown, record: Provider) => (
+
+ } onClick={() => onEdit(record)} size="small" type="link">
+ 编辑
-
-
- ),
- title: "操作",
- width: 180,
- };
+ void handleDelete(record.id)}
+ title="确认删除此供应商?"
+ >
+ } size="small" type="link">
+ 删除
+
+
+
+ ),
+ title: "操作",
+ width: 180,
+ }),
+ [onEdit, handleDelete],
+ );
+
+ const columns = useMemo(() => [...COLUMNS, operationColumn], [operationColumn]);
return (
= [
+const COLUMNS: TableColumnsType = [
{ dataIndex: "name", ellipsis: true, title: "名称", width: 140 },
{ dataIndex: "description", ellipsis: true, title: "描述" },
{
@@ -65,88 +66,102 @@ export function ProjectTable({
const { message } = AntApp.useApp();
const navigate = useNavigate();
- const handleArchive = async (id: string) => {
- try {
- await onArchive(id);
- message.success("项目已归档");
- } catch (err: unknown) {
- message.error((err as Error).message);
- }
- };
+ const handleArchive = useCallback(
+ async (id: string) => {
+ try {
+ await onArchive(id);
+ message.success("项目已归档");
+ } catch (err: unknown) {
+ message.error((err as Error).message);
+ }
+ },
+ [onArchive, message],
+ );
- const handleRestore = async (id: string) => {
- try {
- await onRestore(id);
- message.success("项目已恢复");
- } catch (err: unknown) {
- message.error((err as Error).message);
- }
- };
+ const handleRestore = useCallback(
+ async (id: string) => {
+ try {
+ await onRestore(id);
+ message.success("项目已恢复");
+ } catch (err: unknown) {
+ message.error((err as Error).message);
+ }
+ },
+ [onRestore, message],
+ );
- const handleDelete = async (id: string) => {
- try {
- await onDelete(id);
- message.success("项目已永久删除");
- } catch (err: unknown) {
- message.error((err as Error).message);
- }
- };
+ const handleDelete = useCallback(
+ async (id: string) => {
+ try {
+ await onDelete(id);
+ message.success("项目已永久删除");
+ } catch (err: unknown) {
+ message.error((err as Error).message);
+ }
+ },
+ [onDelete, message],
+ );
- const operationColumn: ColumnsType[number] = {
- dataIndex: "op",
- render: (_value, record: Project) => {
- if (record.status === "active") {
+ const operationColumn = useMemo[number]>(
+ () => ({
+ dataIndex: "op",
+ render: (_value, record: Project) => {
+ if (record.status === "active") {
+ return (
+
+ }
+ onClick={() => void navigate(`/workbench/${record.id}`)}
+ size="small"
+ type="link"
+ >
+ 工作台
+
+ } onClick={() => onEdit(record)} size="small" type="link">
+ 编辑
+
+ void handleArchive(record.id)}
+ title="确认归档此项目?"
+ >
+ } size="small" variant="link">
+ 归档
+
+
+
+ );
+ }
return (
- }
- onClick={() => void navigate(`/workbench/${record.id}`)}
- size="small"
- type="link"
- >
- 工作台
-
- } onClick={() => onEdit(record)} size="small" type="link">
- 编辑
-
+ void handleRestore(record.id)} title="确认恢复此项目?">
+ } size="small" type="link">
+ 恢复
+
+
void handleArchive(record.id)}
- title="确认归档此项目?"
+ description="此操作不可恢复。"
+ onConfirm={() => void handleDelete(record.id)}
+ title="确认永久删除此项目?"
>
- } size="small" variant="link">
- 归档
+ } size="small" type="link">
+ 删除
);
- }
- return (
-
- void handleRestore(record.id)} title="确认恢复此项目?">
- } size="small" type="link">
- 恢复
-
-
- void handleDelete(record.id)}
- title="确认永久删除此项目?"
- >
- } size="small" type="link">
- 删除
-
-
-
- );
- },
- title: "操作",
- width: status === "active" ? 260 : 160,
- };
+ },
+ title: "操作",
+ width: status === "active" ? 260 : 160,
+ }),
+ [navigate, onEdit, handleArchive, handleRestore, handleDelete, status],
+ );
+
+ const columns = useMemo(() => [...COLUMNS, operationColumn], [operationColumn]);
return (
{children};
diff --git a/src/web/layouts/workbench-layout/ProjectContextValue.ts b/src/web/layouts/workbench-layout/ProjectContextValue.ts
deleted file mode 100644
index 9bf9edb..0000000
--- a/src/web/layouts/workbench-layout/ProjectContextValue.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { createContext } from "react";
-
-import type { Project } from "../../../shared/api";
-
-export const ProjectContext = createContext(null);
diff --git a/src/web/layouts/workbench-layout/WorkbenchConsoleLayout.tsx b/src/web/layouts/workbench-layout/WorkbenchConsoleLayout.tsx
index cbe3017..054f904 100644
--- a/src/web/layouts/workbench-layout/WorkbenchConsoleLayout.tsx
+++ b/src/web/layouts/workbench-layout/WorkbenchConsoleLayout.tsx
@@ -5,9 +5,9 @@ import { useNavigate } from "react-router";
import type { Project } from "../../../shared/api";
import { ConsoleShell } from "../../shared/components/ConsoleShell/ConsoleShell";
+import { useCurrentProject } from "../../shared/hooks/use-current-project";
import { ProjectProvider } from "./ProjectContext";
import { getWorkbenchMenuItems } from "./routes";
-import { useCurrentProject } from "./useCurrentProject";
interface WorkbenchConsoleLayoutProps {
project: Project;
diff --git a/src/web/routes.tsx b/src/web/routes.tsx
index ba0df08..aaffd14 100644
--- a/src/web/routes.tsx
+++ b/src/web/routes.tsx
@@ -8,16 +8,17 @@ import { NotFoundPage } from "./features/not-found";
import { ProjectsPage } from "./features/projects";
import { AdminConsoleLayout } from "./layouts/admin-layout/AdminConsoleLayout";
import { WorkbenchProjectGate } from "./layouts/workbench-layout/WorkbenchProjectGate";
+import { RouteError } from "./shared/components/RouteError";
export function AppRoutes() {
return (
- }>
+ } errorElement={}>
} path="/" />
} path="/projects" />
} path="/models" />
- } path="/workbench/:projectId">
+ } errorElement={} path="/workbench/:projectId">
} path="" />
} path="chat" />
} path="inbox" />
diff --git a/src/web/shared/components/ConsoleShell/ConsoleOutlet.tsx b/src/web/shared/components/ConsoleShell/ConsoleOutlet.tsx
index d6e184a..7c6ca1d 100644
--- a/src/web/shared/components/ConsoleShell/ConsoleOutlet.tsx
+++ b/src/web/shared/components/ConsoleShell/ConsoleOutlet.tsx
@@ -1,5 +1,17 @@
+import { Spin } from "antd";
+import { Suspense } from "react";
import { Outlet } from "react-router";
export function ConsoleOutlet() {
- return ;
+ return (
+
+
+
+ }
+ >
+
+
+ );
}
diff --git a/src/web/shared/components/ConsoleShell/ConsoleShell.tsx b/src/web/shared/components/ConsoleShell/ConsoleShell.tsx
index 5b6fddf..ab20aaa 100644
--- a/src/web/shared/components/ConsoleShell/ConsoleShell.tsx
+++ b/src/web/shared/components/ConsoleShell/ConsoleShell.tsx
@@ -3,6 +3,7 @@ import { XProvider } from "@ant-design/x";
import zhCN_X from "@ant-design/x/locale/zh_CN";
import { App as AntApp, Layout, Segmented, theme } from "antd";
import zhCN from "antd/locale/zh_CN";
+import { useMemo } from "react";
import type { ConsoleShellProps } from "./types";
@@ -28,9 +29,10 @@ export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProp
const versionDisplay = meta?.version ? `v${meta.version}` : null;
const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
+ const locale = useMemo(() => ({ ...zhCN, ...zhCN_X }), []);
return (
-
+
diff --git a/src/web/shared/components/RouteError.tsx b/src/web/shared/components/RouteError.tsx
new file mode 100644
index 0000000..cf5a562
--- /dev/null
+++ b/src/web/shared/components/RouteError.tsx
@@ -0,0 +1,29 @@
+import { Alert, Button } from "antd";
+import { isRouteErrorResponse, useNavigate, useRouteError } from "react-router";
+
+export function RouteError() {
+ const error = useRouteError();
+ const navigate = useNavigate();
+
+ const message = isRouteErrorResponse(error)
+ ? `${error.status} ${error.statusText}`
+ : error instanceof Error
+ ? error.message
+ : "未知错误";
+
+ return (
+
+
void navigate("/")} size="small" type="primary">
+ 返回首页
+
+ }
+ description={message}
+ showIcon
+ title="页面加载出错"
+ type="error"
+ />
+
+ );
+}
diff --git a/src/web/layouts/workbench-layout/useCurrentProject.ts b/src/web/shared/hooks/use-current-project.ts
similarity index 70%
rename from src/web/layouts/workbench-layout/useCurrentProject.ts
rename to src/web/shared/hooks/use-current-project.ts
index 7021dc6..88f3023 100644
--- a/src/web/layouts/workbench-layout/useCurrentProject.ts
+++ b/src/web/shared/hooks/use-current-project.ts
@@ -1,8 +1,8 @@
-import { useContext } from "react";
+import { createContext, useContext } from "react";
import type { Project } from "../../../shared/api";
-import { ProjectContext } from "./ProjectContextValue";
+export const ProjectContext = createContext(null);
export function useCurrentProject(): Project {
const project = useContext(ProjectContext);
diff --git a/src/web/shared/hooks/use-is-dark.ts b/src/web/shared/hooks/use-is-dark.ts
index 6f83b92..a390d4e 100644
--- a/src/web/shared/hooks/use-is-dark.ts
+++ b/src/web/shared/hooks/use-is-dark.ts
@@ -1,6 +1,6 @@
-import { theme } from "antd";
+import { useThemePreference } from "./use-theme-preference";
export function useIsDark(): boolean {
- const { token } = theme.useToken();
- return token.colorBgBase === "#000";
+ const { effectiveTheme } = useThemePreference();
+ return effectiveTheme === "dark";
}
diff --git a/src/web/shared/hooks/use-logger.ts b/src/web/shared/hooks/use-logger.ts
index 18968d0..6d64ee6 100644
--- a/src/web/shared/hooks/use-logger.ts
+++ b/src/web/shared/hooks/use-logger.ts
@@ -1,5 +1,5 @@
import { App } from "antd";
-import { useMemo, useState } from "react";
+import { useMemo } from "react";
import type { Logger } from "../utils/logger";
@@ -7,19 +7,12 @@ import { AntdMessageSink, ConsoleSink, createDefaultLogger } from "../utils/logg
export function useLogger(bindings?: Record): Logger {
const { message } = App.useApp();
- const [stableJson, setStableJson] = useState(() => JSON.stringify(bindings ?? {}));
- const [stableBindings, setStableBindings] = useState(() => bindings);
- const currentJson = JSON.stringify(bindings ?? {});
-
- if (currentJson !== stableJson) {
- setStableJson(currentJson);
- setStableBindings(bindings);
- }
+ const bindingsKey = JSON.stringify(bindings ?? {});
return useMemo(() => {
const isProduction = !!import.meta.env["PROD"];
const base = createDefaultLogger([new ConsoleSink(isProduction), new AntdMessageSink(message)], isProduction);
- if (!stableBindings || Object.keys(stableBindings).length === 0) return base;
- return base.child(stableBindings);
- }, [message, stableBindings]);
+ if (bindingsKey === "{}") return base;
+ return base.child(JSON.parse(bindingsKey) as Record);
+ }, [message, bindingsKey]);
}
diff --git a/tests/web/components/ChatPanel.test.tsx b/tests/web/components/ChatPanel.test.tsx
index 6811ff7..d44942f 100644
--- a/tests/web/components/ChatPanel.test.tsx
+++ b/tests/web/components/ChatPanel.test.tsx
@@ -83,8 +83,10 @@ describe("ChatPanel", () => {
renderWithProviders(
createElement(ChatPanel, {
conversationId: null,
+ defaultModelId: "model-1",
onConversationCreated: noop,
projectId: PROJECT_ID,
+ textModels: [TEXT_MODEL],
}),
);
@@ -102,8 +104,10 @@ describe("ChatPanel", () => {
renderWithProviders(
createElement(ChatPanel, {
conversationId: null,
+ defaultModelId: "model-1",
onConversationCreated: onCreated,
projectId: PROJECT_ID,
+ textModels: [TEXT_MODEL],
}),
);
@@ -133,8 +137,10 @@ describe("ChatPanel", () => {
renderWithProviders(
createElement(ChatPanel, {
conversationId: null,
+ defaultModelId: "model-1",
onConversationCreated: noop,
projectId: PROJECT_ID,
+ textModels: [TEXT_MODEL],
}),
);
@@ -154,8 +160,10 @@ describe("ChatPanel", () => {
renderWithProviders(
createElement(ChatPanel, {
conversationId: "conv-1",
+ defaultModelId: "model-1",
onConversationCreated: noop,
projectId: PROJECT_ID,
+ textModels: [TEXT_MODEL],
}),
);