chore: merge with web UI project

This commit is contained in:
Li Xin
2025-04-17 12:02:23 +08:00
parent 3aebb67e2b
commit fd7a803753
58 changed files with 10290 additions and 0 deletions

3
.gitignore vendored
View File

@@ -19,3 +19,6 @@ static/browser_history/*.gif
conf.yaml
.idea/
# mock data
mock-*-*.txt

16
web/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly.
# Example:
# SERVERVAR="foo"
# NEXT_PUBLIC_CLIENTVAR="bar"
NEXT_PUBLIC_API_URL=http://localhost:8000/api

46
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
db.sqlite
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
# idea files
.idea

2
web/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

View File

@@ -0,0 +1,26 @@
# 🦌 Deer Web UI
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
> Come from Open Source, Back to Open Source
This is the web UI project for [`deer`](https://github.com/bytedance/deer).
[`Deer`](https://github.com/bytedance/deer) is a community-driven AI automation framework that builds upon the incredible work of the open source community. Our goal is to combine language models with specialized tools for tasks like web search, crawling, and Python code execution, while giving back to the community that made this possible.
## License
This project is open source and available under the [MIT License](LICENSE).
## Acknowledgments
Special thanks to all the open source projects and contributors that make `Deer` possible. We stand on the shoulders of giants.
In particular, we want to express our deep appreciation for:
* [Next.js](https://nextjs.org/) for their exceptional framework
* [Shadcn](https://ui.shadcn.com/) for their minimalistic components that powers our UI
* [Zustand](https://zustand.docs.pmnd.rs/) for their stunning state management
* [Framer Motion](https://www.framer.com/motion/) for their amazing animation library
* [React Markdown](https://www.npmjs.com/package/react-markdown) for their exceptional markdown rendering and customizability
These amazing projects form the foundation of `Deer` and demonstrate the power of open source collaboration.

21
web/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"iconLibrary": "lucide"
}

90
web/eslint.config.js Normal file
View File

@@ -0,0 +1,90 @@
import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
export default tseslint.config(
{
ignores: [".next"],
},
...compat.extends("next/core-web-vitals"),
{
files: ["**/*.ts", "**/*.tsx"],
extends: [
...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
rules: {
"@next/next/no-img-element": "off",
"@typescript-eslint/array-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": [
"warn",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-empty-object-type": "off",
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"@typescript-eslint/no-redundant-type-constituents": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-return": "off",
"import/order": [
"error",
{
distinctGroup: false,
groups: [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"object",
],
pathGroups: [
{
pattern: "~/**",
group: "internal",
},
{
pattern: "./**.css",
group: "object",
},
{
pattern: "**.md",
group: "object",
},
],
"newlines-between": "always",
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
},
},
{
linterOptions: {
reportUnusedDisableDirectives: true,
},
languageOptions: {
parserOptions: {
projectService: true,
},
},
},
);

10
web/next.config.js Normal file
View File

@@ -0,0 +1,10 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {};
export default config;

71
web/package.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "deer-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"check": "next lint && tsc --noEmit",
"dev": "next dev --turbo",
"scan": "next dev & npx react-scan@latest localhost:3000",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint",
"lint:fix": "next lint --fix",
"preview": "next build && next start",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.0",
"@t3-oss/env-nextjs": "^0.12.0",
"best-effort-json-parser": "^1.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.6.5",
"hast": "^1.0.0",
"katex": "^0.16.22",
"lru-cache": "^11.1.0",
"lucide-react": "^0.487.0",
"motion": "^12.6.5",
"nanoid": "^5.1.5",
"next": "^15.2.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.5",
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.0",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15",
"@types/hast": "^3.0.4",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9.23.0",
"eslint-config-next": "^15.2.3",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.15",
"typescript": "^5.8.2",
"typescript-eslint": "^8.27.0"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"packageManager": "pnpm@10.6.5"
}

5762
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
web/postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

4
web/prettier.config.js Normal file
View File

@@ -0,0 +1,4 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
export default {
plugins: ["prettier-plugin-tailwindcss"],
};

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,242 @@
event: tool_calls
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "coordinator", "id": "run-34e919b0-330d-4e44-a59c-58095c92e50e", "role": "assistant", "tool_calls": [{"name": "handoff_to_planner", "args": {"task_title": ""}, "id": "call_200bfa72f0b84625a278ec", "type": "tool_call"}], "tool_call_chunks": [{"name": "handoff_to_planner", "args": "{\"task_title\": \"", "id": "call_200bfa72f0b84625a278ec", "index": 0, "type": "tool_call_chunk"}]}
event: tool_call_chunks
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "coordinator", "id": "run-34e919b0-330d-4e44-a59c-58095c92e50e", "role": "assistant", "tool_call_chunks": [{"name": null, "args": "查询世界上最高的楼", "id": "", "index": 0, "type": "tool_call_chunk"}]}
event: tool_call_chunks
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "coordinator", "id": "run-34e919b0-330d-4e44-a59c-58095c92e50e", "role": "assistant", "tool_call_chunks": [{"name": null, "args": "的高度,并比较它与埃", "id": "", "index": 0, "type": "tool_call_chunk"}]}
event: tool_call_chunks
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "coordinator", "id": "run-34e919b0-330d-4e44-a59c-58095c92e50e", "role": "assistant", "tool_call_chunks": [{"name": null, "args": "菲尔铁塔高度", "id": "", "index": 0, "type": "tool_call_chunk"}]}
event: tool_call_chunks
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "coordinator", "id": "run-34e919b0-330d-4e44-a59c-58095c92e50e", "role": "assistant", "tool_call_chunks": [{"name": null, "args": "的比例\"}", "id": "", "index": 0, "type": "tool_call_chunk"}]}
event: tool_call_chunks
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "coordinator", "id": "run-34e919b0-330d-4e44-a59c-58095c92e50e", "role": "assistant", "tool_call_chunks": [{"name": null, "args": null, "id": "", "index": 0, "type": "tool_call_chunk"}]}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "coordinator", "id": "run-34e919b0-330d-4e44-a59c-58095c92e50e", "role": "assistant", "finish_reason": "tool_calls"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "{\n"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " "}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \""}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "has_enough_context"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "\": false,\n "}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \"thought\": \""}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "用户想知道世界上最高的"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "楼的高度,以及"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "它比埃菲尔"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "铁塔高多少"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "倍。\",\n \""}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "title\": \"收集"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "关于世界最高楼"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "和埃菲尔铁"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "塔高度的信息\",\n"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \"steps\":"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " [\n {\n "}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \"need_web_search"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "\": true,\n "}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \"title\": \""}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "确定世界上最高的楼"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "及其高度\",\n "}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \"description\": \""}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "查找目前世界上最高的"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "楼是哪一座"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": ",并记录其具体"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "高度(以米"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "为单位)。确保"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "信息来自可靠来源"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": ",如建筑数据库"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "或官方公告。\",\n"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \"step_type"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "\": \"research\"\n"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " },\n {\n"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \"need_web"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "_search\": true,\n"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \"title\":"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \"获取埃菲尔"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "铁塔的高度\",\n"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \"description\":"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \"查找埃菲尔"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "铁塔的具体高度"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "(以米为"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "单位),并确保"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "数据来自权威来源"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": ",例如巴黎市政府"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "或相关旅游网站"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "。\",\n \"step"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "_type\": \"research"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "\"\n },\n "}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " {\n \"need"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "_web_search\": false"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": ",\n \"title"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "\": \"计算高度"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "差及倍数"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "关系\",\n \""}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "description\": \"根据"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "前两步收集"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "的数据,计算世界上"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "最高的楼比埃"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "菲尔铁塔高"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "多少米,并进一步"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "计算出前者是"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "后者的几倍"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "。注意保留小"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "数点后两位"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "以保证精度。\",\n"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " \"step_type"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "\": \"processing\"\n"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": " }\n ]\n"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "content": "}"}
event: message_chunk
data: {"thread_id": "9a3cfaf1-d871-4ec9-a108-265c16b05afa", "agent": "planner", "id": "run-cbb7ef03-36e9-423f-be16-53187f654793", "role": "assistant", "finish_reason": "stop"}
event: interrupt
data: {"thread_id":"9a3cfaf1-d871-4ec9-a108-265c16b05afa","id":"run-cbb7ef03-36e9-423f-be16-53187f654794","role":"assistant","options":[{"text":"Edit plan","value":"edit_plan"},{"text":"Start research","value":"accepted"}],"finish_reason":"interrupt"}

1340
web/public/mock.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
import { motion } from "framer-motion";
import { cn } from "~/lib/utils";
import { Welcome } from "./welcome";
const questions = [
"How many times taller is the Eiffel Tower than the tallest building in the world?",
"How many years does an average Tesla battery last compared to a gasoline engine?",
"How many liters of water are required to produce 1 kg of beef?",
"How many times faster is the speed of light compared to the speed of sound?",
];
export function ConversationStarter({
className,
onSend,
}: {
className?: string;
onSend?: (message: string) => void;
}) {
return (
<div className={cn("flex flex-col items-center", className)}>
<div className="pointer-events-none fixed inset-0 flex items-center justify-center">
<Welcome className="pointer-events-auto mb-15 w-[75%] -translate-y-24" />
</div>
<ul className="flex flex-wrap">
{questions.map((question, index) => (
<motion.li
key={question}
className="flex w-1/2 shrink-0 p-2 active:scale-105"
style={{ transition: "all 0.2s ease-out" }}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.2,
delay: index * 0.1 + 0.5,
ease: "easeOut",
}}
>
<div
className="cursor-pointer rounded-2xl border bg-[rgba(255,255,255,0.5)] px-4 py-4 text-gray-500 transition-all duration-300 hover:bg-[rgba(255,255,255,1)] hover:text-gray-900 hover:shadow-md"
onClick={() => {
onSend?.(question);
}}
>
{question}
</div>
</motion.li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,15 @@
export function FavIcon({ url, title }: { url: string; title?: string }) {
return (
<img
className="h-4 w-4 rounded-full bg-slate-100 shadow-sm"
width={16}
height={16}
src={new URL(url).origin + "/favicon.ico"}
alt={title}
onError={(e) => {
e.currentTarget.src =
"https://perishablepress.com/wp/wp-content/images/2021/favicon-standard.png";
}}
/>
);
}

View File

@@ -0,0 +1,167 @@
import { ArrowUpOutlined, CloseOutlined } from "@ant-design/icons";
import { AnimatePresence, motion } from "framer-motion";
import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Button } from "~/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import type { Option } from "~/core/messages";
import { cn } from "~/lib/utils";
export function InputBox({
className,
size,
responding,
feedback,
onSend,
onCancel,
onRemoveFeedback,
}: {
className?: string;
size?: "large" | "normal";
responding?: boolean;
feedback?: { option: Option } | null;
onSend?: (message: string, feedback: { option: Option } | null) => void;
onCancel?: () => void;
onRemoveFeedback?: () => void;
}) {
const [message, setMessage] = useState("");
const [imeStatus, setImeStatus] = useState<"active" | "inactive">("inactive");
const [indent, setIndent] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const feedbackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (feedback) {
setMessage("");
setTimeout(() => {
if (feedbackRef.current) {
setIndent(feedbackRef.current.offsetWidth);
}
}, 200);
}
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
}, [feedback]);
const handleSendMessage = useCallback(() => {
if (responding) {
onCancel?.();
} else {
if (message.trim() === "") {
return;
}
if (onSend) {
onSend(message, feedback ?? null);
setMessage("");
onRemoveFeedback?.();
}
}
}, [responding, onCancel, message, onSend, feedback, onRemoveFeedback]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (responding) {
return;
}
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
imeStatus === "inactive"
) {
event.preventDefault();
handleSendMessage();
}
},
[responding, imeStatus, handleSendMessage],
);
return (
<div className={cn("relative rounded-[24px] border bg-white", className)}>
<div className="w-full">
<AnimatePresence>
{feedback && (
<motion.div
ref={feedbackRef}
className="absolute top-0 left-0 mt-3 ml-2 flex items-center justify-center gap-1 rounded-2xl border border-[#007aff] bg-white px-2 py-0.5"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<div className="flex h-full w-full items-center justify-center text-sm text-[#007aff] opacity-90">
{feedback.option.text}
</div>
<CloseOutlined
className="cursor-pointer text-[9px]"
onClick={onRemoveFeedback}
/>
</motion.div>
)}
</AnimatePresence>
<textarea
ref={textareaRef}
className={cn(
"m-0 w-full resize-none border-none px-4 py-3 text-lg",
size === "large" ? "min-h-32" : "min-h-4",
)}
style={{ textIndent: feedback ? `${indent}px` : 0 }}
placeholder={
feedback
? `Describe how you ${feedback.option.text.toLocaleLowerCase()}?`
: "What can I do for you?"
}
value={message}
onCompositionStart={() => setImeStatus("active")}
onCompositionEnd={() => setImeStatus("inactive")}
onKeyDown={handleKeyDown}
onChange={(event) => {
setMessage(event.target.value);
}}
/>
</div>
<div className="flex items-center px-4 py-2">
<div className="flex grow"></div>
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className={cn(
"h-10 w-10 rounded-full",
responding ? "bg-button-hover" : "bg-button",
)}
onClick={handleSendMessage}
>
{responding ? (
<div className="flex h-10 w-10 items-center justify-center">
<div className="h-4 w-4 rounded-sm bg-red-300" />
</div>
) : (
<ArrowUpOutlined />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{responding ? "Stop" : "Send"}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
@keyframes bouncing-animation {
to {
opacity: 0.1;
transform: translateY(-8px);
}
}
.loadingAnimation {
display: flex;
}
.loadingAnimation > div {
width: 8px;
height: 8px;
margin: 2px 4px;
border-radius: 50%;
background-color: #a3a1a1;
opacity: 1;
animation: bouncing-animation 0.5s infinite alternate;
}
.loadingAnimation.sm > div {
width: 6px;
height: 6px;
margin: 1px 2px;
}
.loadingAnimation > div:nth-child(2) {
animation-delay: 0.2s;
}
.loadingAnimation > div:nth-child(3) {
animation-delay: 0.4s;
}

View File

@@ -0,0 +1,25 @@
import { cn } from "~/lib/utils";
import styles from "./loading-animation.module.css";
export function LoadingAnimation({
className,
size = "normal",
}: {
className?: string;
size?: "normal" | "sm";
}) {
return (
<div
className={cn(
styles.loadingAnimation,
size === "sm" && styles.sm,
className,
)}
>
<div></div>
<div></div>
<div></div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { useState } from "react";
import { Markdown } from "./markdown";
export function Logo() {
const [text, setText] = useState("🦌 Deer");
return (
<a
className="text-sm opacity-70 transition-opacity duration-300 hover:opacity-100"
target="_blank"
href="https://github.com/bytedance/deer"
onMouseEnter={() =>
setText("🦌 **D**eep **E**xploration and **E**fficient **R**esearch")
}
onMouseLeave={() => setText("🦌 Deer")}
>
<Markdown animate>{text}</Markdown>
</a>
);
}

View File

@@ -0,0 +1,124 @@
import { CheckOutlined, CopyOutlined } from "@ant-design/icons";
import { useMemo, useState } from "react";
import ReactMarkdown, {
type Options as ReactMarkdownOptions,
} from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import "katex/dist/katex.min.css";
import { Button } from "~/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { rehypeSplitWordsIntoSpans } from "~/core/rehype";
import { cn } from "~/lib/utils";
export function Markdown({
className,
children,
style,
enableCopy,
animate = false,
...props
}: ReactMarkdownOptions & {
className?: string;
enableCopy?: boolean;
style?: React.CSSProperties;
animate?: boolean;
}) {
const rehypePlugins = useMemo(() => {
if (animate) {
return [rehypeKatex, rehypeSplitWordsIntoSpans];
}
return [rehypeKatex];
}, [animate]);
return (
<div
className={cn(className, "markdown flex flex-col gap-4")}
style={style}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={rehypePlugins}
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
}}
{...props}
>
{dropMarkdownQuote(processKatexInMarkdown(children))}
</ReactMarkdown>
{enableCopy && typeof children === "string" && (
<div className="flex">
<CopyButton content={children} />
</div>
)}
</div>
);
}
function CopyButton({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="rounded-full"
onClick={async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
} catch (error) {
console.error(error);
}
}}
>
{copied ? (
<CheckOutlined className="h-4 w-4" />
) : (
<CopyOutlined className="h-4 w-4" />
)}{" "}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy</p>
</TooltipContent>
</Tooltip>
);
}
function processKatexInMarkdown(markdown?: string | null) {
if (!markdown) return markdown;
const markdownWithKatexSyntax = markdown
.replace(/\\\\\[/g, "$$$$") // Replace '\\[' with '$$'
.replace(/\\\\\]/g, "$$$$") // Replace '\\]' with '$$'
.replace(/\\\\\(/g, "$$$$") // Replace '\\(' with '$$'
.replace(/\\\\\)/g, "$$$$") // Replace '\\)' with '$$'
.replace(/\\\[/g, "$$$$") // Replace '\[' with '$$'
.replace(/\\\]/g, "$$$$") // Replace '\]' with '$$'
.replace(/\\\(/g, "$$$$") // Replace '\(' with '$$'
.replace(/\\\)/g, "$$$$"); // Replace '\)' with '$$';
return markdownWithKatexSyntax;
}
function dropMarkdownQuote(markdown?: string | null) {
if (!markdown) return markdown;
return markdown
.replace(/^```markdown\n/gm, "")
.replace(/^```text\n/gm, "")
.replace(/^```\n/gm, "")
.replace(/\n```$/gm, "");
}

View File

@@ -0,0 +1,347 @@
import { parse } from "best-effort-json-parser";
import { motion } from "framer-motion";
import { useCallback, useMemo } from "react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import type { Message, Option } from "~/core/messages";
import {
openResearch,
sendMessage,
useMessage,
useResearchTitle,
useStore,
} from "~/core/store";
import { cn } from "~/lib/utils";
import { LoadingAnimation } from "./loading-animation";
import { Markdown } from "./markdown";
import { RainbowText } from "./rainbow-text";
import { RollingText } from "./rolling-text";
import { ScrollContainer } from "./scroll-container";
export function MessageListView({
className,
onFeedback,
}: {
className?: string;
onFeedback?: (feedback: { option: Option }) => void;
}) {
const messageIds = useStore((state) => state.messageIds);
const interruptMessage = useStore((state) => {
if (messageIds.length >= 2) {
const lastMessage = state.messages.get(
messageIds[messageIds.length - 1]!,
);
return lastMessage?.finishReason === "interrupt" ? lastMessage : null;
}
return null;
});
const waitingForFeedbackMessageId = useStore((state) => {
if (messageIds.length >= 2) {
const lastMessage = state.messages.get(
messageIds[messageIds.length - 1]!,
);
if (lastMessage && lastMessage.finishReason === "interrupt") {
return state.messageIds[state.messageIds.length - 2];
}
}
return null;
});
const responding = useStore((state) => state.responding);
const noOngoingResearch = useStore(
(state) => state.ongoingResearchId === null,
);
const ongoingResearchIsOpen = useStore(
(state) => state.ongoingResearchId === state.openResearchId,
);
return (
<ScrollContainer
className={cn(
"flex h-full w-full flex-col overflow-y-auto pt-4",
className,
)}
scrollShadowColor="#f7f5f3"
>
<ul className="flex flex-col">
{messageIds.map((messageId) => (
<MessageListItem
key={messageId}
messageId={messageId}
waitForFeedback={waitingForFeedbackMessageId === messageId}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
/>
))}
<div className="flex h-8 w-full shrink-0"></div>
</ul>
{responding && (noOngoingResearch || !ongoingResearchIsOpen) && (
<LoadingAnimation className="ml-4" />
)}
</ScrollContainer>
);
}
function MessageListItem({
className,
messageId,
waitForFeedback,
onFeedback,
interruptMessage,
}: {
className?: string;
messageId: string;
waitForFeedback?: boolean;
onFeedback?: (feedback: { option: Option }) => void;
interruptMessage?: Message | null;
}) {
const message = useMessage(messageId);
const startOfResearch = useStore((state) =>
state.researchIds.includes(messageId),
);
if (message) {
if (
message.role === "user" ||
message.agent === "coordinator" ||
message.agent === "planner" ||
startOfResearch
) {
let content: React.ReactNode;
if (message.agent === "planner") {
content = (
<div className="w-full px-4">
<PlanCard
message={message}
waitForFeedback={waitForFeedback}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
/>
</div>
);
} else if (startOfResearch) {
content = (
<div className="w-full px-4">
<ResearchCard researchId={message.id} />
</div>
);
} else {
content = message.content ? (
<div
className={cn(
"flex w-full px-4",
message.role === "user" && "justify-end",
className,
)}
>
<MessageBubble message={message}>
<div className="flex w-full flex-col">
<Markdown
animate={message.role !== "user" && message.isStreaming}
>
{message?.content}
</Markdown>
</div>
</MessageBubble>
</div>
) : null;
}
if (content) {
return (
<motion.li
className="mt-10"
key={messageId}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
style={{ transition: "all 0.2s ease-out" }}
transition={{
duration: 0.2,
ease: "easeOut",
}}
>
{content}
</motion.li>
);
}
}
return null;
}
function MessageBubble({
className,
message,
children,
}: {
className?: string;
message: Message;
children: React.ReactNode;
}) {
return (
<div
className={cn(
`flex w-fit max-w-[85%] flex-col rounded-2xl px-4 py-3 shadow-xs`,
message.role === "user" &&
"text-primary-foreground rounded-ee-none bg-[#007aff]",
message.role === "assistant" && "rounded-es-none bg-white",
className,
)}
>
{children}
</div>
);
}
function ResearchCard({
className,
researchId,
}: {
className?: string;
researchId: string;
}) {
const reportId = useStore((state) =>
state.researchReportIds.get(researchId),
);
const hasReport = useStore((state) =>
state.researchReportIds.has(researchId),
);
const reportGenerating = useStore(
(state) => hasReport && state.messages.get(reportId!)!.isStreaming,
);
const openResearchId = useStore((state) => state.openResearchId);
const state = useMemo(() => {
if (hasReport) {
return reportGenerating ? "Generating report..." : "Report generated";
}
return "Researching...";
}, [hasReport, reportGenerating]);
const title = useResearchTitle(researchId);
const handleOpen = useCallback(() => {
if (openResearchId === researchId) {
openResearch(null);
} else {
openResearch(researchId);
}
}, [openResearchId, researchId]);
return (
<Card className={cn("w-full bg-white", className)}>
<CardHeader>
<CardTitle>
<RainbowText animated={state !== "Report generated"}>
{title !== undefined && title !== "" ? title : "Deep Research"}
</RainbowText>
</CardTitle>
</CardHeader>
<CardFooter>
<div className="flex w-full">
<RollingText className="flex-grow text-sm opacity-50">
{state}
</RollingText>
<Button onClick={handleOpen}>
{!openResearchId ? "Open" : "Close"}
</Button>
</div>
</CardFooter>
</Card>
);
}
}
const GREETINGS = ["Cool", "Sounds great", "Looks good", "Great", "Awesome"];
function PlanCard({
className,
message,
interruptMessage,
onFeedback,
waitForFeedback,
}: {
className?: string;
message: Message;
interruptMessage?: Message | null;
onFeedback?: (feedback: { option: Option }) => void;
waitForFeedback?: boolean;
}) {
const plan = useMemo<{
title?: string;
thought?: string;
steps?: { title?: string; description?: string }[];
}>(() => {
return parse(message.content ?? "");
}, [message.content]);
const handleAccept = useCallback(async () => {
await sendMessage(
`${GREETINGS[Math.floor(Math.random() * GREETINGS.length)]}! ${Math.random() > 0.5 ? "Let's get started." : "Let's start."}`,
{
interruptFeedback: "accepted",
},
);
}, []);
return (
<Card className={cn("w-full bg-white", className)}>
<CardHeader>
<CardTitle>
<h1 className="text-xl font-medium">
<Markdown animate>
{plan.title !== undefined && plan.title !== ""
? plan.title
: "Deep Research"}
</Markdown>
</h1>
</CardTitle>
</CardHeader>
<CardContent>
<Markdown className="opacity-80" animate>
{plan.thought}
</Markdown>
{plan.steps && (
<ul className="my-2 flex list-decimal flex-col gap-4 border-l-[2px] pl-8">
{plan.steps.map((step, i) => (
<li key={`step-${i}`}>
<h3 className="mb text-lg font-medium">
<Markdown animate>{step.title}</Markdown>
</h3>
<div className="text-muted-foreground text-sm">
<Markdown animate>{step.description}</Markdown>
</div>
</li>
))}
</ul>
)}
</CardContent>
<CardFooter className="flex justify-end">
{!message.isStreaming && interruptMessage?.options?.length && (
<motion.div
className="flex gap-2"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
{interruptMessage?.options.map((option) => (
<Button
key={option.value}
variant={option.value === "accepted" ? "default" : "outline"}
disabled={!waitForFeedback}
onClick={() => {
if (option.value === "accepted") {
void handleAccept();
} else {
onFeedback?.({
option,
});
}
}}
>
{option.text}
</Button>
))}
</motion.div>
)}
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,70 @@
import { useCallback, useRef, useState } from "react";
import type { Option } from "~/core/messages";
import { sendMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { ConversationStarter } from "./conversation-starter";
import { InputBox } from "./input-box";
import { MessageListView } from "./message-list-view";
export function MessagesBlock({ className }: { className?: string }) {
const messageCount = useStore((state) => state.messageIds.length);
const responding = useStore((state) => state.responding);
const abortControllerRef = useRef<AbortController | null>(null);
const [feedback, setFeedback] = useState<{ option: Option } | null>(null);
const handleSend = useCallback(
async (message: string) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
await sendMessage(
message,
{
maxPlanIterations: 1,
maxStepNum: 3,
interruptFeedback: feedback?.option.value,
},
{
abortSignal: abortController.signal,
},
);
} catch {}
},
[feedback],
);
const handleCancel = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
const handleFeedback = useCallback(
(feedback: { option: Option }) => {
setFeedback(feedback);
},
[setFeedback],
);
const handleRemoveFeedback = useCallback(() => {
setFeedback(null);
}, [setFeedback]);
return (
<div className={cn("flex h-full flex-col", className)}>
<MessageListView className="flex flex-grow" onFeedback={handleFeedback} />
<div className="relative flex h-42 shrink-0 pb-4">
{!responding && messageCount === 0 && (
<ConversationStarter
className="absolute top-[-218px] left-0"
onSend={handleSend}
/>
)}
<InputBox
className="h-full w-full"
responding={responding}
feedback={feedback}
onSend={handleSend}
onCancel={handleCancel}
onRemoveFeedback={handleRemoveFeedback}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
.animated {
background: linear-gradient(
to right,
rgba(0, 0, 0, 0.3) 15%,
rgba(0, 0, 0, 0.7) 35%,
rgba(0, 0, 0, 0.7) 65%,
rgba(0, 0, 0, 0.3) 85%
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-fill-color: transparent;
background-size: 500% auto;
animation: textShine 2s ease-in-out infinite alternate;
}
@keyframes textShine {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}

View File

@@ -0,0 +1,19 @@
import { cn } from "~/lib/utils";
import styles from "./rainbow-text.module.css";
export function RainbowText({
animated,
className,
children,
}: {
animated?: boolean;
className?: string;
children?: React.ReactNode;
}) {
return (
<span className={cn(animated && styles.animated, className)}>
{children}
</span>
);
}

View File

@@ -0,0 +1,204 @@
import {
BookOutlined,
PythonOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { parse } from "best-effort-json-parser";
import { motion } from "framer-motion";
import { LRUCache } from "lru-cache";
import { useMemo } from "react";
import SyntaxHighlighter from "react-syntax-highlighter";
import { docco } from "react-syntax-highlighter/dist/esm/styles/hljs";
import type { ToolCallRuntime } from "~/core/messages";
import { useMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { FavIcon } from "./fav-icon";
import { LoadingAnimation } from "./loading-animation";
import { Markdown } from "./markdown";
import { RainbowText } from "./rainbow-text";
export function ResearchActivitiesBlock({
className,
researchId,
}: {
className?: string;
researchId: string;
}) {
const activityIds = useStore((state) =>
state.researchActivityIds.get(researchId),
)!;
const ongoing = useStore((state) => state.ongoingResearchId === researchId);
return (
<>
<ul className={cn("flex flex-col py-4", className)}>
{activityIds.map(
(activityId, i) =>
i !== 0 && (
<motion.li
key={activityId}
style={{ transition: "all 0.4s ease-out" }}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: "easeOut",
}}
>
<ActivityMessage messageId={activityId} />
<ActivityListItem messageId={activityId} />
{i !== activityIds.length - 1 && <hr className="my-8" />}
</motion.li>
),
)}
</ul>
{ongoing && <LoadingAnimation className="mx-4 my-12" />}
</>
);
}
function ActivityMessage({ messageId }: { messageId: string }) {
const message = useMessage(messageId);
if (message?.agent && message.content) {
if (message.agent !== "reporter" && message.agent !== "planner") {
return (
<div className="px-4 py-2">
<Markdown animate>{message.content}</Markdown>
</div>
);
}
}
return null;
}
function ActivityListItem({ messageId }: { messageId: string }) {
const message = useMessage(messageId);
if (message) {
if (!message.isStreaming && message.toolCalls?.length) {
for (const toolCall of message.toolCalls) {
if (toolCall.name === "web_search") {
return <WebSearchToolCall key={toolCall.id} toolCall={toolCall} />;
} else if (toolCall.name === "crawl_tool") {
return <CrawlToolCall key={toolCall.id} toolCall={toolCall} />;
} else if (toolCall.name === "python_repl_tool") {
return <PythonToolCall key={toolCall.id} toolCall={toolCall} />;
}
}
}
}
return null;
}
const __pageCache = new LRUCache<string, string>({ max: 100 });
function WebSearchToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const searchResults = useMemo<
{ title: string; url: string; content: string }[]
>(() => {
let results: { title: string; url: string; content: string }[] | undefined =
undefined;
try {
results = toolCall.result ? parse(toolCall.result) : undefined;
} catch {
results = undefined;
}
if (Array.isArray(results)) {
results.forEach((result: { url: string; title: string }) => {
__pageCache.set(result.url, result.title);
});
} else {
results = [];
}
return results;
}, [toolCall.result]);
return (
<section>
<div className="font-medium italic">
<RainbowText
className="flex items-center"
animated={searchResults === undefined}
>
<SearchOutlined className={"mr-2"} />
<span>Searching for&nbsp;</span>
<span className="max-w-[500px] overflow-hidden text-ellipsis whitespace-nowrap">
{(toolCall.args as { query: string }).query}
</span>
</RainbowText>
</div>
{searchResults && (
<div className="px-5">
<ul className="mt-2 flex flex-wrap gap-4">
{searchResults.map((searchResult, i) => (
<motion.li
key={`search-result-${i}`}
className="text-muted-foreground flex max-w-40 gap-2 rounded-md bg-slate-100 px-2 py-1 text-sm"
initial={{ opacity: 0, y: 10, scale: 0.66 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.2,
delay: i * 0.1,
ease: "easeOut",
}}
>
<FavIcon url={searchResult.url} title={searchResult.title} />
<a href={searchResult.url} target="_blank">
{searchResult.title}
</a>
</motion.li>
))}
</ul>
</div>
)}
</section>
);
}
function CrawlToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const url = useMemo(
() => (toolCall.args as { url: string }).url,
[toolCall.args],
);
const title = useMemo(() => __pageCache.get(url), [url]);
return (
<section>
<div className="font-medium italic">
<RainbowText
className="flex items-center"
animated={toolCall.result === undefined}
>
<BookOutlined className={"mr-2"} />
<span>Reading&nbsp;</span>
<li className="flex w-fit gap-1 px-2 py-1 text-sm">
<FavIcon url={url} title={title} />
<a className="hover:underline" href={url} target="_blank">
{title}
</a>
</li>
</RainbowText>
</div>
</section>
);
}
function PythonToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const code = useMemo<string>(() => {
return (toolCall.args as { code: string }).code;
}, [toolCall.args]);
return (
<section>
<div className="font-medium italic">
<PythonOutlined className={"mr-2"} />
<RainbowText animated={toolCall.result === undefined}>
Running Python code
</RainbowText>
</div>
<div className="px-5">
<div className="mt-2 rounded-md bg-slate-50 p-2 text-sm">
<SyntaxHighlighter language="python" style={docco}>
{code}
</SyntaxHighlighter>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,102 @@
import { CloseOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { openResearch, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { ResearchActivitiesBlock } from "./research-activities-block";
import { ResearchReportBlock } from "./research-report-block";
import { ScrollContainer } from "./scroll-container";
export function ResearchBlock({
className,
researchId = null,
}: {
className?: string;
researchId: string | null;
}) {
const reportId = useStore((state) =>
researchId ? state.researchReportIds.get(researchId) : undefined,
);
const [activeTab, setActiveTab] = useState("activities");
const hasReport = useStore((state) =>
researchId ? state.researchReportIds.has(researchId) : false,
);
useEffect(() => {
if (hasReport) {
setActiveTab("report");
}
}, [hasReport]);
return (
<div className={cn("h-full w-full", className)}>
<Card className={cn("relative h-full w-full pt-4", className)}>
<div className="absolute right-4 flex h-9 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
className="text-gray-400"
size="sm"
variant="ghost"
onClick={() => {
openResearch(null);
}}
>
<CloseOutlined />
</Button>
</TooltipTrigger>
<TooltipContent>Close</TooltipContent>
</Tooltip>
</div>
<Tabs
className="flex h-full w-full flex-col"
value={activeTab}
onValueChange={(value) => setActiveTab(value)}
>
<div className="flex w-full justify-center">
<TabsList className="">
<TabsTrigger
className="px-8"
value="report"
disabled={!hasReport}
>
Report
</TabsTrigger>
<TabsTrigger className="px-8" value="activities">
Activities
</TabsTrigger>
</TabsList>
</div>
<TabsContent className="h-full min-h-0 flex-grow px-8" value="report">
<ScrollContainer className="px-5pb-20 h-full">
{reportId && (
<ResearchReportBlock className="mt-4" messageId={reportId} />
)}
</ScrollContainer>
</TabsContent>
<TabsContent
className="h-full min-h-0 flex-grow px-8"
value="activities"
>
<ScrollContainer className="h-full">
{researchId && (
<ResearchActivitiesBlock
className="mt-4"
researchId={researchId}
/>
)}
</ScrollContainer>
</TabsContent>
</Tabs>
</Card>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { useMessage } from "~/core/store";
import { cn } from "~/lib/utils";
import { LoadingAnimation } from "./loading-animation";
import { Markdown } from "./markdown";
export function ResearchReportBlock({
className,
messageId,
}: {
className?: string;
messageId: string;
}) {
const message = useMessage(messageId);
return (
<div className={cn("flex flex-col pb-8", className)}>
<Markdown animate>{message?.content}</Markdown>
{message?.isStreaming && <LoadingAnimation className="my-12" />}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "~/lib/utils";
export function RollingText({
className,
children,
}: {
className?: string;
children?: string | string[];
}) {
return (
<span
className={cn(
"relative flex h-[2em] items-center overflow-hidden",
className,
)}
>
<AnimatePresence mode="popLayout">
<motion.div
className="absolute w-fit"
style={{ transition: "all 0.3s ease-in-out" }}
initial={{ y: "100%", opacity: 0 }}
animate={{ y: "0%", opacity: 1 }}
exit={{ y: "-100%", opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
{children}
</motion.div>
</AnimatePresence>
</span>
);
}

View File

@@ -0,0 +1,54 @@
import { useStickToBottom } from "use-stick-to-bottom";
import { cn } from "~/lib/utils";
export function ScrollContainer({
className,
children,
scrollShadow = true,
scrollShadowColor = "white",
}: {
className?: string;
children: React.ReactNode;
scrollShadow?: boolean;
scrollShadowColor?: string;
}) {
const { scrollRef, contentRef } = useStickToBottom({
initial: "instant",
});
return (
<div className={cn("relative", className)}>
{scrollShadow && (
<>
<div
className={cn(
"absolute top-0 right-0 left-0 z-10 h-10 bg-gradient-to-b",
`from-[var(--scroll-shadow-color)] to-transparent`,
)}
style={
{
"--scroll-shadow-color": scrollShadowColor,
} as React.CSSProperties
}
></div>
<div
className={cn(
"absolute right-0 bottom-0 left-0 z-10 h-10 bg-gradient-to-b",
`from-transparent to-[var(--scroll-shadow-color)]`,
)}
style={
{
"--scroll-shadow-color": scrollShadowColor,
} as React.CSSProperties
}
></div>
</>
)}
<div ref={scrollRef} className={"h-full w-full overflow-y-scroll"}>
<div className="h-fit w-full" ref={contentRef}>
{children}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { motion } from "framer-motion";
import { cn } from "~/lib/utils";
export function Welcome({ className }: { className?: string }) {
return (
<motion.div
className={cn("flex flex-col", className)}
style={{ transition: "all 0.2s ease-out" }}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
>
<h3 className="mb-2 text-center text-3xl font-medium">
👋 Hello, there!
</h3>
<div className="px-4 text-center text-lg text-gray-400">
Welcome to{" "}
<a
href="https://github.com/bytedance/deer"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
🦌 Deer
</a>
, a research tool built on cutting-edge language models, helps you
search on web, browse information, and handle complex tasks.
</div>
</motion.div>
);
}

30
web/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,30 @@
import "~/styles/globals.css";
import { type Metadata } from "next";
import { Geist } from "next/font/google";
import { TooltipProvider } from "~/components/ui/tooltip";
export const metadata: Metadata = {
title: "🦌 Deer",
description:
"Deep Exploration and Efficient Research, an AI tool that combines language models with specialized tools for research tasks.",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${geist.variable}`}>
<body className="h-screen w-screen overflow-hidden overscroll-none bg-[#f7f5f3]">
<TooltipProvider>{children}</TooltipProvider>
</body>
</html>
);
}

61
web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,61 @@
"use client";
import { GithubOutlined } from "@ant-design/icons";
import Link from "next/link";
import { useMemo } from "react";
import { Button } from "~/components/ui/button";
import { useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { Logo } from "./_components/logo";
import { MessagesBlock } from "./_components/messages-block";
import { ResearchBlock } from "./_components/research-block";
export default function HomePage() {
const openResearchId = useStore((state) => state.openResearchId);
const doubleColumnMode = useMemo(
() => openResearchId !== null,
[openResearchId],
);
return (
<div className="flex h-full w-full justify-center">
<header className="fixed top-0 left-0 flex h-12 w-full w-screen items-center justify-between px-4">
<Logo />
<Button
className="opacity-70 transition-opacity duration-300 hover:opacity-100"
variant="ghost"
size="icon"
asChild
>
<Link href="https://github.com/bytedance/deer" target="_blank">
<GithubOutlined />
</Link>
</Button>
</header>
<div
className={cn(
"flex h-full w-full justify-center px-4 pt-12",
doubleColumnMode && "gap-8",
)}
>
<MessagesBlock
className={cn(
"shrink-0 transition-all duration-300 ease-out",
!doubleColumnMode &&
`w-[768px] translate-x-[min(calc((100vw-538px)*0.75/2),960px/2)]`,
doubleColumnMode && `w-[538px]`,
)}
/>
<ResearchBlock
className={cn(
"w-[min(calc((100vw-538px)*0.75),960px)] pb-4 transition-all duration-300 ease-out",
!doubleColumnMode && "scale-0",
doubleColumnMode && "",
)}
researchId={openResearchId}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "~/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(
buttonVariants({ variant, size, className }),
"cursor-pointer active:scale-105",
)}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "~/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,61 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

71
web/src/core/api/chat.ts Normal file
View File

@@ -0,0 +1,71 @@
import { env } from "~/env";
import { fetchStream } from "../sse";
import { sleep } from "../utils";
import type { ChatEvent } from "./types";
export function chatStream(
userMessage: string,
params: {
thread_id: string;
max_plan_iterations: number;
max_step_num: number;
interrupt_feedback?: string;
},
options: { abortSignal?: AbortSignal } = {},
) {
if (location.search.includes("mock")) {
return chatStreamMock(userMessage, params, options);
}
return fetchStream<ChatEvent>(
(env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api") + "/chat/stream",
{
body: JSON.stringify({
messages: [{ role: "user", content: userMessage }],
auto_accepted_plan: false,
...params,
}),
signal: options.abortSignal,
},
);
}
async function* chatStreamMock(
userMessage: string,
_: {
thread_id: string;
max_plan_iterations: number;
max_step_num: number;
} = {
thread_id: "__mock__",
max_plan_iterations: 3,
max_step_num: 1,
},
options: { abortSignal?: AbortSignal } = {},
): AsyncIterable<ChatEvent> {
const res = await fetch("/mock.txt", {
signal: options.abortSignal,
});
await sleep(800);
const text = await res.text();
const chunks = text.split("\n\n");
for (const chunk of chunks) {
const [eventRaw, dataRaw] = chunk.split("\n") as [string, string];
const [, event] = eventRaw.split("event: ", 2) as [string, string];
const [, data] = dataRaw.split("data: ", 2) as [string, string];
if (event === "message_chunk") {
await sleep(0);
} else if (event === "tool_call_result") {
await sleep(1500);
}
try {
yield {
type: event,
data: JSON.parse(data),
} as ChatEvent;
} catch (e) {
console.error(e);
}
}
}

View File

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

81
web/src/core/api/types.ts Normal file
View File

@@ -0,0 +1,81 @@
import type { Option } from "../messages";
import type { StreamEvent } from "../sse";
// Tool Calls
export interface ToolCall {
type: "tool_call";
id: string;
name: string;
args: Record<string, unknown>;
}
export interface ToolCallChunk {
type: "tool_call_chunk";
index: number;
id: string;
name: string;
args: string;
}
// Events
interface GenericEvent<T extends string, D extends object> extends StreamEvent {
type: T;
data: {
id: string;
thread_id: string;
agent: "coordinator" | "planner" | "researcher" | "coder" | "reporter";
role: "user" | "assistant" | "tool";
finish_reason?: "stop" | "tool_calls" | "interrupt";
} & D;
}
export interface MessageChunkEvent
extends GenericEvent<
"message_chunk",
{
content?: string;
}
> {}
export interface ToolCallsEvent
extends GenericEvent<
"tool_calls",
{
tool_calls: ToolCall[];
tool_call_chunks: ToolCallChunk[];
}
> {}
export interface ToolCallChunksEvent
extends GenericEvent<
"tool_call_chunks",
{
tool_call_chunks: ToolCallChunk[];
}
> {}
export interface ToolCallResultEvent
extends GenericEvent<
"tool_call_result",
{
tool_call_id: string;
content?: string;
}
> {}
export interface InterruptEvent
extends GenericEvent<
"interrupt",
{
options: Option[];
}
> {}
export type ChatEvent =
| MessageChunkEvent
| ToolCallsEvent
| ToolCallChunksEvent
| ToolCallResultEvent
| InterruptEvent;

View File

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

View File

@@ -0,0 +1,93 @@
import type {
ChatEvent,
InterruptEvent,
MessageChunkEvent,
ToolCallChunksEvent,
ToolCallResultEvent,
ToolCallsEvent,
} from "../api";
import { deepClone } from "../utils/deep-clone";
import type { Message } from "./types";
export function mergeMessage(message: Message, event: ChatEvent) {
if (event.type === "message_chunk") {
mergeTextMessage(message, event);
} else if (event.type === "tool_calls" || event.type === "tool_call_chunks") {
mergeToolCallMessage(message, event);
} else if (event.type === "tool_call_result") {
mergeToolCallResultMessage(message, event);
} else if (event.type === "interrupt") {
mergeInterruptMessage(message, event);
}
if (event.data.finish_reason) {
message.finishReason = event.data.finish_reason;
message.isStreaming = false;
if (message.toolCalls) {
message.toolCalls.forEach((toolCall) => {
if (toolCall.argsChunks?.length) {
toolCall.args = JSON.parse(toolCall.argsChunks.join(""));
delete toolCall.argsChunks;
}
});
}
}
return deepClone(message);
}
function mergeTextMessage(message: Message, event: MessageChunkEvent) {
if (event.data.content) {
message.content += event.data.content;
message.contentChunks.push(event.data.content);
}
}
function mergeToolCallMessage(
message: Message,
event: ToolCallsEvent | ToolCallChunksEvent,
) {
if (event.type === "tool_calls" && event.data.tool_calls[0]?.name) {
message.toolCalls = event.data.tool_calls.map((raw) => ({
id: raw.id,
name: raw.name,
args: raw.args,
result: undefined,
}));
}
message.toolCalls ??= [];
for (const chunk of event.data.tool_call_chunks) {
if (chunk.id) {
const toolCall = message.toolCalls.find(
(toolCall) => toolCall.id === chunk.id,
);
if (toolCall) {
toolCall.argsChunks = [chunk.args];
}
} else {
const streamingToolCall = message.toolCalls.find(
(toolCall) => toolCall.argsChunks?.length,
);
if (streamingToolCall) {
streamingToolCall.argsChunks!.push(chunk.args);
}
}
}
}
function mergeToolCallResultMessage(
message: Message,
event: ToolCallResultEvent,
) {
const toolCall = message.toolCalls?.find(
(toolCall) => toolCall.id === event.data.tool_call_id,
);
if (toolCall) {
toolCall.result = event.data.content;
}
}
function mergeInterruptMessage(message: Message, event: InterruptEvent) {
message.isStreaming = false;
message.options = event.data.options;
}

View File

@@ -0,0 +1,28 @@
export type MessageRole = "user" | "assistant" | "tool";
export interface Message {
id: string;
threadId: string;
agent?: "coordinator" | "planner" | "researcher" | "coder" | "reporter";
role: MessageRole;
isStreaming?: boolean;
content: string;
contentChunks: string[];
toolCalls?: ToolCallRuntime[];
options?: Option[];
finishReason?: "stop" | "interrupt" | "tool_calls";
interruptFeedback?: string;
}
export interface Option {
text: string;
value: string;
}
export interface ToolCallRuntime {
id: string;
name: string;
args: Record<string, unknown>;
argsChunks?: string[];
result?: string;
}

View File

@@ -0,0 +1 @@
export * from "./rehype-split-words-into-spans";

View File

@@ -0,0 +1,40 @@
import type { Element, Root, ElementContent } from "hast";
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">);
};
}

View File

@@ -0,0 +1,4 @@
export interface StreamEvent {
type: string;
data: object;
}

View File

@@ -0,0 +1,70 @@
import { type StreamEvent } from "./StreamEvent";
export async function* fetchStream<T extends StreamEvent>(
url: string,
init: RequestInit,
): AsyncIterable<T> {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
...init,
});
if (response.status !== 200) {
throw new Error(`Failed to fetch from ${url}: ${response.status}`);
}
// Read from response body, event by event. An event always ends with a '\n\n'.
const reader = response.body
?.pipeThrough(new TextDecoderStream())
.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += value;
while (true) {
const index = buffer.indexOf("\n\n");
if (index === -1) {
break;
}
const chunk = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const event = parseEvent<T>(chunk);
if (event) {
yield event;
}
}
}
}
function parseEvent<T extends StreamEvent>(chunk: string) {
let resultType = "message";
let resultData: object | null = null;
for (const line of chunk.split("\n")) {
const pos = line.indexOf(": ");
if (pos === -1) {
continue;
}
const key = line.slice(0, pos);
const value = line.slice(pos + 2);
if (key === "event") {
resultType = value;
} else if (key === "data") {
resultData = JSON.parse(value);
}
}
if (resultType === "message" && resultData === null) {
return undefined;
}
return {
type: resultType,
data: resultData,
} as T;
}

View File

@@ -0,0 +1,2 @@
export * from "./fetch-stream";
export * from "./StreamEvent";

View File

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

239
web/src/core/store/store.ts Normal file
View File

@@ -0,0 +1,239 @@
import { parse } from "best-effort-json-parser";
import { nanoid } from "nanoid";
import { create } from "zustand";
import { chatStream } from "../api";
import type { Message } from "../messages";
import { mergeMessage } from "../messages";
const THREAD_ID = nanoid();
export const useStore = create<{
responding: boolean;
threadId: string | undefined;
messageIds: string[];
messages: Map<string, Message>;
researchIds: string[];
researchPlanIds: Map<string, string>;
researchReportIds: Map<string, string>;
researchActivityIds: Map<string, string[]>;
ongoingResearchId: string | null;
openResearchId: string | null;
}>(() => ({
responding: false,
threadId: THREAD_ID,
messageIds: [],
messages: new Map<string, Message>(),
researchIds: [],
researchPlanIds: new Map<string, string>(),
researchReportIds: new Map<string, string>(),
researchActivityIds: new Map<string, string[]>(),
ongoingResearchId: null,
openResearchId: null,
}));
export async function sendMessage(
content: string,
{
maxPlanIterations = 1,
maxStepNum = 3,
interruptFeedback,
}: {
maxPlanIterations?: number;
maxStepNum?: number;
interruptFeedback?: string;
} = {},
options: { abortSignal?: AbortSignal } = {},
) {
appendMessage({
id: nanoid(),
threadId: THREAD_ID,
role: "user",
content: content,
contentChunks: [content],
});
setResponding(true);
try {
const stream = chatStream(
content,
{
thread_id: THREAD_ID,
max_plan_iterations: maxPlanIterations,
max_step_num: maxStepNum,
interrupt_feedback: interruptFeedback,
},
options,
);
for await (const event of stream) {
const { type, data } = event;
const messageId = data.id;
let message: Message | undefined;
if (type === "tool_call_result") {
message = findMessageByToolCallId(data.tool_call_id);
} else if (!existsMessage(messageId)) {
message = {
id: messageId,
threadId: data.thread_id,
agent: data.agent,
role: data.role,
content: "",
contentChunks: [],
isStreaming: true,
interruptFeedback,
};
appendMessage(message);
}
message ??= findMessage(messageId);
if (message) {
message = mergeMessage(message, event);
updateMessage(message);
}
}
} finally {
setResponding(false);
}
}
function setResponding(value: boolean) {
useStore.setState({ responding: value });
}
function existsMessage(id: string) {
return useStore.getState().messageIds.includes(id);
}
function findMessage(id: string) {
return useStore.getState().messages.get(id);
}
function findMessageByToolCallId(toolCallId: string) {
return Array.from(useStore.getState().messages.values())
.reverse()
.find((message) => {
if (message.toolCalls) {
return message.toolCalls.some((toolCall) => toolCall.id === toolCallId);
}
return false;
});
}
function appendMessage(message: Message) {
if (
message.agent === "coder" ||
message.agent === "reporter" ||
message.agent === "researcher"
) {
appendResearchActivity(message);
}
useStore.setState({
messageIds: [...useStore.getState().messageIds, message.id],
messages: new Map(useStore.getState().messages).set(message.id, message),
});
}
function updateMessage(message: Message) {
if (
message.agent === "researcher" ||
message.agent === "coder" ||
message.agent === "reporter"
) {
const id = message.id;
if (!getOngoingResearchId()) {
appendResearch(id);
openResearch(id);
}
}
if (
getOngoingResearchId() &&
message.agent === "reporter" &&
!message.isStreaming
) {
setOngoingResearchId(null);
}
useStore.setState({
messages: new Map(useStore.getState().messages).set(message.id, message),
});
}
function getOngoingResearchId() {
return useStore.getState().ongoingResearchId;
}
function setOngoingResearchId(value: string | null) {
return useStore.setState({
ongoingResearchId: value,
});
}
function appendResearch(researchId: string) {
let planMessage: Message | undefined;
const reversedMessageIds = [...useStore.getState().messageIds].reverse();
for (const messageId of reversedMessageIds) {
const message = findMessage(messageId);
if (message?.agent === "planner") {
planMessage = message;
break;
}
}
const messageIds = [researchId];
messageIds.unshift(planMessage!.id);
useStore.setState({
ongoingResearchId: researchId,
researchIds: [...useStore.getState().researchIds, researchId],
researchPlanIds: new Map(useStore.getState().researchPlanIds).set(
researchId,
planMessage!.id,
),
researchActivityIds: new Map(useStore.getState().researchActivityIds).set(
researchId,
messageIds,
),
});
}
function appendResearchActivity(message: Message) {
const researchId = getOngoingResearchId();
if (researchId) {
const researchActivityIds = useStore.getState().researchActivityIds;
useStore.setState({
researchActivityIds: new Map(researchActivityIds).set(researchId, [
...researchActivityIds.get(researchId)!,
message.id,
]),
});
if (message.agent === "reporter") {
useStore.setState({
researchReportIds: new Map(useStore.getState().researchReportIds).set(
researchId,
message.id,
),
});
}
}
}
export function openResearch(researchId: string | null) {
useStore.setState({
openResearchId: researchId,
});
}
export function useResearchTitle(researchId: string) {
const planMessage = useMessage(
useStore.getState().researchPlanIds.get(researchId),
);
return planMessage ? parse(planMessage.content).title : undefined;
}
export function useMessage(messageId: string | null | undefined) {
return useStore((state) =>
messageId ? state.messages.get(messageId) : undefined,
);
}
// void sendMessage(
// "How many times taller is the Eiffel Tower than the tallest building in the world?",
// );

View File

@@ -0,0 +1,3 @@
export function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}

View File

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

View File

@@ -0,0 +1,3 @@
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

40
web/src/env.js Normal file
View File

@@ -0,0 +1,40 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
NODE_ENV: z.enum(["development", "test", "production"]),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
NEXT_PUBLIC_API_URL: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

6
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

208
web/src/styles/globals.css Normal file
View File

@@ -0,0 +1,208 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans:
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--animate-fade-in: fade-in 1s;
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: rgba(0, 0, 0, 0.72);
--card: oklch(1 0 0);
--card-foreground: var(--foreground);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
input,
textarea {
outline: none;
}
.markdown {
line-height: 1.75;
a {
color: blue;
&:hover {
text-decoration: underline;
}
}
h1 {
@apply text-2xl font-bold;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
h2 {
@apply text-xl font-bold;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
h3 {
@apply text-lg font-bold;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
h4 {
@apply text-base font-bold;
margin-bottom: 0.5rem;
}
h5 {
@apply text-sm font-bold;
}
h6 {
@apply text-xs font-bold;
}
ul {
@apply list-disc pl-4;
}
ol {
@apply list-decimal pl-4;
}
table {
@apply w-full;
table-layout: fixed;
border-collapse: collapse;
th,
td {
padding: 4px 8px;
border: 1px solid var(--border);
}
th {
@apply bg-muted;
}
}
}

42
web/tsconfig.json Normal file
View File

@@ -0,0 +1,42 @@
{
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"incremental": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}