feat: implement basic web app

This commit is contained in:
Henry Li
2026-01-15 23:40:21 +08:00
parent b44144dd2c
commit 9f2b94ed52
49 changed files with 4142 additions and 626 deletions

View File

@@ -0,0 +1,17 @@
"use client";
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
let _singleton: LangGraphClient | null = null;
export function getLangGraphClient(): LangGraphClient {
let url: URL | null = null;
if (typeof window === "undefined") {
url = new URL("/api/langgraph", "http://localhost:3000");
} else {
url = new URL("/api/langgraph", window.location.origin);
}
_singleton ??= new LangGraphClient({
apiUrl: "http://localhost:2024",
});
return _singleton;
}

View File

@@ -0,0 +1,45 @@
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { MessageThread, MessageThreadState } from "../thread";
import { getLangGraphClient } from "./client";
export function useThreads(
params: Parameters<ThreadsClient["search"]>[0] = {
limit: 50,
sortBy: "updated_at",
sortOrder: "desc",
},
) {
const langGraphClient = getLangGraphClient();
return useQuery<MessageThread[]>({
queryKey: ["threads", "search", params],
queryFn: async () => {
const response =
await langGraphClient.threads.search<MessageThreadState>(params);
return response as MessageThread[];
},
});
}
export function useDeleteThread() {
const queryClient = useQueryClient();
const langGraphClient = getLangGraphClient();
return useMutation({
mutationFn: async ({ threadId }: { threadId: string }) => {
await langGraphClient.threads.delete(threadId);
},
onSuccess(_, { threadId }) {
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<MessageThread>) => {
return oldData.filter((t) => t.thread_id !== threadId);
},
);
},
});
}

View File

@@ -0,0 +1,2 @@
export * from "./client";
export * from "./hooks";

View File

@@ -0,0 +1,49 @@
import type { Element, Root, ElementContent } from "hast";
import { useMemo } from "react";
import { visit } from "unist-util-visit";
import type { BuildVisitor } from "unist-util-visit";
export function rehypeSplitWordsIntoSpans() {
return (tree: Root) => {
visit(tree, "element", ((node: Element) => {
if (
["p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "strong"].includes(
node.tagName,
) &&
node.children
) {
const newChildren: Array<ElementContent> = [];
node.children.forEach((child) => {
if (child.type === "text") {
const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
const segments = segmenter.segment(child.value);
const words = Array.from(segments)
.map((segment) => segment.segment)
.filter(Boolean);
words.forEach((word: string) => {
newChildren.push({
type: "element",
tagName: "span",
properties: {
className: "animate-fade-in",
},
children: [{ type: "text", value: word }],
});
});
} else {
newChildren.push(child);
}
});
node.children = newChildren;
}
}) as BuildVisitor<Root, "element">);
};
}
export function useRehypeSplitWordsIntoSpans(enabled = true) {
const rehypePlugins = useMemo(
() => (enabled ? [rehypeSplitWordsIntoSpans] : []),
[enabled],
);
return rehypePlugins;
}

View File

@@ -0,0 +1 @@
export * from "./types";

View File

@@ -0,0 +1,8 @@
import { type BaseMessage } from "@langchain/core/messages";
import type { Thread } from "@langchain/langgraph-sdk";
export interface MessageThreadState extends Record<string, unknown> {
messages: BaseMessage[];
}
export interface MessageThread extends Thread<MessageThreadState> {}

View File

@@ -0,0 +1,27 @@
import type { BaseMessage } from "@langchain/core/messages";
import type { MessageThread } from "./types";
export function pathOfThread(thread: MessageThread, includeAssistantId = true) {
if (includeAssistantId) {
return `/workspace/chats/${thread.thread_id}`;
}
return `/workspace/chats/${thread.thread_id}`;
}
export function textOfMessage(message: BaseMessage) {
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;
}
return null;
}
export function titleOfThread(thread: MessageThread) {
if (thread.values && "title" in thread.values) {
return thread.values.title as string;
}
return "Untitled";
}

View File

@@ -0,0 +1,10 @@
import { parse } from "best-effort-json-parser";
export function tryParseJSON(json: string) {
try {
const object = parse(json);
return object;
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,10 @@
export function extractTitleFromMarkdown(markdown: string) {
if (markdown.startsWith("# ")) {
let title = markdown.split("\n")[0]!.trim();
if (title.startsWith("# ")) {
title = title.slice(2).trim();
}
return title;
}
return undefined;
}

View File

@@ -0,0 +1 @@
export { v4 as uuid } from "uuid";