mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-02 22:02:13 +08:00
chore: merge with web UI project
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,3 +19,6 @@ static/browser_history/*.gif
|
||||
conf.yaml
|
||||
|
||||
.idea/
|
||||
|
||||
# mock data
|
||||
mock-*-*.txt
|
||||
|
||||
16
web/.env.example
Normal file
16
web/.env.example
Normal 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
46
web/.gitignore
vendored
Normal 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
2
web/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
public-hoist-pattern[]=*eslint*
|
||||
public-hoist-pattern[]=*prettier*
|
||||
@@ -0,0 +1,26 @@
|
||||
# 🦌 Deer Web UI
|
||||
|
||||
[](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
21
web/components.json
Normal 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
90
web/eslint.config.js
Normal 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
10
web/next.config.js
Normal 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
71
web/package.json
Normal 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
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
5
web/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
4
web/prettier.config.js
Normal file
4
web/prettier.config.js
Normal 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
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
242
web/public/mock-interrupted.txt
Normal file
242
web/public/mock-interrupted.txt
Normal 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
1340
web/public/mock.txt
Normal file
File diff suppressed because it is too large
Load Diff
53
web/src/app/_components/conversation-starter.tsx
Normal file
53
web/src/app/_components/conversation-starter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
web/src/app/_components/fav-icon.tsx
Normal file
15
web/src/app/_components/fav-icon.tsx
Normal 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";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
167
web/src/app/_components/input-box.tsx
Normal file
167
web/src/app/_components/input-box.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
web/src/app/_components/loading-animation.module.css
Normal file
34
web/src/app/_components/loading-animation.module.css
Normal 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;
|
||||
}
|
||||
25
web/src/app/_components/loading-animation.tsx
Normal file
25
web/src/app/_components/loading-animation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
web/src/app/_components/logo.tsx
Normal file
20
web/src/app/_components/logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
web/src/app/_components/markdown.tsx
Normal file
124
web/src/app/_components/markdown.tsx
Normal 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, "");
|
||||
}
|
||||
347
web/src/app/_components/message-list-view.tsx
Normal file
347
web/src/app/_components/message-list-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
web/src/app/_components/messages-block.tsx
Normal file
70
web/src/app/_components/messages-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
web/src/app/_components/rainbow-text.module.css
Normal file
24
web/src/app/_components/rainbow-text.module.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
19
web/src/app/_components/rainbow-text.tsx
Normal file
19
web/src/app/_components/rainbow-text.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
web/src/app/_components/research-activities-block.tsx
Normal file
204
web/src/app/_components/research-activities-block.tsx
Normal 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 </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 </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>
|
||||
);
|
||||
}
|
||||
102
web/src/app/_components/research-block.tsx
Normal file
102
web/src/app/_components/research-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
web/src/app/_components/research-report-block.tsx
Normal file
21
web/src/app/_components/research-report-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
web/src/app/_components/rolling-text.tsx
Normal file
33
web/src/app/_components/rolling-text.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
web/src/app/_components/scroll-container.tsx
Normal file
54
web/src/app/_components/scroll-container.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
web/src/app/_components/welcome.tsx
Normal file
31
web/src/app/_components/welcome.tsx
Normal 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
30
web/src/app/layout.tsx
Normal 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
61
web/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
web/src/components/ui/button.tsx
Normal file
62
web/src/components/ui/button.tsx
Normal 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 };
|
||||
92
web/src/components/ui/card.tsx
Normal file
92
web/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
66
web/src/components/ui/tabs.tsx
Normal file
66
web/src/components/ui/tabs.tsx
Normal 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 }
|
||||
61
web/src/components/ui/tooltip.tsx
Normal file
61
web/src/components/ui/tooltip.tsx
Normal 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
71
web/src/core/api/chat.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
web/src/core/api/index.ts
Normal file
2
web/src/core/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./chat";
|
||||
export * from "./types";
|
||||
81
web/src/core/api/types.ts
Normal file
81
web/src/core/api/types.ts
Normal 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;
|
||||
2
web/src/core/messages/index.ts
Normal file
2
web/src/core/messages/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./merge-message";
|
||||
93
web/src/core/messages/merge-message.ts
Normal file
93
web/src/core/messages/merge-message.ts
Normal 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;
|
||||
}
|
||||
28
web/src/core/messages/types.ts
Normal file
28
web/src/core/messages/types.ts
Normal 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;
|
||||
}
|
||||
1
web/src/core/rehype/index.ts
Normal file
1
web/src/core/rehype/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./rehype-split-words-into-spans";
|
||||
40
web/src/core/rehype/rehype-split-words-into-spans.ts
Normal file
40
web/src/core/rehype/rehype-split-words-into-spans.ts
Normal 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">);
|
||||
};
|
||||
}
|
||||
4
web/src/core/sse/StreamEvent.ts
Normal file
4
web/src/core/sse/StreamEvent.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface StreamEvent {
|
||||
type: string;
|
||||
data: object;
|
||||
}
|
||||
70
web/src/core/sse/fetch-stream.ts
Normal file
70
web/src/core/sse/fetch-stream.ts
Normal 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;
|
||||
}
|
||||
2
web/src/core/sse/index.ts
Normal file
2
web/src/core/sse/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./fetch-stream";
|
||||
export * from "./StreamEvent";
|
||||
1
web/src/core/store/index.ts
Normal file
1
web/src/core/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./store";
|
||||
239
web/src/core/store/store.ts
Normal file
239
web/src/core/store/store.ts
Normal 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?",
|
||||
// );
|
||||
3
web/src/core/utils/deep-clone.ts
Normal file
3
web/src/core/utils/deep-clone.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function deepClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
1
web/src/core/utils/index.ts
Normal file
1
web/src/core/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./time";
|
||||
3
web/src/core/utils/time.ts
Normal file
3
web/src/core/utils/time.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
40
web/src/env.js
Normal file
40
web/src/env.js
Normal 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
6
web/src/lib/utils.ts
Normal 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
208
web/src/styles/globals.css
Normal 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
42
web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user