chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,29 @@
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphHttpAgent } from "@copilotkit/runtime/langgraph";
import { NextRequest } from "next/server";
// Empty adapter since the LLM is handled by the remote agent
const serviceAdapter = new ExperimentalEmptyAdapter();
// Configure CopilotKit runtime with the Deep Agents backend
const runtime = new CopilotRuntime({
agents: {
research_assistant: new LangGraphHttpAgent({
url: process.env.LANGGRAPH_DEPLOYMENT_URL || "http://localhost:8123",
}),
},
});
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};
@@ -0,0 +1,589 @@
/* Inter + JetBrains Mono - Refined typography system */
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap");
@import "tailwindcss";
/* Typography plugin for markdown rendering */
@plugin "@tailwindcss/typography";
/* =============================================================================
DESIGN SYSTEM - CopilotKit Palette
============================================================================= */
:root {
/* Background & Surfaces */
--color-background: #fafaf9;
--color-surface: #f5f5f4;
--color-surface-light: #fafaf9;
--color-surface-elevated: #ffffff;
--color-container: #ffffff;
/* Text Hierarchy */
--color-text-primary: #1c1917;
--color-text-secondary: #57534e;
--color-text-tertiary: #a8a29e;
/* Accent Color (Deep Amber) */
--color-accent: #d97706;
--color-accent-light: #f59e0b;
--color-accent-dark: #92400e;
/* Legacy color names for compatibility */
--color-lilac: var(--color-accent);
--color-lilac-light: var(--color-accent-light);
--color-lilac-dark: var(--color-accent-dark);
--color-mint: var(--color-success);
--color-mint-light: #10b981;
--color-mint-dark: var(--color-success);
/* Status Colors */
--color-success: #15803d;
--color-error: #b91c1c;
--color-warning: #d97706;
/* Borders */
--color-border: #e7e5e4;
--color-border-light: #f5f5f4;
--color-border-subtle: #f5f5f4;
--color-border-glass: rgba(231, 229, 228, 0.4);
/* Glassmorphism */
--color-glass: rgba(255, 255, 255, 0.85);
--color-glass-subtle: rgba(255, 255, 255, 0.6);
--color-glass-dark: rgba(255, 255, 255, 0.95);
--color-glass-elevated: rgba(255, 255, 255, 0.95);
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -1px rgba(0, 0, 0, 0.04);
--shadow-lg:
0 10px 25px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.03);
--shadow-glass: 0 4px 30px rgba(0, 0, 0, 0.1);
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-20: 80px;
/* Radii */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
/* Typography */
--font-display: "Inter", system-ui, sans-serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Courier New", monospace;
--font-family: "Inter", system-ui, sans-serif;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
--text-3xl: 2.25rem;
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-base: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 24px;
--font-size-3xl: 32px;
--font-size-4xl: 40px;
--font-light: 300;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-extrabold: 800;
color-scheme: light;
}
@theme inline {
--color-background: var(--color-surface-light);
--color-foreground: var(--color-text-primary);
--font-sans: var(--font-family);
}
/* =============================================================================
BASE STYLES
============================================================================= */
body {
font-family: var(--font-family);
background: var(--color-surface-light);
color: var(--color-text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* =============================================================================
ABSTRACT SHAPES BACKGROUND
============================================================================= */
.abstract-bg {
position: fixed;
inset: 0;
overflow: hidden;
z-index: 0;
background: linear-gradient(
135deg,
var(--color-surface-light) 0%,
var(--color-surface) 100%
);
}
.abstract-bg::before,
.abstract-bg::after {
content: "";
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
}
.abstract-bg::before {
width: 600px;
height: 600px;
background: radial-gradient(
circle,
rgba(217, 119, 6, 0.15),
rgba(217, 119, 6, 0.08),
transparent 70%
);
top: -200px;
right: -100px;
animation: blob1 25s ease-in-out infinite;
}
.abstract-bg::after {
width: 500px;
height: 500px;
background: radial-gradient(
circle,
rgba(168, 162, 158, 0.12),
rgba(168, 162, 158, 0.06),
transparent 70%
);
bottom: -150px;
left: -100px;
animation: blob2 30s ease-in-out infinite;
}
.blob-3 {
position: absolute;
width: 400px;
height: 400px;
background: radial-gradient(
circle,
rgba(217, 119, 6, 0.1),
rgba(231, 229, 228, 0.08),
transparent 70%
);
border-radius: 50%;
filter: blur(100px);
opacity: 0.4;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: blob3 35s ease-in-out infinite;
}
@keyframes blob1 {
0%,
100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(-30px, 50px) scale(1.1);
}
50% {
transform: translate(20px, -30px) scale(0.95);
}
75% {
transform: translate(40px, 20px) scale(1.05);
}
}
@keyframes blob2 {
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(50px, -40px) scale(1.1);
}
66% {
transform: translate(-30px, 30px) scale(0.9);
}
}
@keyframes blob3 {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
}
25% {
transform: translate(-45%, -55%) scale(1.15);
}
50% {
transform: translate(-55%, -45%) scale(0.9);
}
75% {
transform: translate(-48%, -52%) scale(1.1);
}
}
/* =============================================================================
GLASSMORPHISM COMPONENTS
============================================================================= */
.glass {
background: var(--color-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-glass);
}
.glass-subtle {
background: var(--color-glass-subtle);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-lg);
}
.glass-card {
background: var(--color-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-glass);
padding: var(--space-6);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.glass-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12);
}
/* =============================================================================
WORKSPACE PANEL (Deep Research specific)
============================================================================= */
.workspace-panel {
background: var(--color-glass-dark);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
height: 100%;
overflow-y: auto;
}
.workspace-section {
background: var(--color-glass);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.workspace-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
cursor: pointer;
transition: background 0.2s ease;
}
.workspace-section-header:hover {
background: var(--color-glass-subtle);
}
.workspace-section-content {
padding: var(--space-4);
padding-top: 0;
}
/* Todo items */
.todo-item {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3);
border-radius: var(--radius-md);
transition: background 0.2s ease;
}
.todo-item:hover {
background: var(--color-glass-subtle);
}
.todo-item-completed {
text-decoration: line-through;
color: var(--color-text-tertiary);
}
.todo-item-pending {
color: var(--color-text-secondary);
}
.todo-item-inprogress {
color: var(--color-text-primary);
}
/* Status indicators */
.status-completed {
color: var(--color-success);
}
.status-pending {
color: var(--color-text-tertiary);
}
.status-inprogress {
color: var(--color-accent-dark);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* File items */
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3);
border-radius: var(--radius-md);
transition: background 0.2s ease;
cursor: pointer;
}
.file-item:hover {
background: var(--color-glass-subtle);
}
.file-item-icon {
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background: linear-gradient(
135deg,
var(--color-accent),
var(--color-accent-dark)
);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
/* Subagent indicator */
.subagent-indicator {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
background: linear-gradient(
135deg,
var(--color-accent-light),
rgba(231, 229, 228, 0.5)
);
border-radius: var(--radius-lg);
}
.subagent-indicator-active {
animation: shimmer 2s ease-in-out infinite;
}
@keyframes shimmer {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.subagent-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--color-accent-dark),
var(--color-accent)
);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
/* =============================================================================
COPILOTKIT SIDEBAR OVERRIDES
============================================================================= */
[data-copilotkit-sidebar] {
--copilot-kit-background-color: rgba(250, 250, 249, 0.95) !important;
--copilot-kit-secondary-color: rgba(255, 255, 255, 0.7) !important;
--copilot-kit-primary-color: var(--color-accent) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-left: 1px solid var(--color-border-glass) !important;
}
[data-copilotkit-sidebar] .copilotkit-message {
font-family: var(--font-family);
}
[data-copilotkit-sidebar] textarea,
[data-copilotkit-sidebar] input {
font-family: var(--font-family);
border-radius: var(--radius-lg);
}
/* =============================================================================
UTILITY CLASSES
============================================================================= */
.text-gradient {
background: linear-gradient(
135deg,
var(--color-accent-dark),
var(--color-accent)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.border-gradient {
border: 2px solid transparent;
background:
linear-gradient(var(--color-container), var(--color-container)) padding-box,
linear-gradient(135deg, var(--color-accent), var(--color-accent-light))
border-box;
}
/* Empty state styling */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8);
color: var(--color-text-tertiary);
text-align: center;
}
.empty-state-icon {
width: 48px;
height: 48px;
margin-bottom: var(--space-4);
opacity: 0.5;
}
/* Error state styling for failed sources */
.source-failed {
opacity: 0.6;
}
.source-failed .source-indicator {
color: var(--color-error);
}
/* =============================================================================
ANIMATIONS - List items and transitions
============================================================================= */
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeSlideIn {
animation: fadeSlideIn 0.3s ease-out forwards;
}
/* Fade in animation for empty states */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Rotate animation for loading states */
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Slow spin for in-progress tool icons */
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: spin-slow 3s linear infinite;
}
/* Hover scale for interactive elements */
.hover-scale {
transition: transform 0.2s ease;
}
.hover-scale:hover {
transform: scale(1.02);
}
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { CopilotKit } from "@copilotkit/react-core";
import "./globals.css";
import "@copilotkit/react-ui/styles.css";
export const metadata: Metadata = {
title: "Deep Research Assistant | CopilotKit Deep Agents Demo",
description:
"A research assistant powered by Deep Agents and CopilotKit - demonstrating planning, memory, subagents, and generative UI",
openGraph: {
title: "Deep Research Assistant",
description: "A research assistant powered by Deep Agents and CopilotKit",
images: ["/og-image.png"],
},
twitter: {
card: "summary_large_image",
title: "Deep Research Assistant",
description: "A research assistant powered by Deep Agents and CopilotKit",
images: ["/og-image.png"],
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased">
<CopilotKit runtimeUrl="/api/copilotkit" agent="research_assistant">
{children}
</CopilotKit>
</body>
</html>
);
}
@@ -0,0 +1,159 @@
"use client";
import { useState, useRef } from "react";
import { CopilotChat } from "@copilotkit/react-ui";
import { useDefaultTool } from "@copilotkit/react-core";
import { Workspace } from "@/components/Workspace";
import { ResearchState, INITIAL_STATE, Todo } from "@/types/research";
import { ToolCard } from "@/components/ToolCard";
export default function Page() {
const [state, setState] = useState<ResearchState>(INITIAL_STATE);
const processedKeysRef = useRef<Set<string>>(new Set());
useDefaultTool({
render: (props) => {
const { name, status, args, result } = props;
// Prevent duplicate processing on re-renders
if (status === "complete") {
const resultStr = result ? JSON.stringify(result) : "";
const resultHash = resultStr
? `${resultStr.length}-${resultStr.slice(0, 100)}`
: "";
const key = `${name}-${JSON.stringify(args)}-${resultHash}`;
if (processedKeysRef.current.has(key)) {
return <ToolCard {...props} />;
}
processedKeysRef.current.add(key);
}
// Handle research tool - track summary and sources
if (name === "research" && status === "complete" && result) {
const researchResult = result as {
summary: string;
sources: Array<{
url: string;
title: string;
content?: string;
status: "found" | "scraped" | "failed";
}>;
};
// Track sources in state
if (researchResult.sources && researchResult.sources.length > 0) {
queueMicrotask(() =>
setState((prev) => ({
...prev,
sources: [...prev.sources, ...researchResult.sources],
})),
);
}
console.log(
`[UI] Research completed: ${researchResult.sources?.length || 0} sources found`,
);
}
// Handle write_todos tool
if (name === "write_todos" && status === "complete" && args?.todos) {
const todosWithIds = (
args.todos as Array<{ id?: string; content: string; status: string }>
).map((todo, index) => ({
...todo,
id: todo.id || `todo-${Date.now()}-${index}`,
}));
queueMicrotask(() =>
setState((prev) => ({ ...prev, todos: todosWithIds as Todo[] })),
);
}
// Handle write_file tool
// Deep Agents uses file_path (not path) as the parameter name
if (name === "write_file" && status === "complete" && args?.file_path) {
queueMicrotask(() =>
setState((prev) => ({
...prev,
files: [
...prev.files,
{
path: args.file_path as string,
content: args.content as string,
createdAt: new Date().toISOString(),
},
],
})),
);
}
return <ToolCard {...props} />;
},
});
return (
<div className="relative min-h-screen">
{/* Animated background */}
<div className="abstract-bg">
<div className="blob-3" />
</div>
{/* Main content */}
<main className="relative z-10 h-screen flex overflow-hidden">
{/* Chat panel - left side (38%) */}
<div className="w-[38%] h-full border-r border-[var(--color-border-glass)] bg-[var(--color-glass-dark)] backdrop-blur-xl overflow-hidden">
<div className="h-full flex flex-col">
{/* Header */}
<header
style={{ padding: "var(--space-8)" }}
className="border-b border-[var(--color-border-glass)]"
>
<h1
style={{
fontSize: "var(--text-3xl)",
fontWeight: "var(--font-extrabold)",
fontFamily: "var(--font-display)",
fontOpticalSizing: "auto",
}}
className="text-gradient"
>
Deep Research Assistant
</h1>
<p
style={{
fontSize: "var(--text-sm)",
color: "var(--color-text-secondary)",
marginTop: "var(--space-1)",
}}
>
Ask me to research any topic
</p>
</header>
<div
style={{
flex: 1,
minHeight: 0,
overflow: "hidden",
padding: "var(--space-6)",
}}
>
<CopilotChat
className="h-full"
labels={{
title: "Deep Research Assistant",
initial: "What topic would you like me to research?",
placeholder: "Ask me to research any topic...",
}}
/>
</div>
</div>
</div>
{/* Workspace panel - right side (62%) */}
<div className="w-[62%] h-full overflow-hidden">
<Workspace state={state} />
</div>
</main>
</div>
);
}
@@ -0,0 +1,175 @@
"use client";
import { useEffect, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import { X, Download, FileText } from "lucide-react";
import type { ResearchFile } from "@/types/research";
/**
* FileViewerModal - Modal for viewing file content with markdown rendering.
*
* Features:
* - Markdown rendering via react-markdown with typography styles
* - Download button to save file content
* - Closes on backdrop click, X button, or Escape key
* - Responsive sizing with scrollable content
*/
interface FileViewerModalProps {
file: ResearchFile | null;
onClose: () => void;
}
export function FileViewerModal({ file, onClose }: FileViewerModalProps) {
// Handle Escape key to close modal
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
},
[onClose],
);
useEffect(() => {
if (file) {
document.addEventListener("keydown", handleKeyDown);
// Prevent body scroll when modal is open
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
};
}, [file, handleKeyDown]);
// Don't render if no file selected
if (!file) return null;
// Extract filename from path
const filename = file.path.split("/").pop() || file.path;
// Download file content
const handleDownload = () => {
const blob = new Blob([file.content], { type: "text/markdown" });
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);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop with blur */}
<div
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal container */}
<div
className="relative max-w-3xl w-full max-h-[85vh] flex flex-col"
style={{
background: "var(--color-glass-elevated)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
padding: 0,
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border-glass)",
boxShadow: "0 4px 30px rgba(0, 0, 0, 0.1)",
}}
role="dialog"
aria-modal="true"
aria-labelledby="file-viewer-title"
>
{/* Header */}
<div
style={{
padding:
"var(--space-6) var(--space-6) var(--space-4) var(--space-6)",
}}
className="flex items-center justify-between border-b border-[var(--color-border-glass)]"
>
<div className="flex items-center gap-3">
<div
style={{
background:
"linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-dark) 100%)",
padding: "var(--space-3)",
borderRadius: "var(--radius-md)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<FileText
style={{ width: "20px", height: "20px", color: "white" }}
/>
</div>
<h2
id="file-viewer-title"
style={{
fontSize: "var(--text-2xl)",
fontWeight: "var(--font-bold)",
fontFamily: "var(--font-display)",
color: "var(--color-text-primary)",
}}
className="truncate max-w-md"
>
{filename}
</h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDownload}
className="p-2 hover:bg-[var(--color-glass-subtle)] rounded-lg transition-colors"
aria-label="Download file"
title="Download file"
>
<Download className="w-5 h-5 text-[var(--color-text-secondary)]" />
</button>
<button
onClick={onClose}
className="p-2 hover:bg-[var(--color-glass-subtle)] rounded-lg transition-colors"
aria-label="Close modal"
title="Close (Escape)"
>
<X className="w-5 h-5 text-[var(--color-text-secondary)]" />
</button>
</div>
</div>
{/* Scrollable content with markdown rendering */}
<div
className="flex-1 overflow-y-auto"
style={{ padding: "var(--space-8)" }}
>
<div className="prose prose-sm prose-slate max-w-none">
<ReactMarkdown>{file.content}</ReactMarkdown>
</div>
</div>
{/* Footer with file path */}
<div
style={{ padding: "var(--space-3) var(--space-6)" }}
className="border-t border-[var(--color-border-glass)]"
>
<code
style={{
fontFamily: "var(--font-mono)",
fontSize: "var(--text-sm)",
color: "var(--color-text-tertiary)",
}}
>
{file.path}
</code>
</div>
</div>
</div>
);
}
@@ -0,0 +1,435 @@
"use client";
import { useState } from "react";
import {
ChevronDown,
Pencil,
ClipboardList,
Search,
Save,
BookOpen,
Check,
} from "lucide-react";
/**
* ToolCard - Generative UI for tool call rendering in chat.
*
* Two rendering modes:
* - SpecializedToolCard: Emoji-based cards for known tools with result previews
* - DefaultToolCard: Generic JSON display for unknown tools
*
* Result structures expected from backend:
* - internet_search: Array<{url, title, content, raw_content}>
* - write_todos: { todos: Array<{id, content, status}> }
* - write_file: just args (path, content) - result is confirmation
* - task: completion message
*/
interface ToolCardProps {
name: string;
status: "inProgress" | "executing" | "complete";
args: Record<string, unknown>;
result?: unknown;
}
// Tool configuration mapping
const TOOL_CONFIG: Record<
string,
{
icon: React.ComponentType<{
size?: number;
strokeWidth?: number;
className?: string;
style?: React.CSSProperties;
}>;
getDisplayText: (args: Record<string, unknown>) => string;
getResultSummary?: (
result: unknown,
args: Record<string, unknown>,
) => string | null;
}
> = {
write_todos: {
icon: Pencil,
getDisplayText: () => "Updating research plan...",
// Args contains the todos array (result is a Command with ToolMessage string)
getResultSummary: (result, args) => {
const todos = (args as { todos?: unknown[] })?.todos;
if (Array.isArray(todos)) {
return `${todos.length} todo${todos.length !== 1 ? "s" : ""} updated`;
}
return null;
},
},
read_todos: {
icon: ClipboardList,
getDisplayText: () => "Checking research plan...",
getResultSummary: (result) => {
const todos = (result as { todos?: unknown[] })?.todos;
if (Array.isArray(todos)) {
return `${todos.length} todo${todos.length !== 1 ? "s" : ""} found`;
}
return null;
},
},
research: {
icon: Search,
getDisplayText: (args) =>
`Researching: ${((args.query as string) || "...").slice(0, 50)}${(args.query as string)?.length > 50 ? "..." : ""}`,
// Result is now a dict with summary and sources
getResultSummary: (result) => {
if (result && typeof result === "object" && "sources" in result) {
const { sources } = result as { summary: string; sources: unknown[] };
return `Found ${sources.length} source${sources.length !== 1 ? "s" : ""}`;
}
return "Research complete";
},
},
write_file: {
icon: Save,
getDisplayText: (args) => {
const path = args.path as string | undefined;
const filename =
path?.split("/").pop() || (args.filename as string | undefined);
return `Writing: ${filename || "file"}`;
},
// Show first line preview from args (content is in args, not result)
getResultSummary: (_result, args) => {
const content = args.content as string | undefined;
if (content) {
const firstLine = content.split("\n")[0].slice(0, 50);
return firstLine + (content.length > 50 ? "..." : "");
}
return "File written";
},
},
read_file: {
icon: BookOpen,
getDisplayText: (args) => {
const path = args.path as string | undefined;
const filename =
path?.split("/").pop() || (args.filename as string | undefined);
return `Reading: ${filename || "file"}`;
},
getResultSummary: (result) => {
const content = (result as { content?: string })?.content;
if (content && typeof content === "string") {
const preview = content.slice(0, 50);
return preview + (content.length > 50 ? "..." : "");
}
return null;
},
},
};
export function ToolCard({ name, status, args, result }: ToolCardProps) {
const config = TOOL_CONFIG[name];
if (config) {
return (
<SpecializedToolCard
name={name}
status={status}
args={args}
result={result}
config={config}
/>
);
}
return (
<DefaultToolCard name={name} status={status} args={args} result={result} />
);
}
interface SpecializedToolCardProps extends ToolCardProps {
config: {
icon: React.ComponentType<{
size?: number;
strokeWidth?: number;
className?: string;
style?: React.CSSProperties;
}>;
getDisplayText: (args: Record<string, unknown>) => string;
getResultSummary?: (
result: unknown,
args: Record<string, unknown>,
) => string | null;
};
}
function SpecializedToolCard({
name,
status,
args,
result,
config,
}: SpecializedToolCardProps) {
const [expanded, setExpanded] = useState(false);
const isComplete = status === "complete";
const isExecuting = status === "inProgress" || status === "executing";
// Get result summary for completed tools
const resultSummary =
isComplete && config.getResultSummary
? config.getResultSummary(result, args)
: null;
// Determine if this tool has expandable content
const hasExpandableContent =
isComplete && (name === "research" || name === "write_todos");
return (
<div
className={`
glass-subtle
transition-all duration-200
${isComplete ? "opacity-80" : ""}
${hasExpandableContent ? "cursor-pointer" : ""}
`}
style={{
padding: "var(--space-4)",
marginBottom: "var(--space-2)",
}}
onClick={hasExpandableContent ? () => setExpanded(!expanded) : undefined}
>
<div className="flex items-center" style={{ gap: "var(--space-3)" }}>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background: isComplete
? "rgba(21, 128, 61, 0.1)"
: "rgba(217, 119, 6, 0.1)",
}}
>
{isComplete ? (
<Check
size={16}
strokeWidth={2}
style={{ color: "var(--color-success)" }}
/>
) : (
<config.icon
size={16}
strokeWidth={2}
className={isExecuting ? "animate-spin-slow" : ""}
style={{ color: "var(--color-accent)" }}
/>
)}
</div>
<div className="flex-1 min-w-0">
<p
className={`
text-sm font-medium
${
isComplete
? "text-[var(--color-text-tertiary)]"
: "text-[var(--color-text-primary)]"
}
`}
>
{config.getDisplayText(args)}
</p>
{/* Result summary shown below the display text when complete */}
{resultSummary && (
<p
className="text-xs mt-0.5"
style={{ color: "var(--color-success)" }}
>
{resultSummary}
</p>
)}
</div>
{/* Expand indicator for expandable tools */}
{hasExpandableContent && (
<ChevronDown
className={`w-4 h-4 text-[var(--color-text-tertiary)] transition-transform ${expanded ? "rotate-180" : ""}`}
/>
)}
</div>
{/* Expanded details section */}
{expanded && isComplete && (
<div
style={{ marginTop: "var(--space-3)", paddingTop: "var(--space-3)" }}
className="border-t border-[var(--color-border-glass)]"
>
<ExpandedDetails name={name} result={result} args={args} />
</div>
)}
</div>
);
}
/**
* Renders expanded details based on tool type.
* Each tool has its own structured view of the result.
*/
function ExpandedDetails({
name,
result,
args,
}: {
name: string;
result: unknown;
args: Record<string, unknown>;
}) {
// research: show the full prose summary
if (name === "research") {
// Extract summary from object or use string directly
const summary =
typeof result === "object" && result && "summary" in result
? (result as { summary: string; sources: unknown[] }).summary
: typeof result === "string"
? result
: "";
if (!summary)
return (
<p className="text-xs text-[var(--color-text-tertiary)]">No findings</p>
);
return (
<div className="space-y-2">
<p className="text-xs font-medium text-[var(--color-text-tertiary)]">
Query:
</p>
<p className="text-xs text-[var(--color-text-secondary)]">
{(args.query as string) || "..."}
</p>
<p className="text-xs font-medium text-[var(--color-text-tertiary)] mt-2">
Findings:
</p>
<p className="text-sm text-[var(--color-text-primary)] whitespace-pre-wrap">
{summary}
</p>
</div>
);
}
// write_todos: show todo list (from args, not result)
if (name === "write_todos") {
const todos = (
args as { todos?: Array<{ id: string; content: string; status: string }> }
)?.todos;
if (!todos?.length)
return (
<p className="text-xs text-[var(--color-text-tertiary)]">No todos</p>
);
return (
<div className="space-y-1 max-h-40 overflow-y-auto">
{todos.map((todo, i) => (
<div key={todo.id || i} className="flex items-start gap-2 text-xs">
<span
className="mt-0.5"
style={{
color:
todo.status === "completed"
? "var(--color-success)"
: todo.status === "in_progress"
? "var(--color-accent-dark)"
: "var(--color-text-tertiary)",
}}
>
{todo.status === "completed"
? "✓"
: todo.status === "in_progress"
? "●"
: "○"}
</span>
<span
className={
todo.status === "completed"
? "line-through text-[var(--color-text-tertiary)]"
: ""
}
>
{todo.content}
</span>
</div>
))}
</div>
);
}
// Fallback: JSON display
return (
<pre className="text-xs bg-[var(--color-container)] p-2 rounded-md overflow-auto max-h-32 border border-[var(--color-border)]">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</pre>
);
}
function DefaultToolCard({ name, status, args, result }: ToolCardProps) {
const [expanded, setExpanded] = useState(false);
const isComplete = status === "complete";
return (
<div className="glass-subtle p-3 my-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`
w-8 h-8 rounded-lg flex items-center justify-center
text-lg
${
isComplete
? "bg-[var(--color-mint)]/20"
: "bg-[var(--color-lilac)]/20"
}
`}
>
{isComplete ? "✓" : "⚙️"}
</div>
<div className="flex items-center gap-2">
<code className="text-sm text-[var(--color-text-primary)]">
{name}
</code>
<span
className={`
text-xs px-2 py-0.5 rounded-full
${
isComplete
? "bg-[var(--color-mint)]/20 text-[var(--color-mint-dark)]"
: "bg-[var(--color-lilac)]/20 text-[var(--color-lilac-dark)]"
}
`}
>
{status}
</span>
</div>
</div>
<button
onClick={() => setExpanded(!expanded)}
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<ChevronDown
className={`w-4 h-4 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</button>
</div>
{expanded && (
<div className="mt-3 space-y-2">
<div>
<p className="text-xs text-[var(--color-text-tertiary)] mb-1">
Arguments:
</p>
<pre className="text-xs bg-[var(--color-container)] p-2 rounded-md overflow-auto max-h-32 border border-[var(--color-border)]">
{JSON.stringify(args, null, 2)}
</pre>
</div>
{result !== undefined && result !== null && (
<div>
<p className="text-xs text-[var(--color-text-tertiary)] mb-1">
Result:
</p>
<pre className="text-xs bg-[var(--color-container)] p-2 rounded-md overflow-auto max-h-32 border border-[var(--color-border)]">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,350 @@
"use client";
import { useState } from "react";
import {
ChevronDown,
ChevronRight,
ListTodo,
FileText,
Download,
Globe,
Check,
Circle,
CircleDot,
X,
} from "lucide-react";
import { ResearchState, Todo, ResearchFile, Source } from "@/types/research";
import { FileViewerModal } from "@/components/FileViewerModal";
// Helper function to download file content
function downloadFile(file: ResearchFile) {
const blob = new Blob([file.content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.path.split("/").pop() || "file.txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
interface WorkspaceProps {
state: ResearchState;
}
// Collapsible section component with smooth transitions
function Section({
title,
icon: Icon,
children,
defaultOpen = true,
badge,
}: {
title: string;
icon: React.ElementType;
children: React.ReactNode;
defaultOpen?: boolean;
badge?: number;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="workspace-section">
<button
onClick={() => setIsOpen(!isOpen)}
className="workspace-section-header w-full transition-all duration-200"
>
<div className="flex items-center gap-3">
<Icon className="w-5 h-5 text-[var(--color-text-secondary)]" />
<span className="font-semibold text-[var(--color-text-primary)]">
{title}
</span>
{badge !== undefined && badge > 0 && (
<span
style={{
background: "var(--color-accent)",
color: "var(--color-background)",
padding: "var(--space-1) var(--space-2)",
fontSize: "var(--text-xs)",
fontWeight: "var(--font-semibold)",
borderRadius: "var(--radius-lg)",
}}
>
{badge}
</span>
)}
</div>
{isOpen ? (
<ChevronDown className="w-5 h-5 text-[var(--color-text-tertiary)] transition-transform" />
) : (
<ChevronRight className="w-5 h-5 text-[var(--color-text-tertiary)] transition-transform" />
)}
</button>
{isOpen && <div className="workspace-section-content">{children}</div>}
</div>
);
}
// Todo list component with animations
function TodoList({ todos }: { todos: Todo[] }) {
if (todos.length === 0) {
return (
<div
className="empty-state"
style={{
paddingTop: "var(--space-8)",
paddingBottom: "var(--space-8)",
animation: "fadeIn 0.4s ease",
}}
>
<ListTodo
size={32}
strokeWidth={1.5}
style={{
color: "var(--color-text-tertiary)",
marginBottom: "var(--space-3)",
}}
/>
<p style={{ fontSize: "var(--text-sm)" }}>No tasks yet</p>
<p className="text-xs mt-1">Research tasks will appear here</p>
</div>
);
}
return (
<div className="space-y-1">
{todos.map((todo) => (
<div
key={todo.id}
className={`todo-item animate-fadeSlideIn ${
todo.status === "completed"
? "todo-item-completed"
: todo.status === "in_progress"
? "todo-item-inprogress"
: "todo-item-pending"
}`}
>
<span
className={`${
todo.status === "completed"
? "status-completed"
: todo.status === "in_progress"
? "status-inprogress"
: "status-pending"
}`}
>
{todo.status === "completed" ? (
<Check size={14} />
) : todo.status === "in_progress" ? (
<CircleDot size={14} />
) : (
<Circle size={14} />
)}
</span>
<span className="text-sm">{todo.content}</span>
</div>
))}
</div>
);
}
// File list component with click-to-view and animations
function FileList({
files,
onFileClick,
}: {
files: ResearchFile[];
onFileClick: (file: ResearchFile) => void;
}) {
if (files.length === 0) {
return (
<div
className="empty-state"
style={{
paddingTop: "var(--space-8)",
paddingBottom: "var(--space-8)",
animation: "fadeIn 0.4s ease",
}}
>
<FileText
size={32}
strokeWidth={1.5}
style={{
color: "var(--color-text-tertiary)",
marginBottom: "var(--space-3)",
}}
/>
<p style={{ fontSize: "var(--text-sm)" }}>No files yet</p>
<p className="text-xs mt-1">Research artifacts will appear here</p>
</div>
);
}
return (
<div className="space-y-2">
{files.map((file, i) => (
<div
key={`${file.path}-${i}`}
className="file-item animate-fadeSlideIn"
onClick={() => onFileClick(file)}
>
<div className="flex items-center gap-3">
<div className="file-item-icon">
<FileText className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium text-[var(--color-text-primary)]">
{file.path.split("/").pop()}
</p>
<p className="text-xs text-[var(--color-text-tertiary)]">
{file.path}
</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation(); // Don't trigger file view on download click
downloadFile(file);
}}
className="p-2 rounded-lg hover:bg-[var(--color-glass-subtle)] transition-colors"
aria-label="Download file"
title="Download file"
>
<Download className="w-4 h-4 text-[var(--color-text-secondary)]" />
</button>
</div>
))}
</div>
);
}
// Source list component with error states and animations
function SourceList({ sources }: { sources: Source[] }) {
if (sources.length === 0) {
return (
<div
className="empty-state"
style={{
paddingTop: "var(--space-8)",
paddingBottom: "var(--space-8)",
animation: "fadeIn 0.4s ease",
}}
>
<Globe
size={32}
strokeWidth={1.5}
style={{
color: "var(--color-text-tertiary)",
marginBottom: "var(--space-3)",
}}
/>
<p style={{ fontSize: "var(--text-sm)" }}>No sources yet</p>
<p className="text-xs mt-1">Web sources will appear here</p>
</div>
);
}
return (
<div className="space-y-2">
{sources.map((source, i) => (
<div
key={`${source.url}-${i}`}
className={`file-item animate-fadeSlideIn ${source.status === "failed" ? "source-failed" : ""}`}
title={
source.status === "failed"
? "Failed to scrape this source"
: undefined
}
>
<div className="flex items-center gap-3">
<span
className={`source-indicator ${
source.status === "scraped"
? "status-completed"
: source.status === "failed"
? ""
: "status-pending"
}`}
style={
source.status === "failed"
? { color: "var(--color-error)" }
: undefined
}
>
{source.status === "scraped" ? (
<Check size={14} style={{ color: "var(--color-success)" }} />
) : source.status === "failed" ? (
<X size={14} style={{ color: "var(--color-error)" }} />
) : (
<Circle size={14} />
)}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate">
{source.title ||
(() => {
try {
return new URL(source.url).hostname;
} catch {
return source.url.slice(0, 40);
}
})()}
</p>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-accent)] truncate block"
>
{source.url}
</a>
</div>
</div>
</div>
))}
</div>
);
}
// Main Workspace component
export function Workspace({ state }: WorkspaceProps) {
const { todos, files, sources } = state;
const fileCount = files.length;
const todoCount = todos.length;
const sourceCount = sources.length;
// State for file viewer modal
const [selectedFile, setSelectedFile] = useState<ResearchFile | null>(null);
return (
<div className="workspace-panel p-6">
<div className="mb-6">
<h2 className="text-xl font-bold text-[var(--color-text-primary)]">
Workspace
</h2>
<p className="text-sm text-[var(--color-text-secondary)]">
Research progress and artifacts
</p>
</div>
<Section title="Research Plan" icon={ListTodo} badge={todoCount}>
<TodoList todos={todos} />
</Section>
<Section title="Files" icon={FileText} badge={fileCount}>
<FileList files={files} onFileClick={setSelectedFile} />
</Section>
<Section title="Sources" icon={Globe} badge={sourceCount}>
<SourceList sources={sources} />
</Section>
{/* File Viewer Modal */}
<FileViewerModal
file={selectedFile}
onClose={() => setSelectedFile(null)}
/>
</div>
);
}
@@ -0,0 +1,39 @@
/**
* Research State Types
*
* Types for managing research state in the Deep Research Assistant.
* Uses local state + useDefaultTool pattern instead of useCoAgent
* to avoid type mismatches with Python FilesystemMiddleware.
*/
export interface Todo {
id: string;
content: string;
status: "pending" | "in_progress" | "completed";
}
export interface ResearchFile {
path: string;
content: string;
createdAt: string;
}
// Sources found via internet_search (includes content)
export interface Source {
url: string;
title: string;
content?: string;
status: "found" | "scraped" | "failed";
}
export interface ResearchState {
todos: Todo[];
files: ResearchFile[];
sources: Source[];
}
export const INITIAL_STATE: ResearchState = {
todos: [],
files: [],
sources: [],
};