feat(frontend): 合并与 github/main 的安全无冲突更新

This commit is contained in:
肖应宇 2026-03-28 22:41:44 +08:00
parent 63a5cc22c2
commit 0d255e9ac4
27 changed files with 330 additions and 90 deletions

View File

@ -41,6 +41,12 @@ pnpm dev
# Type check
pnpm typecheck
# Check formatting
pnpm format
# Apply formatting
pnpm format:write
# Lint
pnpm lint
@ -101,8 +107,8 @@ src/
│ └── utils/ # Utility functions
├── hooks/ # Custom React hooks
├── lib/ # Shared libraries & utilities
├── server/ # Server-side code (Not available yet)
│ └── better-auth/ # Authentication setup (Not available yet)
├── server/ # Server-side code
│ └── better-auth/ # Authentication setup and session helpers
└── styles/ # Global styles
```
@ -113,6 +119,8 @@ src/
| `pnpm dev` | Start development server with Turbopack |
| `pnpm build` | Build for production |
| `pnpm start` | Start production server |
| `pnpm format` | Check formatting with Prettier |
| `pnpm format:write` | Apply formatting with Prettier |
| `pnpm lint` | Run ESLint |
| `pnpm lint:fix` | Fix ESLint issues |
| `pnpm typecheck` | Run TypeScript type checking |

View File

@ -1,3 +1,4 @@
packages: []
ignoredBuiltDependencies:
- esbuild
- sharp

View File

@ -4,24 +4,28 @@ export function GET() {
{
id: "doubao-seed-1.8",
name: "doubao-seed-1.8",
model: "doubao-seed-1-8",
display_name: "Doubao Seed 1.8",
supports_thinking: true,
},
{
id: "deepseek-v3.2",
name: "deepseek-v3.2",
model: "deepseek-chat",
display_name: "DeepSeek v3.2",
supports_thinking: true,
},
{
id: "gpt-5",
name: "gpt-5",
model: "gpt-5",
display_name: "GPT-5",
supports_thinking: true,
},
{
id: "gemini-3-pro",
name: "gemini-3-pro",
model: "gemini-3-pro",
display_name: "Gemini 3 Pro",
supports_thinking: true,
},

View File

@ -1,27 +1,84 @@
import fs from "fs";
import path from "path";
export function POST() {
type ThreadSearchRequest = {
limit?: number;
offset?: number;
sortBy?: "updated_at" | "created_at";
sortOrder?: "asc" | "desc";
};
type MockThreadSearchResult = Record<string, unknown> & {
thread_id: string;
updated_at: string | undefined;
};
export async function POST(request: Request) {
const body = ((await request.json().catch(() => ({}))) ?? {}) as ThreadSearchRequest;
const rawLimit = body.limit;
let limit = 50;
if (typeof rawLimit === "number") {
const normalizedLimit = Math.max(0, Math.floor(rawLimit));
if (!Number.isNaN(normalizedLimit)) {
limit = normalizedLimit;
}
}
const rawOffset = body.offset;
let offset = 0;
if (typeof rawOffset === "number") {
const normalizedOffset = Math.max(0, Math.floor(rawOffset));
if (!Number.isNaN(normalizedOffset)) {
offset = normalizedOffset;
}
}
const sortBy = body.sortBy ?? "updated_at";
const sortOrder = body.sortOrder ?? "desc";
const threadsDir = fs.readdirSync(
path.resolve(process.cwd(), "public/demo/threads"),
{
withFileTypes: true,
},
);
const threadData = threadsDir
.map((threadId) => {
.map<MockThreadSearchResult | null>((threadId) => {
if (threadId.isDirectory() && !threadId.name.startsWith(".")) {
const threadData = fs.readFileSync(
path.resolve(`public/demo/threads/${threadId.name}/thread.json`),
"utf8",
);
const threadData = JSON.parse(
fs.readFileSync(
path.resolve(`public/demo/threads/${threadId.name}/thread.json`),
"utf8",
),
) as Record<string, unknown>;
return {
...threadData,
thread_id: threadId.name,
values: JSON.parse(threadData).values,
updated_at:
typeof threadData.updated_at === "string"
? threadData.updated_at
: typeof threadData.created_at === "string"
? threadData.created_at
: undefined,
};
}
return false;
return null;
})
.filter(Boolean);
return Response.json(threadData);
.filter((thread): thread is MockThreadSearchResult => thread !== null)
.sort((a, b) => {
const aTimestamp = a[sortBy];
const bTimestamp = b[sortBy];
const aParsed =
typeof aTimestamp === "string" ? Date.parse(aTimestamp) : 0;
const bParsed =
typeof bTimestamp === "string" ? Date.parse(bTimestamp) : 0;
const aValue = Number.isNaN(aParsed) ? 0 : aParsed;
const bValue = Number.isNaN(bParsed) ? 0 : bParsed;
return sortOrder === "asc" ? aValue - bValue : bValue - aValue;
});
const pagedThreads = threadData.slice(offset, offset + limit);
return Response.json(pagedThreads);
}

View File

@ -55,7 +55,7 @@ export function CaseStudySection({ className }: { className?: string }) {
{caseStudies.map((caseStudy) => (
<Link
key={caseStudy.title}
href={pathOfThread(caseStudy.threadId)}
href={pathOfThread(caseStudy.threadId) + "?mock=true"}
target="_blank"
>
<Card className="group/card relative h-64 overflow-hidden">

View File

@ -198,11 +198,28 @@ export default function Galaxy({
useEffect(() => {
if (!ctnDom.current) return;
const ctn = ctnDom.current;
const renderer = new Renderer({
alpha: transparent,
premultipliedAlpha: false,
});
let renderer;
try {
renderer = new Renderer({
alpha: transparent,
premultipliedAlpha: false,
});
} catch (error) {
console.warn(
"Galaxy: WebGL is not available. The galaxy background will not be rendered.",
error,
);
return;
}
const gl = renderer.gl;
if (!gl) {
console.warn(
"Galaxy: WebGL context is null. The galaxy background will not be rendered.",
);
return;
}
if (transparent) {
gl.enable(gl.BLEND);

View File

@ -1,3 +1,4 @@
export * from "./artifact-file-detail";
export * from "./artifact-file-list";
export * from "./artifact-trigger";
export * from "./context";

View File

@ -1,11 +1,11 @@
import type { UseStream } from "@langchain/langgraph-sdk/react";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { createContext, useContext } from "react";
import type { AgentThreadState } from "@/core/threads";
export interface ThreadContextType {
threadId: string;
thread: UseStream<AgentThreadState>;
thread: BaseStream<AgentThreadState>;
isMock?: boolean;
}
export const ThreadContext = createContext<ThreadContextType | undefined>(

View File

@ -1,16 +1,21 @@
"use client";
import { useMemo } from "react";
import type { HTMLAttributes } from "react";
import type { AnchorHTMLAttributes } from "react";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean {
return !!href && /^https?:\/\//.test(href);
}
export type MarkdownContentProps = {
content: string;
isLoading: boolean;
@ -30,7 +35,7 @@ export function MarkdownContent({
}: MarkdownContentProps) {
const components = useMemo(() => {
return {
a: (props: HTMLAttributes<HTMLAnchorElement>) => {
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
@ -38,7 +43,16 @@ export function MarkdownContent({
return <CitationLink {...props}>{text}</CitationLink>;
}
}
return <a {...props} />;
const { className, target, rel, ...rest } = props;
const external = isExternalUrl(props.href);
return (
<a
{...rest}
className={cn("text-primary underline decoration-primary/30 underline-offset-2 hover:decoration-primary/60 transition-colors", className)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
/>
);
},
...componentsFromProps,
};

View File

@ -12,7 +12,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { enUS, zhCN, type Locale } from "@/core/i18n";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
@ -89,7 +89,11 @@ export function AppearanceSettingsPage() {
>
<Select
value={locale}
onValueChange={(value) => changeLocale(value as Locale)}
onValueChange={(value) => {
if (isLocale(value)) {
changeLocale(value);
}
}}
>
<SelectTrigger className="w-[220px]">
<SelectValue />

View File

@ -1,11 +1,50 @@
import type { BaseStream } from "@langchain/langgraph-sdk";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display";
export function ThreadTitle({
threadTitle,
threadId,
thread,
}: {
className?: string;
threadId: string;
threadTitle: string;
thread: BaseStream<AgentThreadState>;
}) {
return <FlipDisplay uniqueKey={threadTitle}>{threadTitle}</FlipDisplay>;
const { t } = useI18n();
const { isNewThread } = useThreadChat();
useEffect(() => {
let _title = t.pages.untitled;
if (thread.values?.title) {
_title = thread.values.title;
} else if (isNewThread) {
_title = t.pages.newChat;
}
if (thread.isThreadLoading) {
document.title = `Loading... - ${t.pages.appName}`;
} else {
document.title = `${_title} - ${t.pages.appName}`;
}
}, [
isNewThread,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.isThreadLoading,
thread.values,
]);
if (!thread.values?.title) {
return null;
}
return (
<FlipDisplay uniqueKey={threadId}>
{thread.values.title ?? "Untitled"}
</FlipDisplay>
);
}

View File

@ -1,4 +1,5 @@
import { ChevronUpIcon, ListTodoIcon } from "lucide-react";
import { useState } from "react";
import type { Todo } from "@/core/todos";
import { cn } from "@/lib/utils";
@ -13,7 +14,7 @@ import {
export function TodoList({
className,
todos,
collapsed = false,
collapsed: controlledCollapsed,
hidden = false,
onToggle,
}: {
@ -23,6 +24,18 @@ export function TodoList({
hidden?: boolean;
onToggle?: () => void;
}) {
const [internalCollapsed, setInternalCollapsed] = useState(true);
const isControlled = controlledCollapsed !== undefined;
const collapsed = isControlled ? controlledCollapsed : internalCollapsed;
const handleToggle = () => {
if (isControlled) {
onToggle?.();
} else {
setInternalCollapsed((prev) => !prev);
}
};
return (
<div
className={cn(
@ -35,9 +48,7 @@ export function TodoList({
className={cn(
"bg-accent flex min-h-8 shrink-0 cursor-pointer items-center justify-between px-4 text-sm transition-all duration-300 ease-out",
)}
onClick={() => {
onToggle?.();
}}
onClick={handleToggle}
>
<div className="text-muted-foreground">
<div className="flex items-center justify-center gap-2">

View File

@ -1,6 +1,6 @@
"use client";
import { MessagesSquare } from "lucide-react";
import { BotIcon, MessagesSquare } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@ -26,6 +26,17 @@ export function WorkspaceNavChatList() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/workspace/agents")}
asChild
>
<Link className="text-muted-foreground" href="/workspace/agents">
<BotIcon />
<span>{t.sidebar.agents}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);

View File

@ -4,10 +4,41 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getLangGraphBaseURL } from "../config";
let _singleton: LangGraphClient | null = null;
export function getAPIClient(): LangGraphClient {
_singleton ??= new LangGraphClient({
apiUrl: getLangGraphBaseURL(),
import { sanitizeRunStreamOptions } from "./stream-mode";
function createCompatibleClient(isMock?: boolean): LangGraphClient {
const client = new LangGraphClient({
apiUrl: getLangGraphBaseURL(isMock),
});
return _singleton;
const originalRunStream = client.runs.stream.bind(client.runs);
client.runs.stream = ((threadId, assistantId, payload) =>
originalRunStream(
threadId,
assistantId,
sanitizeRunStreamOptions(payload),
)) as typeof client.runs.stream;
const originalJoinStream = client.runs.joinStream.bind(client.runs);
client.runs.joinStream = ((threadId, runId, options) =>
originalJoinStream(
threadId,
runId,
sanitizeRunStreamOptions(options),
)) as typeof client.runs.joinStream;
return client;
}
const _clients = new Map<string, LangGraphClient>();
export function getAPIClient(isMock?: boolean): LangGraphClient {
const cacheKey = isMock ? "mock" : "default";
let client = _clients.get(cacheKey);
if (!client) {
client = createCompatibleClient(isMock);
_clients.set(cacheKey, client);
}
return client;
}

View File

@ -17,17 +17,18 @@ export function useArtifactContent({
const isWriteFile = useMemo(() => {
return filepath.startsWith("write-file:");
}, [filepath]);
const { thread } = useThread();
const { thread, isMock } = useThread();
const content = useMemo(() => {
if (isWriteFile) {
return loadArtifactContentFromToolCall({ url: filepath, thread });
}
return null;
}, [filepath, isWriteFile, thread]);
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId],
queryKey: ["artifact", filepath, threadId, isMock],
queryFn: () => {
return loadArtifactContent({ filepath, threadId });
return loadArtifactContent({ filepath, threadId, isMock });
},
enabled,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)

View File

@ -1,4 +1,4 @@
import type { UseStream } from "@langchain/langgraph-sdk/react";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import type { AgentThreadState } from "../threads";
@ -7,15 +7,17 @@ import { urlOfArtifact } from "./utils";
export async function loadArtifactContent({
filepath,
threadId,
isMock,
}: {
filepath: string;
threadId: string;
isMock?: boolean;
}) {
let enhancedFilepath = filepath;
if (filepath.endsWith(".skill")) {
enhancedFilepath = filepath + "/SKILL.md";
}
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId });
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock });
const response = await fetch(url);
const text = await response.text();
return text;
@ -26,7 +28,7 @@ export function loadArtifactContentFromToolCall({
thread,
}: {
url: string;
thread: UseStream<AgentThreadState>;
thread: BaseStream<AgentThreadState>;
}) {
const url = new URL(urlString);
const toolCallId = url.searchParams.get("tool_call_id");

View File

@ -5,11 +5,16 @@ export function urlOfArtifact({
filepath,
threadId,
download = false,
isMock = false,
}: {
filepath: string;
threadId: string;
download?: boolean;
isMock?: boolean;
}) {
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}

View File

@ -1,16 +1,35 @@
import { env } from "@/env";
function getBaseOrigin() {
if (typeof window !== "undefined") {
return window.location.origin;
}
return undefined;
}
export function getBackendBaseURL() {
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
return env.NEXT_PUBLIC_BACKEND_BASE_URL;
return new URL(
env.NEXT_PUBLIC_BACKEND_BASE_URL,
getBaseOrigin(),
).toString();
} else {
return "";
}
}
export function getLangGraphBaseURL() {
export function getLangGraphBaseURL(isMock?: boolean) {
if (env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) {
return env.NEXT_PUBLIC_LANGGRAPH_BASE_URL;
return new URL(
env.NEXT_PUBLIC_LANGGRAPH_BASE_URL,
getBaseOrigin(),
).toString();
} else if (isMock) {
if (typeof window !== "undefined") {
return `${window.location.origin}/mock/api`;
}
return "http://localhost:3000/mock/api";
} else {
// LangGraph SDK requires a full URL, construct it from current origin
if (typeof window !== "undefined") {

View File

@ -7,7 +7,13 @@ import { getLocaleFromCookie, setLocaleInCookie } from "./cookies";
import { enUS } from "./locales/en-US";
import { zhCN } from "./locales/zh-CN";
import { detectLocale, type Locale, type Translations } from "./index";
import {
DEFAULT_LOCALE,
detectLocale,
normalizeLocale,
type Locale,
type Translations,
} from "./index";
const translations: Record<Locale, Translations> = {
"en-US": enUS,
@ -17,7 +23,7 @@ const translations: Record<Locale, Translations> = {
export function useI18n() {
const { locale, setLocale } = useI18nContext();
const t = translations[locale];
const t = translations[locale] ?? translations[DEFAULT_LOCALE];
const changeLocale = (newLocale: Locale) => {
setLocale(newLocale);
@ -26,12 +32,19 @@ export function useI18n() {
// Initialize locale on mount
useEffect(() => {
const saved = getLocaleFromCookie() as Locale | null;
if (!saved) {
const detected = detectLocale();
setLocale(detected);
setLocaleInCookie(detected);
const saved = getLocaleFromCookie();
if (saved) {
const normalizedSaved = normalizeLocale(saved);
setLocale(normalizedSaved);
if (saved !== normalizedSaved) {
setLocaleInCookie(normalizedSaved);
}
return;
}
const detected = detectLocale();
setLocale(detected);
setLocaleInCookie(detected);
}, [setLocale]);
return {

View File

@ -1,23 +1,11 @@
export { enUS } from "./locales/en-US";
export { zhCN } from "./locales/zh-CN";
export type { Translations } from "./locales/types";
export type Locale = "en-US" | "zh-CN";
// Helper function to detect browser locale
export function detectLocale(): Locale {
if (typeof window === "undefined") {
return "en-US";
}
const browserLang =
navigator.language ||
(navigator as unknown as { userLanguage: string }).userLanguage;
// Check if browser language is Chinese (zh, zh-CN, zh-TW, etc.)
if (browserLang.toLowerCase().startsWith("zh")) {
return "zh-CN";
}
return "en-US";
}
export {
DEFAULT_LOCALE,
SUPPORTED_LOCALES,
detectLocale,
isLocale,
normalizeLocale,
} from "./locale";
export type { Locale } from "./locale";

View File

@ -1,9 +1,17 @@
import { cookies } from "next/headers";
export type Locale = "en-US" | "zh-CN";
import { normalizeLocale, type Locale } from "./locale";
export async function detectLocaleServer(): Promise<Locale> {
const cookieStore = await cookies();
const locale = cookieStore.get("locale")?.value ?? "en-US";
return locale as Locale;
let locale = cookieStore.get("locale")?.value;
if (locale !== undefined) {
try {
locale = decodeURIComponent(locale);
} catch {
// Keep raw cookie value when decoding fails.
}
}
return normalizeLocale(locale);
}

View File

@ -3,7 +3,7 @@ import { getBackendBaseURL } from "../config";
import type { Model } from "./types";
export async function loadModels() {
const res = fetch(`${getBackendBaseURL()}/api/models`);
const { models } = (await (await res).json()) as { models: Model[] };
const res = await fetch(`${getBackendBaseURL()}/api/models`);
const { models } = (await res.json()) as { models: Model[] };
return models;
}

View File

@ -7,6 +7,7 @@ export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
queryKey: ["models"],
queryFn: () => loadModels(),
enabled,
refetchOnWindowFocus: false,
});
return { models: data ?? [], isLoading, error };
}

View File

@ -1,7 +1,9 @@
export interface Model {
id: string;
name: string;
model: string;
display_name: string;
description?: string | null;
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
}

View File

@ -7,6 +7,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
context: {
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
},
layout: {
sidebar_collapsed: false,
@ -24,6 +25,7 @@ export interface LocalSettings {
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
};
layout: {
sidebar_collapsed: boolean;

View File

@ -1,11 +1,10 @@
import { type BaseMessage } from "@langchain/core/messages";
import type { Thread } from "@langchain/langgraph-sdk";
import type { Message, Thread } from "@langchain/langgraph-sdk";
import type { Todo } from "../todos";
export interface AgentThreadState extends Record<string, unknown> {
title: string;
messages: BaseMessage[];
messages: Message[];
artifacts: string[];
todos?: Todo[];
}
@ -18,4 +17,6 @@ export interface AgentThreadContext extends Record<string, unknown> {
thinking_enabled: boolean;
is_plan_mode: boolean;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
agent_name?: string;
}

View File

@ -1,4 +1,4 @@
import type { BaseMessage } from "@langchain/core/messages";
import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread } from "./types";
@ -6,19 +6,19 @@ export function pathOfThread(threadId: string) {
return `/workspace/chats/${threadId}`;
}
export function textOfMessage(message: BaseMessage) {
export function textOfMessage(message: Message) {
if (typeof message.content === "string") {
return message.content;
} else if (Array.isArray(message.content)) {
return message.content.find((part) => part.type === "text" && part.text)
?.text as string;
for (const part of message.content) {
if (part.type === "text") {
return part.text;
}
}
}
return null;
}
export function titleOfThread(thread: AgentThread) {
if (thread.values && "title" in thread.values) {
return thread.values.title;
}
return "Untitled";
return thread.values?.title ?? "Untitled";
}