Initial
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch: # allow manual runs from the GitHub UI
|
||||||
|
|
||||||
|
# Cancel an in-progress run when a newer commit is pushed to the same ref.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
name: Format, lint & build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
# `npm ci` does a clean, reproducible install from package-lock.json
|
||||||
|
# and fails on lockfile drift.
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: npm run format:check
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# production
|
||||||
|
build
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# misc
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# local planning notes (not committed)
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# lock files
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
package-lock.json
|
||||||
@@ -1,2 +1,66 @@
|
|||||||
# EvoScientist-WebUI
|
# EvoScientist WebUI
|
||||||
EvoScientist WebUI
|
|
||||||
|
Web UI for **EvoScientist** — a self-evolving AI scientist built on DeepAgents/LangGraph.
|
||||||
|
|
||||||
|
The browser connects directly to a running EvoScientist deployment and gives you a
|
||||||
|
chat interface with streaming responses, tool calls, sub-agent activity, files, and tasks.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node.js 20+**
|
||||||
|
- A running **EvoScientist backend** (the LangGraph deployment). From your EvoScientist
|
||||||
|
install, start it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EvoSci deploy # serves the LangGraph API at http://127.0.0.1:6174
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep this running in its own terminal.
|
||||||
|
|
||||||
|
## Quick start (development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:4716>. In the configuration dialog, enter your **Deployment URL**
|
||||||
|
(default `http://127.0.0.1:6174`) and click **Save**. That's it — start chatting.
|
||||||
|
|
||||||
|
> You need two things running: the EvoScientist backend (`EvoSci deploy`, port 6174)
|
||||||
|
> and this UI (`npm run dev`, port 4716).
|
||||||
|
|
||||||
|
## Production build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # outputs the optimized app
|
||||||
|
npm start # serves it on http://localhost:4716
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- **Deployment URL** — the EvoScientist LangGraph endpoint (default `http://127.0.0.1:6174`,
|
||||||
|
the `EvoSci deploy` default port). Saved in your browser's local storage.
|
||||||
|
- The UI always talks to the **EvoScientist** main agent; its sub-agents
|
||||||
|
(`writing-agent`, `data-analysis-agent`) are internal and not user-selectable.
|
||||||
|
- _(Optional, advanced)_ Set `NEXT_PUBLIC_LANGSMITH_API_KEY` if you connect to a
|
||||||
|
deployment that requires LangSmith authentication.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| ---------------------- | --------------------------------------- |
|
||||||
|
| `npm run dev` | Start the dev server on port 4716 |
|
||||||
|
| `npm run build` | Production build |
|
||||||
|
| `npm start` | Serve the production build on port 4716 |
|
||||||
|
| `npm run lint` | Lint with ESLint |
|
||||||
|
| `npm run format` | Format with Prettier |
|
||||||
|
| `npm run format:check` | Check formatting (used in CI) |
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
Next.js 16 · React 19 · TypeScript · Tailwind CSS · `@langchain/langgraph-sdk`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0 — see [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist", ".next", "node_modules"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"@typescript-eslint/no-explicit-any": 0,
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ args: "none", argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
// EvoScientist `EvoSci deploy` backend (langgraph dev), default 127.0.0.1:6174.
|
||||||
|
// Override with LANGGRAPH_API_URL if you used `EvoSci deploy --port <other>`.
|
||||||
|
const LANGGRAPH_TARGET =
|
||||||
|
process.env.LANGGRAPH_API_URL || "http://127.0.0.1:6174";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// Allow loading the dev UI (and its HMR resources) from these LAN hosts,
|
||||||
|
// e.g. opening http://192.168.0.59:3000 from a phone on the same Wi-Fi.
|
||||||
|
// Add/replace with your Mac's LAN IP (see `ipconfig getifaddr en0`).
|
||||||
|
allowedDevOrigins: ["192.168.0.59"],
|
||||||
|
|
||||||
|
// Same-origin reverse proxy to the EvoScientist backend. The browser talks
|
||||||
|
// only to this Next server; Next (running on the Mac) forwards to the
|
||||||
|
// backend on 127.0.0.1. This lets a phone reach the backend WITHOUT the
|
||||||
|
// EvoScientist repo binding to 0.0.0.0, and avoids cross-origin/CORS issues.
|
||||||
|
// In the UI's config dialog, set Deployment URL to: <this-origin>/lg
|
||||||
|
// Dev-only: rewrites don't apply to a future static export build.
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/lg/:path*",
|
||||||
|
destination: `${LANGGRAPH_TARGET}/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+10059
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"name": "evoscientist-webui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Web UI for EvoScientist — a self-evolving AI scientist built on DeepAgents/LangGraph.",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack --port 4716",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start --port 4716",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@langchain/core": "1.1.19",
|
||||||
|
"@langchain/langgraph-sdk": "1.0.3",
|
||||||
|
"@radix-ui/colors": "^1.0.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^1.2.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.539.0",
|
||||||
|
"next": "^16.2.5",
|
||||||
|
"nuqs": "^2.8.8",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
|
"react-resizable-panels": "^3.0.6",
|
||||||
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"swr": "^2.4.1",
|
||||||
|
"tailwind-merge": "^2.6",
|
||||||
|
"use-stick-to-bottom": "^1.1.1",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9",
|
||||||
|
"@headlessui/tailwindcss": "^0.2.2",
|
||||||
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
|
"@tailwindcss/typography": "^0.5.9",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"autoprefixer": "^10.4.24",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"prettier": "^2.8.8",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.54.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"tailwindcss/nesting": {},
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* @see https://prettier.io/docs/configuration
|
||||||
|
* @type {import("prettier").Config}
|
||||||
|
*/
|
||||||
|
const config = {
|
||||||
|
endOfLine: "auto",
|
||||||
|
singleAttributePerLine: true,
|
||||||
|
plugins: ["prettier-plugin-tailwindcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,545 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
FormEvent,
|
||||||
|
Fragment,
|
||||||
|
} from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Square,
|
||||||
|
ArrowUp,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Circle,
|
||||||
|
FileIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { ChatMessage } from "@/app/components/ChatMessage";
|
||||||
|
import type {
|
||||||
|
TodoItem,
|
||||||
|
ToolCall,
|
||||||
|
ActionRequest,
|
||||||
|
ReviewConfig,
|
||||||
|
} from "@/app/types/types";
|
||||||
|
import { Assistant, Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { extractStringFromMessageContent } from "@/app/utils/utils";
|
||||||
|
import { useChatContext } from "@/providers/ChatProvider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useStickToBottom } from "use-stick-to-bottom";
|
||||||
|
import { FilesPopover } from "@/app/components/TasksFilesSidebar";
|
||||||
|
|
||||||
|
interface ChatInterfaceProps {
|
||||||
|
assistant: Assistant | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusIcon = (status: TodoItem["status"], className?: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return (
|
||||||
|
<CheckCircle
|
||||||
|
size={16}
|
||||||
|
className={cn("text-success/80", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "in_progress":
|
||||||
|
return (
|
||||||
|
<Clock
|
||||||
|
size={16}
|
||||||
|
className={cn("text-warning/80", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Circle
|
||||||
|
size={16}
|
||||||
|
className={cn("text-tertiary/70", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||||
|
const [metaOpen, setMetaOpen] = useState<"tasks" | "files" | null>(null);
|
||||||
|
const tasksContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const { scrollRef, contentRef } = useStickToBottom();
|
||||||
|
|
||||||
|
const {
|
||||||
|
stream,
|
||||||
|
messages,
|
||||||
|
todos,
|
||||||
|
files,
|
||||||
|
ui,
|
||||||
|
setFiles,
|
||||||
|
isLoading,
|
||||||
|
isThreadLoading,
|
||||||
|
interrupt,
|
||||||
|
sendMessage,
|
||||||
|
stopStream,
|
||||||
|
resumeInterrupt,
|
||||||
|
} = useChatContext();
|
||||||
|
|
||||||
|
const submitDisabled = isLoading || !assistant;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e?: FormEvent) => {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
const messageText = input.trim();
|
||||||
|
if (!messageText || isLoading || submitDisabled) return;
|
||||||
|
sendMessage(messageText);
|
||||||
|
setInput("");
|
||||||
|
},
|
||||||
|
[input, isLoading, sendMessage, setInput, submitDisabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (submitDisabled) return;
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSubmit, submitDisabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: can we make this part of the hook?
|
||||||
|
const processedMessages = useMemo(() => {
|
||||||
|
/*
|
||||||
|
1. Loop through all messages
|
||||||
|
2. For each AI message, add the AI message, and any tool calls to the messageMap
|
||||||
|
3. For each tool message, find the corresponding tool call in the messageMap and update the status and output
|
||||||
|
*/
|
||||||
|
const messageMap = new Map<
|
||||||
|
string,
|
||||||
|
{ message: Message; toolCalls: ToolCall[] }
|
||||||
|
>();
|
||||||
|
messages.forEach((message: Message) => {
|
||||||
|
if (message.type === "ai") {
|
||||||
|
const toolCallsInMessage: Array<{
|
||||||
|
id?: string;
|
||||||
|
function?: { name?: string; arguments?: unknown };
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
args?: unknown;
|
||||||
|
input?: unknown;
|
||||||
|
}> = [];
|
||||||
|
if (
|
||||||
|
message.additional_kwargs?.tool_calls &&
|
||||||
|
Array.isArray(message.additional_kwargs.tool_calls)
|
||||||
|
) {
|
||||||
|
toolCallsInMessage.push(...message.additional_kwargs.tool_calls);
|
||||||
|
} else if (message.tool_calls && Array.isArray(message.tool_calls)) {
|
||||||
|
toolCallsInMessage.push(
|
||||||
|
...message.tool_calls.filter(
|
||||||
|
(toolCall: { name?: string }) => toolCall.name !== ""
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (Array.isArray(message.content)) {
|
||||||
|
const toolUseBlocks = message.content.filter(
|
||||||
|
(block: { type?: string }) => block.type === "tool_use"
|
||||||
|
);
|
||||||
|
toolCallsInMessage.push(...toolUseBlocks);
|
||||||
|
}
|
||||||
|
const toolCallsWithStatus = toolCallsInMessage.map(
|
||||||
|
(toolCall: {
|
||||||
|
id?: string;
|
||||||
|
function?: { name?: string; arguments?: unknown };
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
args?: unknown;
|
||||||
|
input?: unknown;
|
||||||
|
}) => {
|
||||||
|
const name =
|
||||||
|
toolCall.function?.name ||
|
||||||
|
toolCall.name ||
|
||||||
|
toolCall.type ||
|
||||||
|
"unknown";
|
||||||
|
const args =
|
||||||
|
toolCall.function?.arguments ||
|
||||||
|
toolCall.args ||
|
||||||
|
toolCall.input ||
|
||||||
|
{};
|
||||||
|
return {
|
||||||
|
id: toolCall.id || `tool-${Math.random()}`,
|
||||||
|
name,
|
||||||
|
args,
|
||||||
|
status: interrupt ? "interrupted" : ("pending" as const),
|
||||||
|
} as ToolCall;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
messageMap.set(message.id!, {
|
||||||
|
message,
|
||||||
|
toolCalls: toolCallsWithStatus,
|
||||||
|
});
|
||||||
|
} else if (message.type === "tool") {
|
||||||
|
const toolCallId = message.tool_call_id;
|
||||||
|
if (!toolCallId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [, data] of messageMap.entries()) {
|
||||||
|
const toolCallIndex = data.toolCalls.findIndex(
|
||||||
|
(tc: ToolCall) => tc.id === toolCallId
|
||||||
|
);
|
||||||
|
if (toolCallIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
data.toolCalls[toolCallIndex] = {
|
||||||
|
...data.toolCalls[toolCallIndex],
|
||||||
|
status: "completed" as const,
|
||||||
|
result: extractStringFromMessageContent(message),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (message.type === "human") {
|
||||||
|
messageMap.set(message.id!, {
|
||||||
|
message,
|
||||||
|
toolCalls: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const processedArray = Array.from(messageMap.values());
|
||||||
|
return processedArray.map((data, index) => {
|
||||||
|
const prevMessage = index > 0 ? processedArray[index - 1].message : null;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
showAvatar: data.message.type !== prevMessage?.type,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [messages, interrupt]);
|
||||||
|
|
||||||
|
const groupedTodos = {
|
||||||
|
in_progress: todos.filter((t) => t.status === "in_progress"),
|
||||||
|
pending: todos.filter((t) => t.status === "pending"),
|
||||||
|
completed: todos.filter((t) => t.status === "completed"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasTasks = todos.length > 0;
|
||||||
|
const hasFiles = Object.keys(files).length > 0;
|
||||||
|
|
||||||
|
// Parse out any action requests or review configs from the interrupt
|
||||||
|
const actionRequestsMap: Map<string, ActionRequest> | null = useMemo(() => {
|
||||||
|
const actionRequests =
|
||||||
|
interrupt?.value && (interrupt.value as any)["action_requests"];
|
||||||
|
if (!actionRequests) return new Map<string, ActionRequest>();
|
||||||
|
return new Map(actionRequests.map((ar: ActionRequest) => [ar.name, ar]));
|
||||||
|
}, [interrupt]);
|
||||||
|
|
||||||
|
const reviewConfigsMap: Map<string, ReviewConfig> | null = useMemo(() => {
|
||||||
|
const reviewConfigs =
|
||||||
|
interrupt?.value && (interrupt.value as any)["review_configs"];
|
||||||
|
if (!reviewConfigs) return new Map<string, ReviewConfig>();
|
||||||
|
return new Map(
|
||||||
|
reviewConfigs.map((rc: ReviewConfig) => [rc.actionName, rc])
|
||||||
|
);
|
||||||
|
}, [interrupt]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mx-auto w-full max-w-[1024px] px-6 pb-6 pt-4"
|
||||||
|
ref={contentRef}
|
||||||
|
>
|
||||||
|
{isThreadLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{processedMessages.map((data, index) => {
|
||||||
|
const messageUi = ui?.filter(
|
||||||
|
(u: any) => u.metadata?.message_id === data.message.id
|
||||||
|
);
|
||||||
|
const isLastMessage = index === processedMessages.length - 1;
|
||||||
|
return (
|
||||||
|
<ChatMessage
|
||||||
|
key={data.message.id}
|
||||||
|
message={data.message}
|
||||||
|
toolCalls={data.toolCalls}
|
||||||
|
isLoading={isLoading}
|
||||||
|
actionRequestsMap={
|
||||||
|
isLastMessage ? actionRequestsMap : undefined
|
||||||
|
}
|
||||||
|
reviewConfigsMap={
|
||||||
|
isLastMessage ? reviewConfigsMap : undefined
|
||||||
|
}
|
||||||
|
ui={messageUi}
|
||||||
|
stream={stream}
|
||||||
|
onResumeInterrupt={resumeInterrupt}
|
||||||
|
graphId={assistant?.graph_id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 bg-background">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mx-4 mb-6 flex flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-background",
|
||||||
|
"mx-auto w-[calc(100%-32px)] max-w-[1024px] transition-colors duration-200 ease-in-out"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(hasTasks || hasFiles) && (
|
||||||
|
<div className="flex max-h-72 flex-col overflow-y-auto border-b border-border bg-sidebar empty:hidden">
|
||||||
|
{!metaOpen && (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
const activeTask = todos.find(
|
||||||
|
(t) => t.status === "in_progress"
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalTasks = todos.length;
|
||||||
|
const remainingTasks =
|
||||||
|
totalTasks - groupedTodos.pending.length;
|
||||||
|
const isCompleted = totalTasks === remainingTasks;
|
||||||
|
|
||||||
|
const tasksTrigger = (() => {
|
||||||
|
if (!hasTasks) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setMetaOpen((prev) =>
|
||||||
|
prev === "tasks" ? null : "tasks"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="grid w-full cursor-pointer grid-cols-[auto_auto_1fr] items-center gap-3 px-[18px] py-3 text-left"
|
||||||
|
aria-expanded={metaOpen === "tasks"}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
if (isCompleted) {
|
||||||
|
return [
|
||||||
|
<CheckCircle
|
||||||
|
key="icon"
|
||||||
|
size={16}
|
||||||
|
className="text-success/80"
|
||||||
|
/>,
|
||||||
|
<span
|
||||||
|
key="label"
|
||||||
|
className="ml-[1px] min-w-0 truncate text-sm"
|
||||||
|
>
|
||||||
|
All tasks completed
|
||||||
|
</span>,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTask != null) {
|
||||||
|
return [
|
||||||
|
<div key="icon">
|
||||||
|
{getStatusIcon(activeTask.status)}
|
||||||
|
</div>,
|
||||||
|
<span
|
||||||
|
key="label"
|
||||||
|
className="ml-[1px] min-w-0 truncate text-sm"
|
||||||
|
>
|
||||||
|
Task{" "}
|
||||||
|
{totalTasks - groupedTodos.pending.length} of{" "}
|
||||||
|
{totalTasks}
|
||||||
|
</span>,
|
||||||
|
<span
|
||||||
|
key="content"
|
||||||
|
className="min-w-0 gap-2 truncate text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{activeTask.content}
|
||||||
|
</span>,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
<Circle
|
||||||
|
key="icon"
|
||||||
|
size={16}
|
||||||
|
className="text-tertiary/70"
|
||||||
|
/>,
|
||||||
|
<span
|
||||||
|
key="label"
|
||||||
|
className="ml-[1px] min-w-0 truncate text-sm"
|
||||||
|
>
|
||||||
|
Task {totalTasks - groupedTodos.pending.length}{" "}
|
||||||
|
of {totalTasks}
|
||||||
|
</span>,
|
||||||
|
];
|
||||||
|
})()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const filesTrigger = (() => {
|
||||||
|
if (!hasFiles) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setMetaOpen((prev) =>
|
||||||
|
prev === "files" ? null : "files"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex flex-shrink-0 cursor-pointer items-center gap-2 px-[18px] py-3 text-left text-sm"
|
||||||
|
aria-expanded={metaOpen === "files"}
|
||||||
|
>
|
||||||
|
<FileIcon size={16} />
|
||||||
|
Files (State)
|
||||||
|
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
|
||||||
|
{Object.keys(files).length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto] items-center">
|
||||||
|
{tasksTrigger}
|
||||||
|
{filesTrigger}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metaOpen && (
|
||||||
|
<>
|
||||||
|
<div className="sticky top-0 flex items-stretch bg-sidebar text-sm">
|
||||||
|
{hasTasks && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
|
||||||
|
onClick={() =>
|
||||||
|
setMetaOpen((prev) =>
|
||||||
|
prev === "tasks" ? null : "tasks"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-expanded={metaOpen === "tasks"}
|
||||||
|
>
|
||||||
|
Tasks
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasFiles && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
|
||||||
|
onClick={() =>
|
||||||
|
setMetaOpen((prev) =>
|
||||||
|
prev === "files" ? null : "files"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-expanded={metaOpen === "files"}
|
||||||
|
>
|
||||||
|
Files (State)
|
||||||
|
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
|
||||||
|
{Object.keys(files).length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setMetaOpen(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={tasksContainerRef}
|
||||||
|
className="px-[18px]"
|
||||||
|
>
|
||||||
|
{metaOpen === "tasks" &&
|
||||||
|
Object.entries(groupedTodos)
|
||||||
|
.filter(([_, todos]) => todos.length > 0)
|
||||||
|
.map(([status, todos]) => (
|
||||||
|
<div
|
||||||
|
key={status}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||||
|
{
|
||||||
|
{
|
||||||
|
pending: "Pending",
|
||||||
|
in_progress: "In Progress",
|
||||||
|
completed: "Completed",
|
||||||
|
}[status]
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-[auto_1fr] gap-3 rounded-sm p-1 pl-0 text-sm">
|
||||||
|
{todos.map((todo, index) => (
|
||||||
|
<Fragment key={`${status}_${todo.id}_${index}`}>
|
||||||
|
{getStatusIcon(todo.status, "mt-0.5")}
|
||||||
|
<span className="break-words text-inherit">
|
||||||
|
{todo.content}
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{metaOpen === "files" && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<FilesPopover
|
||||||
|
files={files}
|
||||||
|
setFiles={setFiles}
|
||||||
|
editDisabled={
|
||||||
|
isLoading === true || interrupt !== undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={isLoading ? "Running..." : "Write your message..."}
|
||||||
|
className="font-inherit field-sizing-content flex-1 resize-none border-0 bg-transparent px-[18px] pb-[13px] pt-[14px] text-sm leading-7 text-primary outline-none placeholder:text-tertiary"
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between gap-2 p-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type={isLoading ? "button" : "submit"}
|
||||||
|
variant={isLoading ? "destructive" : "default"}
|
||||||
|
onClick={isLoading ? stopStream : handleSubmit}
|
||||||
|
disabled={!isLoading && (submitDisabled || !input.trim())}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Square size={14} />
|
||||||
|
<span>Stop</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowUp size={18} />
|
||||||
|
<span>Send</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatInterface.displayName = "ChatInterface";
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState, useCallback } from "react";
|
||||||
|
import { SubAgentIndicator } from "@/app/components/SubAgentIndicator";
|
||||||
|
import { ToolCallBox } from "@/app/components/ToolCallBox";
|
||||||
|
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||||
|
import type {
|
||||||
|
SubAgent,
|
||||||
|
ToolCall,
|
||||||
|
ActionRequest,
|
||||||
|
ReviewConfig,
|
||||||
|
} from "@/app/types/types";
|
||||||
|
import { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import {
|
||||||
|
extractSubAgentContent,
|
||||||
|
extractStringFromMessageContent,
|
||||||
|
} from "@/app/utils/utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ChatMessageProps {
|
||||||
|
message: Message;
|
||||||
|
toolCalls: ToolCall[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
actionRequestsMap?: Map<string, ActionRequest>;
|
||||||
|
reviewConfigsMap?: Map<string, ReviewConfig>;
|
||||||
|
ui?: any[];
|
||||||
|
stream?: any;
|
||||||
|
onResumeInterrupt?: (value: any) => void;
|
||||||
|
graphId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatMessage = React.memo<ChatMessageProps>(
|
||||||
|
({
|
||||||
|
message,
|
||||||
|
toolCalls,
|
||||||
|
isLoading,
|
||||||
|
actionRequestsMap,
|
||||||
|
reviewConfigsMap,
|
||||||
|
ui,
|
||||||
|
stream,
|
||||||
|
onResumeInterrupt,
|
||||||
|
graphId,
|
||||||
|
}) => {
|
||||||
|
const isUser = message.type === "human";
|
||||||
|
const messageContent = extractStringFromMessageContent(message);
|
||||||
|
const hasContent = messageContent && messageContent.trim() !== "";
|
||||||
|
const hasToolCalls = toolCalls.length > 0;
|
||||||
|
const subAgents = useMemo(() => {
|
||||||
|
return toolCalls
|
||||||
|
.filter((toolCall: ToolCall) => {
|
||||||
|
return (
|
||||||
|
toolCall.name === "task" &&
|
||||||
|
toolCall.args["subagent_type"] &&
|
||||||
|
toolCall.args["subagent_type"] !== "" &&
|
||||||
|
toolCall.args["subagent_type"] !== null
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((toolCall: ToolCall) => {
|
||||||
|
const subagentType = (toolCall.args as Record<string, unknown>)[
|
||||||
|
"subagent_type"
|
||||||
|
] as string;
|
||||||
|
return {
|
||||||
|
id: toolCall.id,
|
||||||
|
name: toolCall.name,
|
||||||
|
subAgentName: subagentType,
|
||||||
|
input: toolCall.args,
|
||||||
|
output: toolCall.result ? { result: toolCall.result } : undefined,
|
||||||
|
status: toolCall.status,
|
||||||
|
} as SubAgent;
|
||||||
|
});
|
||||||
|
}, [toolCalls]);
|
||||||
|
|
||||||
|
const [expandedSubAgents, setExpandedSubAgents] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
const isSubAgentExpanded = useCallback(
|
||||||
|
(id: string) => expandedSubAgents[id] ?? true,
|
||||||
|
[expandedSubAgents]
|
||||||
|
);
|
||||||
|
const toggleSubAgent = useCallback((id: string) => {
|
||||||
|
setExpandedSubAgents((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: prev[id] === undefined ? false : !prev[id],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full max-w-full overflow-x-hidden",
|
||||||
|
isUser && "flex-row-reverse"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 max-w-full",
|
||||||
|
isUser ? "max-w-[70%]" : "w-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasContent && (
|
||||||
|
<div className={cn("relative flex items-end gap-0")}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-4 overflow-hidden break-words text-sm font-normal leading-[150%]",
|
||||||
|
isUser
|
||||||
|
? "rounded-xl rounded-br-none border border-border px-3 py-2 text-foreground"
|
||||||
|
: "text-primary"
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
isUser
|
||||||
|
? { backgroundColor: "var(--color-user-message-bg)" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isUser ? (
|
||||||
|
<p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed">
|
||||||
|
{messageContent}
|
||||||
|
</p>
|
||||||
|
) : hasContent ? (
|
||||||
|
<MarkdownContent content={messageContent} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasToolCalls && (
|
||||||
|
<div className="mt-4 flex w-full flex-col">
|
||||||
|
{toolCalls.map((toolCall: ToolCall) => {
|
||||||
|
if (toolCall.name === "task") return null;
|
||||||
|
const toolCallGenUiComponent = ui?.find(
|
||||||
|
(u) => u.metadata?.tool_call_id === toolCall.id
|
||||||
|
);
|
||||||
|
const actionRequest = actionRequestsMap?.get(toolCall.name);
|
||||||
|
const reviewConfig = reviewConfigsMap?.get(toolCall.name);
|
||||||
|
return (
|
||||||
|
<ToolCallBox
|
||||||
|
key={toolCall.id}
|
||||||
|
toolCall={toolCall}
|
||||||
|
uiComponent={toolCallGenUiComponent}
|
||||||
|
stream={stream}
|
||||||
|
graphId={graphId}
|
||||||
|
actionRequest={actionRequest}
|
||||||
|
reviewConfig={reviewConfig}
|
||||||
|
onResume={onResumeInterrupt}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isUser && subAgents.length > 0 && (
|
||||||
|
<div className="flex w-fit max-w-full flex-col gap-4">
|
||||||
|
{subAgents.map((subAgent) => (
|
||||||
|
<div
|
||||||
|
key={subAgent.id}
|
||||||
|
className="flex w-full flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="w-[calc(100%-100px)]">
|
||||||
|
<SubAgentIndicator
|
||||||
|
subAgent={subAgent}
|
||||||
|
onClick={() => toggleSubAgent(subAgent.id)}
|
||||||
|
isExpanded={isSubAgentExpanded(subAgent.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSubAgentExpanded(subAgent.id) && (
|
||||||
|
<div className="w-full max-w-full">
|
||||||
|
<div className="bg-surface border-border-light rounded-md border p-4">
|
||||||
|
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Input
|
||||||
|
</h4>
|
||||||
|
<div className="mb-4">
|
||||||
|
<MarkdownContent
|
||||||
|
content={extractSubAgentContent(subAgent.input)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{subAgent.output && (
|
||||||
|
<>
|
||||||
|
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Output
|
||||||
|
</h4>
|
||||||
|
<MarkdownContent
|
||||||
|
content={extractSubAgentContent(subAgent.output)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ChatMessage.displayName = "ChatMessage";
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { DEFAULT_ASSISTANT_ID, StandaloneConfig } from "@/lib/config";
|
||||||
|
|
||||||
|
interface ConfigDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSave: (config: StandaloneConfig) => void;
|
||||||
|
initialConfig?: StandaloneConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSave,
|
||||||
|
initialConfig,
|
||||||
|
}: ConfigDialogProps) {
|
||||||
|
const [deploymentUrl, setDeploymentUrl] = useState(
|
||||||
|
initialConfig?.deploymentUrl || "http://127.0.0.1:6174"
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && initialConfig) {
|
||||||
|
setDeploymentUrl(initialConfig.deploymentUrl);
|
||||||
|
}
|
||||||
|
}, [open, initialConfig]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!deploymentUrl) {
|
||||||
|
alert("Please enter the deployment URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave({
|
||||||
|
deploymentUrl,
|
||||||
|
// Fixed to the EvoScientist main agent (see DEFAULT_ASSISTANT_ID).
|
||||||
|
assistantId: DEFAULT_ASSISTANT_ID,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[525px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Configuration</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter the URL of your EvoScientist deployment (from{" "}
|
||||||
|
<code>EvoSci deploy</code>). This is saved in your browser's
|
||||||
|
local storage.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="deploymentUrl">Deployment URL</Label>
|
||||||
|
<Input
|
||||||
|
id="deploymentUrl"
|
||||||
|
placeholder="http://127.0.0.1:6174"
|
||||||
|
value={deploymentUrl}
|
||||||
|
onChange={(e) => setDeploymentUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>Save</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useCallback, useState, useEffect } from "react";
|
||||||
|
import { FileText, Copy, Download, Edit, Save, X, Loader2 } from "lucide-react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||||
|
import type { FileItem } from "@/app/types/types";
|
||||||
|
import useSWRMutation from "swr/mutation";
|
||||||
|
|
||||||
|
const LANGUAGE_MAP: Record<string, string> = {
|
||||||
|
js: "javascript",
|
||||||
|
jsx: "javascript",
|
||||||
|
ts: "typescript",
|
||||||
|
tsx: "typescript",
|
||||||
|
py: "python",
|
||||||
|
rb: "ruby",
|
||||||
|
go: "go",
|
||||||
|
rs: "rust",
|
||||||
|
java: "java",
|
||||||
|
cpp: "cpp",
|
||||||
|
c: "c",
|
||||||
|
cs: "csharp",
|
||||||
|
php: "php",
|
||||||
|
swift: "swift",
|
||||||
|
kt: "kotlin",
|
||||||
|
scala: "scala",
|
||||||
|
sh: "bash",
|
||||||
|
bash: "bash",
|
||||||
|
zsh: "bash",
|
||||||
|
json: "json",
|
||||||
|
xml: "xml",
|
||||||
|
html: "html",
|
||||||
|
css: "css",
|
||||||
|
scss: "scss",
|
||||||
|
sass: "sass",
|
||||||
|
less: "less",
|
||||||
|
sql: "sql",
|
||||||
|
yaml: "yaml",
|
||||||
|
yml: "yaml",
|
||||||
|
toml: "toml",
|
||||||
|
ini: "ini",
|
||||||
|
dockerfile: "dockerfile",
|
||||||
|
makefile: "makefile",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileViewDialog = React.memo<{
|
||||||
|
file: FileItem | null;
|
||||||
|
onSaveFile: (fileName: string, content: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
editDisabled: boolean;
|
||||||
|
}>(({ file, onSaveFile, onClose, editDisabled }) => {
|
||||||
|
const [isEditingMode, setIsEditingMode] = useState(file === null);
|
||||||
|
const [fileName, setFileName] = useState(String(file?.path || ""));
|
||||||
|
const [fileContent, setFileContent] = useState(String(file?.content || ""));
|
||||||
|
|
||||||
|
const fileUpdate = useSWRMutation(
|
||||||
|
{ kind: "files-update", fileName, fileContent },
|
||||||
|
async ({ fileName, fileContent }) => {
|
||||||
|
if (!fileName || !fileContent) return;
|
||||||
|
return await onSaveFile(fileName, fileContent);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => setIsEditingMode(false),
|
||||||
|
onError: (error) => toast.error(`Failed to save file: ${error}`),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFileName(String(file?.path || ""));
|
||||||
|
setFileContent(String(file?.content || ""));
|
||||||
|
setIsEditingMode(file === null);
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
const fileExtension = useMemo(() => {
|
||||||
|
const fileNameStr = String(fileName || "");
|
||||||
|
return fileNameStr.split(".").pop()?.toLowerCase() || "";
|
||||||
|
}, [fileName]);
|
||||||
|
|
||||||
|
const isMarkdown = useMemo(() => {
|
||||||
|
return fileExtension === "md" || fileExtension === "markdown";
|
||||||
|
}, [fileExtension]);
|
||||||
|
|
||||||
|
const language = useMemo(() => {
|
||||||
|
return LANGUAGE_MAP[fileExtension] || "text";
|
||||||
|
}, [fileExtension]);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
if (fileContent) {
|
||||||
|
navigator.clipboard.writeText(fileContent);
|
||||||
|
}
|
||||||
|
}, [fileContent]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (fileContent && fileName) {
|
||||||
|
const blob = new Blob([fileContent], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}, [fileContent, fileName]);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(() => {
|
||||||
|
setIsEditingMode(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
if (file === null) {
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setFileName(String(file.path));
|
||||||
|
setFileContent(String(file.content));
|
||||||
|
setIsEditingMode(false);
|
||||||
|
}
|
||||||
|
}, [file, onClose]);
|
||||||
|
|
||||||
|
const fileNameIsValid = useMemo(() => {
|
||||||
|
return (
|
||||||
|
fileName.trim() !== "" &&
|
||||||
|
!fileName.includes("/") &&
|
||||||
|
!fileName.includes(" ")
|
||||||
|
);
|
||||||
|
}, [fileName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={onClose}
|
||||||
|
>
|
||||||
|
<DialogContent className="flex h-[80vh] max-h-[80vh] min-w-[60vw] flex-col p-6">
|
||||||
|
<DialogTitle className="sr-only">
|
||||||
|
{file?.path || "New File"}
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="mb-4 flex items-center justify-between border-b border-border pb-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<FileText className="text-primary/50 h-5 w-5 shrink-0" />
|
||||||
|
{isEditingMode && file === null ? (
|
||||||
|
<Input
|
||||||
|
value={fileName}
|
||||||
|
onChange={(e) => setFileName(e.target.value)}
|
||||||
|
placeholder="Enter filename..."
|
||||||
|
className="text-base font-medium"
|
||||||
|
aria-invalid={!fileNameIsValid}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium text-primary">
|
||||||
|
{file?.path}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{!isEditingMode && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={handleEdit}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2"
|
||||||
|
disabled={editDisabled}
|
||||||
|
>
|
||||||
|
<Edit
|
||||||
|
size={16}
|
||||||
|
className="mr-1"
|
||||||
|
/>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCopy}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
<Copy
|
||||||
|
size={16}
|
||||||
|
className="mr-1"
|
||||||
|
/>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDownload}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
<Download
|
||||||
|
size={16}
|
||||||
|
className="mr-1"
|
||||||
|
/>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
{isEditingMode ? (
|
||||||
|
<Textarea
|
||||||
|
value={fileContent}
|
||||||
|
onChange={(e) => setFileContent(e.target.value)}
|
||||||
|
placeholder="Enter file content..."
|
||||||
|
className="h-full min-h-[400px] resize-none font-mono text-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="bg-surface h-full rounded-md">
|
||||||
|
<div className="p-4">
|
||||||
|
{fileContent ? (
|
||||||
|
isMarkdown ? (
|
||||||
|
<div className="rounded-md p-6">
|
||||||
|
<MarkdownContent content={fileContent} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language={language}
|
||||||
|
style={oneDark}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
showLineNumbers
|
||||||
|
wrapLines={true}
|
||||||
|
lineProps={{
|
||||||
|
style: {
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fileContent}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
File is empty
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isEditingMode && (
|
||||||
|
<div className="mt-4 flex justify-end gap-2 border-t border-border pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleCancel}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<X
|
||||||
|
size={16}
|
||||||
|
className="mr-1"
|
||||||
|
/>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => fileUpdate.trigger()}
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
fileUpdate.isMutating ||
|
||||||
|
!fileName.trim() ||
|
||||||
|
!fileContent.trim() ||
|
||||||
|
!fileNameIsValid
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{fileUpdate.isMutating ? (
|
||||||
|
<Loader2
|
||||||
|
size={16}
|
||||||
|
className="mr-1 animate-spin"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Save
|
||||||
|
size={16}
|
||||||
|
className="mr-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
FileViewDialog.displayName = "FileViewDialog";
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface MarkdownContentProps {
|
||||||
|
content: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkdownContent = React.memo<MarkdownContentProps>(
|
||||||
|
({ content, className = "" }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"prose min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed text-inherit [&_h1:first-child]:mt-0 [&_h1]:mb-4 [&_h1]:mt-6 [&_h1]:font-semibold [&_h2:first-child]:mt-0 [&_h2]:mb-4 [&_h2]:mt-6 [&_h2]:font-semibold [&_h3:first-child]:mt-0 [&_h3]:mb-4 [&_h3]:mt-6 [&_h3]:font-semibold [&_h4:first-child]:mt-0 [&_h4]:mb-4 [&_h4]:mt-6 [&_h4]:font-semibold [&_h5:first-child]:mt-0 [&_h5]:mb-4 [&_h5]:mt-6 [&_h5]:font-semibold [&_h6:first-child]:mt-0 [&_h6]:mb-4 [&_h6]:mt-6 [&_h6]:font-semibold [&_p:last-child]:mb-0 [&_p]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
code({
|
||||||
|
inline,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
inline?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const match = /language-(\w+)/.exec(className || "");
|
||||||
|
return !inline && match ? (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={oneDark}
|
||||||
|
language={match[1]}
|
||||||
|
PreTag="div"
|
||||||
|
className="max-w-full rounded-md text-sm"
|
||||||
|
wrapLines={true}
|
||||||
|
wrapLongLines={true}
|
||||||
|
lineProps={{
|
||||||
|
style: {
|
||||||
|
wordBreak: "break-all",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
overflowWrap: "break-word",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
maxWidth: "100%",
|
||||||
|
overflowX: "auto",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, "")}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
) : (
|
||||||
|
<code
|
||||||
|
className="bg-surface rounded-sm px-1 py-0.5 font-mono text-[0.9em]"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pre({ children }: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-full overflow-hidden last:mb-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
a({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary no-underline hover:underline"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
blockquote({ children }: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<blockquote className="text-primary/50 my-4 border-l-4 border-border pl-4 italic">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ul({ children }: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ul className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ol({ children }: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ol className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
table({ children }: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 overflow-x-auto">
|
||||||
|
<table className="[&_th]:bg-surface w-full border-collapse [&_td]:border [&_td]:border-border [&_td]:p-2 [&_th]:border [&_th]:border-border [&_th]:p-2 [&_th]:text-left [&_th]:font-semibold">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
MarkdownContent.displayName = "MarkdownContent";
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import type { SubAgent } from "@/app/types/types";
|
||||||
|
|
||||||
|
interface SubAgentIndicatorProps {
|
||||||
|
subAgent: SubAgent;
|
||||||
|
onClick: () => void;
|
||||||
|
isExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubAgentIndicator = React.memo<SubAgentIndicatorProps>(
|
||||||
|
({ subAgent, onClick, isExpanded = true }) => {
|
||||||
|
return (
|
||||||
|
<div className="w-fit max-w-[70vw] overflow-hidden rounded-lg border-none bg-card shadow-none outline-none">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex w-full items-center justify-between gap-2 border-none px-4 py-2 text-left shadow-none outline-none transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-sans text-[15px] font-bold leading-[140%] tracking-[-0.6px] text-[#3F3F46]">
|
||||||
|
{subAgent.subAgentName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp
|
||||||
|
size={14}
|
||||||
|
className="shrink-0 text-[#70707B]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className="shrink-0 text-[#70707B]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
SubAgentIndicator.displayName = "SubAgentIndicator";
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
CheckCircle,
|
||||||
|
Circle,
|
||||||
|
Clock,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import type { TodoItem, FileItem } from "@/app/types/types";
|
||||||
|
import { useChatContext } from "@/providers/ChatProvider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { FileViewDialog } from "@/app/components/FileViewDialog";
|
||||||
|
|
||||||
|
export function FilesPopover({
|
||||||
|
files,
|
||||||
|
setFiles,
|
||||||
|
editDisabled,
|
||||||
|
}: {
|
||||||
|
files: Record<string, string>;
|
||||||
|
setFiles: (files: Record<string, string>) => Promise<void>;
|
||||||
|
editDisabled: boolean;
|
||||||
|
}) {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
|
||||||
|
|
||||||
|
const handleSaveFile = useCallback(
|
||||||
|
async (fileName: string, content: string) => {
|
||||||
|
await setFiles({ ...files, [fileName]: content });
|
||||||
|
setSelectedFile({ path: fileName, content: content });
|
||||||
|
},
|
||||||
|
[files, setFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Object.keys(files).length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">No files created yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(256px,1fr))] gap-2">
|
||||||
|
{Object.keys(files).map((file) => {
|
||||||
|
const filePath = String(file);
|
||||||
|
const rawContent = files[file];
|
||||||
|
let fileContent: string;
|
||||||
|
if (
|
||||||
|
typeof rawContent === "object" &&
|
||||||
|
rawContent !== null &&
|
||||||
|
"content" in rawContent
|
||||||
|
) {
|
||||||
|
const contentArray = (rawContent as { content: unknown }).content;
|
||||||
|
if (Array.isArray(contentArray)) {
|
||||||
|
fileContent = contentArray.join("\n");
|
||||||
|
} else {
|
||||||
|
fileContent = String(contentArray || "");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fileContent = String(rawContent || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filePath}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedFile({ path: filePath, content: fileContent })
|
||||||
|
}
|
||||||
|
className="cursor-pointer space-y-1 truncate rounded-md border border-border px-2 py-3 shadow-sm transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--color-file-button)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--color-file-button-hover)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--color-file-button)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText
|
||||||
|
size={24}
|
||||||
|
className="mx-auto text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<span className="mx-auto block w-full truncate break-words text-center text-sm leading-relaxed text-foreground">
|
||||||
|
{filePath}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedFile && (
|
||||||
|
<FileViewDialog
|
||||||
|
file={selectedFile}
|
||||||
|
onSaveFile={handleSaveFile}
|
||||||
|
onClose={() => setSelectedFile(null)}
|
||||||
|
editDisabled={editDisabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TasksFilesSidebar = React.memo<{
|
||||||
|
todos: TodoItem[];
|
||||||
|
files: Record<string, string>;
|
||||||
|
setFiles: (files: Record<string, string>) => Promise<void>;
|
||||||
|
}>(({ todos, files, setFiles }) => {
|
||||||
|
const { isLoading, interrupt } = useChatContext();
|
||||||
|
const [tasksOpen, setTasksOpen] = useState(false);
|
||||||
|
const [filesOpen, setFilesOpen] = useState(false);
|
||||||
|
|
||||||
|
// Track previous counts to detect when content goes from empty to having items
|
||||||
|
const prevTodosCount = useRef(todos.length);
|
||||||
|
const prevFilesCount = useRef(Object.keys(files).length);
|
||||||
|
|
||||||
|
// Auto-expand when todos go from empty to having content
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevTodosCount.current === 0 && todos.length > 0) {
|
||||||
|
setTasksOpen(true);
|
||||||
|
}
|
||||||
|
prevTodosCount.current = todos.length;
|
||||||
|
}, [todos.length]);
|
||||||
|
|
||||||
|
// Auto-expand when files go from empty to having content
|
||||||
|
const filesCount = Object.keys(files).length;
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevFilesCount.current === 0 && filesCount > 0) {
|
||||||
|
setFilesOpen(true);
|
||||||
|
}
|
||||||
|
prevFilesCount.current = filesCount;
|
||||||
|
}, [filesCount]);
|
||||||
|
|
||||||
|
const getStatusIcon = useCallback((status: TodoItem["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return (
|
||||||
|
<CheckCircle
|
||||||
|
size={12}
|
||||||
|
className="text-success/80"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "in_progress":
|
||||||
|
return (
|
||||||
|
<Clock
|
||||||
|
size={12}
|
||||||
|
className="text-warning/80"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Circle
|
||||||
|
size={10}
|
||||||
|
className="text-tertiary/70"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const groupedTodos = useMemo(() => {
|
||||||
|
return {
|
||||||
|
pending: todos.filter((t) => t.status === "pending"),
|
||||||
|
in_progress: todos.filter((t) => t.status === "in_progress"),
|
||||||
|
completed: todos.filter((t) => t.status === "completed"),
|
||||||
|
};
|
||||||
|
}, [todos]);
|
||||||
|
|
||||||
|
const groupedLabels = {
|
||||||
|
pending: "Pending",
|
||||||
|
in_progress: "In Progress",
|
||||||
|
completed: "Completed",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 w-full flex-1">
|
||||||
|
<div className="font-inter flex h-full w-full flex-col p-0">
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
|
||||||
|
<span className="text-xs font-semibold tracking-wide text-zinc-600">
|
||||||
|
AGENT TASKS
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setTasksOpen((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
|
||||||
|
tasksOpen ? "rotate-180" : "rotate-0"
|
||||||
|
)}
|
||||||
|
aria-label="Toggle tasks panel"
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tasksOpen && (
|
||||||
|
<div className="bg-muted-secondary rounded-xl px-3 pb-2">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
{todos.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No tasks created yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ml-1 p-0.5">
|
||||||
|
{Object.entries(groupedTodos).map(([status, todos]) => (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||||
|
{groupedLabels[status as keyof typeof groupedLabels]}
|
||||||
|
</h3>
|
||||||
|
{todos.map((todo, index) => (
|
||||||
|
<div
|
||||||
|
key={`${status}_${todo.id}_${index}`}
|
||||||
|
className="mb-1.5 flex items-start gap-2 rounded-sm p-1 text-sm"
|
||||||
|
>
|
||||||
|
{getStatusIcon(todo.status)}
|
||||||
|
<span className="flex-1 break-words leading-relaxed text-inherit">
|
||||||
|
{todo.content}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
|
||||||
|
<span className="text-xs font-semibold tracking-wide text-zinc-600">
|
||||||
|
FILE SYSTEM
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilesOpen((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
|
||||||
|
filesOpen ? "rotate-180" : "rotate-0"
|
||||||
|
)}
|
||||||
|
aria-label="Toggle files panel"
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{filesOpen && (
|
||||||
|
<FilesPopover
|
||||||
|
files={files}
|
||||||
|
setFiles={setFiles}
|
||||||
|
editDisabled={isLoading === true || interrupt !== undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TasksFilesSidebar.displayName = "TasksFilesSidebar";
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Loader2, MessageSquare, X } from "lucide-react";
|
||||||
|
import { useQueryState } from "nuqs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ThreadItem } from "@/app/hooks/useThreads";
|
||||||
|
import { useThreads } from "@/app/hooks/useThreads";
|
||||||
|
|
||||||
|
type StatusFilter = "all" | "idle" | "busy" | "interrupted" | "error";
|
||||||
|
|
||||||
|
const GROUP_LABELS = {
|
||||||
|
interrupted: "Requiring Attention",
|
||||||
|
today: "Today",
|
||||||
|
yesterday: "Yesterday",
|
||||||
|
week: "This Week",
|
||||||
|
older: "Older",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<ThreadItem["status"], string> = {
|
||||||
|
idle: "bg-green-500",
|
||||||
|
busy: "bg-blue-500",
|
||||||
|
interrupted: "bg-orange-500",
|
||||||
|
error: "bg-red-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getThreadColor(status: ThreadItem["status"]): string {
|
||||||
|
return STATUS_COLORS[status] ?? "bg-gray-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date, now = new Date()): string {
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days === 0) return format(date, "HH:mm");
|
||||||
|
if (days === 1) return "Yesterday";
|
||||||
|
if (days < 7) return format(date, "EEEE");
|
||||||
|
return format(date, "MM/dd");
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusFilterItem({
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
badge,
|
||||||
|
}: {
|
||||||
|
status: ThreadItem["status"];
|
||||||
|
label: string;
|
||||||
|
badge?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block size-2 rounded-full",
|
||||||
|
getThreadColor(status)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
{badge !== undefined && badge > 0 && (
|
||||||
|
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-red-600 px-1.5 py-0.5 text-xs font-bold leading-none text-white">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<p className="text-sm text-red-600">Failed to load threads</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className="h-16 w-full"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<MessageSquare className="mb-2 h-12 w-12 text-gray-300" />
|
||||||
|
<p className="text-sm text-muted-foreground">No threads found</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadListProps {
|
||||||
|
onThreadSelect: (id: string) => void;
|
||||||
|
onMutateReady?: (mutate: () => void) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
onInterruptCountChange?: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreadList({
|
||||||
|
onThreadSelect,
|
||||||
|
onMutateReady,
|
||||||
|
onClose,
|
||||||
|
onInterruptCountChange,
|
||||||
|
}: ThreadListProps) {
|
||||||
|
const [currentThreadId] = useQueryState("threadId");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||||
|
|
||||||
|
const threads = useThreads({
|
||||||
|
status: statusFilter === "all" ? undefined : statusFilter,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flattened = useMemo(() => {
|
||||||
|
return threads.data?.flat() ?? [];
|
||||||
|
}, [threads.data]);
|
||||||
|
|
||||||
|
const isLoadingMore =
|
||||||
|
threads.size > 0 && threads.data?.[threads.size - 1] == null;
|
||||||
|
const isEmpty = threads.data?.at(0)?.length === 0;
|
||||||
|
const isReachingEnd = isEmpty || (threads.data?.at(-1)?.length ?? 0) < 20;
|
||||||
|
|
||||||
|
// Group threads by time and status
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const groups: Record<keyof typeof GROUP_LABELS, ThreadItem[]> = {
|
||||||
|
interrupted: [],
|
||||||
|
today: [],
|
||||||
|
yesterday: [],
|
||||||
|
week: [],
|
||||||
|
older: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
flattened.forEach((thread) => {
|
||||||
|
if (thread.status === "interrupted") {
|
||||||
|
groups.interrupted.push(thread);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = now.getTime() - thread.updatedAt.getTime();
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days === 0) {
|
||||||
|
groups.today.push(thread);
|
||||||
|
} else if (days === 1) {
|
||||||
|
groups.yesterday.push(thread);
|
||||||
|
} else if (days < 7) {
|
||||||
|
groups.week.push(thread);
|
||||||
|
} else {
|
||||||
|
groups.older.push(thread);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [flattened]);
|
||||||
|
|
||||||
|
const interruptedCount = useMemo(() => {
|
||||||
|
return flattened.filter((t) => t.status === "interrupted").length;
|
||||||
|
}, [flattened]);
|
||||||
|
|
||||||
|
// Expose thread list revalidation to parent component
|
||||||
|
// Use refs to create a stable callback that always calls the latest mutate function
|
||||||
|
const onMutateReadyRef = useRef(onMutateReady);
|
||||||
|
const mutateRef = useRef(threads.mutate);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onMutateReadyRef.current = onMutateReady;
|
||||||
|
}, [onMutateReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mutateRef.current = threads.mutate;
|
||||||
|
}, [threads.mutate]);
|
||||||
|
|
||||||
|
const mutateFn = useCallback(() => {
|
||||||
|
mutateRef.current();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onMutateReadyRef.current?.(mutateFn);
|
||||||
|
// Only run once on mount to avoid infinite loops
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Notify parent of interrupt count changes
|
||||||
|
useEffect(() => {
|
||||||
|
onInterruptCountChange?.(interruptedCount);
|
||||||
|
}, [interruptedCount, onInterruptCountChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-col">
|
||||||
|
{/* Header with title, filter, and close button */}
|
||||||
|
<div className="grid flex-shrink-0 grid-cols-[1fr_auto] items-center gap-3 border-b border-border p-4">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">Threads</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-fit">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="end">
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
<SelectSeparator />
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Active</SelectLabel>
|
||||||
|
<SelectItem value="idle">
|
||||||
|
<StatusFilterItem
|
||||||
|
status="idle"
|
||||||
|
label="Idle"
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="busy">
|
||||||
|
<StatusFilterItem
|
||||||
|
status="busy"
|
||||||
|
label="Busy"
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
<SelectSeparator />
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Attention</SelectLabel>
|
||||||
|
<SelectItem value="interrupted">
|
||||||
|
<StatusFilterItem
|
||||||
|
status="interrupted"
|
||||||
|
label="Interrupted"
|
||||||
|
badge={interruptedCount}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="error">
|
||||||
|
<StatusFilterItem
|
||||||
|
status="error"
|
||||||
|
label="Error"
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{onClose && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label="Close threads sidebar"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-0 flex-1">
|
||||||
|
{threads.error && <ErrorState message={threads.error.message} />}
|
||||||
|
|
||||||
|
{!threads.error && !threads.data && threads.isLoading && (
|
||||||
|
<LoadingState />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!threads.error && !threads.isLoading && isEmpty && <EmptyState />}
|
||||||
|
|
||||||
|
{!threads.error && !isEmpty && (
|
||||||
|
<div className="box-border w-full max-w-full overflow-hidden p-2">
|
||||||
|
{(
|
||||||
|
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
|
||||||
|
).map((group) => {
|
||||||
|
const groupThreads = grouped[group];
|
||||||
|
if (groupThreads.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<h4 className="m-0 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{GROUP_LABELS[group]}
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{groupThreads.map((thread) => (
|
||||||
|
<button
|
||||||
|
key={thread.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onThreadSelect(thread.id)}
|
||||||
|
className={cn(
|
||||||
|
"grid w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors duration-200",
|
||||||
|
"hover:bg-accent",
|
||||||
|
currentThreadId === thread.id
|
||||||
|
? "border border-primary bg-accent hover:bg-accent"
|
||||||
|
: "border border-transparent bg-transparent"
|
||||||
|
)}
|
||||||
|
aria-current={currentThreadId === thread.id}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Title + Timestamp Row */}
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<h3 className="truncate text-sm font-semibold">
|
||||||
|
{thread.title}
|
||||||
|
</h3>
|
||||||
|
<span className="ml-2 flex-shrink-0 text-xs text-muted-foreground">
|
||||||
|
{formatTime(thread.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Description + Status Row */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="flex-1 truncate text-sm text-muted-foreground">
|
||||||
|
{thread.description}
|
||||||
|
</p>
|
||||||
|
<div className="ml-2 flex-shrink-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 rounded-full",
|
||||||
|
getThreadColor(thread.status)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!isReachingEnd && (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => threads.setSize(threads.size + 1)}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
>
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Load More"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { AlertCircle, Check, X, Pencil } from "lucide-react";
|
||||||
|
import type { ActionRequest, ReviewConfig } from "@/app/types/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ToolApprovalInterruptProps {
|
||||||
|
actionRequest: ActionRequest;
|
||||||
|
reviewConfig?: ReviewConfig;
|
||||||
|
onResume: (value: any) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolApprovalInterrupt({
|
||||||
|
actionRequest,
|
||||||
|
reviewConfig,
|
||||||
|
onResume,
|
||||||
|
isLoading,
|
||||||
|
}: ToolApprovalInterruptProps) {
|
||||||
|
const [rejectionMessage, setRejectionMessage] = useState("");
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>({});
|
||||||
|
const [showRejectionInput, setShowRejectionInput] = useState(false);
|
||||||
|
|
||||||
|
const allowedDecisions = reviewConfig?.allowedDecisions ?? [
|
||||||
|
"approve",
|
||||||
|
"reject",
|
||||||
|
"edit",
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleApprove = () => {
|
||||||
|
onResume({
|
||||||
|
decisions: [{ type: "approve" }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = () => {
|
||||||
|
if (showRejectionInput) {
|
||||||
|
onResume({
|
||||||
|
decisions: [
|
||||||
|
{
|
||||||
|
type: "reject",
|
||||||
|
message: rejectionMessage.trim(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setShowRejectionInput(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectConfirm = () => {
|
||||||
|
onResume({
|
||||||
|
decisions: [
|
||||||
|
{
|
||||||
|
type: "reject",
|
||||||
|
message: rejectionMessage.trim(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (isEditing) {
|
||||||
|
onResume({
|
||||||
|
decisions: [
|
||||||
|
{
|
||||||
|
type: "edit",
|
||||||
|
edited_action: {
|
||||||
|
name: actionRequest.name,
|
||||||
|
args: editedArgs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditedArgs({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
setIsEditing(true);
|
||||||
|
setEditedArgs(JSON.parse(JSON.stringify(actionRequest.args)));
|
||||||
|
setShowRejectionInput(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditedArgs({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEditedArg = (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
const parsedValue =
|
||||||
|
value.trim().startsWith("{") || value.trim().startsWith("[")
|
||||||
|
? JSON.parse(value)
|
||||||
|
: value;
|
||||||
|
setEditedArgs((prev) => ({ ...prev, [key]: parsedValue }));
|
||||||
|
} catch {
|
||||||
|
setEditedArgs((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-md border border-border bg-muted/30 p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-foreground">
|
||||||
|
<AlertCircle
|
||||||
|
size={16}
|
||||||
|
className="text-yellow-600 dark:text-yellow-400"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Approval Required
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{actionRequest.description && (
|
||||||
|
<p className="mb-3 text-sm text-muted-foreground">
|
||||||
|
{actionRequest.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tool Info Card */}
|
||||||
|
<div className="mb-4 rounded-sm border border-border bg-background p-3">
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Tool
|
||||||
|
</span>
|
||||||
|
<p className="mt-1 font-mono text-sm font-medium text-foreground">
|
||||||
|
{actionRequest.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Edit Arguments
|
||||||
|
</span>
|
||||||
|
<div className="mt-2 space-y-3">
|
||||||
|
{Object.entries(actionRequest.args).map(([key, value]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-foreground">
|
||||||
|
{key}
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={
|
||||||
|
editedArgs[key] !== undefined
|
||||||
|
? typeof editedArgs[key] === "string"
|
||||||
|
? (editedArgs[key] as string)
|
||||||
|
: JSON.stringify(editedArgs[key], null, 2)
|
||||||
|
: typeof value === "string"
|
||||||
|
? value
|
||||||
|
: JSON.stringify(value, null, 2)
|
||||||
|
}
|
||||||
|
onChange={(e) => updateEditedArg(key, e.target.value)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
rows={
|
||||||
|
typeof value === "string" && value.length < 100 ? 2 : 4
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Arguments
|
||||||
|
</span>
|
||||||
|
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs text-foreground">
|
||||||
|
{JSON.stringify(actionRequest.args, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rejection Message Input */}
|
||||||
|
{showRejectionInput && !isEditing && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="mb-2 block text-xs font-medium text-foreground">
|
||||||
|
Rejection Message (optional)
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={rejectionMessage}
|
||||||
|
onChange={(e) => setRejectionMessage(e.target.value)}
|
||||||
|
placeholder="Explain why you're rejecting this action..."
|
||||||
|
className="text-sm"
|
||||||
|
rows={2}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={cancelEditing}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEdit}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
{isLoading ? "Saving..." : "Save & Approve"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : showRejectionInput ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowRejectionInput(false);
|
||||||
|
setRejectionMessage("");
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRejectConfirm}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Rejecting..." : "Confirm Reject"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{allowedDecisions.includes("reject") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{allowedDecisions.includes("edit") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={startEditing}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{allowedDecisions.includes("approve") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={cn(
|
||||||
|
"bg-green-600 text-white hover:bg-green-700",
|
||||||
|
"dark:bg-green-600 dark:hover:bg-green-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
{isLoading ? "Approving..." : "Approve"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Terminal,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
CircleCheckBigIcon,
|
||||||
|
StopCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ToolCall, ActionRequest, ReviewConfig } from "@/app/types/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
||||||
|
import { ToolApprovalInterrupt } from "@/app/components/ToolApprovalInterrupt";
|
||||||
|
|
||||||
|
interface ToolCallBoxProps {
|
||||||
|
toolCall: ToolCall;
|
||||||
|
uiComponent?: any;
|
||||||
|
stream?: any;
|
||||||
|
graphId?: string;
|
||||||
|
actionRequest?: ActionRequest;
|
||||||
|
reviewConfig?: ReviewConfig;
|
||||||
|
onResume?: (value: any) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolCallBox = React.memo<ToolCallBoxProps>(
|
||||||
|
({
|
||||||
|
toolCall,
|
||||||
|
uiComponent,
|
||||||
|
stream,
|
||||||
|
graphId,
|
||||||
|
actionRequest,
|
||||||
|
reviewConfig,
|
||||||
|
onResume,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(
|
||||||
|
() => !!uiComponent || !!actionRequest
|
||||||
|
);
|
||||||
|
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { name, args, result, status } = useMemo(() => {
|
||||||
|
return {
|
||||||
|
name: toolCall.name || "Unknown Tool",
|
||||||
|
args: toolCall.args || {},
|
||||||
|
result: toolCall.result,
|
||||||
|
status: toolCall.status || "completed",
|
||||||
|
};
|
||||||
|
}, [toolCall]);
|
||||||
|
|
||||||
|
const statusIcon = useMemo(() => {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return <CircleCheckBigIcon />;
|
||||||
|
case "error":
|
||||||
|
return (
|
||||||
|
<AlertCircle
|
||||||
|
size={14}
|
||||||
|
className="text-destructive"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "pending":
|
||||||
|
return (
|
||||||
|
<Loader2
|
||||||
|
size={14}
|
||||||
|
className="animate-spin"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "interrupted":
|
||||||
|
return (
|
||||||
|
<StopCircle
|
||||||
|
size={14}
|
||||||
|
className="text-orange-500"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Terminal
|
||||||
|
size={14}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const toggleExpanded = useCallback(() => {
|
||||||
|
setIsExpanded((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleArgExpanded = useCallback((argKey: string) => {
|
||||||
|
setExpandedArgs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[argKey]: !prev[argKey],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasContent = result || Object.keys(args).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full overflow-hidden rounded-lg border-none shadow-none outline-none transition-colors duration-200 hover:bg-accent",
|
||||||
|
isExpanded && hasContent && "bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-2 border-none px-2 py-2 text-left shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-default"
|
||||||
|
)}
|
||||||
|
disabled={!hasContent}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{statusIcon}
|
||||||
|
<span className="text-[15px] font-medium tracking-[-0.6px] text-foreground">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hasContent &&
|
||||||
|
(isExpanded ? (
|
||||||
|
<ChevronUp
|
||||||
|
size={14}
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isExpanded && hasContent && (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
{uiComponent && stream && graphId ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<LoadExternalComponent
|
||||||
|
key={uiComponent.id}
|
||||||
|
stream={stream}
|
||||||
|
message={uiComponent}
|
||||||
|
namespace={graphId}
|
||||||
|
meta={{ status, args, result: result ?? "No Result Yet" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : actionRequest && onResume ? (
|
||||||
|
// Show tool approval UI when there's an action request but no GenUI
|
||||||
|
<div className="mt-4">
|
||||||
|
<ToolApprovalInterrupt
|
||||||
|
actionRequest={actionRequest}
|
||||||
|
reviewConfig={reviewConfig}
|
||||||
|
onResume={onResume}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{Object.keys(args).length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Arguments
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(args).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="rounded-sm border border-border"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleArgExpanded(key)}
|
||||||
|
className="flex w-full items-center justify-between bg-muted/30 p-2 text-left text-xs font-medium transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span className="font-mono">{key}</span>
|
||||||
|
{expandedArgs[key] ? (
|
||||||
|
<ChevronUp
|
||||||
|
size={12}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ChevronDown
|
||||||
|
size={12}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expandedArgs[key] && (
|
||||||
|
<div className="border-t border-border bg-muted/20 p-2">
|
||||||
|
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs leading-6 text-foreground">
|
||||||
|
{typeof value === "string"
|
||||||
|
? value
|
||||||
|
: JSON.stringify(value, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Result
|
||||||
|
</h4>
|
||||||
|
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs leading-7 text-foreground">
|
||||||
|
{typeof result === "string"
|
||||||
|
? result
|
||||||
|
: JSON.stringify(result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ToolCallBox.displayName = "ToolCallBox";
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
/* Remove default focus box-shadows */
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Set default outline color to match brand instead of browser blue */
|
||||||
|
* {
|
||||||
|
outline-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* App-specific color variables */
|
||||||
|
--color-primary: #1c3c3c;
|
||||||
|
--color-user-message: #076699;
|
||||||
|
--color-user-message-bg: #e8f4f8;
|
||||||
|
--color-avatar-bg: #e8ebeb;
|
||||||
|
--color-secondary: #1c3c3c;
|
||||||
|
--color-success: #10b981;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-error: #ef4444;
|
||||||
|
--color-background: #f9f9f9;
|
||||||
|
--color-subagent-hover: #bbc4c4;
|
||||||
|
--color-surface: #f9fafb;
|
||||||
|
--color-border: #e5e7eb;
|
||||||
|
--color-border-light: #f3f4f6;
|
||||||
|
--color-text-primary: #111827;
|
||||||
|
--color-text-secondary: #6b7280;
|
||||||
|
--color-text-tertiary: #9ca3af;
|
||||||
|
--color-file-button: #ffffff;
|
||||||
|
--color-file-button-hover: #e5e7eb;
|
||||||
|
|
||||||
|
/* Dark theme colors */
|
||||||
|
--color-primary-dark: #2dd4bf;
|
||||||
|
--color-user-message-dark: #076699;
|
||||||
|
--color-avatar-bg-dark: #bcb2fd;
|
||||||
|
--color-secondary-dark: #2dd4bf;
|
||||||
|
--color-success-dark: #34d399;
|
||||||
|
--color-warning-dark: #fbbf24;
|
||||||
|
--color-error-dark: #f87171;
|
||||||
|
--color-background-dark: #0f0f0f;
|
||||||
|
--color-subagent-hover-dark: #d0c9fe;
|
||||||
|
--color-surface-dark: #1a1a1a;
|
||||||
|
--color-border-dark: #2d2d2d;
|
||||||
|
--color-border-light-dark: #232323;
|
||||||
|
--color-text-primary-dark: #f3f4f6;
|
||||||
|
--color-text-secondary-dark: #9ca3af;
|
||||||
|
--color-text-tertiary-dark: #6b7280;
|
||||||
|
|
||||||
|
/* Spacing variables */
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
--spacing-2xl: 3rem;
|
||||||
|
|
||||||
|
/* Font family variables */
|
||||||
|
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-family-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono",
|
||||||
|
Consolas, "Courier New", monospace;
|
||||||
|
|
||||||
|
/* Font size variables */
|
||||||
|
--font-size-xs: 0.75rem;
|
||||||
|
--font-size-sm: 0.875rem;
|
||||||
|
--font-size-base: 1rem;
|
||||||
|
--font-size-lg: 1.125rem;
|
||||||
|
--font-size-xl: 1.25rem;
|
||||||
|
--font-size-2xl: 1.5rem;
|
||||||
|
--font-size-3xl: 1.875rem;
|
||||||
|
|
||||||
|
/* Font weight variables */
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-semibold: 600;
|
||||||
|
|
||||||
|
/* Line height variables */
|
||||||
|
--line-height-tight: 1.25;
|
||||||
|
--line-height-normal: 1.5;
|
||||||
|
--line-height-relaxed: 1.75;
|
||||||
|
|
||||||
|
/* Border radius variables */
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Shadow variables */
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
/* Transition variables */
|
||||||
|
--transition-base: 200ms ease;
|
||||||
|
|
||||||
|
/* Layout variables */
|
||||||
|
--sidebar-width: 320px;
|
||||||
|
--sidebar-collapsed-width: 60px;
|
||||||
|
--header-height: 64px;
|
||||||
|
--panel-width: 40vw;
|
||||||
|
--chat-max-width: 900px;
|
||||||
|
|
||||||
|
/* Tailwind/Radix UI component variables */
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--background: 0 0% 98%;
|
||||||
|
--foreground: 220 13% 13%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 220 13% 13%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 220 13% 13%;
|
||||||
|
--primary: 180 35% 17%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 220 13% 91%;
|
||||||
|
--secondary-foreground: 220 13% 13%;
|
||||||
|
--muted: 220 13% 95%;
|
||||||
|
--muted-foreground: 220 9% 46%;
|
||||||
|
--accent: 220 13% 95%;
|
||||||
|
--accent-foreground: 220 13% 13%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 220 13% 91%;
|
||||||
|
--input: 220 13% 91%;
|
||||||
|
--ring: 180 35% 17%;
|
||||||
|
--sidebar: 220 13% 95%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
/* App-specific color variables */
|
||||||
|
--color-primary: #1c3c3c;
|
||||||
|
--color-user-message: #065a8a;
|
||||||
|
--color-user-message-bg: #2d2d2d;
|
||||||
|
--color-avatar-bg: #1c3c3c;
|
||||||
|
--color-secondary: #bbc4c4;
|
||||||
|
--color-success: #34d399;
|
||||||
|
--color-warning: #fbbf24;
|
||||||
|
--color-error: #f87171;
|
||||||
|
--color-background: #202020;
|
||||||
|
--color-subagent-hover: #1e3f3f;
|
||||||
|
--color-surface: #2a2a2a;
|
||||||
|
--color-border: #404040;
|
||||||
|
--color-border-light: #353535;
|
||||||
|
--color-text-primary: #f3f4f6;
|
||||||
|
--color-text-secondary: #9ca3af;
|
||||||
|
--color-text-tertiary: #6b7280;
|
||||||
|
--color-file-button: #2a2a2a;
|
||||||
|
--color-file-button-hover: #353535;
|
||||||
|
|
||||||
|
/* Tailwind/Radix UI component variables for dark mode */
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--background: 0 0% 13%;
|
||||||
|
--foreground: 220 13% 95%;
|
||||||
|
--card: 0 0% 18%;
|
||||||
|
--card-foreground: 220 13% 95%;
|
||||||
|
--popover: 0 0% 18%;
|
||||||
|
--popover-foreground: 220 13% 95%;
|
||||||
|
--primary: 174 72% 56%;
|
||||||
|
--primary-foreground: 0 0% 13%;
|
||||||
|
--secondary: 0 0% 25%;
|
||||||
|
--secondary-foreground: 220 13% 95%;
|
||||||
|
--muted: 0 0% 22%;
|
||||||
|
--muted-foreground: 220 9% 70%;
|
||||||
|
--accent: 0 0% 22%;
|
||||||
|
--accent-foreground: 220 13% 95%;
|
||||||
|
--destructive: 0 63% 71%;
|
||||||
|
--destructive-foreground: 0 0% 13%;
|
||||||
|
--border: 0 0% 28%;
|
||||||
|
--input: 0 0% 28%;
|
||||||
|
--ring: 174 72% 56%;
|
||||||
|
--sidebar: 0 0% 18%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
h6 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
||||||
|
"Courier New", monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0.125em 0.25em;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
||||||
|
"Courier New", monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimization Window animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styles */
|
||||||
|
.scrollbar-pretty {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-pretty::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-pretty::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-pretty::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #d1d5db;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-pretty::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global diff highlighting styles */
|
||||||
|
.word-added {
|
||||||
|
background-color: rgba(46, 160, 67, 0.4);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-removed {
|
||||||
|
background-color: rgba(248, 81, 73, 0.4);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: #f85149;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||||
|
import {
|
||||||
|
type Message,
|
||||||
|
type Assistant,
|
||||||
|
type Checkpoint,
|
||||||
|
} from "@langchain/langgraph-sdk";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
|
||||||
|
import type { TodoItem } from "@/app/types/types";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
import { useQueryState } from "nuqs";
|
||||||
|
|
||||||
|
export type StateType = {
|
||||||
|
messages: Message[];
|
||||||
|
todos: TodoItem[];
|
||||||
|
files: Record<string, string>;
|
||||||
|
email?: {
|
||||||
|
id?: string;
|
||||||
|
subject?: string;
|
||||||
|
page_content?: string;
|
||||||
|
};
|
||||||
|
ui?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useChat({
|
||||||
|
activeAssistant,
|
||||||
|
onHistoryRevalidate,
|
||||||
|
thread,
|
||||||
|
}: {
|
||||||
|
activeAssistant: Assistant | null;
|
||||||
|
onHistoryRevalidate?: () => void;
|
||||||
|
thread?: UseStreamThread<StateType>;
|
||||||
|
}) {
|
||||||
|
const [threadId, setThreadId] = useQueryState("threadId");
|
||||||
|
const client = useClient();
|
||||||
|
|
||||||
|
const stream = useStream<StateType>({
|
||||||
|
assistantId: activeAssistant?.assistant_id || "",
|
||||||
|
client: client ?? undefined,
|
||||||
|
reconnectOnMount: true,
|
||||||
|
threadId: threadId ?? null,
|
||||||
|
onThreadId: setThreadId,
|
||||||
|
defaultHeaders: { "x-auth-scheme": "langsmith" },
|
||||||
|
// Enable fetching state history when switching to existing threads
|
||||||
|
fetchStateHistory: true,
|
||||||
|
// Revalidate thread list when stream finishes, errors, or creates new thread
|
||||||
|
onFinish: onHistoryRevalidate,
|
||||||
|
onError: onHistoryRevalidate,
|
||||||
|
onCreated: onHistoryRevalidate,
|
||||||
|
experimental_thread: thread,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
(content: string) => {
|
||||||
|
const newMessage: Message = { id: uuidv4(), type: "human", content };
|
||||||
|
stream.submit(
|
||||||
|
{ messages: [newMessage] },
|
||||||
|
{
|
||||||
|
optimisticValues: (prev) => ({
|
||||||
|
messages: [...(prev.messages ?? []), newMessage],
|
||||||
|
}),
|
||||||
|
config: { ...(activeAssistant?.config ?? {}), recursion_limit: 100 },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Update thread list immediately when sending a message
|
||||||
|
onHistoryRevalidate?.();
|
||||||
|
},
|
||||||
|
[stream, activeAssistant?.config, onHistoryRevalidate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const runSingleStep = useCallback(
|
||||||
|
(
|
||||||
|
messages: Message[],
|
||||||
|
checkpoint?: Checkpoint,
|
||||||
|
isRerunningSubagent?: boolean,
|
||||||
|
optimisticMessages?: Message[]
|
||||||
|
) => {
|
||||||
|
if (checkpoint) {
|
||||||
|
stream.submit(undefined, {
|
||||||
|
...(optimisticMessages
|
||||||
|
? { optimisticValues: { messages: optimisticMessages } }
|
||||||
|
: {}),
|
||||||
|
config: activeAssistant?.config,
|
||||||
|
checkpoint: checkpoint,
|
||||||
|
...(isRerunningSubagent
|
||||||
|
? { interruptAfter: ["tools"] }
|
||||||
|
: { interruptBefore: ["tools"] }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stream.submit(
|
||||||
|
{ messages },
|
||||||
|
{ config: activeAssistant?.config, interruptBefore: ["tools"] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[stream, activeAssistant?.config]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setFiles = useCallback(
|
||||||
|
async (files: Record<string, string>) => {
|
||||||
|
if (!threadId) return;
|
||||||
|
// TODO: missing a way how to revalidate the internal state
|
||||||
|
// I think we do want to have the ability to externally manage the state
|
||||||
|
await client.threads.updateState(threadId, { values: { files } });
|
||||||
|
},
|
||||||
|
[client, threadId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const continueStream = useCallback(
|
||||||
|
(hasTaskToolCall?: boolean) => {
|
||||||
|
stream.submit(undefined, {
|
||||||
|
config: {
|
||||||
|
...(activeAssistant?.config || {}),
|
||||||
|
recursion_limit: 100,
|
||||||
|
},
|
||||||
|
...(hasTaskToolCall
|
||||||
|
? { interruptAfter: ["tools"] }
|
||||||
|
: { interruptBefore: ["tools"] }),
|
||||||
|
});
|
||||||
|
// Update thread list when continuing stream
|
||||||
|
onHistoryRevalidate?.();
|
||||||
|
},
|
||||||
|
[stream, activeAssistant?.config, onHistoryRevalidate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const markCurrentThreadAsResolved = useCallback(() => {
|
||||||
|
stream.submit(null, { command: { goto: "__end__", update: null } });
|
||||||
|
// Update thread list when marking thread as resolved
|
||||||
|
onHistoryRevalidate?.();
|
||||||
|
}, [stream, onHistoryRevalidate]);
|
||||||
|
|
||||||
|
const resumeInterrupt = useCallback(
|
||||||
|
(value: any) => {
|
||||||
|
stream.submit(null, { command: { resume: value } });
|
||||||
|
// Update thread list when resuming from interrupt
|
||||||
|
onHistoryRevalidate?.();
|
||||||
|
},
|
||||||
|
[stream, onHistoryRevalidate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
stream.stop();
|
||||||
|
}, [stream]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream,
|
||||||
|
todos: stream.values.todos ?? [],
|
||||||
|
files: stream.values.files ?? {},
|
||||||
|
email: stream.values.email,
|
||||||
|
ui: stream.values.ui,
|
||||||
|
setFiles,
|
||||||
|
messages: stream.messages,
|
||||||
|
isLoading: stream.isLoading,
|
||||||
|
isThreadLoading: stream.isThreadLoading,
|
||||||
|
interrupt: stream.interrupt,
|
||||||
|
getMessagesMetadata: stream.getMessagesMetadata,
|
||||||
|
sendMessage,
|
||||||
|
runSingleStep,
|
||||||
|
continueStream,
|
||||||
|
stopStream,
|
||||||
|
markCurrentThreadAsResolved,
|
||||||
|
resumeInterrupt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import useSWRInfinite from "swr/infinite";
|
||||||
|
import type { Thread } from "@langchain/langgraph-sdk";
|
||||||
|
import { Client } from "@langchain/langgraph-sdk";
|
||||||
|
import { getConfig } from "@/lib/config";
|
||||||
|
|
||||||
|
export interface ThreadItem {
|
||||||
|
id: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
status: Thread["status"];
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
assistantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export function useThreads(props: {
|
||||||
|
status?: Thread["status"];
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const pageSize = props.limit || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
|
return useSWRInfinite(
|
||||||
|
(pageIndex: number, previousPageData: ThreadItem[] | null) => {
|
||||||
|
const config = getConfig();
|
||||||
|
const apiKey =
|
||||||
|
config?.langsmithApiKey ||
|
||||||
|
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the previous page returned no items, we've reached the end
|
||||||
|
if (previousPageData && previousPageData.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "threads" as const,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
deploymentUrl: config.deploymentUrl,
|
||||||
|
assistantId: config.assistantId,
|
||||||
|
apiKey,
|
||||||
|
status: props?.status,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async ({
|
||||||
|
deploymentUrl,
|
||||||
|
assistantId,
|
||||||
|
apiKey,
|
||||||
|
status,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
}: {
|
||||||
|
kind: "threads";
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
deploymentUrl: string;
|
||||||
|
assistantId: string;
|
||||||
|
apiKey: string;
|
||||||
|
status?: Thread["status"];
|
||||||
|
}) => {
|
||||||
|
const client = new Client({
|
||||||
|
apiUrl: deploymentUrl,
|
||||||
|
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always scope the thread list to the selected assistant so we never
|
||||||
|
// show threads spawned by async sub-agents (e.g. writing-agent,
|
||||||
|
// data-analysis-agent) that share the same deployment store.
|
||||||
|
//
|
||||||
|
// Every thread carries both `graph_id` (the graph name) and
|
||||||
|
// `assistant_id` (a deterministic UUID per graph) in its metadata.
|
||||||
|
// A deployed UUID is matched against `assistant_id`; a graph name
|
||||||
|
// (the local-dev case, e.g. "EvoScientist") is matched against
|
||||||
|
// `graph_id`.
|
||||||
|
const isUUID =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
|
assistantId
|
||||||
|
);
|
||||||
|
const metadata = isUUID
|
||||||
|
? { assistant_id: assistantId }
|
||||||
|
: { graph_id: assistantId };
|
||||||
|
|
||||||
|
const threads = await client.threads.search({
|
||||||
|
limit: pageSize,
|
||||||
|
offset: pageIndex * pageSize,
|
||||||
|
sortBy: "updated_at" as const,
|
||||||
|
sortOrder: "desc" as const,
|
||||||
|
status,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return threads.map((thread): ThreadItem => {
|
||||||
|
let title = "Untitled Thread";
|
||||||
|
let description = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (thread.values && typeof thread.values === "object") {
|
||||||
|
const values = thread.values as any;
|
||||||
|
const firstHumanMessage = values.messages.find(
|
||||||
|
(m: any) => m.type === "human"
|
||||||
|
);
|
||||||
|
if (firstHumanMessage?.content) {
|
||||||
|
const content =
|
||||||
|
typeof firstHumanMessage.content === "string"
|
||||||
|
? firstHumanMessage.content
|
||||||
|
: firstHumanMessage.content[0]?.text || "";
|
||||||
|
title = content.slice(0, 50) + (content.length > 50 ? "..." : "");
|
||||||
|
}
|
||||||
|
const firstAiMessage = values.messages.find(
|
||||||
|
(m: any) => m.type === "ai"
|
||||||
|
);
|
||||||
|
if (firstAiMessage?.content) {
|
||||||
|
const content =
|
||||||
|
typeof firstAiMessage.content === "string"
|
||||||
|
? firstAiMessage.content
|
||||||
|
: firstAiMessage.content[0]?.text || "";
|
||||||
|
description = content.slice(0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to thread ID
|
||||||
|
title = `Thread ${thread.thread_id.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: thread.thread_id,
|
||||||
|
updatedAt: new Date(thread.updated_at),
|
||||||
|
status: thread.status,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
assistantId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revalidateFirstPage: true,
|
||||||
|
revalidateOnFocus: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "EvoScientist WebUI",
|
||||||
|
description:
|
||||||
|
"Web UI for EvoScientist — a self-evolving AI scientist built on DeepAgents/LangGraph.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<body
|
||||||
|
className={inter.className}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<NuqsAdapter>{children}</NuqsAdapter>
|
||||||
|
<Toaster />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, Suspense } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useQueryState } from "nuqs";
|
||||||
|
import { getConfig, saveConfig, StandaloneConfig } from "@/lib/config";
|
||||||
|
import { ConfigDialog } from "@/app/components/ConfigDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Assistant } from "@langchain/langgraph-sdk";
|
||||||
|
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||||
|
import { Settings, MessagesSquare, SquarePen } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { ThreadList } from "@/app/components/ThreadList";
|
||||||
|
import { ChatProvider } from "@/providers/ChatProvider";
|
||||||
|
import { ChatInterface } from "@/app/components/ChatInterface";
|
||||||
|
|
||||||
|
interface HomePageInnerProps {
|
||||||
|
config: StandaloneConfig;
|
||||||
|
configDialogOpen: boolean;
|
||||||
|
setConfigDialogOpen: (open: boolean) => void;
|
||||||
|
handleSaveConfig: (config: StandaloneConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomePageInner({
|
||||||
|
config,
|
||||||
|
configDialogOpen,
|
||||||
|
setConfigDialogOpen,
|
||||||
|
handleSaveConfig,
|
||||||
|
}: HomePageInnerProps) {
|
||||||
|
const client = useClient();
|
||||||
|
const [threadId, setThreadId] = useQueryState("threadId");
|
||||||
|
const [sidebar, setSidebar] = useQueryState("sidebar");
|
||||||
|
|
||||||
|
const [mutateThreads, setMutateThreads] = useState<(() => void) | null>(null);
|
||||||
|
const [interruptCount, setInterruptCount] = useState(0);
|
||||||
|
const [assistant, setAssistant] = useState<Assistant | null>(null);
|
||||||
|
|
||||||
|
const fetchAssistant = useCallback(async () => {
|
||||||
|
const isUUID =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
|
config.assistantId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isUUID) {
|
||||||
|
// We should try to fetch the assistant directly with this UUID
|
||||||
|
try {
|
||||||
|
const data = await client.assistants.get(config.assistantId);
|
||||||
|
setAssistant(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch assistant:", error);
|
||||||
|
setAssistant({
|
||||||
|
assistant_id: config.assistantId,
|
||||||
|
graph_id: config.assistantId,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
config: {},
|
||||||
|
metadata: {},
|
||||||
|
version: 1,
|
||||||
|
name: "Assistant",
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// We should try to list out the assistants for this graph, and then use the default one.
|
||||||
|
// TODO: Paginate this search, but 100 should be enough for graph name
|
||||||
|
const assistants = await client.assistants.search({
|
||||||
|
graphId: config.assistantId,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
const defaultAssistant = assistants.find(
|
||||||
|
(assistant) => assistant.metadata?.["created_by"] === "system"
|
||||||
|
);
|
||||||
|
if (defaultAssistant === undefined) {
|
||||||
|
throw new Error("No default assistant found");
|
||||||
|
}
|
||||||
|
setAssistant(defaultAssistant);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Failed to find default assistant from graph_id: try setting the assistant_id directly:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
setAssistant({
|
||||||
|
assistant_id: config.assistantId,
|
||||||
|
graph_id: config.assistantId,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
config: {},
|
||||||
|
metadata: {},
|
||||||
|
version: 1,
|
||||||
|
name: config.assistantId,
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [client, config.assistantId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAssistant();
|
||||||
|
}, [fetchAssistant]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfigDialog
|
||||||
|
open={configDialogOpen}
|
||||||
|
onOpenChange={setConfigDialogOpen}
|
||||||
|
onSave={handleSaveConfig}
|
||||||
|
initialConfig={config}
|
||||||
|
/>
|
||||||
|
<div className="flex h-screen flex-col">
|
||||||
|
<header className="flex h-16 items-center justify-between border-b border-border px-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Image
|
||||||
|
src="/evoscientist-logo.png"
|
||||||
|
alt="EvoScientist"
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<h1 className="text-xl font-semibold">EvoScientist</h1>
|
||||||
|
</div>
|
||||||
|
{!sidebar && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSidebar("1")}
|
||||||
|
className="rounded-md border border-border bg-card p-3 text-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
<MessagesSquare className="mr-2 h-4 w-4" />
|
||||||
|
Threads
|
||||||
|
{interruptCount > 0 && (
|
||||||
|
<span className="ml-2 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] text-destructive-foreground">
|
||||||
|
{interruptCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium">Assistant:</span>{" "}
|
||||||
|
{config.assistantId}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setConfigDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setThreadId(null)}
|
||||||
|
disabled={!threadId}
|
||||||
|
className="border-[#2F6868] bg-[#2F6868] text-white hover:bg-[#2F6868]/80"
|
||||||
|
>
|
||||||
|
<SquarePen className="mr-2 h-4 w-4" />
|
||||||
|
New Thread
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
autoSaveId="standalone-chat"
|
||||||
|
>
|
||||||
|
{sidebar && (
|
||||||
|
<>
|
||||||
|
<ResizablePanel
|
||||||
|
id="thread-history"
|
||||||
|
order={1}
|
||||||
|
defaultSize={25}
|
||||||
|
minSize={20}
|
||||||
|
className="relative min-w-[380px]"
|
||||||
|
>
|
||||||
|
<ThreadList
|
||||||
|
onThreadSelect={async (id) => {
|
||||||
|
await setThreadId(id);
|
||||||
|
}}
|
||||||
|
onMutateReady={(fn) => setMutateThreads(() => fn)}
|
||||||
|
onClose={() => setSidebar(null)}
|
||||||
|
onInterruptCountChange={setInterruptCount}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ResizablePanel
|
||||||
|
id="chat"
|
||||||
|
className="relative flex flex-col"
|
||||||
|
order={2}
|
||||||
|
>
|
||||||
|
<ChatProvider
|
||||||
|
activeAssistant={assistant}
|
||||||
|
onHistoryRevalidate={() => mutateThreads?.()}
|
||||||
|
>
|
||||||
|
<ChatInterface assistant={assistant} />
|
||||||
|
</ChatProvider>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomePageContent() {
|
||||||
|
const [config, setConfig] = useState<StandaloneConfig | null>(null);
|
||||||
|
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||||
|
const [assistantId, setAssistantId] = useQueryState("assistantId");
|
||||||
|
|
||||||
|
// On mount, check for saved config, otherwise show config dialog
|
||||||
|
useEffect(() => {
|
||||||
|
const savedConfig = getConfig();
|
||||||
|
if (savedConfig) {
|
||||||
|
setConfig(savedConfig);
|
||||||
|
if (!assistantId) {
|
||||||
|
setAssistantId(savedConfig.assistantId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setConfigDialogOpen(true);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// If config changes, update the assistantId
|
||||||
|
useEffect(() => {
|
||||||
|
if (config && !assistantId) {
|
||||||
|
setAssistantId(config.assistantId);
|
||||||
|
}
|
||||||
|
}, [config, assistantId, setAssistantId]);
|
||||||
|
|
||||||
|
const handleSaveConfig = useCallback((newConfig: StandaloneConfig) => {
|
||||||
|
saveConfig(newConfig);
|
||||||
|
setConfig(newConfig);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const langsmithApiKey =
|
||||||
|
config?.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfigDialog
|
||||||
|
open={configDialogOpen}
|
||||||
|
onOpenChange={setConfigDialogOpen}
|
||||||
|
onSave={handleSaveConfig}
|
||||||
|
/>
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold">Welcome to Standalone Chat</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Configure your deployment to get started
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setConfigDialogOpen(true)}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
Open Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClientProvider
|
||||||
|
deploymentUrl={config.deploymentUrl}
|
||||||
|
apiKey={langsmithApiKey}
|
||||||
|
>
|
||||||
|
<HomePageInner
|
||||||
|
config={config}
|
||||||
|
configDialogOpen={configDialogOpen}
|
||||||
|
setConfigDialogOpen={setConfigDialogOpen}
|
||||||
|
handleSaveConfig={handleSaveConfig}
|
||||||
|
/>
|
||||||
|
</ClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<HomePageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export interface ToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
result?: string;
|
||||||
|
status: "pending" | "completed" | "error" | "interrupted";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubAgent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subAgentName: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
output?: Record<string, unknown>;
|
||||||
|
status: "pending" | "active" | "completed" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileItem {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoItem {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
status: "pending" | "in_progress" | "completed";
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Thread {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InterruptData {
|
||||||
|
value: any;
|
||||||
|
ns?: string[];
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionRequest {
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewConfig {
|
||||||
|
actionName: string;
|
||||||
|
allowedDecisions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolApprovalInterruptData {
|
||||||
|
action_requests: ActionRequest[];
|
||||||
|
review_configs?: ReviewConfig[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractStringFromMessageContent(message: Message): string {
|
||||||
|
return typeof message.content === "string"
|
||||||
|
? message.content
|
||||||
|
: Array.isArray(message.content)
|
||||||
|
? message.content
|
||||||
|
.filter(
|
||||||
|
(c: unknown) =>
|
||||||
|
(typeof c === "object" &&
|
||||||
|
c !== null &&
|
||||||
|
"type" in c &&
|
||||||
|
(c as { type: string }).type === "text") ||
|
||||||
|
typeof c === "string"
|
||||||
|
)
|
||||||
|
.map((c: unknown) =>
|
||||||
|
typeof c === "string"
|
||||||
|
? c
|
||||||
|
: typeof c === "object" && c !== null && "text" in c
|
||||||
|
? (c as { text?: string }).text || ""
|
||||||
|
: ""
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSubAgentContent(data: unknown): string {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
const dataObj = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Try to extract description first
|
||||||
|
if (dataObj.description && typeof dataObj.description === "string") {
|
||||||
|
return dataObj.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then try prompt
|
||||||
|
if (dataObj.prompt && typeof dataObj.prompt === "string") {
|
||||||
|
return dataObj.prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For output objects, try result
|
||||||
|
if (dataObj.result && typeof dataObj.result === "string") {
|
||||||
|
return dataObj.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to JSON stringification
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for any other type
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPreparingToCallTaskTool(messages: Message[]): boolean {
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
return (
|
||||||
|
(lastMessage.type === "ai" &&
|
||||||
|
lastMessage.tool_calls?.some(
|
||||||
|
(call: { name?: string }) => call.name === "task"
|
||||||
|
)) ||
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMessageForLLM(message: Message): string {
|
||||||
|
let role: string;
|
||||||
|
if (message.type === "human") {
|
||||||
|
role = "Human";
|
||||||
|
} else if (message.type === "ai") {
|
||||||
|
role = "Assistant";
|
||||||
|
} else if (message.type === "tool") {
|
||||||
|
role = `Tool Result`;
|
||||||
|
} else {
|
||||||
|
role = message.type || "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = message.id ? ` (${message.id.slice(0, 8)})` : "";
|
||||||
|
|
||||||
|
let contentText = "";
|
||||||
|
|
||||||
|
// Extract content text
|
||||||
|
if (typeof message.content === "string") {
|
||||||
|
contentText = message.content;
|
||||||
|
} else if (Array.isArray(message.content)) {
|
||||||
|
const textParts: string[] = [];
|
||||||
|
|
||||||
|
message.content.forEach((part: any) => {
|
||||||
|
if (typeof part === "string") {
|
||||||
|
textParts.push(part);
|
||||||
|
} else if (part && typeof part === "object" && part.type === "text") {
|
||||||
|
textParts.push(part.text || "");
|
||||||
|
}
|
||||||
|
// Ignore other types like tool_use in content - we handle tool calls separately
|
||||||
|
});
|
||||||
|
|
||||||
|
contentText = textParts.join("\n\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tool messages, include additional tool metadata
|
||||||
|
if (message.type === "tool") {
|
||||||
|
const toolName = (message as any).name || "unknown_tool";
|
||||||
|
const toolCallId = (message as any).tool_call_id || "";
|
||||||
|
role = `Tool Result [${toolName}]`;
|
||||||
|
if (toolCallId) {
|
||||||
|
role += ` (call_id: ${toolCallId.slice(0, 8)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tool calls from .tool_calls property (for AI messages)
|
||||||
|
const toolCallsText: string[] = [];
|
||||||
|
if (
|
||||||
|
message.type === "ai" &&
|
||||||
|
message.tool_calls &&
|
||||||
|
Array.isArray(message.tool_calls) &&
|
||||||
|
message.tool_calls.length > 0
|
||||||
|
) {
|
||||||
|
message.tool_calls.forEach((call: any) => {
|
||||||
|
const toolName = call.name || "unknown_tool";
|
||||||
|
const toolArgs = call.args ? JSON.stringify(call.args, null, 2) : "{}";
|
||||||
|
toolCallsText.push(`[Tool Call: ${toolName}]\nArguments: ${toolArgs}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine content and tool calls
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (contentText) {
|
||||||
|
parts.push(contentText);
|
||||||
|
}
|
||||||
|
if (toolCallsText.length > 0) {
|
||||||
|
parts.push(...toolCallsText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return `${role}${timestamp}: [Empty message]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return `${role}${timestamp}: ${parts[0]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${role}${timestamp}:\n${parts.join("\n\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatConversationForLLM(messages: Message[]): string {
|
||||||
|
const formattedMessages = messages.map(formatMessageForLLM);
|
||||||
|
return formattedMessages.join("\n\n---\n\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
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 }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Root
|
||||||
|
data-slot="dialog"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Trigger
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Portal
|
||||||
|
data-slot="dialog-portal"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="focus:outline-hidden absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg font-semibold leading-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-blue-200 selection:text-gray-900 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 dark:selection:bg-blue-600 dark:selection:text-white md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GripVertical } from "lucide-react";
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ResizablePanelGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ResizablePanel = ResizablePrimitive.Panel;
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean;
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none p-px transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export interface StandaloneConfig {
|
||||||
|
deploymentUrl: string;
|
||||||
|
assistantId: string;
|
||||||
|
langsmithApiKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The UI always talks to the EvoScientist main agent. writing-agent and
|
||||||
|
// data-analysis-agent are internal sub-agents and are intentionally not
|
||||||
|
// user-selectable, so the assistant is fixed rather than configurable.
|
||||||
|
export const DEFAULT_ASSISTANT_ID = "EvoScientist";
|
||||||
|
|
||||||
|
const CONFIG_KEY = "evoscientist-config";
|
||||||
|
|
||||||
|
export function getConfig(): StandaloneConfig | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
const stored = localStorage.getItem(CONFIG_KEY);
|
||||||
|
if (!stored) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored) as StandaloneConfig;
|
||||||
|
// Always pin the assistant to the EvoScientist main agent.
|
||||||
|
return { ...parsed, assistantId: DEFAULT_ASSISTANT_ID };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConfig(config: StandaloneConfig): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode, createContext, useContext } from "react";
|
||||||
|
import { Assistant } from "@langchain/langgraph-sdk";
|
||||||
|
import { type StateType, useChat } from "@/app/hooks/useChat";
|
||||||
|
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
|
||||||
|
|
||||||
|
interface ChatProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
activeAssistant: Assistant | null;
|
||||||
|
onHistoryRevalidate?: () => void;
|
||||||
|
thread?: UseStreamThread<StateType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatProvider({
|
||||||
|
children,
|
||||||
|
activeAssistant,
|
||||||
|
onHistoryRevalidate,
|
||||||
|
thread,
|
||||||
|
}: ChatProviderProps) {
|
||||||
|
const chat = useChat({ activeAssistant, onHistoryRevalidate, thread });
|
||||||
|
return <ChatContext.Provider value={chat}>{children}</ChatContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatContextType = ReturnType<typeof useChat>;
|
||||||
|
|
||||||
|
export const ChatContext = createContext<ChatContextType | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useChatContext() {
|
||||||
|
const context = useContext(ChatContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useChatContext must be used within a ChatProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo, ReactNode } from "react";
|
||||||
|
import { Client } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
|
interface ClientContextValue {
|
||||||
|
client: Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClientContext = createContext<ClientContextValue | null>(null);
|
||||||
|
|
||||||
|
interface ClientProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
deploymentUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientProvider({
|
||||||
|
children,
|
||||||
|
deploymentUrl,
|
||||||
|
apiKey,
|
||||||
|
}: ClientProviderProps) {
|
||||||
|
const client = useMemo(() => {
|
||||||
|
return new Client({
|
||||||
|
apiUrl: deploymentUrl,
|
||||||
|
defaultHeaders: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Api-Key": apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [deploymentUrl, apiKey]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ client }), [client]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClientContext.Provider value={value}>{children}</ClientContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClient(): Client {
|
||||||
|
const context = useContext(ClientContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useClient must be used within a ClientProvider");
|
||||||
|
}
|
||||||
|
return context.client;
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import { blackA, green, mauve, slate, violet } from "@radix-ui/colors";
|
||||||
|
import plugin from "tailwindcss/plugin";
|
||||||
|
import containerQueries from "@tailwindcss/container-queries";
|
||||||
|
import typography from "@tailwindcss/typography";
|
||||||
|
import forms from "@tailwindcss/forms";
|
||||||
|
import tailwindcssAnimate from "tailwindcss-animate";
|
||||||
|
import headlessui from "@headlessui/tailwindcss";
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
|
darkMode: ["class", '[data-joy-color-scheme="dark"]'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontSize: {
|
||||||
|
xxs: [
|
||||||
|
"0.75rem", // 12px
|
||||||
|
{
|
||||||
|
lineHeight: "1.125rem", // 18px
|
||||||
|
},
|
||||||
|
],
|
||||||
|
xs: [
|
||||||
|
"0.8125rem", // 13px
|
||||||
|
{
|
||||||
|
lineHeight: "1.125rem", // 18px
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sm: [
|
||||||
|
"0.875rem", // 14px
|
||||||
|
{
|
||||||
|
lineHeight: "1.25rem", // 20px
|
||||||
|
},
|
||||||
|
],
|
||||||
|
base: [
|
||||||
|
"1rem", // 16px
|
||||||
|
{
|
||||||
|
lineHeight: "1.5rem", // 24px
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lg: [
|
||||||
|
"1.125rem", // 18px
|
||||||
|
{
|
||||||
|
lineHeight: "1.75rem", // 28px
|
||||||
|
letterSpacing: "-0.01em", // tracking-tight
|
||||||
|
},
|
||||||
|
],
|
||||||
|
xl: [
|
||||||
|
"1.25rem", // 20px
|
||||||
|
{
|
||||||
|
lineHeight: "1.875rem", // 30px
|
||||||
|
letterSpacing: "-0.01em", // tracking-tight
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
mono: [
|
||||||
|
`"Fira Code"`,
|
||||||
|
`ui-monospace`,
|
||||||
|
`SFMono-Regular`,
|
||||||
|
`Menlo`,
|
||||||
|
`Monaco`,
|
||||||
|
`Consolas`,
|
||||||
|
`"Liberation Mono"`,
|
||||||
|
`"Courier New"`,
|
||||||
|
`monospace`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
letterSpacing: {
|
||||||
|
tighter: "-0.04em",
|
||||||
|
tight: "-0.03em",
|
||||||
|
snug: "-0.02em",
|
||||||
|
normal: "0",
|
||||||
|
wide: "0.03em",
|
||||||
|
},
|
||||||
|
lineHeight: {
|
||||||
|
tight: "1.20",
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
navMenu: "linear-gradient(132deg, #4499F7 0%, #3FCDD6 100%)",
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
xs: "3px",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
xs: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||||
|
},
|
||||||
|
backgroundColor: {
|
||||||
|
primary: "var(--bg-primary)",
|
||||||
|
"primary-hover": "var(--bg-primary_hover)",
|
||||||
|
secondary: "var(--bg-secondary)",
|
||||||
|
"secondary-hover": "var(--bg-secondary_hover)",
|
||||||
|
tertiary: "var(--bg-tertiary)",
|
||||||
|
quaternary: "var(--bg-quaternary)",
|
||||||
|
|
||||||
|
"brand-primary": "var(--bg-brand-primary)",
|
||||||
|
"brand-primary-hover": "var(--bg-brand-primary_hover)",
|
||||||
|
"brand-secondary": "var(--bg-brand-secondary)",
|
||||||
|
"brand-tertiary": "var(--bg-brand-tertiary)",
|
||||||
|
purple: "var(--bg-purple)",
|
||||||
|
|
||||||
|
"success-primary": "var(--bg-success-primary)",
|
||||||
|
"success-secondary": "var(--bg-success-secondary)",
|
||||||
|
"success-strong": "var(--bg-success-strong)",
|
||||||
|
"error-primary": "var(--bg-error-primary)",
|
||||||
|
"error-secondary": "var(--bg-error-secondary)",
|
||||||
|
"error-strong": "var(--bg-error-strong)",
|
||||||
|
"error-strong-hover": "var(--bg-error-strong-hover)",
|
||||||
|
"warning-primary": "var(--bg-warning-primary)",
|
||||||
|
"warning-secondary": "var(--bg-warning-secondary)",
|
||||||
|
"warning-strong": "var(--bg-warning-strong)",
|
||||||
|
},
|
||||||
|
borderColor: {
|
||||||
|
primary: "var(--border-primary)",
|
||||||
|
secondary: "var(--border-secondary)",
|
||||||
|
tertiary: "var(--border-tertiary)",
|
||||||
|
error: "var(--border-error)",
|
||||||
|
"error-strong": "var(--border-error-strong)",
|
||||||
|
brand: "var(--border-brand)",
|
||||||
|
"brand-strong": "var(--border-brand-strong)",
|
||||||
|
"brand-subtle": "var(--border-brand-subtle)",
|
||||||
|
strong: "var(--border-strong)",
|
||||||
|
warning: "var(--border-warning)",
|
||||||
|
success: "var(--border-success)",
|
||||||
|
purple: "var(--border-purple)",
|
||||||
|
"status-green": "var(--border-status-green)",
|
||||||
|
"status-orange": "var(--border-status-orange)",
|
||||||
|
"status-yellow": "var(--border-status-yellow)",
|
||||||
|
"status-red": "var(--border-status-red)",
|
||||||
|
},
|
||||||
|
textColor: {
|
||||||
|
primary: "var(--text-primary)",
|
||||||
|
secondary: "var(--text-secondary)",
|
||||||
|
tertiary: "var(--text-tertiary)",
|
||||||
|
quaternary: "var(--text-quaternary)",
|
||||||
|
disabled: "var(--text-disabled)",
|
||||||
|
error: "var(--text-error)",
|
||||||
|
warning: "var(--text-warning)",
|
||||||
|
success: "var(--text-success)",
|
||||||
|
placeholder: "var(--text-placeholder)",
|
||||||
|
purple: "var(--text-purple)",
|
||||||
|
"brand-primary": "var(--text-brand-primary)",
|
||||||
|
"brand-secondary": "var(--text-brand-secondary)",
|
||||||
|
"brand-tertiary": "var(--text-brand-tertiary)",
|
||||||
|
"brand-disabled": "var(--text-brand-disabled)",
|
||||||
|
"status-green": "var(--text-status-green)",
|
||||||
|
"status-orange": "var(--text-status-orange)",
|
||||||
|
"status-yellow": "var(--text-status-yellow)",
|
||||||
|
"status-red": "var(--text-status-red)",
|
||||||
|
"button-primary": "var(--text-button-primary)",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
DEFAULT: "hsl(var(--sidebar))",
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
1: "hsl(var(--chart-1))",
|
||||||
|
2: "hsl(var(--chart-2))",
|
||||||
|
3: "hsl(var(--chart-3))",
|
||||||
|
4: "hsl(var(--chart-4))",
|
||||||
|
5: "hsl(var(--chart-5))",
|
||||||
|
},
|
||||||
|
ls: {
|
||||||
|
blue: "hsl(211.5, 91.8%, 61.8%)",
|
||||||
|
black: "hsl(var(--ls-black))",
|
||||||
|
green: {
|
||||||
|
600: "hsl(122, 63%, 38%)",
|
||||||
|
},
|
||||||
|
white: "var(--white)",
|
||||||
|
black: "var(--black)",
|
||||||
|
red: {
|
||||||
|
25: "var(--red-25)",
|
||||||
|
50: "var(--red-50)",
|
||||||
|
100: "var(--red-100)",
|
||||||
|
200: "var(--red-200)",
|
||||||
|
300: "var(--red-300)",
|
||||||
|
400: "var(--red-400)",
|
||||||
|
500: "var(--red-500)",
|
||||||
|
600: "var(--red-600)",
|
||||||
|
700: "var(--red-700)",
|
||||||
|
800: "var(--red-800)",
|
||||||
|
900: "var(--red-900)",
|
||||||
|
950: "var(--red-950)",
|
||||||
|
},
|
||||||
|
orange: {
|
||||||
|
25: "var(--orange-25)",
|
||||||
|
50: "var(--orange-50)",
|
||||||
|
100: "var(--orange-100)",
|
||||||
|
200: "var(--orange-200)",
|
||||||
|
300: "var(--orange-300)",
|
||||||
|
400: "var(--orange-400)",
|
||||||
|
500: "var(--orange-500)",
|
||||||
|
600: "var(--orange-600)",
|
||||||
|
700: "var(--orange-700)",
|
||||||
|
800: "var(--orange-800)",
|
||||||
|
900: "var(--orange-900)",
|
||||||
|
950: "var(--orange-950)",
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
50: "var(--gray-50)",
|
||||||
|
100: "var(--gray-100)",
|
||||||
|
200: "var(--gray-200)",
|
||||||
|
300: "var(--gray-300)",
|
||||||
|
400: "var(--gray-400)",
|
||||||
|
500: "var(--gray-500)",
|
||||||
|
600: "var(--gray-600)",
|
||||||
|
700: "var(--gray-700)",
|
||||||
|
800: "var(--gray-800)",
|
||||||
|
900: "var(--gray-900)",
|
||||||
|
950: "var(--gray-950)",
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
25: "var(--green-25)",
|
||||||
|
50: "var(--green-50)",
|
||||||
|
100: "var(--green-100)",
|
||||||
|
200: "var(--green-200)",
|
||||||
|
300: "var(--green-300)",
|
||||||
|
400: "var(--green-400)",
|
||||||
|
500: "var(--green-500)",
|
||||||
|
600: "var(--green-600)",
|
||||||
|
700: "var(--green-700)",
|
||||||
|
800: "var(--green-800)",
|
||||||
|
900: "var(--green-900)",
|
||||||
|
950: "var(--green-950)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
green: {
|
||||||
|
25: "var(--brand-25)",
|
||||||
|
50: "var(--brand-50)",
|
||||||
|
100: "var(--brand-100)",
|
||||||
|
200: "var(--brand-200)",
|
||||||
|
300: "var(--brand-300)",
|
||||||
|
400: "var(--brand-400)",
|
||||||
|
500: "var(--brand-500)",
|
||||||
|
600: "var(--brand-600)",
|
||||||
|
700: "var(--brand-700)",
|
||||||
|
800: "var(--brand-800)",
|
||||||
|
900: "var(--brand-900)",
|
||||||
|
950: "var(--brand-950)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
hide: {
|
||||||
|
from: { opacity: 1 },
|
||||||
|
to: { opacity: 0 },
|
||||||
|
},
|
||||||
|
slideIn: {
|
||||||
|
from: {
|
||||||
|
transform: "translateX(calc(100% + var(--viewport-padding)))",
|
||||||
|
},
|
||||||
|
to: { transform: "translateX(0)" },
|
||||||
|
},
|
||||||
|
swipeOut: {
|
||||||
|
from: { transform: "translateX(var(--radix-toast-swipe-end-x))" },
|
||||||
|
to: { transform: "translateX(calc(100% + var(--viewport-padding)))" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
hide: "hide 100ms ease-in",
|
||||||
|
slideIn: "slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
swipeOut: "swipeOut 100ms ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
playground: {
|
||||||
|
css: {
|
||||||
|
"h1, h2, h3, h4, h5, h6": {
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
h1: {
|
||||||
|
fontSize: "24px",
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: "20px",
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: "18px",
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
fontSize: "16px",
|
||||||
|
},
|
||||||
|
h5: {
|
||||||
|
fontSize: "14px",
|
||||||
|
},
|
||||||
|
h6: {
|
||||||
|
fontSize: "12px",
|
||||||
|
},
|
||||||
|
ul: {
|
||||||
|
marginLeft: "20px !important",
|
||||||
|
listStyleType: "disc !important",
|
||||||
|
},
|
||||||
|
ol: {
|
||||||
|
marginLeft: "20px !important",
|
||||||
|
listStyleType: "decimal !important",
|
||||||
|
},
|
||||||
|
a: {
|
||||||
|
color: "#287977",
|
||||||
|
textDecoration: "underline",
|
||||||
|
"&:hover": {
|
||||||
|
textDecoration: "underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
th: {
|
||||||
|
padding: "0.5rem",
|
||||||
|
border: "1px solid var(--gray-100)",
|
||||||
|
fontWeight: "bold",
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
td: {
|
||||||
|
padding: "0.5rem",
|
||||||
|
border: "1px solid var(--gray-100)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blockquote: {
|
||||||
|
borderLeft: "2px solid var(--gray-100)",
|
||||||
|
paddingLeft: "1rem",
|
||||||
|
marginLeft: "0",
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
|
||||||
|
"s, strike, del": {
|
||||||
|
textDecoration: "line-through",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
containerQueries,
|
||||||
|
typography,
|
||||||
|
forms,
|
||||||
|
tailwindcssAnimate,
|
||||||
|
headlessui,
|
||||||
|
plugin(({ addUtilities, addBase }) => {
|
||||||
|
addBase({
|
||||||
|
input: {
|
||||||
|
borderWidth: "0",
|
||||||
|
padding: "0",
|
||||||
|
},
|
||||||
|
// Global scrollbar styles for all scrollable elements
|
||||||
|
"html, body, *": {
|
||||||
|
"scrollbar-width": "thin",
|
||||||
|
"scrollbar-color": "var(--scrollbar-thumb) var(--bg-primary)",
|
||||||
|
},
|
||||||
|
"html::-webkit-scrollbar, body::-webkit-scrollbar, *::-webkit-scrollbar":
|
||||||
|
{
|
||||||
|
width: "8px",
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
},
|
||||||
|
"html::-webkit-scrollbar-track, body::-webkit-scrollbar-track, *::-webkit-scrollbar-track":
|
||||||
|
{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
},
|
||||||
|
"html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-track, *::-webkit-scrollbar-thumb":
|
||||||
|
{
|
||||||
|
background: "var(--scrollbar-thumb)",
|
||||||
|
"border-radius": "4px",
|
||||||
|
},
|
||||||
|
"html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover, *::-webkit-scrollbar-thumb:hover":
|
||||||
|
{
|
||||||
|
background: "var(--scrollbar-thumb-hover)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addUtilities({
|
||||||
|
".no-scrollbar": {
|
||||||
|
"scrollbar-width": "none",
|
||||||
|
"&::-webkit-scrollbar": {
|
||||||
|
display: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://github.com/tailwindlabs/tailwindcss/discussions/12127
|
||||||
|
addUtilities({
|
||||||
|
".break-anywhere": {
|
||||||
|
"@supports (overflow-wrap: anywhere)": {
|
||||||
|
"overflow-wrap": "anywhere",
|
||||||
|
},
|
||||||
|
"@supports not (overflow-wrap: anywhere)": {
|
||||||
|
"word-break": "break-word",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
addUtilities({
|
||||||
|
".no-number-spinner": {
|
||||||
|
MozAppearance: "textfield",
|
||||||
|
"&::-webkit-outer-spin-button": {
|
||||||
|
WebkitAppearance: "none !important",
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
"&::-webkit-inner-spin-button": {
|
||||||
|
WebkitAppearance: "none !important",
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
addUtilities({
|
||||||
|
".text-security": {
|
||||||
|
textSecurity: "disc",
|
||||||
|
WebkitTextSecurity: "disc",
|
||||||
|
MozTextSecurity: "disc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
addUtilities({
|
||||||
|
".display-sm": {
|
||||||
|
fontSize: "1rem", // 16px
|
||||||
|
lineHeight: "1.5rem", // 24px
|
||||||
|
fontWeight: "600", // semibold
|
||||||
|
},
|
||||||
|
".display-base": {
|
||||||
|
fontSize: "1.5rem", // 24px
|
||||||
|
lineHeight: "2rem", // 32px
|
||||||
|
letterSpacing: "-0.01em", // tracking-tight
|
||||||
|
},
|
||||||
|
".display-lg": {
|
||||||
|
fontSize: "1.875rem", // 30px
|
||||||
|
lineHeight: "2.375rem", // 38px
|
||||||
|
letterSpacing: "-0.01em", // tracking-tight
|
||||||
|
},
|
||||||
|
".display-xl": {
|
||||||
|
fontSize: "2.25rem", // 36px
|
||||||
|
lineHeight: "2.75rem", // 44px
|
||||||
|
letterSpacing: "-0.01em", // tracking-tight
|
||||||
|
},
|
||||||
|
".display-2xl": {
|
||||||
|
fontSize: "3rem", // 48px
|
||||||
|
lineHeight: "3.75rem", // 60px
|
||||||
|
letterSpacing: "-0.01em", // tracking-tight
|
||||||
|
},
|
||||||
|
".caps-label-sm": {
|
||||||
|
fontSize: "0.875rem", // 14px
|
||||||
|
lineHeight: "1.25rem", // 20px
|
||||||
|
letterSpacing: "0.02625rem", // 0.42px
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
".caps-label-xs": {
|
||||||
|
fontSize: "0.75rem", // 14px
|
||||||
|
lineHeight: "1.125rem", // 20px
|
||||||
|
letterSpacing: "0.0225rem", // 0.42px
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user