chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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: [],
|
||||
};
|
||||
Reference in New Issue
Block a user