mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
28 Commits
v0.6.13
...
feat/new-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30377d775b | ||
|
|
d013132d0e | ||
|
|
7b0ce8064a | ||
|
|
0ea73263df | ||
|
|
edc502384b | ||
|
|
e2be99263c | ||
|
|
f6b461ad47 | ||
|
|
e4d35735b1 | ||
|
|
b4064c57fb | ||
|
|
eac41ca105 | ||
|
|
d2c3c1c39e | ||
|
|
8f3e864751 | ||
|
|
23c3072784 | ||
|
|
33fdb11396 | ||
|
|
21156dd54a | ||
|
|
c05e2e0fc8 | ||
|
|
a7c1e510e6 | ||
|
|
271624a402 | ||
|
|
dda012eae9 | ||
|
|
2dd6d3d1e6 | ||
|
|
b90bb75cda | ||
|
|
fb233d003d | ||
|
|
34df3333d1 | ||
|
|
23677d41a0 | ||
|
|
a489f91085 | ||
|
|
ed6e7845cc | ||
|
|
e698f9fe14 | ||
|
|
db1798267e |
10
README.md
10
README.md
@@ -74,6 +74,10 @@ docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
#### Background worker note
|
||||
|
||||
The Docker Compose stack starts a dedicated worker container by default. If `REDIS_URL` is not configured, the worker will start, log that it is idle, and do no queue processing. This is expected. Queue-backed API, webhook, and schedule execution requires Redis; installs without Redis continue to use the inline execution path.
|
||||
|
||||
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
|
||||
|
||||
### Self-hosted: Manual Setup
|
||||
@@ -113,10 +117,12 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
|
||||
5. Start development servers:
|
||||
|
||||
```bash
|
||||
bun run dev:full # Starts both Next.js app and realtime socket server
|
||||
bun run dev:full # Starts Next.js app, realtime socket server, and the BullMQ worker
|
||||
```
|
||||
|
||||
Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
|
||||
If `REDIS_URL` is not configured, the worker will remain idle and execution continues inline.
|
||||
|
||||
Or run separately: `bun run dev` (Next.js), `cd apps/sim && bun run dev:sockets` (realtime), and `cd apps/sim && bun run worker` (BullMQ worker).
|
||||
|
||||
## Copilot API Keys
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export const metadata = {
|
||||
metadataBase: new URL('https://docs.sim.ai'),
|
||||
title: {
|
||||
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||
template: '%s',
|
||||
template: '%s | Sim Docs',
|
||||
},
|
||||
description:
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
|
||||
@@ -195,6 +195,17 @@ By default, your usage is capped at the credits included in your plan. To allow
|
||||
|
||||
Max (individual) shares the same rate limits as team plans. Team plans (Pro or Max for Teams) use the Max-tier rate limits.
|
||||
|
||||
### Concurrent Execution Limits
|
||||
|
||||
| Plan | Concurrent Executions |
|
||||
|------|----------------------|
|
||||
| **Free** | 5 |
|
||||
| **Pro** | 50 |
|
||||
| **Max / Team** | 200 |
|
||||
| **Enterprise** | 200 (customizable) |
|
||||
|
||||
Concurrent execution limits control how many workflow executions can run simultaneously within a workspace. When the limit is reached, new executions are queued and admitted as running executions complete. Manual runs from the editor are not subject to these limits.
|
||||
|
||||
### File Storage
|
||||
|
||||
| Plan | Storage |
|
||||
|
||||
@@ -26,6 +26,8 @@ const RESOURCES_LINKS: FooterItem[] = [
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
// { label: 'Templates', href: '/templates' },
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Academy', href: '/academy' },
|
||||
{ label: 'Partners', href: '/partners' },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
]
|
||||
|
||||
@@ -25,6 +25,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'5GB file storage',
|
||||
'3 tables · 1,000 rows each',
|
||||
'5 min execution limit',
|
||||
'5 concurrent/workspace',
|
||||
'7-day log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
@@ -42,6 +43,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'50GB file storage',
|
||||
'25 tables · 5,000 rows each',
|
||||
'50 min execution · 150 runs/min',
|
||||
'50 concurrent/workspace',
|
||||
'Unlimited log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
@@ -59,6 +61,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'500GB file storage',
|
||||
'25 tables · 5,000 rows each',
|
||||
'50 min execution · 300 runs/min',
|
||||
'200 concurrent/workspace',
|
||||
'Unlimited log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
@@ -75,6 +78,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'Custom file storage',
|
||||
'10,000 tables · 1M rows each',
|
||||
'Custom execution limits',
|
||||
'Custom concurrency limits',
|
||||
'Unlimited log retention',
|
||||
'SSO & SCIM · SOC2 & HIPAA',
|
||||
'Self hosting · Dedicated support',
|
||||
|
||||
43
apps/sim/app/(landing)/blog/components/blog-image.tsx
Normal file
43
apps/sim/app/(landing)/blog/components/blog-image.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import NextImage from 'next/image'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { Lightbox } from '@/app/(landing)/blog/components/lightbox'
|
||||
|
||||
interface BlogImageProps {
|
||||
src: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BlogImage({ src, alt = '', width = 800, height = 450, className }: BlogImageProps) {
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(
|
||||
'h-auto w-full cursor-pointer rounded-lg transition-opacity hover:opacity-95',
|
||||
className
|
||||
)}
|
||||
sizes='(max-width: 768px) 100vw, 800px'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
onClick={() => setIsLightboxOpen(true)}
|
||||
/>
|
||||
<Lightbox
|
||||
isOpen={isLightboxOpen}
|
||||
onClose={() => setIsLightboxOpen(false)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
62
apps/sim/app/(landing)/blog/components/lightbox.tsx
Normal file
62
apps/sim/app/(landing)/blog/components/lightbox.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface LightboxProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (overlayRef.current && event.target === overlayRef.current) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-label='Image viewer'
|
||||
>
|
||||
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className='max-h-[75vh] max-w-[75vw] cursor-pointer rounded-xl object-contain'
|
||||
loading='lazy'
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
291
apps/sim/app/(landing)/partners/page.tsx
Normal file
291
apps/sim/app/(landing)/partners/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Partner Program',
|
||||
description:
|
||||
'Join the Sim partner program. Build, deploy, and sell AI workflow solutions. Earn your certification through Sim Academy.',
|
||||
metadataBase: new URL('https://sim.ai'),
|
||||
openGraph: {
|
||||
title: 'Partner Program | Sim',
|
||||
description: 'Join the Sim partner program.',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
const PARTNER_TIERS = [
|
||||
{
|
||||
name: 'Certified Partner',
|
||||
badge: 'Entry',
|
||||
color: '#3A3A3A',
|
||||
requirements: ['Complete Sim Academy certification', 'Deploy at least 1 live workflow'],
|
||||
perks: [
|
||||
'Official partner badge',
|
||||
'Listed in partner directory',
|
||||
'Early access to new features',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Silver Partner',
|
||||
badge: 'Growth',
|
||||
color: '#5A5A5A',
|
||||
requirements: [
|
||||
'All Certified requirements',
|
||||
'3+ active client deployments',
|
||||
'Sim Academy advanced certification',
|
||||
],
|
||||
perks: [
|
||||
'All Certified perks',
|
||||
'Dedicated partner Slack channel',
|
||||
'Co-marketing opportunities',
|
||||
'Priority support',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Gold Partner',
|
||||
badge: 'Premier',
|
||||
color: '#8B7355',
|
||||
requirements: [
|
||||
'All Silver requirements',
|
||||
'10+ active client deployments',
|
||||
'Sim solutions architect certification',
|
||||
],
|
||||
perks: [
|
||||
'All Silver perks',
|
||||
'Revenue share program',
|
||||
'Joint case studies',
|
||||
'Dedicated partner success manager',
|
||||
'Influence product roadmap',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const HOW_IT_WORKS = [
|
||||
{
|
||||
step: '01',
|
||||
title: 'Sign up & complete Sim Academy',
|
||||
description:
|
||||
'Create an account and work through the Sim Academy certification program. Learn to build, integrate, and deploy AI workflows through hands-on canvas exercises.',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: 'Build & deploy real solutions',
|
||||
description:
|
||||
'Put your skills to work. Build workflow automations for clients, integrate Sim into existing products, or create your own Sim-powered applications.',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: 'Get certified & grow',
|
||||
description:
|
||||
'Earn your partner certification and unlock perks, co-marketing opportunities, and revenue share as you scale your practice.',
|
||||
},
|
||||
]
|
||||
|
||||
const BENEFITS = [
|
||||
{
|
||||
icon: '🎓',
|
||||
title: 'Interactive Learning',
|
||||
description:
|
||||
'Learn on the real Sim canvas with drag-and-drop exercises, instant feedback, and guided exercises — not just videos.',
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Co-Marketing',
|
||||
description:
|
||||
'Get listed in the Sim partner directory, featured in case studies, and promoted to the Sim user base.',
|
||||
},
|
||||
{
|
||||
icon: '💰',
|
||||
title: 'Revenue Share',
|
||||
description: 'Gold partners earn revenue share on referred customers and managed deployments.',
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: 'Early Access',
|
||||
description:
|
||||
'Partners get early access to new Sim features, APIs, and integrations before they launch publicly.',
|
||||
},
|
||||
{
|
||||
icon: '🛠️',
|
||||
title: 'Technical Support',
|
||||
description:
|
||||
'Priority technical support, private Slack access, and a dedicated partner success manager for Gold partners.',
|
||||
},
|
||||
{
|
||||
icon: '📣',
|
||||
title: 'Community',
|
||||
description:
|
||||
'Join a growing community of Sim builders. Share workflows, collaborate on solutions, and shape the product roadmap.',
|
||||
},
|
||||
]
|
||||
|
||||
export default async function PartnersPage() {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]`}
|
||||
>
|
||||
<header>
|
||||
<Navbar logoOnly={false} blogPosts={blogPosts} />
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className='border-[#2A2A2A] border-b px-[80px] py-[100px]'>
|
||||
<div className='mx-auto max-w-4xl'>
|
||||
<div className='mb-4 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Partner Program
|
||||
</div>
|
||||
<h1 className='mb-5 text-[64px] text-white leading-[105%] tracking-[-0.03em]'>
|
||||
Build the future
|
||||
<br />
|
||||
of AI automation
|
||||
</h1>
|
||||
<p className='mb-10 max-w-xl text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
|
||||
Become a certified Sim partner. Complete Sim Academy, deploy real solutions, and earn
|
||||
recognition in the growing ecosystem of AI workflow builders.
|
||||
</p>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Link
|
||||
href='/academy'
|
||||
className='inline-flex h-[44px] items-center rounded-[5px] bg-white px-6 text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
|
||||
>
|
||||
Start Sim Academy →
|
||||
</Link>
|
||||
<a
|
||||
href='#how-it-works'
|
||||
className='inline-flex h-[44px] items-center rounded-[5px] border border-[#3A3A3A] px-6 text-[#ECECEC] text-[15px] transition-colors hover:border-[#4A4A4A]'
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits grid */}
|
||||
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
|
||||
<div className='mx-auto max-w-5xl'>
|
||||
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Why partner with Sim
|
||||
</div>
|
||||
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{BENEFITS.map((b) => (
|
||||
<div key={b.title} className='rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'>
|
||||
<div className='mb-3 text-[24px]'>{b.icon}</div>
|
||||
<h3 className='mb-2 text-[#ECECEC] text-[15px]'>{b.title}</h3>
|
||||
<p className='text-[#999] text-[14px] leading-[160%]'>{b.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section id='how-it-works' className='border-[#2A2A2A] border-b px-[80px] py-20'>
|
||||
<div className='mx-auto max-w-4xl'>
|
||||
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
How it works
|
||||
</div>
|
||||
<div className='space-y-10'>
|
||||
{HOW_IT_WORKS.map((step) => (
|
||||
<div key={step.step} className='flex gap-8'>
|
||||
<div className='flex-shrink-0 font-[430] text-[#2A2A2A] text-[48px] leading-none'>
|
||||
{step.step}
|
||||
</div>
|
||||
<div className='pt-2'>
|
||||
<h3 className='mb-2 text-[#ECECEC] text-[18px]'>{step.title}</h3>
|
||||
<p className='text-[#999] text-[15px] leading-[160%]'>{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Partner tiers */}
|
||||
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
|
||||
<div className='mx-auto max-w-5xl'>
|
||||
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Partner tiers
|
||||
</div>
|
||||
<div className='grid gap-5 lg:grid-cols-3'>
|
||||
{PARTNER_TIERS.map((tier) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
className='flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'
|
||||
>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h3 className='text-[#ECECEC] text-[16px]'>{tier.name}</h3>
|
||||
<span
|
||||
className='rounded-full px-2.5 py-0.5 text-[11px]'
|
||||
style={{
|
||||
backgroundColor: `${tier.color}33`,
|
||||
color: tier.color === '#8B7355' ? '#C8A96E' : '#999',
|
||||
border: `1px solid ${tier.color}`,
|
||||
}}
|
||||
>
|
||||
{tier.badge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='mb-4'>
|
||||
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>
|
||||
Requirements
|
||||
</p>
|
||||
<ul className='space-y-1.5'>
|
||||
{tier.requirements.map((r) => (
|
||||
<li key={r} className='flex items-start gap-2 text-[#999] text-[13px]'>
|
||||
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#555]' />
|
||||
{r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-auto'>
|
||||
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>Perks</p>
|
||||
<ul className='space-y-1.5'>
|
||||
{tier.perks.map((p) => (
|
||||
<li key={p} className='flex items-start gap-2 text-[#ECECEC] text-[13px]'>
|
||||
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#4CAF50]' />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className='px-[80px] py-[100px]'>
|
||||
<div className='mx-auto max-w-3xl text-center'>
|
||||
<h2 className='mb-4 text-[48px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Ready to get started?
|
||||
</h2>
|
||||
<p className='mb-10 text-[#F6F6F0]/60 text-[18px] leading-[160%]'>
|
||||
Complete Sim Academy to earn your first certification and unlock partner benefits.
|
||||
It's free to start — no credit card required.
|
||||
</p>
|
||||
<Link
|
||||
href='/academy'
|
||||
className='inline-flex h-[48px] items-center rounded-[5px] bg-white px-8 font-[430] text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
|
||||
>
|
||||
Start Sim Academy →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
pathname.startsWith('/form') ||
|
||||
pathname.startsWith('/oauth')
|
||||
|
||||
const isDarkModePage = pathname.startsWith('/academy')
|
||||
|
||||
const forcedTheme = isLightModePage ? 'light' : isDarkModePage ? 'dark' : undefined
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
@@ -32,7 +36,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
forcedTheme={isLightModePage ? 'light' : undefined}
|
||||
forcedTheme={forcedTheme}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -188,7 +188,8 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
|
||||
--border-1: #e0e0e0; /* stronger border */
|
||||
--surface-6: #e5e5e5; /* popovers, elevated surfaces */
|
||||
--surface-7: #d9d9d9;
|
||||
--surface-active: #ececec; /* hover/active state */
|
||||
--surface-hover: #f2f2f2; /* hover state */
|
||||
--surface-active: #ececec; /* active/selected state */
|
||||
|
||||
--workflow-edge: #e0e0e0; /* workflow handles/edges - matches border-1 */
|
||||
|
||||
@@ -342,7 +343,8 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
|
||||
--border-1: #3d3d3d;
|
||||
--surface-6: #454545;
|
||||
--surface-7: #505050;
|
||||
--surface-active: #2c2c2c; /* hover/active state */
|
||||
--surface-hover: #262626; /* hover state */
|
||||
--surface-active: #2c2c2c; /* active/selected state */
|
||||
|
||||
--workflow-edge: #454545; /* workflow handles/edges - same as surface-6 in dark */
|
||||
|
||||
@@ -501,9 +503,6 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
|
||||
caret-color: var(--text-primary);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply antialiased;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: var(--scrollbar-size);
|
||||
height: var(--scrollbar-size);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CheckCircle2, Circle, ExternalLink, GraduationCap, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { getCompletedLessons } from '@/lib/academy/local-progress'
|
||||
import type { Course } from '@/lib/academy/types'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useCourseCertificate, useIssueCertificate } from '@/hooks/queries/academy'
|
||||
|
||||
interface CourseProgressProps {
|
||||
course: Course
|
||||
courseSlug: string
|
||||
}
|
||||
|
||||
export function CourseProgress({ course, courseSlug }: CourseProgressProps) {
|
||||
// Start with an empty set so SSR and initial client render match, then hydrate from localStorage.
|
||||
const [completedIds, setCompletedIds] = useState<Set<string>>(() => new Set())
|
||||
useEffect(() => {
|
||||
setCompletedIds(getCompletedLessons())
|
||||
}, [])
|
||||
const { data: session } = useSession()
|
||||
const { data: fetchedCert } = useCourseCertificate(session ? course.id : undefined)
|
||||
const { mutate: issueCertificate, isPending, data: issuedCert, error } = useIssueCertificate()
|
||||
const certificate = fetchedCert ?? issuedCert
|
||||
|
||||
const allLessons = course.modules.flatMap((m) => m.lessons)
|
||||
const totalLessons = allLessons.length
|
||||
const completedCount = allLessons.filter((l) => completedIds.has(l.id)).length
|
||||
const percentComplete = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{completedCount > 0 && (
|
||||
<div className='px-4 pt-8 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#2A2A2A] bg-[#222] p-4'>
|
||||
<div className='mb-2 flex items-center justify-between text-[13px]'>
|
||||
<span className='text-[#999]'>Your progress</span>
|
||||
<span className='text-[#ECECEC]'>
|
||||
{completedCount}/{totalLessons} lessons
|
||||
</span>
|
||||
</div>
|
||||
<div className='h-1.5 w-full overflow-hidden rounded-full bg-[#2A2A2A]'>
|
||||
<div
|
||||
className='h-full rounded-full bg-[#ECECEC] transition-all'
|
||||
style={{ width: `${percentComplete}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className='px-4 py-14 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl space-y-10'>
|
||||
{course.modules.map((mod, modIndex) => (
|
||||
<div key={mod.id}>
|
||||
<div className='mb-4 flex items-center gap-3'>
|
||||
<span className='text-[#555] text-[12px]'>Module {modIndex + 1}</span>
|
||||
<div className='h-px flex-1 bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<h2 className='mb-4 font-[430] text-[#ECECEC] text-[18px]'>{mod.title}</h2>
|
||||
<div className='space-y-2'>
|
||||
{mod.lessons.map((lesson) => (
|
||||
<Link
|
||||
key={lesson.id}
|
||||
href={`/academy/${courseSlug}/${lesson.slug}`}
|
||||
className='flex items-center gap-3 rounded-[8px] border border-[#2A2A2A] bg-[#222] px-4 py-3 text-[14px] transition-colors hover:border-[#3A3A3A] hover:bg-[#272727]'
|
||||
>
|
||||
{completedIds.has(lesson.id) ? (
|
||||
<CheckCircle2 className='h-4 w-4 flex-shrink-0 text-[#4CAF50]' />
|
||||
) : (
|
||||
<Circle className='h-4 w-4 flex-shrink-0 text-[#444]' />
|
||||
)}
|
||||
<span className='flex-1 text-[#ECECEC]'>{lesson.title}</span>
|
||||
<span className='text-[#555] text-[12px] capitalize'>{lesson.lessonType}</span>
|
||||
{lesson.videoDurationSeconds && (
|
||||
<span className='text-[#555] text-[12px]'>
|
||||
{Math.round(lesson.videoDurationSeconds / 60)} min
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{totalLessons > 0 && completedCount === totalLessons && (
|
||||
<section className='px-4 pb-16 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#3A4A3A] bg-[#1F2A1F] p-6'>
|
||||
{certificate ? (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
|
||||
<div>
|
||||
<p className='font-[430] text-[#ECECEC] text-[15px]'>Certificate issued!</p>
|
||||
<p className='font-mono text-[#666] text-[13px]'>
|
||||
{certificate.certificateNumber}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/academy/certificate/${certificate.certificateNumber}`}
|
||||
className='flex items-center gap-1.5 rounded-[5px] bg-[#4CAF50] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-[#5DBF61]'
|
||||
>
|
||||
View certificate
|
||||
<ExternalLink className='h-3.5 w-3.5' />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
|
||||
<div>
|
||||
<p className='font-[430] text-[#ECECEC] text-[15px]'>Course Complete!</p>
|
||||
<p className='text-[#666] text-[13px]'>
|
||||
{session
|
||||
? error
|
||||
? 'Something went wrong. Try again.'
|
||||
: 'Claim your certificate of completion.'
|
||||
: 'Sign in to claim your certificate.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{session ? (
|
||||
<button
|
||||
type='button'
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
issueCertificate({
|
||||
courseId: course.id,
|
||||
completedLessonIds: [...completedIds],
|
||||
})
|
||||
}
|
||||
className='flex items-center gap-2 rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white disabled:opacity-50'
|
||||
>
|
||||
{isPending && <Loader2 className='h-3.5 w-3.5 animate-spin' />}
|
||||
{isPending ? 'Issuing…' : 'Get certificate'}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href='/login'
|
||||
className='rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white'
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
68
apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx
Normal file
68
apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Clock, GraduationCap } from 'lucide-react'
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { COURSES, getCourse } from '@/lib/academy/content'
|
||||
import { CourseProgress } from './components/course-progress'
|
||||
|
||||
interface CourseDetailPageProps {
|
||||
params: Promise<{ courseSlug: string }>
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return COURSES.map((course) => ({ courseSlug: course.slug }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: CourseDetailPageProps): Promise<Metadata> {
|
||||
const { courseSlug } = await params
|
||||
const course = getCourse(courseSlug)
|
||||
if (!course) return { title: 'Course Not Found' }
|
||||
return {
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function CourseDetailPage({ params }: CourseDetailPageProps) {
|
||||
const { courseSlug } = await params
|
||||
const course = getCourse(courseSlug)
|
||||
|
||||
if (!course) notFound()
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className='border-[#2A2A2A] border-b px-4 py-16 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<Link
|
||||
href='/academy'
|
||||
className='mb-4 inline-flex items-center gap-1.5 text-[#666] text-[13px] transition-colors hover:text-[#999]'
|
||||
>
|
||||
← All courses
|
||||
</Link>
|
||||
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[36px] leading-[115%] tracking-[-0.02em]'>
|
||||
{course.title}
|
||||
</h1>
|
||||
{course.description && (
|
||||
<p className='mb-6 text-[#F6F6F0]/60 text-[16px] leading-[160%]'>
|
||||
{course.description}
|
||||
</p>
|
||||
)}
|
||||
<div className='mt-6 flex items-center gap-5 text-[#666] text-[13px]'>
|
||||
{course.estimatedMinutes && (
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Clock className='h-3.5 w-3.5' />
|
||||
{course.estimatedMinutes} min total
|
||||
</span>
|
||||
)}
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<GraduationCap className='h-3.5 w-3.5' />
|
||||
Certificate upon completion
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CourseProgress course={course} courseSlug={courseSlug} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { cache } from 'react'
|
||||
import { db } from '@sim/db'
|
||||
import { academyCertificate } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { CheckCircle2, GraduationCap, XCircle } from 'lucide-react'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { AcademyCertificate } from '@/lib/academy/types'
|
||||
|
||||
interface CertificatePageProps {
|
||||
params: Promise<{ certificateNumber: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: CertificatePageProps): Promise<Metadata> {
|
||||
const { certificateNumber } = await params
|
||||
const certificate = await fetchCertificate(certificateNumber)
|
||||
if (!certificate) return { title: 'Certificate Not Found' }
|
||||
return {
|
||||
title: `${certificate.metadata?.courseTitle ?? 'Certificate'} — Certificate`,
|
||||
description: `Verified certificate of completion awarded to ${certificate.metadata?.recipientName ?? 'a recipient'}.`,
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCertificate = cache(
|
||||
async (certificateNumber: string): Promise<AcademyCertificate | null> => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(eq(academyCertificate.certificateNumber, certificateNumber))
|
||||
.limit(1)
|
||||
return (row as unknown as AcademyCertificate) ?? null
|
||||
}
|
||||
)
|
||||
|
||||
const DATE_FORMAT: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
|
||||
function formatDate(date: string | Date) {
|
||||
return new Date(date).toLocaleDateString('en-US', DATE_FORMAT)
|
||||
}
|
||||
|
||||
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='flex items-center justify-between px-5 py-3.5'>
|
||||
<span className='text-[#666] text-[13px]'>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function CertificatePage({ params }: CertificatePageProps) {
|
||||
const { certificateNumber } = await params
|
||||
const certificate = await fetchCertificate(certificateNumber)
|
||||
|
||||
if (!certificate) notFound()
|
||||
|
||||
return (
|
||||
<main className='flex flex-1 items-center justify-center px-6 py-20'>
|
||||
<div className='w-full max-w-2xl'>
|
||||
<div className='rounded-[12px] border border-[#3A4A3A] bg-[#1C2A1C] p-10 text-center'>
|
||||
<div className='mb-6 flex justify-center'>
|
||||
<div className='flex h-16 w-16 items-center justify-center rounded-full border-2 border-[#4CAF50]/40 bg-[#4CAF50]/10'>
|
||||
<GraduationCap className='h-8 w-8 text-[#4CAF50]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-2 text-[#4CAF50]/70 text-[13px] uppercase tracking-[0.12em]'>
|
||||
Certificate of Completion
|
||||
</div>
|
||||
|
||||
<h1 className='mb-1 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>
|
||||
{certificate.metadata?.courseTitle}
|
||||
</h1>
|
||||
|
||||
{certificate.metadata?.recipientName && (
|
||||
<p className='mb-6 text-[#999] text-[16px]'>
|
||||
Awarded to{' '}
|
||||
<span className='text-[#ECECEC]'>{certificate.metadata.recipientName}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{certificate.status === 'active' ? (
|
||||
<div className='flex items-center justify-center gap-2 text-[#4CAF50]'>
|
||||
<CheckCircle2 className='h-4 w-4' />
|
||||
<span className='font-[430] text-[14px]'>Verified</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center justify-center gap-2 text-[#f44336]'>
|
||||
<XCircle className='h-4 w-4' />
|
||||
<span className='font-[430] text-[14px] capitalize'>{certificate.status}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='mt-6 divide-y divide-[#2A2A2A] rounded-[8px] border border-[#2A2A2A] bg-[#222]'>
|
||||
<MetaRow label='Certificate number'>
|
||||
<span className='font-mono text-[#ECECEC] text-[13px]'>
|
||||
{certificate.certificateNumber}
|
||||
</span>
|
||||
</MetaRow>
|
||||
<MetaRow label='Issued'>
|
||||
<span className='text-[#ECECEC] text-[13px]'>{formatDate(certificate.issuedAt)}</span>
|
||||
</MetaRow>
|
||||
<MetaRow label='Status'>
|
||||
<span
|
||||
className={`text-[13px] capitalize ${
|
||||
certificate.status === 'active' ? 'text-[#4CAF50]' : 'text-[#f44336]'
|
||||
}`}
|
||||
>
|
||||
{certificate.status}
|
||||
</span>
|
||||
</MetaRow>
|
||||
{certificate.expiresAt && (
|
||||
<MetaRow label='Expires'>
|
||||
<span className='text-[#ECECEC] text-[13px]'>
|
||||
{formatDate(certificate.expiresAt)}
|
||||
</span>
|
||||
</MetaRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className='mt-5 text-center text-[#555] text-[13px]'>
|
||||
This certificate was issued by Sim AI, Inc. and verifies the holder has completed the{' '}
|
||||
{certificate.metadata?.courseTitle} program.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
16
apps/sim/app/academy/(catalog)/layout.tsx
Normal file
16
apps/sim/app/academy/(catalog)/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type React from 'react'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default async function AcademyCatalogLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
{children}
|
||||
<Footer hideCTA />
|
||||
</>
|
||||
)
|
||||
}
|
||||
19
apps/sim/app/academy/(catalog)/not-found.tsx
Normal file
19
apps/sim/app/academy/(catalog)/not-found.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AcademyNotFound() {
|
||||
return (
|
||||
<main className='flex flex-1 flex-col items-center justify-center px-6 py-32 text-center'>
|
||||
<p className='mb-2 font-mono text-[#555] text-[13px] uppercase tracking-widest'>404</p>
|
||||
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>Page not found</h1>
|
||||
<p className='mb-8 text-[#666] text-[15px]'>
|
||||
That course or lesson doesn't exist in the Academy.
|
||||
</p>
|
||||
<Link
|
||||
href='/academy'
|
||||
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white'
|
||||
>
|
||||
Back to Academy
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
76
apps/sim/app/academy/(catalog)/page.tsx
Normal file
76
apps/sim/app/academy/(catalog)/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BookOpen, Clock } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { COURSES } from '@/lib/academy/content'
|
||||
|
||||
export default function AcademyCatalogPage() {
|
||||
return (
|
||||
<main>
|
||||
<section className='border-[#2A2A2A] border-b px-4 py-20 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<div className='mb-3 text-[#999] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Sim Academy
|
||||
</div>
|
||||
<h1 className='mb-4 font-[430] text-[#ECECEC] text-[48px] leading-[110%] tracking-[-0.02em]'>
|
||||
Become a certified
|
||||
<br />
|
||||
Sim partner
|
||||
</h1>
|
||||
<p className='text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
|
||||
Master AI workflow automation with hands-on interactive exercises on the real Sim
|
||||
canvas. Complete the program to earn your partner certification.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='px-4 py-16 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
<h2 className='mb-8 text-[#999] text-[13px] uppercase tracking-[0.12em]'>Courses</h2>
|
||||
<div className='grid gap-5 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{COURSES.map((course) => {
|
||||
const totalLessons = course.modules.reduce((n, m) => n + m.lessons.length, 0)
|
||||
return (
|
||||
<Link
|
||||
key={course.id}
|
||||
href={`/academy/${course.slug}`}
|
||||
className='group flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#232323] p-5 transition-colors hover:border-[#3A3A3A] hover:bg-[#282828]'
|
||||
>
|
||||
{course.imageUrl && (
|
||||
<div className='mb-4 aspect-video w-full overflow-hidden rounded-[6px] bg-[#1A1A1A]'>
|
||||
<img
|
||||
src={course.imageUrl}
|
||||
alt={course.title}
|
||||
className='h-full w-full object-cover opacity-80'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex-1'>
|
||||
<h3 className='mb-2 font-[430] text-[#ECECEC] text-[16px] leading-[130%] group-hover:text-white'>
|
||||
{course.title}
|
||||
</h3>
|
||||
{course.description && (
|
||||
<p className='mb-4 line-clamp-2 text-[#999] text-[14px] leading-[150%]'>
|
||||
{course.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='mt-auto flex items-center gap-4 text-[#666] text-[12px]'>
|
||||
{course.estimatedMinutes && (
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Clock className='h-3 w-3' />
|
||||
{course.estimatedMinutes} min
|
||||
</span>
|
||||
)}
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<BookOpen className='h-3 w-3' />
|
||||
{totalLessons} lessons
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { markLessonComplete } from '@/lib/academy/local-progress'
|
||||
import type { ExerciseBlockState, ExerciseDefinition, ExerciseEdgeState } from '@/lib/academy/types'
|
||||
import { SandboxCanvasProvider } from '@/app/academy/components/sandbox-canvas-provider'
|
||||
|
||||
interface ExerciseViewProps {
|
||||
lessonId: string
|
||||
exerciseConfig: ExerciseDefinition
|
||||
onComplete?: () => void
|
||||
videoUrl?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the sandbox canvas for an exercise lesson.
|
||||
* Completion is determined client-side by the validation engine and persisted to localStorage.
|
||||
*/
|
||||
export function ExerciseView({
|
||||
lessonId,
|
||||
exerciseConfig,
|
||||
onComplete,
|
||||
videoUrl,
|
||||
description,
|
||||
}: ExerciseViewProps) {
|
||||
const [completed, setCompleted] = useState(false)
|
||||
// Reset completion banner when the lesson changes (component is reused across exercise navigations).
|
||||
const [prevLessonId, setPrevLessonId] = useState(lessonId)
|
||||
if (prevLessonId !== lessonId) {
|
||||
setPrevLessonId(lessonId)
|
||||
setCompleted(false)
|
||||
}
|
||||
|
||||
const handleComplete = useCallback(
|
||||
(_blocks: ExerciseBlockState[], _edges: ExerciseEdgeState[]) => {
|
||||
setCompleted(true)
|
||||
markLessonComplete(lessonId)
|
||||
onComplete?.()
|
||||
},
|
||||
[lessonId, onComplete]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full w-full flex-col overflow-hidden'>
|
||||
<SandboxCanvasProvider
|
||||
exerciseId={lessonId}
|
||||
exerciseConfig={exerciseConfig}
|
||||
onComplete={handleComplete}
|
||||
videoUrl={videoUrl}
|
||||
description={description}
|
||||
className='flex-1'
|
||||
/>
|
||||
|
||||
{completed && (
|
||||
<div className='pointer-events-none absolute inset-0 flex items-start justify-center pt-5'>
|
||||
<div className='pointer-events-auto flex items-center gap-2 rounded-full border border-[#3A4A3A] bg-[#1F2A1F]/95 px-4 py-2 font-[430] text-[#4CAF50] text-[13px] shadow-lg backdrop-blur-sm'>
|
||||
<CheckCircle2 className='h-4 w-4' />
|
||||
Exercise complete!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { markLessonComplete } from '@/lib/academy/local-progress'
|
||||
import type { QuizDefinition, QuizQuestion } from '@/lib/academy/types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface LessonQuizProps {
|
||||
lessonId: string
|
||||
quizConfig: QuizDefinition
|
||||
onPass?: () => void
|
||||
}
|
||||
|
||||
type Answers = Record<number, number | number[] | boolean>
|
||||
|
||||
interface QuizResult {
|
||||
score: number
|
||||
passed: boolean
|
||||
feedback: Array<{ correct: boolean; explanation?: string }>
|
||||
}
|
||||
|
||||
function scoreQuiz(questions: QuizQuestion[], answers: Answers, passingScore: number): QuizResult {
|
||||
const feedback = questions.map((q, i) => {
|
||||
const answer = answers[i]
|
||||
let correct = false
|
||||
if (q.type === 'multiple_choice') correct = answer === q.correctIndex
|
||||
else if (q.type === 'true_false') correct = answer === q.correctAnswer
|
||||
else if (q.type === 'multi_select') {
|
||||
const selected = (answer as number[] | undefined) ?? []
|
||||
correct =
|
||||
selected.length === q.correctIndices.length &&
|
||||
selected.every((v) => q.correctIndices.includes(v))
|
||||
} else {
|
||||
const _exhaustive: never = q
|
||||
void _exhaustive
|
||||
}
|
||||
return { correct, explanation: 'explanation' in q ? q.explanation : undefined }
|
||||
})
|
||||
const score = Math.round((feedback.filter((f) => f.correct).length / questions.length) * 100)
|
||||
return { score, passed: score >= passingScore, feedback }
|
||||
}
|
||||
|
||||
const optionBase =
|
||||
'w-full text-left rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default'
|
||||
|
||||
/**
|
||||
* Interactive quiz component with per-question feedback and retry support.
|
||||
* Scoring is performed entirely client-side.
|
||||
*/
|
||||
export function LessonQuiz({ lessonId, quizConfig, onPass }: LessonQuizProps) {
|
||||
const [answers, setAnswers] = useState<Answers>({})
|
||||
const [result, setResult] = useState<QuizResult | null>(null)
|
||||
// Reset quiz state when the lesson changes (component is reused across quiz-lesson navigations).
|
||||
const [prevLessonId, setPrevLessonId] = useState(lessonId)
|
||||
if (prevLessonId !== lessonId) {
|
||||
setPrevLessonId(lessonId)
|
||||
setAnswers({})
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
const handleAnswer = (qi: number, value: number | boolean) => {
|
||||
if (!result) setAnswers((prev) => ({ ...prev, [qi]: value }))
|
||||
}
|
||||
|
||||
const handleMultiSelect = (qi: number, oi: number) => {
|
||||
if (result) return
|
||||
setAnswers((prev) => {
|
||||
const current = (prev[qi] as number[] | undefined) ?? []
|
||||
const next = current.includes(oi) ? current.filter((i) => i !== oi) : [...current, oi]
|
||||
return { ...prev, [qi]: next }
|
||||
})
|
||||
}
|
||||
|
||||
const allAnswered = quizConfig.questions.every((q, i) => {
|
||||
if (q.type === 'multi_select')
|
||||
return Array.isArray(answers[i]) && (answers[i] as number[]).length > 0
|
||||
return answers[i] !== undefined
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
const scored = scoreQuiz(quizConfig.questions, answers, quizConfig.passingScore)
|
||||
setResult(scored)
|
||||
if (scored.passed) {
|
||||
markLessonComplete(lessonId)
|
||||
onPass?.()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className='font-[430] text-[#ECECEC] text-[20px]'>Quiz</h2>
|
||||
<p className='mt-1 text-[#666] text-[14px]'>
|
||||
Score {quizConfig.passingScore}% or higher to pass.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{quizConfig.questions.map((q, qi) => {
|
||||
const feedback = result?.feedback[qi]
|
||||
const isCorrect = feedback?.correct
|
||||
|
||||
return (
|
||||
<div key={qi} className='rounded-[8px] bg-[#222] p-5'>
|
||||
<p className='mb-4 font-[430] text-[#ECECEC] text-[15px]'>{q.question}</p>
|
||||
|
||||
{q.type === 'multiple_choice' && (
|
||||
<div className='space-y-2'>
|
||||
{q.options.map((opt, oi) => (
|
||||
<button
|
||||
key={oi}
|
||||
type='button'
|
||||
onClick={() => handleAnswer(qi, oi)}
|
||||
disabled={Boolean(result)}
|
||||
className={cn(
|
||||
optionBase,
|
||||
answers[qi] === oi
|
||||
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
|
||||
result &&
|
||||
oi === q.correctIndex &&
|
||||
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
|
||||
result &&
|
||||
answers[qi] === oi &&
|
||||
oi !== q.correctIndex &&
|
||||
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === 'true_false' && (
|
||||
<div className='flex gap-3'>
|
||||
{(['True', 'False'] as const).map((label) => {
|
||||
const val = label === 'True'
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type='button'
|
||||
onClick={() => handleAnswer(qi, val)}
|
||||
disabled={Boolean(result)}
|
||||
className={cn(
|
||||
'flex-1 rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default',
|
||||
answers[qi] === val
|
||||
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
|
||||
result &&
|
||||
val === q.correctAnswer &&
|
||||
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
|
||||
result &&
|
||||
answers[qi] === val &&
|
||||
val !== q.correctAnswer &&
|
||||
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === 'multi_select' && (
|
||||
<div className='space-y-2'>
|
||||
{q.options.map((opt, oi) => {
|
||||
const selected = ((answers[qi] as number[]) ?? []).includes(oi)
|
||||
return (
|
||||
<button
|
||||
key={oi}
|
||||
type='button'
|
||||
onClick={() => handleMultiSelect(qi, oi)}
|
||||
disabled={Boolean(result)}
|
||||
className={cn(
|
||||
optionBase,
|
||||
selected
|
||||
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
|
||||
result &&
|
||||
q.correctIndices.includes(oi) &&
|
||||
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
|
||||
result &&
|
||||
selected &&
|
||||
!q.correctIndices.includes(oi) &&
|
||||
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-3 flex items-start gap-2 rounded-[6px] px-3 py-2.5 text-[13px]',
|
||||
isCorrect ? 'bg-[#4CAF50]/10 text-[#4CAF50]' : 'bg-[#f44336]/10 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{isCorrect ? (
|
||||
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
|
||||
) : (
|
||||
<XCircle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
|
||||
)}
|
||||
<span>{isCorrect ? 'Correct!' : (feedback.explanation ?? 'Incorrect.')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[8px] border p-5',
|
||||
result.passed
|
||||
? 'border-[#3A4A3A] bg-[#1F2A1F] text-[#4CAF50]'
|
||||
: 'border-[#3A2A2A] bg-[#2A1F1F] text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
<p className='font-[430] text-[15px]'>{result.passed ? 'Passed!' : 'Keep trying!'}</p>
|
||||
<p className='mt-1 text-[13px] opacity-80'>
|
||||
Score: {result.score}% (passing: {quizConfig.passingScore}%)
|
||||
</p>
|
||||
{!result.passed && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setAnswers({})
|
||||
setResult(null)
|
||||
}}
|
||||
className='mt-3 rounded-[5px] border border-[#3A2A2A] bg-[#2A1F1F] px-3 py-1.5 text-[#999] text-[13px] transition-colors hover:border-[#4A3A3A] hover:text-[#ECECEC]'
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={!allAnswered}
|
||||
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white disabled:opacity-40'
|
||||
>
|
||||
Submit answers
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx
Normal file
23
apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type React from 'react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
interface LessonLayoutProps {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ courseSlug: string; lessonSlug: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side auth gate for lesson pages.
|
||||
* Redirects unauthenticated users to login before any client JS runs.
|
||||
*/
|
||||
export default async function LessonLayout({ children, params }: LessonLayoutProps) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
const { courseSlug, lessonSlug } = await params
|
||||
redirect(`/login?callbackUrl=/academy/${courseSlug}/${lessonSlug}`)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
219
apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx
Normal file
219
apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { getCourse } from '@/lib/academy/content'
|
||||
import { markLessonComplete } from '@/lib/academy/local-progress'
|
||||
import type { Lesson } from '@/lib/academy/types'
|
||||
import { LessonVideo } from '@/app/academy/components/lesson-video'
|
||||
import { ExerciseView } from './components/exercise-view'
|
||||
import { LessonQuiz } from './components/lesson-quiz'
|
||||
|
||||
const navBtnClass =
|
||||
'flex items-center gap-1 rounded-[5px] border border-[#2A2A2A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
|
||||
|
||||
interface LessonPageProps {
|
||||
params: Promise<{ courseSlug: string; lessonSlug: string }>
|
||||
}
|
||||
|
||||
export default function LessonPage({ params }: LessonPageProps) {
|
||||
const { courseSlug, lessonSlug } = use(params)
|
||||
const course = getCourse(courseSlug)
|
||||
const [exerciseComplete, setExerciseComplete] = useState(false)
|
||||
const [quizComplete, setQuizComplete] = useState(false)
|
||||
// Reset completion state when the lesson changes (Next.js reuses the component across navigations).
|
||||
const [prevLessonSlug, setPrevLessonSlug] = useState(lessonSlug)
|
||||
if (prevLessonSlug !== lessonSlug) {
|
||||
setPrevLessonSlug(lessonSlug)
|
||||
setExerciseComplete(false)
|
||||
setQuizComplete(false)
|
||||
}
|
||||
|
||||
const allLessons = useMemo<Lesson[]>(
|
||||
() => course?.modules.flatMap((m) => m.lessons) ?? [],
|
||||
[course]
|
||||
)
|
||||
|
||||
const currentIndex = allLessons.findIndex((l) => l.slug === lessonSlug)
|
||||
const lesson = allLessons[currentIndex]
|
||||
const prevLesson = currentIndex > 0 ? allLessons[currentIndex - 1] : null
|
||||
const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null
|
||||
|
||||
const handleExerciseComplete = useCallback(() => setExerciseComplete(true), [])
|
||||
const handleQuizPass = useCallback(() => setQuizComplete(true), [])
|
||||
const canAdvance =
|
||||
(!lesson?.exerciseConfig && !lesson?.quizConfig) ||
|
||||
(Boolean(lesson?.exerciseConfig) && Boolean(lesson?.quizConfig)
|
||||
? exerciseComplete && quizComplete
|
||||
: lesson?.exerciseConfig
|
||||
? exerciseComplete
|
||||
: quizComplete)
|
||||
|
||||
const isUngatedLesson =
|
||||
lesson?.lessonType === 'video' ||
|
||||
(lesson?.lessonType === 'mixed' && !lesson.exerciseConfig && !lesson.quizConfig)
|
||||
|
||||
useEffect(() => {
|
||||
if (isUngatedLesson && lesson) {
|
||||
markLessonComplete(lesson.id)
|
||||
}
|
||||
}, [lesson?.id, isUngatedLesson])
|
||||
|
||||
if (!course || !lesson) {
|
||||
return (
|
||||
<div className='flex h-screen items-center justify-center bg-[#1C1C1C]'>
|
||||
<p className='text-[#666] text-[14px]'>Lesson not found.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasVideo = Boolean(lesson.videoUrl)
|
||||
const hasExercise = Boolean(lesson.exerciseConfig)
|
||||
const hasQuiz = Boolean(lesson.quizConfig)
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 flex flex-col overflow-hidden bg-[#1C1C1C]'>
|
||||
<header className='flex h-[52px] flex-shrink-0 items-center justify-between border-[#2A2A2A] border-b bg-[#1C1C1C] px-5'>
|
||||
<div className='flex items-center gap-3 text-[13px]'>
|
||||
<Link href='/' aria-label='Sim home'>
|
||||
<Image
|
||||
src='/logo/b&w/text/b&w.svg'
|
||||
alt='Sim'
|
||||
width={40}
|
||||
height={14}
|
||||
className='opacity-70 invert transition-opacity hover:opacity-100'
|
||||
/>
|
||||
</Link>
|
||||
<span className='text-[#333]'>/</span>
|
||||
<Link href='/academy' className='text-[#666] transition-colors hover:text-[#999]'>
|
||||
Academy
|
||||
</Link>
|
||||
<span className='text-[#333]'>/</span>
|
||||
<Link
|
||||
href={`/academy/${courseSlug}`}
|
||||
className='max-w-[160px] truncate text-[#666] transition-colors hover:text-[#999]'
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
<span className='text-[#333]'>/</span>
|
||||
<span className='max-w-[200px] truncate text-[#ECECEC]'>{lesson.title}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
{prevLesson ? (
|
||||
<Link href={`/academy/${courseSlug}/${prevLesson.slug}`} className={navBtnClass}>
|
||||
<ChevronLeft className='h-3.5 w-3.5' />
|
||||
Previous
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/academy/${courseSlug}`} className={navBtnClass}>
|
||||
<ChevronLeft className='h-3.5 w-3.5' />
|
||||
Course
|
||||
</Link>
|
||||
)}
|
||||
{nextLesson && (
|
||||
<Link
|
||||
href={`/academy/${courseSlug}/${nextLesson.slug}`}
|
||||
onClick={(e) => {
|
||||
if (!canAdvance) e.preventDefault()
|
||||
}}
|
||||
className={`flex items-center gap-1 rounded-[5px] px-3 py-1.5 text-[12px] transition-colors ${
|
||||
canAdvance
|
||||
? 'bg-[#ECECEC] text-[#1C1C1C] hover:bg-white'
|
||||
: 'cursor-not-allowed border border-[#2A2A2A] text-[#444]'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className='h-3.5 w-3.5' />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className='flex min-h-0 flex-1 overflow-hidden'>
|
||||
{lesson.lessonType === 'video' && hasVideo && (
|
||||
<div className='flex-1 overflow-y-auto p-10'>
|
||||
<div className='mx-auto w-full max-w-3xl'>
|
||||
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
|
||||
{lesson.description && (
|
||||
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>{lesson.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lesson.lessonType === 'exercise' && hasExercise && (
|
||||
<ExerciseView
|
||||
lessonId={lesson.id}
|
||||
exerciseConfig={lesson.exerciseConfig!}
|
||||
onComplete={handleExerciseComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{lesson.lessonType === 'quiz' && hasQuiz && (
|
||||
<div className='flex-1 overflow-y-auto p-10'>
|
||||
<div className='mx-auto w-full max-w-2xl'>
|
||||
<LessonQuiz
|
||||
lessonId={lesson.id}
|
||||
quizConfig={lesson.quizConfig!}
|
||||
onPass={handleQuizPass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lesson.lessonType === 'mixed' && (
|
||||
<>
|
||||
{hasExercise && (!exerciseComplete || !hasQuiz) && (
|
||||
<ExerciseView
|
||||
lessonId={lesson.id}
|
||||
exerciseConfig={lesson.exerciseConfig!}
|
||||
onComplete={handleExerciseComplete}
|
||||
videoUrl={!hasQuiz ? lesson.videoUrl : undefined}
|
||||
description={!hasQuiz ? lesson.description : undefined}
|
||||
/>
|
||||
)}
|
||||
{hasExercise && exerciseComplete && hasQuiz && (
|
||||
<div className='flex-1 overflow-y-auto p-8'>
|
||||
<div className='mx-auto w-full max-w-xl space-y-8'>
|
||||
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
|
||||
<LessonQuiz
|
||||
lessonId={lesson.id}
|
||||
quizConfig={lesson.quizConfig!}
|
||||
onPass={handleQuizPass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasExercise && hasQuiz && (
|
||||
<div className='flex-1 overflow-y-auto p-8'>
|
||||
<div className='mx-auto w-full max-w-xl space-y-8'>
|
||||
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
|
||||
<LessonQuiz
|
||||
lessonId={lesson.id}
|
||||
quizConfig={lesson.quizConfig!}
|
||||
onPass={handleQuizPass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasExercise && !hasQuiz && hasVideo && (
|
||||
<div className='flex-1 overflow-y-auto p-10'>
|
||||
<div className='mx-auto w-full max-w-3xl'>
|
||||
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
|
||||
{lesson.description && (
|
||||
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>
|
||||
{lesson.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
apps/sim/app/academy/components/lesson-video.tsx
Normal file
56
apps/sim/app/academy/components/lesson-video.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
interface LessonVideoProps {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function LessonVideo({ url, title }: LessonVideoProps) {
|
||||
const embedUrl = resolveEmbedUrl(url)
|
||||
|
||||
if (!embedUrl) {
|
||||
return (
|
||||
<div className='flex aspect-video items-center justify-center rounded-lg bg-[#1A1A1A] text-[#666] text-sm'>
|
||||
Video unavailable
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='aspect-video w-full overflow-hidden rounded-lg bg-black'>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={title}
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
|
||||
allowFullScreen
|
||||
className='h-full w-full border-0'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveEmbedUrl(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
|
||||
if (parsed.hostname === 'youtu.be') {
|
||||
return `https://www.youtube.com/embed${parsed.pathname}`
|
||||
}
|
||||
if (parsed.hostname.includes('youtube.com')) {
|
||||
// Shorts: youtube.com/shorts/VIDEO_ID
|
||||
const shortsMatch = parsed.pathname.match(/^\/shorts\/([^/?]+)/)
|
||||
if (shortsMatch) return `https://www.youtube.com/embed/${shortsMatch[1]}`
|
||||
const v = parsed.searchParams.get('v')
|
||||
if (v) return `https://www.youtube.com/embed/${v}`
|
||||
}
|
||||
|
||||
if (parsed.hostname === 'vimeo.com') {
|
||||
const id = parsed.pathname.replace(/^\//, '')
|
||||
if (id) return `https://player.vimeo.com/video/${id}`
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (_e: unknown) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
432
apps/sim/app/academy/components/sandbox-canvas-provider.tsx
Normal file
432
apps/sim/app/academy/components/sandbox-canvas-provider.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { buildMockExecutionPlan } from '@/lib/academy/mock-execution'
|
||||
import type {
|
||||
ExerciseBlockState,
|
||||
ExerciseDefinition,
|
||||
ExerciseEdgeState,
|
||||
ValidationResult,
|
||||
} from '@/lib/academy/types'
|
||||
import { validateExercise } from '@/lib/academy/validation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { LessonVideo } from './lesson-video'
|
||||
import { ValidationChecklist } from './validation-checklist'
|
||||
|
||||
const logger = createLogger('SandboxCanvasProvider')
|
||||
|
||||
const SANDBOX_WORKSPACE_ID = 'sandbox'
|
||||
|
||||
interface SandboxCanvasProviderProps {
|
||||
/** Unique ID for this exercise instance */
|
||||
exerciseId: string
|
||||
/** Full exercise configuration */
|
||||
exerciseConfig: ExerciseDefinition
|
||||
/**
|
||||
* Called when all validation rules pass for the first time.
|
||||
* Receives the current canvas state so the caller can persist it.
|
||||
*/
|
||||
onComplete?: (blocks: ExerciseBlockState[], edges: ExerciseEdgeState[]) => void
|
||||
/** Optional video URL (YouTube/Vimeo) shown above the checklist — used for mixed lessons */
|
||||
videoUrl?: string
|
||||
/** Optional description shown below the video (or below checklist if no video) */
|
||||
description?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Zustand-compatible WorkflowState from exercise block/edge definitions.
|
||||
* Looks up each block type in the registry to construct proper sub-block and output maps.
|
||||
*/
|
||||
function buildWorkflowState(
|
||||
initialBlocks: ExerciseBlockState[],
|
||||
initialEdges: ExerciseEdgeState[]
|
||||
): WorkflowState {
|
||||
const blocks: Record<string, BlockState> = {}
|
||||
|
||||
for (const exerciseBlock of initialBlocks) {
|
||||
const config = getBlock(exerciseBlock.type)
|
||||
if (!config) {
|
||||
logger.warn(`Unknown block type "${exerciseBlock.type}" in exercise config`)
|
||||
continue
|
||||
}
|
||||
|
||||
const subBlocks: Record<string, SubBlockState> = {}
|
||||
for (const sb of config.subBlocks ?? []) {
|
||||
const overrideValue = exerciseBlock.subBlocks?.[sb.id]
|
||||
subBlocks[sb.id] = {
|
||||
id: sb.id,
|
||||
type: sb.type,
|
||||
value: (overrideValue !== undefined ? overrideValue : null) as SubBlockState['value'],
|
||||
}
|
||||
}
|
||||
|
||||
const outputs = getEffectiveBlockOutputs(exerciseBlock.type, subBlocks, {
|
||||
triggerMode: false,
|
||||
preferToolOutputs: true,
|
||||
})
|
||||
|
||||
blocks[exerciseBlock.id] = {
|
||||
id: exerciseBlock.id,
|
||||
type: exerciseBlock.type,
|
||||
name: config.name,
|
||||
position: exerciseBlock.position,
|
||||
subBlocks,
|
||||
outputs,
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
height: 0,
|
||||
locked: exerciseBlock.locked ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
const edges: Edge[] = initialEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle,
|
||||
targetHandle: e.targetHandle,
|
||||
type: 'default',
|
||||
data: {},
|
||||
}))
|
||||
|
||||
return { blocks, edges, loops: {}, parallels: {}, lastSaved: Date.now() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current canvas state from the workflow store and converts it to
|
||||
* the exercise block/edge format used by the validation engine.
|
||||
*/
|
||||
function readCurrentCanvasState(workflowId: string): {
|
||||
blocks: ExerciseBlockState[]
|
||||
edges: ExerciseEdgeState[]
|
||||
} {
|
||||
const workflowStore = useWorkflowStore.getState()
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
const blocks: ExerciseBlockState[] = Object.values(workflowStore.blocks).map((block) => {
|
||||
const storedValues = subBlockStore.workflowValues[workflowId] ?? {}
|
||||
const blockValues = storedValues[block.id] ?? {}
|
||||
const subBlocks: Record<string, unknown> = { ...blockValues }
|
||||
return {
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
position: block.position,
|
||||
subBlocks,
|
||||
}
|
||||
})
|
||||
|
||||
const edges: ExerciseEdgeState[] = workflowStore.edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? undefined,
|
||||
targetHandle: e.targetHandle ?? undefined,
|
||||
}))
|
||||
|
||||
return { blocks, edges }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the real Sim canvas in sandbox mode for Sim Academy exercises.
|
||||
*
|
||||
* - Pre-hydrates workflow stores directly (no API calls)
|
||||
* - Provides sandbox permissions (canEdit: true, no workspace dependency)
|
||||
* - Displays a constrained block toolbar and live validation checklist
|
||||
* - Supports mock execution to simulate workflow runs
|
||||
*/
|
||||
export function SandboxCanvasProvider({
|
||||
exerciseId,
|
||||
exerciseConfig,
|
||||
onComplete,
|
||||
videoUrl,
|
||||
description,
|
||||
className,
|
||||
}: SandboxCanvasProviderProps) {
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult>({
|
||||
passed: false,
|
||||
results: [],
|
||||
})
|
||||
const [hintIndex, setHintIndex] = useState(-1)
|
||||
const completedRef = useRef(false)
|
||||
const onCompleteRef = useRef(onComplete)
|
||||
onCompleteRef.current = onComplete
|
||||
const isMockRunningRef = useRef(false)
|
||||
const handleMockRunRef = useRef<() => Promise<void>>(async () => {})
|
||||
|
||||
// Stable exercise ID — used as the workflow ID in the stores
|
||||
const workflowId = `sandbox-${exerciseId}`
|
||||
|
||||
const runValidation = useCallback(() => {
|
||||
const { blocks, edges } = readCurrentCanvasState(workflowId)
|
||||
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
|
||||
|
||||
setValidationResult((prev) => {
|
||||
if (
|
||||
prev.passed === result.passed &&
|
||||
prev.results.length === result.results.length &&
|
||||
prev.results.every((r, i) => r.passed === result.results[i].passed)
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
if (result.passed && !completedRef.current) {
|
||||
completedRef.current = true
|
||||
onCompleteRef.current?.(blocks, edges)
|
||||
}
|
||||
}, [workflowId, exerciseConfig.validationRules])
|
||||
|
||||
useEffect(() => {
|
||||
completedRef.current = false
|
||||
setHintIndex(-1)
|
||||
|
||||
const workflowState = buildWorkflowState(
|
||||
exerciseConfig.initialBlocks ?? [],
|
||||
exerciseConfig.initialEdges ?? []
|
||||
)
|
||||
|
||||
const syntheticMetadata: WorkflowMetadata = {
|
||||
id: workflowId,
|
||||
name: 'Exercise',
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
color: '#3972F6',
|
||||
workspaceId: SANDBOX_WORKSPACE_ID,
|
||||
sortOrder: 0,
|
||||
isSandbox: true,
|
||||
}
|
||||
|
||||
useWorkflowStore.getState().replaceWorkflowState(workflowState)
|
||||
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks)
|
||||
useWorkflowRegistry.setState((state) => ({
|
||||
workflows: { ...state.workflows, [workflowId]: syntheticMetadata },
|
||||
activeWorkflowId: workflowId,
|
||||
hydration: {
|
||||
phase: 'ready',
|
||||
workspaceId: SANDBOX_WORKSPACE_ID,
|
||||
workflowId,
|
||||
requestId: null,
|
||||
error: null,
|
||||
},
|
||||
}))
|
||||
|
||||
logger.info('Sandbox stores hydrated', { workflowId })
|
||||
setIsReady(true)
|
||||
|
||||
// Coalesce rapid store updates so validation runs at most once per animation frame.
|
||||
let rafId: number | null = null
|
||||
const scheduleValidation = () => {
|
||||
if (rafId !== null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
runValidation()
|
||||
})
|
||||
}
|
||||
|
||||
const unsubWorkflow = useWorkflowStore.subscribe(scheduleValidation)
|
||||
const unsubSubBlock = useSubBlockStore.subscribe(scheduleValidation)
|
||||
|
||||
// When the panel's Run button is clicked, useWorkflowExecution sets isExecuting=true
|
||||
// and returns immediately (no API call). Detect that signal here and run mock execution.
|
||||
const unsubExecution = useExecutionStore.subscribe((state) => {
|
||||
const isExec = state.workflowExecutions.get(workflowId)?.isExecuting
|
||||
if (isExec && !isMockRunningRef.current) {
|
||||
void handleMockRunRef.current()
|
||||
}
|
||||
})
|
||||
|
||||
runValidation()
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
unsubWorkflow()
|
||||
unsubSubBlock()
|
||||
unsubExecution()
|
||||
useWorkflowRegistry.setState((state) => {
|
||||
const { [workflowId]: _removed, ...rest } = state.workflows
|
||||
return {
|
||||
workflows: rest,
|
||||
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
|
||||
hydration:
|
||||
state.hydration.workflowId === workflowId
|
||||
? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null }
|
||||
: state.hydration,
|
||||
}
|
||||
})
|
||||
useWorkflowStore.setState({ blocks: {}, edges: [], loops: {}, parallels: {} })
|
||||
useSubBlockStore.setState((state) => {
|
||||
const { [workflowId]: _removed, ...rest } = state.workflowValues
|
||||
return { workflowValues: rest }
|
||||
})
|
||||
}
|
||||
}, [workflowId, exerciseConfig.initialBlocks, exerciseConfig.initialEdges, runValidation])
|
||||
|
||||
const handleMockRun = useCallback(async () => {
|
||||
if (isMockRunningRef.current) return
|
||||
isMockRunningRef.current = true
|
||||
|
||||
const { setActiveBlocks, setIsExecuting } = useExecutionStore.getState()
|
||||
const { blocks, edges } = readCurrentCanvasState(workflowId)
|
||||
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
|
||||
setValidationResult(result)
|
||||
if (!result.passed) {
|
||||
isMockRunningRef.current = false
|
||||
setIsExecuting(workflowId, false)
|
||||
return
|
||||
}
|
||||
|
||||
const plan = buildMockExecutionPlan(blocks, edges, exerciseConfig.mockOutputs ?? {})
|
||||
if (plan.length === 0) {
|
||||
isMockRunningRef.current = false
|
||||
setIsExecuting(workflowId, false)
|
||||
return
|
||||
}
|
||||
const { addConsole, clearWorkflowConsole } = useTerminalConsoleStore.getState()
|
||||
const workflowBlocks = useWorkflowStore.getState().blocks
|
||||
|
||||
setIsExecuting(workflowId, true)
|
||||
clearWorkflowConsole(workflowId)
|
||||
useTerminalConsoleStore.setState({ isOpen: true })
|
||||
|
||||
try {
|
||||
for (let i = 0; i < plan.length; i++) {
|
||||
const step = plan[i]
|
||||
setActiveBlocks(workflowId, new Set([step.blockId]))
|
||||
await new Promise((resolve) => setTimeout(resolve, step.delay))
|
||||
addConsole({
|
||||
workflowId,
|
||||
blockId: step.blockId,
|
||||
blockName: workflowBlocks[step.blockId]?.name ?? step.blockType,
|
||||
blockType: step.blockType,
|
||||
executionOrder: i,
|
||||
output: step.output,
|
||||
success: true,
|
||||
durationMs: step.delay,
|
||||
})
|
||||
setActiveBlocks(workflowId, new Set())
|
||||
}
|
||||
} finally {
|
||||
setIsExecuting(workflowId, false)
|
||||
isMockRunningRef.current = false
|
||||
}
|
||||
}, [workflowId, exerciseConfig.validationRules, exerciseConfig.mockOutputs])
|
||||
handleMockRunRef.current = handleMockRun
|
||||
|
||||
const handleShowHint = useCallback(() => {
|
||||
const hints = exerciseConfig.hints ?? []
|
||||
if (hints.length === 0) return
|
||||
setHintIndex((i) => Math.min(i + 1, hints.length - 1))
|
||||
}, [exerciseConfig.hints])
|
||||
|
||||
const handlePrevHint = useCallback(() => {
|
||||
setHintIndex((i) => Math.max(i - 1, 0))
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className='flex h-full w-full items-center justify-center bg-[#0e0e0e]'>
|
||||
<div className='h-5 w-5 animate-spin rounded-full border-2 border-[#ECECEC] border-t-transparent' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hints = exerciseConfig.hints ?? []
|
||||
const currentHint = hintIndex >= 0 ? hints[hintIndex] : null
|
||||
|
||||
return (
|
||||
<SandboxBlockConstraintsContext.Provider value={exerciseConfig.availableBlocks}>
|
||||
<GlobalCommandsProvider>
|
||||
<SandboxWorkspacePermissionsProvider>
|
||||
<div className={cn('flex h-full w-full overflow-hidden', className)}>
|
||||
<div className='flex w-56 flex-shrink-0 flex-col gap-3 overflow-y-auto border-[#1F1F1F] border-r bg-[#141414] p-3'>
|
||||
{(videoUrl || description) && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{videoUrl && <LessonVideo url={videoUrl} title='Lesson video' />}
|
||||
{description && (
|
||||
<p className='text-[#666] text-[11px] leading-relaxed'>{description}</p>
|
||||
)}
|
||||
<div className='border-[#1F1F1F] border-t' />
|
||||
</div>
|
||||
)}
|
||||
{exerciseConfig.instructions && (
|
||||
<p className='text-[#999] text-[11px] leading-relaxed'>
|
||||
{exerciseConfig.instructions}
|
||||
</p>
|
||||
)}
|
||||
<ValidationChecklist
|
||||
results={validationResult.results}
|
||||
allPassed={validationResult.passed}
|
||||
/>
|
||||
|
||||
<div className='mt-auto flex flex-col gap-2'>
|
||||
{currentHint && (
|
||||
<div className='rounded-[6px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-2 text-[11px]'>
|
||||
<div className='mb-1 flex items-center justify-between'>
|
||||
<span className='font-[430] text-[#666]'>
|
||||
Hint {hintIndex + 1}/{hints.length}
|
||||
</span>
|
||||
<div className='flex gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handlePrevHint}
|
||||
disabled={hintIndex === 0}
|
||||
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
|
||||
aria-label='Previous hint'
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleShowHint}
|
||||
disabled={hintIndex === hints.length - 1}
|
||||
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
|
||||
aria-label='Next hint'
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className='text-[#ECECEC]'>{currentHint}</span>
|
||||
</div>
|
||||
)}
|
||||
{hints.length > 0 && hintIndex < 0 && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleShowHint}
|
||||
className='w-full rounded-[5px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
|
||||
>
|
||||
Show hint
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative flex-1 overflow-hidden'>
|
||||
<Workflow workspaceId={SANDBOX_WORKSPACE_ID} workflowId={workflowId} sandbox />
|
||||
</div>
|
||||
</div>
|
||||
</SandboxWorkspacePermissionsProvider>
|
||||
</GlobalCommandsProvider>
|
||||
</SandboxBlockConstraintsContext.Provider>
|
||||
)
|
||||
}
|
||||
50
apps/sim/app/academy/components/validation-checklist.tsx
Normal file
50
apps/sim/app/academy/components/validation-checklist.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle2, Circle } from 'lucide-react'
|
||||
import type { ValidationRuleResult } from '@/lib/academy/types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface ValidationChecklistProps {
|
||||
results: ValidationRuleResult[]
|
||||
allPassed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Checklist showing exercise validation rules and their current pass/fail state.
|
||||
* Rendered inside the exercise sidebar, not as a canvas overlay.
|
||||
*/
|
||||
export function ValidationChecklist({ results, allPassed }: ValidationChecklistProps) {
|
||||
if (results.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-2.5 flex items-center gap-1.5'>
|
||||
<span className='font-[430] text-[#ECECEC] text-[12px]'>Checklist</span>
|
||||
{allPassed && (
|
||||
<span className='ml-auto rounded-full bg-[#4CAF50]/15 px-2 py-0.5 font-[430] text-[#4CAF50] text-[10px]'>
|
||||
Complete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className='space-y-1.5'>
|
||||
{results.map((result, i) => (
|
||||
<li key={i} className='flex items-start gap-2'>
|
||||
{result.passed ? (
|
||||
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#4CAF50]' />
|
||||
) : (
|
||||
<Circle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#444]' />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] leading-tight',
|
||||
result.passed ? 'text-[#555] line-through' : 'text-[#ECECEC]'
|
||||
)}
|
||||
>
|
||||
{result.message}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
apps/sim/app/academy/layout.tsx
Normal file
25
apps/sim/app/academy/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type React from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: 'Sim Academy',
|
||||
template: '%s | Sim Academy',
|
||||
},
|
||||
description:
|
||||
'Become a certified Sim partner — learn to build, integrate, and deploy AI workflows.',
|
||||
metadataBase: new URL('https://sim.ai'),
|
||||
openGraph: {
|
||||
title: 'Sim Academy',
|
||||
description: 'Become a certified Sim partner.',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
export default function AcademyLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
215
apps/sim/app/api/academy/certificates/route.ts
Normal file
215
apps/sim/app/api/academy/certificates/route.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { db } from '@sim/db'
|
||||
import { academyCertificate, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getCourseById } from '@/lib/academy/content'
|
||||
import type { CertificateMetadata } from '@/lib/academy/types'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
|
||||
const logger = createLogger('AcademyCertificatesAPI')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
const CERT_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 5,
|
||||
refillRate: 1,
|
||||
refillIntervalMs: 60 * 60_000, // 1 per hour refill
|
||||
}
|
||||
|
||||
const IssueCertificateSchema = z.object({
|
||||
courseId: z.string(),
|
||||
completedLessonIds: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/academy/certificates
|
||||
* Issues a certificate for the given course after verifying all lessons are completed.
|
||||
* Completion is client-attested: the client sends completed lesson IDs and the server
|
||||
* validates them against the full lesson list for the course.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { allowed } = await rateLimiter.checkRateLimitDirect(
|
||||
`academy:cert:${session.user.id}`,
|
||||
CERT_RATE_LIMIT
|
||||
)
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const parsed = IssueCertificateSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
const { courseId, completedLessonIds } = parsed.data
|
||||
|
||||
const course = getCourseById(courseId)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify all lessons in the course are reported as completed
|
||||
const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id))
|
||||
const completedSet = new Set(completedLessonIds)
|
||||
const incomplete = allLessonIds.filter((id) => !completedSet.has(id))
|
||||
if (incomplete.length > 0) {
|
||||
return NextResponse.json({ error: 'Course not fully completed', incomplete }, { status: 422 })
|
||||
}
|
||||
|
||||
const [existing, learner] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(
|
||||
and(
|
||||
eq(academyCertificate.userId, session.user.id),
|
||||
eq(academyCertificate.courseId, courseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
])
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'active') {
|
||||
return NextResponse.json({ certificate: existing })
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'A certificate for this course already exists but is not active.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const certificateNumber = generateCertificateNumber()
|
||||
const metadata: CertificateMetadata = {
|
||||
recipientName: learner?.name ?? session.user.name ?? 'Partner',
|
||||
courseTitle: course.title,
|
||||
}
|
||||
|
||||
const [certificate] = await db
|
||||
.insert(academyCertificate)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
courseId,
|
||||
status: 'active',
|
||||
certificateNumber,
|
||||
metadata,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
|
||||
if (!certificate) {
|
||||
const [race] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(
|
||||
and(
|
||||
eq(academyCertificate.userId, session.user.id),
|
||||
eq(academyCertificate.courseId, courseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (race?.status === 'active') {
|
||||
return NextResponse.json({ certificate: race })
|
||||
}
|
||||
if (race) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A certificate for this course already exists but is not active.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info('Certificate issued', {
|
||||
userId: session.user.id,
|
||||
courseId,
|
||||
certificateNumber,
|
||||
})
|
||||
|
||||
return NextResponse.json({ certificate }, { status: 201 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to issue certificate', { error })
|
||||
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/academy/certificates?certificateNumber=SIM-2026-00042
|
||||
* Public endpoint for verifying a certificate by its number.
|
||||
*
|
||||
* GET /api/academy/certificates?courseId=...
|
||||
* Authenticated endpoint for looking up the current user's certificate for a course.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const certificateNumber = searchParams.get('certificateNumber')
|
||||
const courseId = searchParams.get('courseId')
|
||||
|
||||
if (certificateNumber) {
|
||||
const [certificate] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(eq(academyCertificate.certificateNumber, certificateNumber))
|
||||
.limit(1)
|
||||
|
||||
if (!certificate) {
|
||||
return NextResponse.json({ error: 'Certificate not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ certificate })
|
||||
}
|
||||
|
||||
if (courseId) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [certificate] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(
|
||||
and(
|
||||
eq(academyCertificate.userId, session.user.id),
|
||||
eq(academyCertificate.courseId, courseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({ certificate: certificate ?? null })
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'certificateNumber or courseId query parameter is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify certificate', { error })
|
||||
return NextResponse.json({ error: 'Failed to verify certificate' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** Generates a human-readable certificate number, e.g. SIM-2026-A3K9XZ2P */
|
||||
function generateCertificateNumber(): string {
|
||||
const year = new Date().getFullYear()
|
||||
return `SIM-${year}-${nanoid(8).toUpperCase()}`
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
|
||||
import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import {
|
||||
@@ -539,10 +540,26 @@ export async function POST(req: NextRequest) {
|
||||
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
|
||||
}
|
||||
|
||||
const nsExecutionId = crypto.randomUUID()
|
||||
const nsRunId = crypto.randomUUID()
|
||||
|
||||
if (actualChatId) {
|
||||
await createRunSegment({
|
||||
id: nsRunId,
|
||||
executionId: nsExecutionId,
|
||||
chatId: actualChatId,
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
streamId: userMessageIdToUse,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const nonStreamingResult = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
chatId: actualChatId,
|
||||
executionId: nsExecutionId,
|
||||
runId: nsRunId,
|
||||
goRoute: '/api/copilot',
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
mockReturning,
|
||||
mockSelect,
|
||||
mockFrom,
|
||||
mockWhere,
|
||||
mockAuthenticate,
|
||||
mockCreateUnauthorizedResponse,
|
||||
mockCreateBadRequestResponse,
|
||||
@@ -23,6 +24,7 @@ const {
|
||||
mockReturning: vi.fn(),
|
||||
mockSelect: vi.fn(),
|
||||
mockFrom: vi.fn(),
|
||||
mockWhere: vi.fn(),
|
||||
mockAuthenticate: vi.fn(),
|
||||
mockCreateUnauthorizedResponse: vi.fn(),
|
||||
mockCreateBadRequestResponse: vi.fn(),
|
||||
@@ -81,7 +83,8 @@ describe('Copilot Feedback API Route', () => {
|
||||
mockValues.mockReturnValue({ returning: mockReturning })
|
||||
mockReturning.mockResolvedValue([])
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockResolvedValue([])
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockResolvedValue([])
|
||||
|
||||
mockCreateRequestTracker.mockReturnValue({
|
||||
requestId: 'test-request-id',
|
||||
@@ -386,7 +389,7 @@ edges:
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFrom.mockResolvedValueOnce([])
|
||||
mockWhere.mockResolvedValueOnce([])
|
||||
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
@@ -397,7 +400,7 @@ edges:
|
||||
expect(responseData.feedback).toEqual([])
|
||||
})
|
||||
|
||||
it('should return all feedback records', async () => {
|
||||
it('should only return feedback records for the authenticated user', async () => {
|
||||
mockAuthenticate.mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
@@ -415,19 +418,8 @@ edges:
|
||||
workflowYaml: null,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
},
|
||||
{
|
||||
feedbackId: 'feedback-2',
|
||||
userId: 'user-456',
|
||||
chatId: 'chat-2',
|
||||
userQuery: 'Query 2',
|
||||
agentResponse: 'Response 2',
|
||||
isPositive: false,
|
||||
feedback: 'Not helpful',
|
||||
workflowYaml: 'yaml: content',
|
||||
createdAt: new Date('2024-01-02'),
|
||||
},
|
||||
]
|
||||
mockFrom.mockResolvedValueOnce(mockFeedback)
|
||||
mockWhere.mockResolvedValueOnce(mockFeedback)
|
||||
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
@@ -435,9 +427,14 @@ edges:
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.success).toBe(true)
|
||||
expect(responseData.feedback).toHaveLength(2)
|
||||
expect(responseData.feedback).toHaveLength(1)
|
||||
expect(responseData.feedback[0].feedbackId).toBe('feedback-1')
|
||||
expect(responseData.feedback[1].feedbackId).toBe('feedback-2')
|
||||
expect(responseData.feedback[0].userId).toBe('user-123')
|
||||
|
||||
// Verify the where clause was called with the authenticated user's ID
|
||||
const { eq } = await import('drizzle-orm')
|
||||
expect(mockWhere).toHaveBeenCalled()
|
||||
expect(eq).toHaveBeenCalledWith('userId', 'user-123')
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
@@ -446,7 +443,7 @@ edges:
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFrom.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
mockWhere.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
@@ -462,7 +459,7 @@ edges:
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFrom.mockResolvedValueOnce([])
|
||||
mockWhere.mockResolvedValueOnce([])
|
||||
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotFeedback } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
@@ -109,7 +110,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
/**
|
||||
* GET /api/copilot/feedback
|
||||
* Get all feedback records (for analytics)
|
||||
* Get feedback records for the authenticated user
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const tracker = createRequestTracker()
|
||||
@@ -123,7 +124,7 @@ export async function GET(req: NextRequest) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
// Get all feedback records
|
||||
// Get feedback records for the authenticated user only
|
||||
const feedbackRecords = await db
|
||||
.select({
|
||||
feedbackId: copilotFeedback.feedbackId,
|
||||
@@ -137,6 +138,7 @@ export async function GET(req: NextRequest) {
|
||||
createdAt: copilotFeedback.createdAt,
|
||||
})
|
||||
.from(copilotFeedback)
|
||||
.where(eq(copilotFeedback.userId, authenticatedUserId))
|
||||
|
||||
logger.info(`[${tracker.requestId}] Retrieved ${feedbackRecords.length} feedback records`)
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('CopilotTrainingExamplesAPI')
|
||||
@@ -16,6 +20,11 @@ const TrainingExampleSchema = z.object({
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const baseUrl = env.AGENT_INDEXER_URL
|
||||
if (!baseUrl) {
|
||||
logger.error('Missing AGENT_INDEXER_URL environment variable')
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('CopilotTrainingAPI')
|
||||
@@ -22,6 +26,11 @@ const TrainingDataSchema = z.object({
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = env.AGENT_INDEXER_URL
|
||||
if (!baseUrl) {
|
||||
|
||||
@@ -26,6 +26,14 @@ vi.mock('@/lib/execution/e2b', () => ({
|
||||
executeInE2B: mockExecuteInE2B,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isHosted: false,
|
||||
isE2bEnabled: false,
|
||||
isProd: false,
|
||||
isDev: false,
|
||||
isTest: true,
|
||||
}))
|
||||
|
||||
import { validateProxyUrl } from '@/lib/core/security/input-validation'
|
||||
import { POST } from '@/app/api/function/execute/route'
|
||||
|
||||
|
||||
160
apps/sim/app/api/jobs/[jobId]/route.test.ts
Normal file
160
apps/sim/app/api/jobs/[jobId]/route.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockCheckHybridAuth,
|
||||
mockGetDispatchJobRecord,
|
||||
mockGetJobQueue,
|
||||
mockVerifyWorkflowAccess,
|
||||
mockGetWorkflowById,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCheckHybridAuth: vi.fn(),
|
||||
mockGetDispatchJobRecord: vi.fn(),
|
||||
mockGetJobQueue: vi.fn(),
|
||||
mockVerifyWorkflowAccess: vi.fn(),
|
||||
mockGetWorkflowById: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: mockCheckHybridAuth,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/async-jobs', () => ({
|
||||
JOB_STATUS: {
|
||||
PENDING: 'pending',
|
||||
PROCESSING: 'processing',
|
||||
COMPLETED: 'completed',
|
||||
FAILED: 'failed',
|
||||
},
|
||||
getJobQueue: mockGetJobQueue,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/workspace-dispatch/store', () => ({
|
||||
getDispatchJobRecord: mockGetDispatchJobRecord,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('request-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/socket/middleware/permissions', () => ({
|
||||
verifyWorkflowAccess: mockVerifyWorkflowAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
getWorkflowById: mockGetWorkflowById,
|
||||
}))
|
||||
|
||||
import { GET } from './route'
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
return {
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
} as NextRequest
|
||||
}
|
||||
|
||||
describe('GET /api/jobs/[jobId]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockCheckHybridAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
apiKeyType: undefined,
|
||||
workspaceId: undefined,
|
||||
})
|
||||
|
||||
mockVerifyWorkflowAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockGetWorkflowById.mockResolvedValue({
|
||||
id: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
})
|
||||
|
||||
mockGetJobQueue.mockResolvedValue({
|
||||
getJob: vi.fn().mockResolvedValue(null),
|
||||
})
|
||||
})
|
||||
|
||||
it('returns dispatcher-aware waiting status with metadata', async () => {
|
||||
mockGetDispatchJobRecord.mockResolvedValue({
|
||||
id: 'dispatch-1',
|
||||
workspaceId: 'workspace-1',
|
||||
lane: 'runtime',
|
||||
queueName: 'workflow-execution',
|
||||
bullmqJobName: 'workflow-execution',
|
||||
bullmqPayload: {},
|
||||
metadata: {
|
||||
workflowId: 'workflow-1',
|
||||
},
|
||||
priority: 10,
|
||||
status: 'waiting',
|
||||
createdAt: 1000,
|
||||
admittedAt: 2000,
|
||||
})
|
||||
|
||||
const response = await GET(createMockRequest(), {
|
||||
params: Promise.resolve({ jobId: 'dispatch-1' }),
|
||||
})
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.status).toBe('waiting')
|
||||
expect(body.metadata.queueName).toBe('workflow-execution')
|
||||
expect(body.metadata.lane).toBe('runtime')
|
||||
expect(body.metadata.workspaceId).toBe('workspace-1')
|
||||
})
|
||||
|
||||
it('returns completed output from dispatch state', async () => {
|
||||
mockGetDispatchJobRecord.mockResolvedValue({
|
||||
id: 'dispatch-2',
|
||||
workspaceId: 'workspace-1',
|
||||
lane: 'interactive',
|
||||
queueName: 'workflow-execution',
|
||||
bullmqJobName: 'direct-workflow-execution',
|
||||
bullmqPayload: {},
|
||||
metadata: {
|
||||
workflowId: 'workflow-1',
|
||||
},
|
||||
priority: 1,
|
||||
status: 'completed',
|
||||
createdAt: 1000,
|
||||
startedAt: 2000,
|
||||
completedAt: 7000,
|
||||
output: { success: true },
|
||||
})
|
||||
|
||||
const response = await GET(createMockRequest(), {
|
||||
params: Promise.resolve({ jobId: 'dispatch-2' }),
|
||||
})
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.status).toBe('completed')
|
||||
expect(body.output).toEqual({ success: true })
|
||||
expect(body.metadata.duration).toBe(5000)
|
||||
})
|
||||
|
||||
it('returns 404 when neither dispatch nor BullMQ job exists', async () => {
|
||||
mockGetDispatchJobRecord.mockResolvedValue(null)
|
||||
|
||||
const response = await GET(createMockRequest(), {
|
||||
params: Promise.resolve({ jobId: 'missing-job' }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getJobQueue, JOB_STATUS } from '@/lib/core/async-jobs'
|
||||
import { getJobQueue } from '@/lib/core/async-jobs'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { presentDispatchOrJobStatus } from '@/lib/core/workspace-dispatch/status'
|
||||
import { getDispatchJobRecord } from '@/lib/core/workspace-dispatch/store'
|
||||
import { createErrorResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('TaskStatusAPI')
|
||||
@@ -23,68 +25,54 @@ export async function GET(
|
||||
|
||||
const authenticatedUserId = authResult.userId
|
||||
|
||||
const dispatchJob = await getDispatchJobRecord(taskId)
|
||||
const jobQueue = await getJobQueue()
|
||||
const job = await jobQueue.getJob(taskId)
|
||||
const job = dispatchJob ? null : await jobQueue.getJob(taskId)
|
||||
|
||||
if (!job) {
|
||||
if (!job && !dispatchJob) {
|
||||
return createErrorResponse('Task not found', 404)
|
||||
}
|
||||
|
||||
if (job.metadata?.workflowId) {
|
||||
const metadataToCheck = dispatchJob?.metadata ?? job?.metadata
|
||||
|
||||
if (metadataToCheck?.workflowId) {
|
||||
const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions')
|
||||
const accessCheck = await verifyWorkflowAccess(
|
||||
authenticatedUserId,
|
||||
job.metadata.workflowId as string
|
||||
metadataToCheck.workflowId as string
|
||||
)
|
||||
if (!accessCheck.hasAccess) {
|
||||
logger.warn(`[${requestId}] Access denied to workflow ${job.metadata.workflowId}`)
|
||||
logger.warn(`[${requestId}] Access denied to workflow ${metadataToCheck.workflowId}`)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
}
|
||||
|
||||
if (authResult.apiKeyType === 'workspace' && authResult.workspaceId) {
|
||||
const { getWorkflowById } = await import('@/lib/workflows/utils')
|
||||
const workflow = await getWorkflowById(job.metadata.workflowId as string)
|
||||
const workflow = await getWorkflowById(metadataToCheck.workflowId as string)
|
||||
if (!workflow?.workspaceId || workflow.workspaceId !== authResult.workspaceId) {
|
||||
return createErrorResponse('API key is not authorized for this workspace', 403)
|
||||
}
|
||||
}
|
||||
} else if (job.metadata?.userId && job.metadata.userId !== authenticatedUserId) {
|
||||
logger.warn(`[${requestId}] Access denied to user ${job.metadata.userId}`)
|
||||
} else if (metadataToCheck?.userId && metadataToCheck.userId !== authenticatedUserId) {
|
||||
logger.warn(`[${requestId}] Access denied to user ${metadataToCheck.userId}`)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
} else if (!job.metadata?.userId && !job.metadata?.workflowId) {
|
||||
} else if (!metadataToCheck?.userId && !metadataToCheck?.workflowId) {
|
||||
logger.warn(`[${requestId}] Access denied to job ${taskId}`)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
}
|
||||
|
||||
const mappedStatus = job.status === JOB_STATUS.PENDING ? 'queued' : job.status
|
||||
|
||||
const presented = presentDispatchOrJobStatus(dispatchJob, job)
|
||||
const response: any = {
|
||||
success: true,
|
||||
taskId,
|
||||
status: mappedStatus,
|
||||
metadata: {
|
||||
startedAt: job.startedAt,
|
||||
},
|
||||
status: presented.status,
|
||||
metadata: presented.metadata,
|
||||
}
|
||||
|
||||
if (job.status === JOB_STATUS.COMPLETED) {
|
||||
response.output = job.output
|
||||
response.metadata.completedAt = job.completedAt
|
||||
if (job.startedAt && job.completedAt) {
|
||||
response.metadata.duration = job.completedAt.getTime() - job.startedAt.getTime()
|
||||
}
|
||||
}
|
||||
|
||||
if (job.status === JOB_STATUS.FAILED) {
|
||||
response.error = job.error
|
||||
response.metadata.completedAt = job.completedAt
|
||||
if (job.startedAt && job.completedAt) {
|
||||
response.metadata.duration = job.completedAt.getTime() - job.startedAt.getTime()
|
||||
}
|
||||
}
|
||||
|
||||
if (job.status === JOB_STATUS.PROCESSING || job.status === JOB_STATUS.PENDING) {
|
||||
response.estimatedDuration = 300000
|
||||
if (presented.output !== undefined) response.output = presented.output
|
||||
if (presented.error !== undefined) response.error = presented.error
|
||||
if (presented.estimatedDuration !== undefined) {
|
||||
response.estimatedDuration = presented.estimatedDuration
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
|
||||
@@ -237,7 +237,7 @@ describe('Knowledge Connector By ID API Route', () => {
|
||||
.mockReturnValueOnce(mockDbChain)
|
||||
.mockResolvedValueOnce([{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }])
|
||||
.mockReturnValueOnce(mockDbChain)
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }])
|
||||
mockDbChain.returning.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
|
||||
@@ -292,7 +292,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const connectorDocuments = await db.transaction(async (tx) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const deleteDocuments = searchParams.get('deleteDocuments') === 'true'
|
||||
|
||||
const { deletedDocs, docCount } = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`)
|
||||
|
||||
const docs = await tx
|
||||
@@ -306,10 +309,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
)
|
||||
)
|
||||
|
||||
const documentIds = docs.map((doc) => doc.id)
|
||||
if (documentIds.length > 0) {
|
||||
await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
|
||||
await tx.delete(document).where(inArray(document.id, documentIds))
|
||||
if (deleteDocuments) {
|
||||
const documentIds = docs.map((doc) => doc.id)
|
||||
if (documentIds.length > 0) {
|
||||
await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
|
||||
await tx.delete(document).where(inArray(document.id, documentIds))
|
||||
}
|
||||
}
|
||||
|
||||
const deletedConnectors = await tx
|
||||
@@ -328,16 +333,23 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
throw new Error('Connector not found')
|
||||
}
|
||||
|
||||
return docs
|
||||
return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length }
|
||||
})
|
||||
|
||||
await deleteDocumentStorageFiles(connectorDocuments, requestId)
|
||||
if (deleteDocuments) {
|
||||
await Promise.all([
|
||||
deletedDocs.length > 0
|
||||
? deleteDocumentStorageFiles(deletedDocs, requestId)
|
||||
: Promise.resolve(),
|
||||
cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
|
||||
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
|
||||
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Hard-deleted connector ${connectorId} and its documents`)
|
||||
logger.info(
|
||||
`[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
||||
@@ -349,7 +361,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
resourceId: connectorId,
|
||||
resourceName: existingConnector[0].connectorType,
|
||||
description: `Deleted connector from knowledge base "${writeCheck.knowledgeBase.name}"`,
|
||||
metadata: { knowledgeBaseId, documentsDeleted: connectorDocuments.length },
|
||||
metadata: {
|
||||
knowledgeBaseId,
|
||||
documentsDeleted: deleteDocuments ? docCount : 0,
|
||||
documentsKept: deleteDocuments ? 0 : docCount,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
|
||||
import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent'
|
||||
@@ -727,10 +728,25 @@ async function handleBuildToolCall(
|
||||
chatId,
|
||||
}
|
||||
|
||||
const executionId = crypto.randomUUID()
|
||||
const runId = crypto.randomUUID()
|
||||
const messageId = requestPayload.messageId as string
|
||||
|
||||
await createRunSegment({
|
||||
id: runId,
|
||||
executionId,
|
||||
chatId,
|
||||
userId,
|
||||
workflowId: resolved.workflowId,
|
||||
streamId: messageId,
|
||||
}).catch(() => {})
|
||||
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId,
|
||||
workflowId: resolved.workflowId,
|
||||
chatId,
|
||||
executionId,
|
||||
runId,
|
||||
goRoute: '/api/mcp',
|
||||
autoExecuteTools: true,
|
||||
timeout: ORCHESTRATION_TIMEOUT_MS,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
|
||||
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
|
||||
import { appendCopilotLogContext } from '@/lib/copilot/logging'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
@@ -71,10 +72,24 @@ export async function POST(req: NextRequest) {
|
||||
...(userPermission ? { userPermission } : {}),
|
||||
}
|
||||
|
||||
const executionId = crypto.randomUUID()
|
||||
const runId = crypto.randomUUID()
|
||||
|
||||
await createRunSegment({
|
||||
id: runId,
|
||||
executionId,
|
||||
chatId: effectiveChatId,
|
||||
userId,
|
||||
workspaceId,
|
||||
streamId: messageId,
|
||||
}).catch(() => {})
|
||||
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId,
|
||||
workspaceId,
|
||||
chatId: effectiveChatId,
|
||||
executionId,
|
||||
runId,
|
||||
goRoute: '/api/mothership/execute',
|
||||
autoExecuteTools: true,
|
||||
interactive: false,
|
||||
|
||||
@@ -61,6 +61,21 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify caller is either an org member or the invitee
|
||||
const isInvitee = session.user.email?.toLowerCase() === orgInvitation.email.toLowerCase()
|
||||
|
||||
if (!isInvitee) {
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const org = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
|
||||
@@ -9,10 +9,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const {
|
||||
mockVerifyCronAuth,
|
||||
mockExecuteScheduleJob,
|
||||
mockExecuteJobInline,
|
||||
mockFeatureFlags,
|
||||
mockDbReturning,
|
||||
mockDbUpdate,
|
||||
mockEnqueue,
|
||||
mockEnqueueWorkspaceDispatch,
|
||||
mockStartJob,
|
||||
mockCompleteJob,
|
||||
mockMarkJobFailed,
|
||||
@@ -22,6 +24,7 @@ const {
|
||||
const mockDbSet = vi.fn().mockReturnValue({ where: mockDbWhere })
|
||||
const mockDbUpdate = vi.fn().mockReturnValue({ set: mockDbSet })
|
||||
const mockEnqueue = vi.fn().mockResolvedValue('job-id-1')
|
||||
const mockEnqueueWorkspaceDispatch = vi.fn().mockResolvedValue('job-id-1')
|
||||
const mockStartJob = vi.fn().mockResolvedValue(undefined)
|
||||
const mockCompleteJob = vi.fn().mockResolvedValue(undefined)
|
||||
const mockMarkJobFailed = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -29,6 +32,7 @@ const {
|
||||
return {
|
||||
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
|
||||
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
|
||||
mockExecuteJobInline: vi.fn().mockResolvedValue(undefined),
|
||||
mockFeatureFlags: {
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
@@ -38,6 +42,7 @@ const {
|
||||
mockDbReturning,
|
||||
mockDbUpdate,
|
||||
mockEnqueue,
|
||||
mockEnqueueWorkspaceDispatch,
|
||||
mockStartJob,
|
||||
mockCompleteJob,
|
||||
mockMarkJobFailed,
|
||||
@@ -50,6 +55,8 @@ vi.mock('@/lib/auth/internal', () => ({
|
||||
|
||||
vi.mock('@/background/schedule-execution', () => ({
|
||||
executeScheduleJob: mockExecuteScheduleJob,
|
||||
executeJobInline: mockExecuteJobInline,
|
||||
releaseScheduleLock: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => mockFeatureFlags)
|
||||
@@ -68,6 +75,22 @@ vi.mock('@/lib/core/async-jobs', () => ({
|
||||
shouldExecuteInline: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/bullmq', () => ({
|
||||
isBullMQEnabled: vi.fn().mockReturnValue(true),
|
||||
createBullMQJobData: vi.fn((payload: unknown) => ({ payload })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/workspace-dispatch', () => ({
|
||||
enqueueWorkspaceDispatch: mockEnqueueWorkspaceDispatch,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
getWorkflowById: vi.fn().mockResolvedValue({
|
||||
id: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
@@ -142,6 +165,18 @@ const MULTIPLE_SCHEDULES = [
|
||||
},
|
||||
]
|
||||
|
||||
const SINGLE_JOB = [
|
||||
{
|
||||
id: 'job-1',
|
||||
cronExpression: '0 * * * *',
|
||||
failedCount: 0,
|
||||
lastQueuedAt: undefined,
|
||||
sourceUserId: 'user-1',
|
||||
sourceWorkspaceId: 'workspace-1',
|
||||
sourceType: 'job',
|
||||
},
|
||||
]
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
const mockHeaders = new Map([
|
||||
['authorization', 'Bearer test-cron-secret'],
|
||||
@@ -211,30 +246,44 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
expect(data).toHaveProperty('executedCount', 2)
|
||||
})
|
||||
|
||||
it('should queue mothership jobs to BullMQ when available', async () => {
|
||||
mockDbReturning.mockReturnValueOnce([]).mockReturnValueOnce(SINGLE_JOB)
|
||||
|
||||
const response = await GET(createMockRequest())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'workspace-1',
|
||||
lane: 'runtime',
|
||||
queueName: 'mothership-job-execution',
|
||||
bullmqJobName: 'mothership-job-execution',
|
||||
bullmqPayload: {
|
||||
payload: {
|
||||
scheduleId: 'job-1',
|
||||
cronExpression: '0 * * * *',
|
||||
failedCount: 0,
|
||||
now: expect.any(String),
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
expect(mockExecuteJobInline).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should enqueue preassigned correlation metadata for schedules', async () => {
|
||||
mockDbReturning.mockReturnValue(SINGLE_SCHEDULE)
|
||||
|
||||
const response = await GET(createMockRequest())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockEnqueue).toHaveBeenCalledWith(
|
||||
'schedule-execution',
|
||||
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'schedule-execution-1',
|
||||
requestId: 'test-request-id',
|
||||
correlation: {
|
||||
executionId: 'schedule-execution-1',
|
||||
requestId: 'test-request-id',
|
||||
source: 'schedule',
|
||||
workflowId: 'workflow-1',
|
||||
scheduleId: 'schedule-1',
|
||||
triggerType: 'schedule',
|
||||
scheduledFor: '2025-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
id: 'schedule-execution-1',
|
||||
workspaceId: 'workspace-1',
|
||||
lane: 'runtime',
|
||||
queueName: 'schedule-execution',
|
||||
bullmqJobName: 'schedule-execution',
|
||||
metadata: {
|
||||
workflowId: 'workflow-1',
|
||||
correlation: {
|
||||
@@ -247,7 +296,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
scheduledFor: '2025-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,9 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
|
||||
import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch'
|
||||
import {
|
||||
executeJobInline,
|
||||
executeScheduleJob,
|
||||
@@ -73,6 +75,8 @@ export async function GET(request: NextRequest) {
|
||||
cronExpression: workflowSchedule.cronExpression,
|
||||
failedCount: workflowSchedule.failedCount,
|
||||
lastQueuedAt: workflowSchedule.lastQueuedAt,
|
||||
sourceWorkspaceId: workflowSchedule.sourceWorkspaceId,
|
||||
sourceUserId: workflowSchedule.sourceUserId,
|
||||
sourceType: workflowSchedule.sourceType,
|
||||
})
|
||||
|
||||
@@ -111,9 +115,40 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const jobId = await jobQueue.enqueue('schedule-execution', payload, {
|
||||
metadata: { workflowId: schedule.workflowId ?? undefined, correlation },
|
||||
})
|
||||
const { getWorkflowById } = await import('@/lib/workflows/utils')
|
||||
const resolvedWorkflow = schedule.workflowId
|
||||
? await getWorkflowById(schedule.workflowId)
|
||||
: null
|
||||
const resolvedWorkspaceId = resolvedWorkflow?.workspaceId
|
||||
|
||||
let jobId: string
|
||||
if (isBullMQEnabled()) {
|
||||
if (!resolvedWorkspaceId) {
|
||||
throw new Error(
|
||||
`Missing workspace for scheduled workflow ${schedule.workflowId}; refusing to bypass workspace admission`
|
||||
)
|
||||
}
|
||||
|
||||
jobId = await enqueueWorkspaceDispatch({
|
||||
id: executionId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
lane: 'runtime',
|
||||
queueName: 'schedule-execution',
|
||||
bullmqJobName: 'schedule-execution',
|
||||
bullmqPayload: createBullMQJobData(payload, {
|
||||
workflowId: schedule.workflowId ?? undefined,
|
||||
correlation,
|
||||
}),
|
||||
metadata: {
|
||||
workflowId: schedule.workflowId ?? undefined,
|
||||
correlation,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
jobId = await jobQueue.enqueue('schedule-execution', payload, {
|
||||
metadata: { workflowId: schedule.workflowId ?? undefined, correlation },
|
||||
})
|
||||
}
|
||||
logger.info(
|
||||
`[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}`
|
||||
)
|
||||
@@ -165,7 +200,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
})
|
||||
|
||||
// Jobs always execute inline (no TriggerDev)
|
||||
// Mothership jobs use BullMQ when available, otherwise direct inline execution.
|
||||
const jobPromises = dueJobs.map(async (job) => {
|
||||
const queueTime = job.lastQueuedAt ?? queuedAt
|
||||
const payload = {
|
||||
@@ -176,7 +211,24 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
await executeJobInline(payload)
|
||||
if (isBullMQEnabled()) {
|
||||
if (!job.sourceWorkspaceId || !job.sourceUserId) {
|
||||
throw new Error(`Mothership job ${job.id} is missing workspace/user ownership`)
|
||||
}
|
||||
|
||||
await enqueueWorkspaceDispatch({
|
||||
workspaceId: job.sourceWorkspaceId!,
|
||||
lane: 'runtime',
|
||||
queueName: 'mothership-job-execution',
|
||||
bullmqJobName: 'mothership-job-execution',
|
||||
bullmqPayload: createBullMQJobData(payload),
|
||||
metadata: {
|
||||
userId: job.sourceUserId,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await executeJobInline(payload)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Job execution failed for ${job.id}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { ImapFlow } from 'imapflow'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
|
||||
const logger = createLogger('ImapMailboxesAPI')
|
||||
|
||||
@@ -9,7 +10,6 @@ interface ImapMailboxRequest {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
rejectUnauthorized: boolean
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as ImapMailboxRequest
|
||||
const { host, port, secure, rejectUnauthorized, username, password } = body
|
||||
const { host, port, secure, username, password } = body
|
||||
|
||||
if (!host || !username || !password) {
|
||||
return NextResponse.json(
|
||||
@@ -31,8 +31,14 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const hostValidation = await validateDatabaseHost(host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
return NextResponse.json({ success: false, message: hostValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const client = new ImapFlow({
|
||||
host,
|
||||
host: hostValidation.resolvedIP!,
|
||||
servername: host,
|
||||
port: port || 993,
|
||||
secure: secure ?? true,
|
||||
auth: {
|
||||
@@ -40,7 +46,7 @@ export async function POST(request: NextRequest) {
|
||||
pass: password,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: rejectUnauthorized ?? true,
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
logger: false,
|
||||
})
|
||||
@@ -79,21 +85,12 @@ export async function POST(request: NextRequest) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Error fetching IMAP mailboxes:', errorMessage)
|
||||
|
||||
let userMessage = 'Failed to connect to IMAP server'
|
||||
let userMessage = 'Failed to connect to IMAP server. Please check your connection settings.'
|
||||
if (
|
||||
errorMessage.includes('AUTHENTICATIONFAILED') ||
|
||||
errorMessage.includes('Invalid credentials')
|
||||
) {
|
||||
userMessage = 'Invalid username or password. For Gmail, use an App Password.'
|
||||
} else if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) {
|
||||
userMessage = 'Could not find IMAP server. Please check the hostname.'
|
||||
} else if (errorMessage.includes('ECONNREFUSED')) {
|
||||
userMessage = 'Connection refused. Please check the port and SSL settings.'
|
||||
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL')) {
|
||||
userMessage =
|
||||
'TLS/SSL error. Try disabling "Verify TLS Certificate" for self-signed certificates.'
|
||||
} else if (errorMessage.includes('timeout')) {
|
||||
userMessage = 'Connection timed out. Please check your network and server settings.'
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, message: userMessage }, { status: 500 })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Attributes, Client, type ConnectConfig, type SFTPWrapper } from 'ssh2'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
|
||||
const S_IFMT = 0o170000
|
||||
const S_IFDIR = 0o040000
|
||||
@@ -91,16 +92,23 @@ function formatSftpError(err: Error, config: { host: string; port: number }): Er
|
||||
* Creates an SSH connection for SFTP using the provided configuration.
|
||||
* Uses ssh2 library defaults which align with OpenSSH standards.
|
||||
*/
|
||||
export function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
|
||||
export async function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
|
||||
const host = config.host
|
||||
|
||||
if (!host || host.trim() === '') {
|
||||
throw new Error('Host is required. Please provide a valid hostname or IP address.')
|
||||
}
|
||||
|
||||
const hostValidation = await validateDatabaseHost(host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
throw new Error(hostValidation.error)
|
||||
}
|
||||
|
||||
const resolvedHost = hostValidation.resolvedIP ?? host.trim()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new Client()
|
||||
const port = config.port || 22
|
||||
const host = config.host
|
||||
|
||||
if (!host || host.trim() === '') {
|
||||
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
|
||||
return
|
||||
}
|
||||
|
||||
const hasPassword = config.password && config.password.trim() !== ''
|
||||
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
|
||||
@@ -111,7 +119,7 @@ export function createSftpConnection(config: SftpConnectionConfig): Promise<Clie
|
||||
}
|
||||
|
||||
const connectConfig: ConnectConfig = {
|
||||
host: host.trim(),
|
||||
host: resolvedHost,
|
||||
port,
|
||||
username: config.username,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
@@ -56,6 +57,15 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const validatedData = SmtpSendSchema.parse(body)
|
||||
|
||||
const hostValidation = await validateDatabaseHost(validatedData.smtpHost, 'smtpHost')
|
||||
if (!hostValidation.isValid) {
|
||||
logger.warn(`[${requestId}] SMTP host validation failed`, {
|
||||
host: validatedData.smtpHost,
|
||||
error: hostValidation.error,
|
||||
})
|
||||
return NextResponse.json({ success: false, error: hostValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Sending email via SMTP`, {
|
||||
host: validatedData.smtpHost,
|
||||
port: validatedData.smtpPort,
|
||||
@@ -64,8 +74,13 @@ export async function POST(request: NextRequest) {
|
||||
secure: validatedData.smtpSecure,
|
||||
})
|
||||
|
||||
// Pin the pre-resolved IP to prevent DNS rebinding (TOCTOU) attacks.
|
||||
// Pass resolvedIP as the host so nodemailer connects to the validated address,
|
||||
// and set servername for correct TLS SNI/certificate validation.
|
||||
const pinnedHost = hostValidation.resolvedIP ?? validatedData.smtpHost
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: validatedData.smtpHost,
|
||||
host: pinnedHost,
|
||||
port: validatedData.smtpPort,
|
||||
secure: validatedData.smtpSecure === 'SSL',
|
||||
auth: {
|
||||
@@ -74,12 +89,8 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
tls:
|
||||
validatedData.smtpSecure === 'None'
|
||||
? {
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
: {
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
? { rejectUnauthorized: false, servername: validatedData.smtpHost }
|
||||
: { rejectUnauthorized: true, servername: validatedData.smtpHost },
|
||||
})
|
||||
|
||||
const contentType = validatedData.contentType || 'text'
|
||||
@@ -189,16 +200,16 @@ export async function POST(request: NextRequest) {
|
||||
if (isNodeError(error)) {
|
||||
if (error.code === 'EAUTH') {
|
||||
errorMessage = 'SMTP authentication failed - check username and password'
|
||||
} else if (error.code === 'ECONNECTION' || error.code === 'ECONNREFUSED') {
|
||||
} else if (
|
||||
error.code === 'ECONNECTION' ||
|
||||
error.code === 'ECONNREFUSED' ||
|
||||
error.code === 'ECONNRESET' ||
|
||||
error.code === 'ETIMEDOUT'
|
||||
) {
|
||||
errorMessage = 'Could not connect to SMTP server - check host and port'
|
||||
} else if (error.code === 'ECONNRESET') {
|
||||
errorMessage = 'Connection was reset by SMTP server'
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
errorMessage = 'SMTP server connection timeout'
|
||||
}
|
||||
}
|
||||
|
||||
// Check for SMTP response codes
|
||||
const hasResponseCode = (err: unknown): err is { responseCode: number } => {
|
||||
return typeof err === 'object' && err !== null && 'responseCode' in err
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type Attributes, Client, type ConnectConfig } from 'ssh2'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
|
||||
const logger = createLogger('SSHUtils')
|
||||
|
||||
@@ -108,16 +109,23 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
* - keepaliveInterval: 0 (disabled, same as OpenSSH ServerAliveInterval)
|
||||
* - keepaliveCountMax: 3 (same as OpenSSH ServerAliveCountMax)
|
||||
*/
|
||||
export function createSSHConnection(config: SSHConnectionConfig): Promise<Client> {
|
||||
export async function createSSHConnection(config: SSHConnectionConfig): Promise<Client> {
|
||||
const host = config.host
|
||||
|
||||
if (!host || host.trim() === '') {
|
||||
throw new Error('Host is required. Please provide a valid hostname or IP address.')
|
||||
}
|
||||
|
||||
const hostValidation = await validateDatabaseHost(host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
throw new Error(hostValidation.error)
|
||||
}
|
||||
|
||||
const resolvedHost = hostValidation.resolvedIP ?? host.trim()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new Client()
|
||||
const port = config.port || 22
|
||||
const host = config.host
|
||||
|
||||
if (!host || host.trim() === '') {
|
||||
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
|
||||
return
|
||||
}
|
||||
|
||||
const hasPassword = config.password && config.password.trim() !== ''
|
||||
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
|
||||
@@ -128,7 +136,7 @@ export function createSSHConnection(config: SSHConnectionConfig): Promise<Client
|
||||
}
|
||||
|
||||
const connectConfig: ConnectConfig = {
|
||||
host: host.trim(),
|
||||
host: resolvedHost,
|
||||
port,
|
||||
username: config.username,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
|
||||
import { appendCopilotLogContext } from '@/lib/copilot/logging'
|
||||
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
@@ -104,10 +105,24 @@ export async function POST(req: NextRequest) {
|
||||
chatId,
|
||||
}
|
||||
|
||||
const executionId = crypto.randomUUID()
|
||||
const runId = crypto.randomUUID()
|
||||
|
||||
await createRunSegment({
|
||||
id: runId,
|
||||
executionId,
|
||||
chatId,
|
||||
userId: auth.userId,
|
||||
workflowId: resolved.workflowId,
|
||||
streamId: messageId,
|
||||
}).catch(() => {})
|
||||
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: auth.userId,
|
||||
workflowId: resolved.workflowId,
|
||||
chatId,
|
||||
executionId,
|
||||
runId,
|
||||
goRoute: '/api/mcp',
|
||||
autoExecuteTools: parsed.autoExecuteTools,
|
||||
timeout: parsed.timeout,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { DispatchQueueFullError } from '@/lib/core/workspace-dispatch'
|
||||
import {
|
||||
checkWebhookPreprocessing,
|
||||
findAllWebhooksForPath,
|
||||
@@ -41,10 +43,25 @@ export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string }> }
|
||||
) {
|
||||
const ticket = tryAdmit()
|
||||
if (!ticket) {
|
||||
return admissionRejectedResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await handleWebhookPost(request, params)
|
||||
} finally {
|
||||
ticket.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebhookPost(
|
||||
request: NextRequest,
|
||||
params: Promise<{ path: string }>
|
||||
): Promise<NextResponse> {
|
||||
const requestId = generateRequestId()
|
||||
const { path } = await params
|
||||
|
||||
// Handle provider challenges before body parsing (Microsoft Graph validationToken, etc.)
|
||||
const earlyChallenge = await handleProviderChallenges({}, request, requestId, path)
|
||||
if (earlyChallenge) {
|
||||
return earlyChallenge
|
||||
@@ -140,17 +157,30 @@ export async function POST(
|
||||
continue
|
||||
}
|
||||
|
||||
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
|
||||
requestId,
|
||||
path,
|
||||
actorUserId: preprocessResult.actorUserId,
|
||||
executionId: preprocessResult.executionId,
|
||||
correlation: preprocessResult.correlation,
|
||||
})
|
||||
responses.push(response)
|
||||
try {
|
||||
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
|
||||
requestId,
|
||||
path,
|
||||
actorUserId: preprocessResult.actorUserId,
|
||||
executionId: preprocessResult.executionId,
|
||||
correlation: preprocessResult.correlation,
|
||||
})
|
||||
responses.push(response)
|
||||
} catch (error) {
|
||||
if (error instanceof DispatchQueueFullError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Service temporarily at capacity',
|
||||
message: error.message,
|
||||
retryAfterSeconds: 10,
|
||||
},
|
||||
{ status: 503, headers: { 'Retry-After': '10' } }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Return the last successful response, or a combined response for multiple webhooks
|
||||
if (responses.length === 0) {
|
||||
return new NextResponse('No webhooks processed successfully', { status: 500 })
|
||||
}
|
||||
|
||||
@@ -10,15 +10,18 @@ const {
|
||||
mockAuthorizeWorkflowByWorkspacePermission,
|
||||
mockPreprocessExecution,
|
||||
mockEnqueue,
|
||||
mockEnqueueWorkspaceDispatch,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCheckHybridAuth: vi.fn(),
|
||||
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
|
||||
mockPreprocessExecution: vi.fn(),
|
||||
mockEnqueue: vi.fn().mockResolvedValue('job-123'),
|
||||
mockEnqueueWorkspaceDispatch: vi.fn().mockResolvedValue('job-123'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: mockCheckHybridAuth,
|
||||
hasExternalApiCredentials: vi.fn().mockReturnValue(true),
|
||||
AuthType: {
|
||||
SESSION: 'session',
|
||||
API_KEY: 'api_key',
|
||||
@@ -44,6 +47,16 @@ vi.mock('@/lib/core/async-jobs', () => ({
|
||||
markJobFailed: vi.fn(),
|
||||
}),
|
||||
shouldExecuteInline: vi.fn().mockReturnValue(false),
|
||||
shouldUseBullMQ: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/bullmq', () => ({
|
||||
createBullMQJobData: vi.fn((payload: unknown, metadata?: unknown) => ({ payload, metadata })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/workspace-dispatch', () => ({
|
||||
enqueueWorkspaceDispatch: mockEnqueueWorkspaceDispatch,
|
||||
waitForDispatchJob: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
@@ -132,22 +145,13 @@ describe('workflow execute async route', () => {
|
||||
expect(response.status).toBe(202)
|
||||
expect(body.executionId).toBe('execution-123')
|
||||
expect(body.jobId).toBe('job-123')
|
||||
expect(mockEnqueue).toHaveBeenCalledWith(
|
||||
'workflow-execution',
|
||||
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'actor-1',
|
||||
executionId: 'execution-123',
|
||||
requestId: 'req-12345678',
|
||||
correlation: {
|
||||
executionId: 'execution-123',
|
||||
requestId: 'req-12345678',
|
||||
source: 'workflow',
|
||||
workflowId: 'workflow-1',
|
||||
triggerType: 'manual',
|
||||
},
|
||||
}),
|
||||
{
|
||||
id: 'execution-123',
|
||||
workspaceId: 'workspace-1',
|
||||
lane: 'runtime',
|
||||
queueName: 'workflow-execution',
|
||||
bullmqJobName: 'workflow-execution',
|
||||
metadata: {
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'actor-1',
|
||||
@@ -159,7 +163,7 @@ describe('workflow execute async route', () => {
|
||||
triggerType: 'manual',
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,10 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { validate as uuidValidate, v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
|
||||
import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth/hybrid'
|
||||
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
|
||||
import { getJobQueue, shouldExecuteInline, shouldUseBullMQ } from '@/lib/core/async-jobs'
|
||||
import { createBullMQJobData } from '@/lib/core/bullmq'
|
||||
import {
|
||||
createTimeoutAbortController,
|
||||
getTimeoutErrorMessage,
|
||||
@@ -12,6 +14,13 @@ import {
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
DispatchQueueFullError,
|
||||
enqueueWorkspaceDispatch,
|
||||
type WorkspaceDispatchLane,
|
||||
waitForDispatchJob,
|
||||
} from '@/lib/core/workspace-dispatch'
|
||||
import { createBufferedExecutionStream } from '@/lib/execution/buffered-stream'
|
||||
import {
|
||||
buildNextCallChain,
|
||||
parseCallChain,
|
||||
@@ -33,6 +42,11 @@ import {
|
||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
import {
|
||||
DIRECT_WORKFLOW_JOB_NAME,
|
||||
type QueuedWorkflowExecutionPayload,
|
||||
type QueuedWorkflowExecutionResult,
|
||||
} from '@/lib/workflows/executor/queued-workflow-execution'
|
||||
import {
|
||||
loadDeployedWorkflowState,
|
||||
loadWorkflowFromNormalizedTables,
|
||||
@@ -104,6 +118,8 @@ const ExecuteWorkflowSchema = z.object({
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const INLINE_TRIGGER_TYPES = new Set<CoreTriggerType>(['manual', 'workflow'])
|
||||
|
||||
function resolveOutputIds(
|
||||
selectedOutputs: string[] | undefined,
|
||||
blocks: Record<string, any>
|
||||
@@ -161,6 +177,7 @@ type AsyncExecutionParams = {
|
||||
requestId: string
|
||||
workflowId: string
|
||||
userId: string
|
||||
workspaceId: string
|
||||
input: any
|
||||
triggerType: CoreTriggerType
|
||||
executionId: string
|
||||
@@ -168,7 +185,8 @@ type AsyncExecutionParams = {
|
||||
}
|
||||
|
||||
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
|
||||
const { requestId, workflowId, userId, input, triggerType, executionId, callChain } = params
|
||||
const { requestId, workflowId, userId, workspaceId, input, triggerType, executionId, callChain } =
|
||||
params
|
||||
|
||||
const correlation = {
|
||||
executionId,
|
||||
@@ -181,6 +199,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
||||
const payload: WorkflowExecutionPayload = {
|
||||
workflowId,
|
||||
userId,
|
||||
workspaceId,
|
||||
input,
|
||||
triggerType,
|
||||
executionId,
|
||||
@@ -190,22 +209,42 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
||||
}
|
||||
|
||||
try {
|
||||
const jobQueue = await getJobQueue()
|
||||
const jobId = await jobQueue.enqueue('workflow-execution', payload, {
|
||||
metadata: { workflowId, userId, correlation },
|
||||
})
|
||||
const useBullMQ = shouldUseBullMQ()
|
||||
const jobQueue = useBullMQ ? null : await getJobQueue()
|
||||
const jobId = useBullMQ
|
||||
? await enqueueWorkspaceDispatch({
|
||||
id: executionId,
|
||||
workspaceId,
|
||||
lane: 'runtime',
|
||||
queueName: 'workflow-execution',
|
||||
bullmqJobName: 'workflow-execution',
|
||||
bullmqPayload: createBullMQJobData(payload, {
|
||||
workflowId,
|
||||
userId,
|
||||
correlation,
|
||||
}),
|
||||
metadata: {
|
||||
workflowId,
|
||||
userId,
|
||||
correlation,
|
||||
},
|
||||
})
|
||||
: await jobQueue!.enqueue('workflow-execution', payload, {
|
||||
metadata: { workflowId, userId, correlation },
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Queued async workflow execution`, {
|
||||
workflowId,
|
||||
jobId,
|
||||
})
|
||||
|
||||
if (shouldExecuteInline()) {
|
||||
if (shouldExecuteInline() && jobQueue) {
|
||||
const inlineJobQueue = jobQueue
|
||||
void (async () => {
|
||||
try {
|
||||
await jobQueue.startJob(jobId)
|
||||
await inlineJobQueue.startJob(jobId)
|
||||
const output = await executeWorkflowJob(payload)
|
||||
await jobQueue.completeJob(jobId, output)
|
||||
await inlineJobQueue.completeJob(jobId, output)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logger.error(`[${requestId}] Async workflow execution failed`, {
|
||||
@@ -213,7 +252,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
||||
error: errorMessage,
|
||||
})
|
||||
try {
|
||||
await jobQueue.markJobFailed(jobId, errorMessage)
|
||||
await inlineJobQueue.markJobFailed(jobId, errorMessage)
|
||||
} catch (markFailedError) {
|
||||
logger.error(`[${requestId}] Failed to mark job as failed`, {
|
||||
jobId,
|
||||
@@ -239,6 +278,17 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
||||
{ status: 202 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
if (error instanceof DispatchQueueFullError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Service temporarily at capacity',
|
||||
message: error.message,
|
||||
retryAfterSeconds: 10,
|
||||
},
|
||||
{ status: 503, headers: { 'Retry-After': '10' } }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Failed to queue async execution`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to queue async execution: ${error.message}` },
|
||||
@@ -247,6 +297,31 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueDirectWorkflowExecution(
|
||||
payload: QueuedWorkflowExecutionPayload,
|
||||
priority: number,
|
||||
lane: WorkspaceDispatchLane
|
||||
) {
|
||||
return enqueueWorkspaceDispatch({
|
||||
id: payload.metadata.executionId,
|
||||
workspaceId: payload.metadata.workspaceId,
|
||||
lane,
|
||||
queueName: 'workflow-execution',
|
||||
bullmqJobName: DIRECT_WORKFLOW_JOB_NAME,
|
||||
bullmqPayload: createBullMQJobData(payload, {
|
||||
workflowId: payload.metadata.workflowId,
|
||||
userId: payload.metadata.userId,
|
||||
correlation: payload.metadata.correlation,
|
||||
}),
|
||||
metadata: {
|
||||
workflowId: payload.metadata.workflowId,
|
||||
userId: payload.metadata.userId,
|
||||
correlation: payload.metadata.correlation,
|
||||
},
|
||||
priority,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/workflows/[id]/execute
|
||||
*
|
||||
@@ -254,6 +329,27 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
||||
* Supports both SSE streaming (for interactive/manual runs) and direct JSON responses (for background jobs).
|
||||
*/
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const isSessionRequest = req.headers.has('cookie') && !hasExternalApiCredentials(req.headers)
|
||||
if (isSessionRequest) {
|
||||
return handleExecutePost(req, params)
|
||||
}
|
||||
|
||||
const ticket = tryAdmit()
|
||||
if (!ticket) {
|
||||
return admissionRejectedResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
return await handleExecutePost(req, params)
|
||||
} finally {
|
||||
ticket.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExecutePost(
|
||||
req: NextRequest,
|
||||
params: Promise<{ id: string }>
|
||||
): Promise<NextResponse | Response> {
|
||||
const requestId = generateRequestId()
|
||||
const { id: workflowId } = await params
|
||||
|
||||
@@ -584,6 +680,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
requestId,
|
||||
workflowId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
input,
|
||||
triggerType: loggingTriggerType,
|
||||
executionId,
|
||||
@@ -676,30 +773,116 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
if (!enableSSE) {
|
||||
logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`)
|
||||
const metadata: ExecutionMetadata = {
|
||||
requestId,
|
||||
executionId,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
userId: actorUserId,
|
||||
sessionUserId: isClientSession ? userId : undefined,
|
||||
workflowUserId: workflow.userId,
|
||||
triggerType,
|
||||
useDraftState: shouldUseDraftState,
|
||||
startTime: new Date().toISOString(),
|
||||
isClientSession,
|
||||
enforceCredentialAccess: useAuthenticatedUserAsActor,
|
||||
workflowStateOverride: effectiveWorkflowStateOverride,
|
||||
callChain,
|
||||
}
|
||||
|
||||
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
|
||||
|
||||
if (shouldUseBullMQ() && !INLINE_TRIGGER_TYPES.has(triggerType)) {
|
||||
try {
|
||||
const dispatchJobId = await enqueueDirectWorkflowExecution(
|
||||
{
|
||||
workflow,
|
||||
metadata,
|
||||
input: processedInput,
|
||||
variables: executionVariables,
|
||||
selectedOutputs,
|
||||
includeFileBase64,
|
||||
base64MaxBytes,
|
||||
stopAfterBlockId,
|
||||
timeoutMs: preprocessResult.executionTimeout?.sync,
|
||||
runFromBlock: resolvedRunFromBlock,
|
||||
},
|
||||
5,
|
||||
'interactive'
|
||||
)
|
||||
|
||||
const resultRecord = await waitForDispatchJob(
|
||||
dispatchJobId,
|
||||
(preprocessResult.executionTimeout?.sync ?? 300000) + 30000
|
||||
)
|
||||
|
||||
if (resultRecord.status === 'failed') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
executionId,
|
||||
error: resultRecord.error ?? 'Workflow execution failed',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = resultRecord.output as QueuedWorkflowExecutionResult
|
||||
|
||||
const resultForResponseBlock = {
|
||||
success: result.success,
|
||||
logs: result.logs,
|
||||
output: result.output,
|
||||
}
|
||||
|
||||
if (
|
||||
auth.authType !== AuthType.INTERNAL_JWT &&
|
||||
workflowHasResponseBlock(resultForResponseBlock)
|
||||
) {
|
||||
return createHttpResponseFromBlock(resultForResponseBlock)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: result.success,
|
||||
executionId,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
metadata: result.metadata,
|
||||
},
|
||||
{ status: result.statusCode ?? 200 }
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DispatchQueueFullError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Service temporarily at capacity',
|
||||
message: error.message,
|
||||
retryAfterSeconds: 10,
|
||||
},
|
||||
{ status: 503, headers: { 'Retry-After': '10' } }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
logger.error(`[${requestId}] Queued non-SSE execution failed: ${errorMessage}`)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutController = createTimeoutAbortController(
|
||||
preprocessResult.executionTimeout?.sync
|
||||
)
|
||||
|
||||
try {
|
||||
const metadata: ExecutionMetadata = {
|
||||
requestId,
|
||||
executionId,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
userId: actorUserId,
|
||||
sessionUserId: isClientSession ? userId : undefined,
|
||||
workflowUserId: workflow.userId,
|
||||
triggerType,
|
||||
useDraftState: shouldUseDraftState,
|
||||
startTime: new Date().toISOString(),
|
||||
isClientSession,
|
||||
enforceCredentialAccess: useAuthenticatedUserAsActor,
|
||||
workflowStateOverride: effectiveWorkflowStateOverride,
|
||||
callChain,
|
||||
}
|
||||
|
||||
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
|
||||
|
||||
const snapshot = new ExecutionSnapshot(
|
||||
metadata,
|
||||
workflow,
|
||||
@@ -809,6 +992,53 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
if (shouldUseDraftState) {
|
||||
const shouldDispatchViaQueue = shouldUseBullMQ() && !INLINE_TRIGGER_TYPES.has(triggerType)
|
||||
if (shouldDispatchViaQueue) {
|
||||
const metadata: ExecutionMetadata = {
|
||||
requestId,
|
||||
executionId,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
userId: actorUserId,
|
||||
sessionUserId: isClientSession ? userId : undefined,
|
||||
workflowUserId: workflow.userId,
|
||||
triggerType,
|
||||
useDraftState: shouldUseDraftState,
|
||||
startTime: new Date().toISOString(),
|
||||
isClientSession,
|
||||
enforceCredentialAccess: useAuthenticatedUserAsActor,
|
||||
workflowStateOverride: effectiveWorkflowStateOverride,
|
||||
callChain,
|
||||
}
|
||||
|
||||
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
|
||||
|
||||
await enqueueDirectWorkflowExecution(
|
||||
{
|
||||
workflow,
|
||||
metadata,
|
||||
input: processedInput,
|
||||
variables: executionVariables,
|
||||
selectedOutputs,
|
||||
includeFileBase64,
|
||||
base64MaxBytes,
|
||||
stopAfterBlockId,
|
||||
timeoutMs: preprocessResult.executionTimeout?.sync,
|
||||
runFromBlock: resolvedRunFromBlock,
|
||||
streamEvents: true,
|
||||
},
|
||||
1,
|
||||
'interactive'
|
||||
)
|
||||
|
||||
return new NextResponse(createBufferedExecutionStream(executionId), {
|
||||
headers: {
|
||||
...SSE_HEADERS,
|
||||
'X-Execution-Id': executionId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Using SSE console log streaming (manual execution)`)
|
||||
} else {
|
||||
logger.info(`[${requestId}] Using streaming API response`)
|
||||
@@ -1277,6 +1507,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error instanceof DispatchQueueFullError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Service temporarily at capacity',
|
||||
message: error.message,
|
||||
retryAfterSeconds: 10,
|
||||
},
|
||||
{ status: 503, headers: { 'Retry-After': '10' } }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Failed to start workflow execution:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to start workflow execution' },
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
updateApiKeyLastUsed,
|
||||
} from '@/lib/api-key/service'
|
||||
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMiddleware')
|
||||
@@ -81,11 +80,6 @@ export async function validateWorkflowAccess(
|
||||
}
|
||||
}
|
||||
|
||||
const internalSecret = request.headers.get('X-Internal-Secret')
|
||||
if (env.INTERNAL_API_SECRET && internalSecret === env.INTERNAL_API_SECRET) {
|
||||
return { workflow }
|
||||
}
|
||||
|
||||
let apiKeyHeader = null
|
||||
for (const [key, value] of request.headers.entries()) {
|
||||
if (key.toLowerCase() === 'x-api-key' && value) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
||||
import { listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
|
||||
import { deduplicateWorkflowName, listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -25,6 +25,7 @@ const CreateWorkflowSchema = z.object({
|
||||
workspaceId: z.string().optional(),
|
||||
folderId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
deduplicate: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
|
||||
@@ -126,12 +127,13 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const {
|
||||
id: clientId,
|
||||
name,
|
||||
name: requestedName,
|
||||
description,
|
||||
color,
|
||||
workspaceId,
|
||||
folderId,
|
||||
sortOrder: providedSortOrder,
|
||||
deduplicate,
|
||||
} = CreateWorkflowSchema.parse(body)
|
||||
|
||||
if (!workspaceId) {
|
||||
@@ -162,19 +164,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`)
|
||||
|
||||
import('@/lib/core/telemetry')
|
||||
.then(({ PlatformEvents }) => {
|
||||
PlatformEvents.workflowCreated({
|
||||
workflowId,
|
||||
name,
|
||||
workspaceId: workspaceId || undefined,
|
||||
folderId: folderId || undefined,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail
|
||||
})
|
||||
|
||||
let sortOrder: number
|
||||
if (providedSortOrder !== undefined) {
|
||||
sortOrder = providedSortOrder
|
||||
@@ -214,30 +203,49 @@ export async function POST(req: NextRequest) {
|
||||
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
|
||||
}
|
||||
|
||||
const duplicateConditions = [
|
||||
eq(workflow.workspaceId, workspaceId),
|
||||
isNull(workflow.archivedAt),
|
||||
eq(workflow.name, name),
|
||||
]
|
||||
let name = requestedName
|
||||
|
||||
if (folderId) {
|
||||
duplicateConditions.push(eq(workflow.folderId, folderId))
|
||||
if (deduplicate) {
|
||||
name = await deduplicateWorkflowName(requestedName, workspaceId, folderId)
|
||||
} else {
|
||||
duplicateConditions.push(isNull(workflow.folderId))
|
||||
const duplicateConditions = [
|
||||
eq(workflow.workspaceId, workspaceId),
|
||||
isNull(workflow.archivedAt),
|
||||
eq(workflow.name, requestedName),
|
||||
]
|
||||
|
||||
if (folderId) {
|
||||
duplicateConditions.push(eq(workflow.folderId, folderId))
|
||||
} else {
|
||||
duplicateConditions.push(isNull(workflow.folderId))
|
||||
}
|
||||
|
||||
const [duplicateWorkflow] = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(...duplicateConditions))
|
||||
.limit(1)
|
||||
|
||||
if (duplicateWorkflow) {
|
||||
return NextResponse.json(
|
||||
{ error: `A workflow named "${requestedName}" already exists in this folder` },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const [duplicateWorkflow] = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(...duplicateConditions))
|
||||
.limit(1)
|
||||
|
||||
if (duplicateWorkflow) {
|
||||
return NextResponse.json(
|
||||
{ error: `A workflow named "${name}" already exists in this folder` },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
import('@/lib/core/telemetry')
|
||||
.then(({ PlatformEvents }) => {
|
||||
PlatformEvents.workflowCreated({
|
||||
workflowId,
|
||||
name,
|
||||
workspaceId: workspaceId || undefined,
|
||||
folderId: folderId || undefined,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail
|
||||
})
|
||||
|
||||
await db.insert(workflow).values({
|
||||
id: workflowId,
|
||||
|
||||
@@ -79,6 +79,22 @@ vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/emails', () => ({
|
||||
WorkspaceInvitationEmail: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/messaging/email/mailer', () => ({
|
||||
sendEmail: vi.fn().mockResolvedValue({ success: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/messaging/email/utils', () => ({
|
||||
getFromEmailAddress: vi.fn().mockReturnValue('noreply@test.com'),
|
||||
}))
|
||||
|
||||
vi.mock('@react-email/render', () => ({
|
||||
render: vi.fn().mockResolvedValue('<html></html>'),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: () => mockDbSelect(),
|
||||
@@ -171,9 +187,10 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
})
|
||||
|
||||
describe('GET /api/workspaces/invitations/[invitationId]', () => {
|
||||
it('should return invitation details when called without token', async () => {
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
it('should return invitation details when caller is the invitee', async () => {
|
||||
const session = createSession({ userId: mockUser.id, email: 'invited@example.com' })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
|
||||
dbSelectResults = [[mockInvitation], [mockWorkspace]]
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
|
||||
@@ -191,6 +208,43 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should return invitation details when caller is a workspace admin', async () => {
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
|
||||
dbSelectResults = [[mockInvitation], [mockWorkspace]]
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toMatchObject({
|
||||
id: 'invitation-789',
|
||||
email: 'invited@example.com',
|
||||
status: 'pending',
|
||||
workspaceName: 'Test Workspace',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 403 when caller is neither invitee nor workspace admin', async () => {
|
||||
const session = createSession({ userId: mockUser.id, email: 'unrelated@example.com' })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
|
||||
dbSelectResults = [[mockInvitation], [mockWorkspace]]
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toEqual({ error: 'Insufficient permissions' })
|
||||
})
|
||||
|
||||
it('should redirect to login when unauthenticated with token', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
|
||||
@@ -198,6 +198,15 @@ export async function GET(
|
||||
)
|
||||
}
|
||||
|
||||
const isInvitee = session.user.email?.toLowerCase() === invitation.email.toLowerCase()
|
||||
|
||||
if (!isInvitee) {
|
||||
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...invitation,
|
||||
workspaceName: workspaceDetails.name,
|
||||
|
||||
@@ -8,7 +8,9 @@ const baseUrl = getBaseUrl()
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(baseUrl),
|
||||
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
title: {
|
||||
absolute: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
},
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
|
||||
keywords:
|
||||
|
||||
@@ -16,7 +16,6 @@ const Joyride = dynamic(() => import('react-joyride'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const NAV_TOUR_STORAGE_KEY = 'sim-nav-tour-completed-v1'
|
||||
export const START_NAV_TOUR_EVENT = 'start-nav-tour'
|
||||
|
||||
export function NavTour() {
|
||||
@@ -25,9 +24,6 @@ export function NavTour() {
|
||||
|
||||
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
|
||||
steps: navTourSteps,
|
||||
storageKey: NAV_TOUR_STORAGE_KEY,
|
||||
autoStartDelay: 1200,
|
||||
resettable: true,
|
||||
triggerEvent: START_NAV_TOUR_EVENT,
|
||||
tourName: 'Navigation tour',
|
||||
disabled: isWorkflowPage,
|
||||
|
||||
@@ -12,17 +12,11 @@ const FADE_OUT_MS = 80
|
||||
interface UseTourOptions {
|
||||
/** Tour step definitions */
|
||||
steps: Step[]
|
||||
/** localStorage key for completion persistence */
|
||||
storageKey: string
|
||||
/** Delay before auto-starting the tour (ms) */
|
||||
autoStartDelay?: number
|
||||
/** Whether this tour can be reset/retriggered */
|
||||
resettable?: boolean
|
||||
/** Custom event name to listen for manual triggers */
|
||||
triggerEvent?: string
|
||||
/** Identifier for logging */
|
||||
tourName?: string
|
||||
/** When true, suppresses auto-start (e.g. to avoid overlapping with another active tour) */
|
||||
/** When true, stops a running tour (e.g. navigating away from the relevant page) */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
@@ -41,49 +35,14 @@ interface UseTourReturn {
|
||||
handleCallback: (data: CallBackProps) => void
|
||||
}
|
||||
|
||||
function isTourCompleted(storageKey: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(storageKey) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function markTourCompleted(storageKey: string): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey, 'true')
|
||||
} catch {
|
||||
logger.warn('Failed to persist tour completion', { storageKey })
|
||||
}
|
||||
}
|
||||
|
||||
function clearTourCompletion(storageKey: string): void {
|
||||
try {
|
||||
localStorage.removeItem(storageKey)
|
||||
} catch {
|
||||
logger.warn('Failed to clear tour completion', { storageKey })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which tours have already attempted auto-start in this page session.
|
||||
* Module-level so it survives component remounts (e.g. navigating between
|
||||
* workflows remounts WorkflowTour), while still resetting on full page reload.
|
||||
*/
|
||||
const autoStartAttempted = new Set<string>()
|
||||
|
||||
/**
|
||||
* Shared hook for managing product tour state with smooth transitions.
|
||||
*
|
||||
* Handles auto-start on first visit, localStorage persistence,
|
||||
* manual triggering via custom events, and coordinated fade
|
||||
* Handles manual triggering via custom events and coordinated fade
|
||||
* transitions between steps to prevent layout shift.
|
||||
*/
|
||||
export function useTour({
|
||||
steps,
|
||||
storageKey,
|
||||
autoStartDelay = 1200,
|
||||
resettable = false,
|
||||
triggerEvent,
|
||||
tourName = 'tour',
|
||||
disabled = false,
|
||||
@@ -94,15 +53,10 @@ export function useTour({
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(true)
|
||||
const [isEntrance, setIsEntrance] = useState(true)
|
||||
|
||||
const disabledRef = useRef(disabled)
|
||||
const retriggerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
disabledRef.current = disabled
|
||||
}, [disabled])
|
||||
|
||||
/**
|
||||
* Schedules a two-frame rAF to reveal the tooltip after the browser
|
||||
* finishes repositioning. Stores the outer frame ID in `rafRef` so
|
||||
@@ -137,8 +91,7 @@ export function useTour({
|
||||
setRun(false)
|
||||
setIsTooltipVisible(true)
|
||||
setIsEntrance(true)
|
||||
markTourCompleted(storageKey)
|
||||
}, [storageKey, cancelPendingTransitions])
|
||||
}, [cancelPendingTransitions])
|
||||
|
||||
/** Transition to a new step with a coordinated fade-out/fade-in */
|
||||
const transitionToStep = useCallback(
|
||||
@@ -164,40 +117,17 @@ export function useTour({
|
||||
/** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */
|
||||
useEffect(() => {
|
||||
if (disabled && run) {
|
||||
cancelPendingTransitions()
|
||||
setRun(false)
|
||||
setIsTooltipVisible(true)
|
||||
setIsEntrance(true)
|
||||
stopTour()
|
||||
logger.info(`${tourName} paused — disabled became true`)
|
||||
}
|
||||
}, [disabled, run, tourName, cancelPendingTransitions])
|
||||
|
||||
/** Auto-start on first visit (once per page session per tour) */
|
||||
useEffect(() => {
|
||||
if (disabled || autoStartAttempted.has(storageKey) || isTourCompleted(storageKey)) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (disabledRef.current) return
|
||||
|
||||
autoStartAttempted.add(storageKey)
|
||||
setStepIndex(0)
|
||||
setIsEntrance(true)
|
||||
setIsTooltipVisible(false)
|
||||
setRun(true)
|
||||
logger.info(`Auto-starting ${tourName}`)
|
||||
scheduleReveal()
|
||||
}, autoStartDelay)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [disabled, storageKey, autoStartDelay, tourName, scheduleReveal])
|
||||
}, [disabled, run, tourName, stopTour])
|
||||
|
||||
/** Listen for manual trigger events */
|
||||
useEffect(() => {
|
||||
if (!triggerEvent || !resettable) return
|
||||
if (!triggerEvent) return
|
||||
|
||||
const handleTrigger = () => {
|
||||
setRun(false)
|
||||
clearTourCompletion(storageKey)
|
||||
setTourKey((k) => k + 1)
|
||||
|
||||
if (retriggerTimerRef.current) {
|
||||
@@ -222,7 +152,7 @@ export function useTour({
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [triggerEvent, resettable, storageKey, tourName, scheduleReveal])
|
||||
}, [triggerEvent, tourName, scheduleReveal])
|
||||
|
||||
/** Clean up all pending async work on unmount */
|
||||
useEffect(() => {
|
||||
|
||||
@@ -15,19 +15,15 @@ const Joyride = dynamic(() => import('react-joyride'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const WORKFLOW_TOUR_STORAGE_KEY = 'sim-workflow-tour-completed-v1'
|
||||
export const START_WORKFLOW_TOUR_EVENT = 'start-workflow-tour'
|
||||
|
||||
/**
|
||||
* Workflow tour that covers the canvas, blocks, copilot, and deployment.
|
||||
* Runs on first workflow visit and can be retriggered via "Take a tour".
|
||||
* Triggered via "Take a tour" in the sidebar menu.
|
||||
*/
|
||||
export function WorkflowTour() {
|
||||
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
|
||||
steps: workflowTourSteps,
|
||||
storageKey: WORKFLOW_TOUR_STORAGE_KEY,
|
||||
autoStartDelay: 800,
|
||||
resettable: true,
|
||||
triggerEvent: START_WORKFLOW_TOUR_EVENT,
|
||||
tourName: 'Workflow tour',
|
||||
})
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { memo } from 'react'
|
||||
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
|
||||
import type { WorkspaceMember } from '@/hooks/queries/workspace'
|
||||
|
||||
function OwnerAvatar({ name, image }: { name: string; image: string | null }) {
|
||||
interface OwnerAvatarProps {
|
||||
name: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) {
|
||||
if (image) {
|
||||
return (
|
||||
<img
|
||||
@@ -18,7 +24,7 @@ function OwnerAvatar({ name, image }: { name: string; image: string | null }) {
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolves a user ID into a ResourceCell with an avatar icon and display name.
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
|
||||
|
||||
const HEADER_PLUS_ICON = <Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
|
||||
export interface DropdownOption {
|
||||
label: string
|
||||
icon?: React.ElementType
|
||||
@@ -122,7 +124,7 @@ export const ResourceHeader = memo(function ResourceHeader({
|
||||
variant='subtle'
|
||||
className='px-2 py-1 text-caption'
|
||||
>
|
||||
<Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
{HEADER_PLUS_ICON}
|
||||
{create.label}
|
||||
</Button>
|
||||
)}
|
||||
@@ -132,19 +134,21 @@ export const ResourceHeader = memo(function ResourceHeader({
|
||||
)
|
||||
})
|
||||
|
||||
function BreadcrumbSegment({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
dropdownItems,
|
||||
editing,
|
||||
}: {
|
||||
interface BreadcrumbSegmentProps {
|
||||
icon?: React.ElementType
|
||||
label: string
|
||||
onClick?: () => void
|
||||
dropdownItems?: DropdownOption[]
|
||||
editing?: BreadcrumbEditing
|
||||
}) {
|
||||
}
|
||||
|
||||
const BreadcrumbSegment = memo(function BreadcrumbSegment({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
dropdownItems,
|
||||
editing,
|
||||
}: BreadcrumbSegmentProps) {
|
||||
if (editing?.isEditing) {
|
||||
return (
|
||||
<span className='inline-flex items-center px-2 py-1'>
|
||||
@@ -203,4 +207,4 @@ function BreadcrumbSegment({
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import { memo, type ReactNode, useCallback, useRef, useState } from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const SEARCH_ICON = (
|
||||
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
|
||||
)
|
||||
const FILTER_ICON = <ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
const SORT_ICON = <ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export interface ColumnOption {
|
||||
@@ -79,56 +85,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
|
||||
return (
|
||||
<div className={cn('border-[var(--border)] border-b py-2.5', search ? 'px-6' : 'px-4')}>
|
||||
<div className='flex items-center justify-between'>
|
||||
{search && (
|
||||
<div className='relative flex flex-1 items-center'>
|
||||
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
|
||||
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto pl-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
{search.tags?.map((tag, i) => (
|
||||
<Button
|
||||
key={`${tag.label}-${tag.value}-${i}`}
|
||||
variant='subtle'
|
||||
className={cn(
|
||||
'shrink-0 px-2 py-1 text-caption',
|
||||
search.highlightedTagIndex === i &&
|
||||
'ring-1 ring-[var(--border-focus)] ring-offset-1'
|
||||
)}
|
||||
onClick={tag.onRemove}
|
||||
>
|
||||
{tag.label}: {tag.value}
|
||||
<span className='ml-1 text-[var(--text-icon)] text-micro'>✕</span>
|
||||
</Button>
|
||||
))}
|
||||
<input
|
||||
ref={search.inputRef}
|
||||
type='text'
|
||||
value={search.value}
|
||||
onChange={(e) => search.onChange(e.target.value)}
|
||||
onKeyDown={search.onKeyDown}
|
||||
onFocus={search.onFocus}
|
||||
onBlur={search.onBlur}
|
||||
placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')}
|
||||
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
|
||||
/>
|
||||
</div>
|
||||
{search.tags?.length || search.value ? (
|
||||
<button
|
||||
type='button'
|
||||
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
|
||||
onClick={search.onClearAll}
|
||||
>
|
||||
<span className='text-caption'>✕</span>
|
||||
</button>
|
||||
) : null}
|
||||
{search.dropdown && (
|
||||
<div
|
||||
ref={search.dropdownRef}
|
||||
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
|
||||
>
|
||||
{search.dropdown}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{search && <SearchSection search={search} />}
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{extras}
|
||||
{filterTags?.map((tag) => (
|
||||
@@ -146,7 +103,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
|
||||
<PopoverPrimitive.Root>
|
||||
<PopoverPrimitive.Trigger asChild>
|
||||
<Button variant='subtle' className='px-2 py-1 text-caption'>
|
||||
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
{FILTER_ICON}
|
||||
Filter
|
||||
</Button>
|
||||
</PopoverPrimitive.Trigger>
|
||||
@@ -170,14 +127,94 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
|
||||
)
|
||||
})
|
||||
|
||||
function SortDropdown({ config }: { config: SortConfig }) {
|
||||
const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) {
|
||||
const [localValue, setLocalValue] = useState(search.value)
|
||||
|
||||
const lastReportedRef = useRef(search.value)
|
||||
|
||||
if (search.value !== lastReportedRef.current) {
|
||||
setLocalValue(search.value)
|
||||
lastReportedRef.current = search.value
|
||||
}
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const next = e.target.value
|
||||
setLocalValue(next)
|
||||
search.onChange(next)
|
||||
},
|
||||
[search.onChange]
|
||||
)
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
setLocalValue('')
|
||||
lastReportedRef.current = ''
|
||||
if (search.onClearAll) {
|
||||
search.onClearAll()
|
||||
} else {
|
||||
search.onChange('')
|
||||
}
|
||||
}, [search.onClearAll, search.onChange])
|
||||
|
||||
return (
|
||||
<div className='relative flex flex-1 items-center'>
|
||||
{SEARCH_ICON}
|
||||
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto pl-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
{search.tags?.map((tag, i) => (
|
||||
<Button
|
||||
key={`${tag.label}-${tag.value}-${i}`}
|
||||
variant='subtle'
|
||||
className={cn(
|
||||
'shrink-0 px-2 py-1 text-caption',
|
||||
search.highlightedTagIndex === i && 'ring-1 ring-[var(--border-focus)] ring-offset-1'
|
||||
)}
|
||||
onClick={tag.onRemove}
|
||||
>
|
||||
{tag.label}: {tag.value}
|
||||
<span className='ml-1 text-[var(--text-icon)] text-micro'>✕</span>
|
||||
</Button>
|
||||
))}
|
||||
<input
|
||||
ref={search.inputRef}
|
||||
type='text'
|
||||
value={localValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={search.onKeyDown}
|
||||
onFocus={search.onFocus}
|
||||
onBlur={search.onBlur}
|
||||
placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')}
|
||||
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
|
||||
/>
|
||||
</div>
|
||||
{search.tags?.length || localValue ? (
|
||||
<button
|
||||
type='button'
|
||||
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
<span className='text-caption'>✕</span>
|
||||
</button>
|
||||
) : null}
|
||||
{search.dropdown && (
|
||||
<div
|
||||
ref={search.dropdownRef}
|
||||
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
|
||||
>
|
||||
{search.dropdown}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig }) {
|
||||
const { options, active, onSort, onClear } = config
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='subtle' className='px-2 py-1 text-caption'>
|
||||
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
{SORT_ICON}
|
||||
Sort
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -218,4 +255,4 @@ function SortDropdown({ config }: { config: SortConfig }) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ResourceHeader } from './components/resource-header'
|
||||
import type { FilterTag, SearchConfig, SortConfig } from './components/resource-options-bar'
|
||||
import { ResourceOptionsBar } from './components/resource-options-bar'
|
||||
|
||||
const CREATE_ROW_PLUS_ICON = <Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
|
||||
export interface ResourceColumn {
|
||||
id: string
|
||||
header: string
|
||||
@@ -69,11 +71,13 @@ interface ResourceProps {
|
||||
const EMPTY_CELL_PLACEHOLDER = '- - -'
|
||||
const SKELETON_ROW_COUNT = 5
|
||||
|
||||
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation()
|
||||
|
||||
/**
|
||||
* Shared page shell for resource list pages (tables, files, knowledge, schedules, logs).
|
||||
* Renders the header, toolbar with search, and a data table from column/row definitions.
|
||||
*/
|
||||
export function Resource({
|
||||
export const Resource = memo(function Resource({
|
||||
icon,
|
||||
title,
|
||||
breadcrumbs,
|
||||
@@ -135,7 +139,7 @@ export function Resource({
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export interface ResourceTableProps {
|
||||
columns: ResourceColumn[]
|
||||
@@ -229,6 +233,13 @@ export const ResourceTable = memo(function ResourceTable({
|
||||
const hasCheckbox = selectable != null
|
||||
const totalColSpan = columns.length + (hasCheckbox ? 1 : 0)
|
||||
|
||||
const handleSelectAll = useCallback(
|
||||
(checked: boolean | 'indeterminate') => {
|
||||
selectable?.onSelectAll(checked as boolean)
|
||||
},
|
||||
[selectable]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DataTableSkeleton
|
||||
@@ -259,7 +270,7 @@ export const ResourceTable = memo(function ResourceTable({
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={selectable.isAllSelected}
|
||||
onCheckedChange={(checked) => selectable.onSelectAll(checked as boolean)}
|
||||
onCheckedChange={handleSelectAll}
|
||||
disabled={selectable.disabled}
|
||||
aria-label='Select all'
|
||||
/>
|
||||
@@ -306,68 +317,20 @@ export const ResourceTable = memo(function ResourceTable({
|
||||
<table className='w-full table-fixed text-small'>
|
||||
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
|
||||
<tbody>
|
||||
{displayRows.map((row) => {
|
||||
const isSelected = selectable?.selectedIds.has(row.id) ?? false
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
data-resource-row
|
||||
data-row-id={row.id}
|
||||
className={cn(
|
||||
'transition-colors hover-hover:bg-[var(--surface-3)]',
|
||||
onRowClick && 'cursor-pointer',
|
||||
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
|
||||
)}
|
||||
onClick={() => onRowClick?.(row.id)}
|
||||
onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined}
|
||||
onContextMenu={(e) => onRowContextMenu?.(e, row.id)}
|
||||
>
|
||||
{hasCheckbox && (
|
||||
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
selectable.onSelectRow(row.id, checked as boolean)
|
||||
}
|
||||
disabled={selectable.disabled}
|
||||
aria-label='Select row'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col, colIdx) => {
|
||||
const cell = row.cells[col.id]
|
||||
return (
|
||||
<td key={col.id} className='px-6 py-2.5 align-middle'>
|
||||
<CellContent
|
||||
cell={{ ...cell, label: cell?.label || EMPTY_CELL_PLACEHOLDER }}
|
||||
primary={colIdx === 0}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{create && (
|
||||
<tr
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
create.disabled
|
||||
? 'cursor-not-allowed'
|
||||
: 'cursor-pointer hover-hover:bg-[var(--surface-3)]'
|
||||
)}
|
||||
onClick={create.disabled ? undefined : create.onClick}
|
||||
>
|
||||
<td colSpan={totalColSpan} className='px-6 py-2.5 align-middle'>
|
||||
<span className='flex items-center gap-3 font-medium text-[var(--text-secondary)] text-sm'>
|
||||
<Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
{create.label}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{displayRows.map((row) => (
|
||||
<DataRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
columns={columns}
|
||||
selectedRowId={selectedRowId}
|
||||
selectable={selectable}
|
||||
onRowClick={onRowClick}
|
||||
onRowHover={onRowHover}
|
||||
onRowContextMenu={onRowContextMenu}
|
||||
hasCheckbox={hasCheckbox}
|
||||
/>
|
||||
))}
|
||||
{create && <CreateRow create={create} totalColSpan={totalColSpan} />}
|
||||
</tbody>
|
||||
</table>
|
||||
{hasMore && (
|
||||
@@ -390,7 +353,7 @@ export const ResourceTable = memo(function ResourceTable({
|
||||
)
|
||||
})
|
||||
|
||||
function Pagination({
|
||||
const Pagination = memo(function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
@@ -447,10 +410,17 @@ function Pagination({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
interface CellContentProps {
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
content?: ReactNode
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean }) {
|
||||
if (cell.content) return <>{cell.content}</>
|
||||
const CellContent = memo(function CellContent({ icon, label, content, primary }: CellContentProps) {
|
||||
if (content) return <>{content}</>
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -458,19 +428,132 @@ function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean
|
||||
primary ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{cell.icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{cell.icon}</span>}
|
||||
<span className='truncate'>{cell.label}</span>
|
||||
{icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{icon}</span>}
|
||||
<span className='truncate'>{label}</span>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
interface DataRowProps {
|
||||
row: ResourceRow
|
||||
columns: ResourceColumn[]
|
||||
selectedRowId?: string | null
|
||||
selectable?: SelectableConfig
|
||||
onRowClick?: (rowId: string) => void
|
||||
onRowHover?: (rowId: string) => void
|
||||
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
|
||||
hasCheckbox: boolean
|
||||
}
|
||||
|
||||
function ResourceColGroup({
|
||||
const DataRow = memo(function DataRow({
|
||||
row,
|
||||
columns,
|
||||
selectedRowId,
|
||||
selectable,
|
||||
onRowClick,
|
||||
onRowHover,
|
||||
onRowContextMenu,
|
||||
hasCheckbox,
|
||||
}: {
|
||||
}: DataRowProps) {
|
||||
const isSelected = selectable?.selectedIds.has(row.id) ?? false
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onRowClick?.(row.id)
|
||||
}, [onRowClick, row.id])
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
onRowHover?.(row.id)
|
||||
}, [onRowHover, row.id])
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
onRowContextMenu?.(e, row.id)
|
||||
},
|
||||
[onRowContextMenu, row.id]
|
||||
)
|
||||
|
||||
const handleSelectRow = useCallback(
|
||||
(checked: boolean | 'indeterminate') => {
|
||||
selectable?.onSelectRow(row.id, checked as boolean)
|
||||
},
|
||||
[selectable, row.id]
|
||||
)
|
||||
|
||||
return (
|
||||
<tr
|
||||
data-resource-row
|
||||
data-row-id={row.id}
|
||||
className={cn(
|
||||
'transition-colors hover-hover:bg-[var(--surface-3)]',
|
||||
onRowClick && 'cursor-pointer',
|
||||
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
|
||||
)}
|
||||
onClick={onRowClick ? handleClick : undefined}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onContextMenu={onRowContextMenu ? handleContextMenu : undefined}
|
||||
>
|
||||
{hasCheckbox && selectable && (
|
||||
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={isSelected}
|
||||
onCheckedChange={handleSelectRow}
|
||||
disabled={selectable.disabled}
|
||||
aria-label='Select row'
|
||||
onClick={stopPropagation}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col, colIdx) => {
|
||||
const cell = row.cells[col.id]
|
||||
return (
|
||||
<td key={col.id} className='px-6 py-2.5 align-middle'>
|
||||
<CellContent
|
||||
icon={cell?.icon}
|
||||
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
|
||||
content={cell?.content}
|
||||
primary={colIdx === 0}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
|
||||
interface CreateRowProps {
|
||||
create: CreateAction
|
||||
totalColSpan: number
|
||||
}
|
||||
|
||||
const CreateRow = memo(function CreateRow({ create, totalColSpan }: CreateRowProps) {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
create.disabled ? 'cursor-not-allowed' : 'cursor-pointer hover-hover:bg-[var(--surface-3)]'
|
||||
)}
|
||||
onClick={create.disabled ? undefined : create.onClick}
|
||||
>
|
||||
<td colSpan={totalColSpan} className='px-6 py-2.5 align-middle'>
|
||||
<span className='flex items-center gap-3 font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{CREATE_ROW_PLUS_ICON}
|
||||
{create.label}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
|
||||
interface ResourceColGroupProps {
|
||||
columns: ResourceColumn[]
|
||||
hasCheckbox?: boolean
|
||||
}) {
|
||||
}
|
||||
|
||||
const ResourceColGroup = memo(function ResourceColGroup({
|
||||
columns,
|
||||
hasCheckbox,
|
||||
}: ResourceColGroupProps) {
|
||||
return (
|
||||
<colgroup>
|
||||
{hasCheckbox && <col className='w-[52px]' />}
|
||||
@@ -486,17 +569,19 @@ function ResourceColGroup({
|
||||
))}
|
||||
</colgroup>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function DataTableSkeleton({
|
||||
columns,
|
||||
rowCount,
|
||||
hasCheckbox,
|
||||
}: {
|
||||
interface DataTableSkeletonProps {
|
||||
columns: ResourceColumn[]
|
||||
rowCount: number
|
||||
hasCheckbox?: boolean
|
||||
}) {
|
||||
}
|
||||
|
||||
const DataTableSkeleton = memo(function DataTableSkeleton({
|
||||
columns,
|
||||
rowCount,
|
||||
hasCheckbox,
|
||||
}: DataTableSkeletonProps) {
|
||||
return (
|
||||
<>
|
||||
<div className='overflow-hidden'>
|
||||
@@ -549,4 +634,4 @@ function DataTableSkeleton({
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -183,6 +183,8 @@ function TextEditor({
|
||||
} = useWorkspaceFileContent(workspaceId, file.id, file.key, file.type === 'text/x-pptxgenjs')
|
||||
|
||||
const updateContent = useUpdateWorkspaceFileContent()
|
||||
const updateContentRef = useRef(updateContent)
|
||||
updateContentRef.current = updateContent
|
||||
|
||||
const [content, setContent] = useState('')
|
||||
const [savedContent, setSavedContent] = useState('')
|
||||
@@ -230,14 +232,14 @@ function TextEditor({
|
||||
const currentContent = contentRef.current
|
||||
if (currentContent === savedContentRef.current) return
|
||||
|
||||
await updateContent.mutateAsync({
|
||||
await updateContentRef.current.mutateAsync({
|
||||
workspaceId,
|
||||
fileId: file.id,
|
||||
content: currentContent,
|
||||
})
|
||||
setSavedContent(currentContent)
|
||||
savedContentRef.current = currentContent
|
||||
}, [workspaceId, file.id, updateContent])
|
||||
}, [workspaceId, file.id])
|
||||
|
||||
const { saveStatus, saveImmediately, isDirty } = useAutosave({
|
||||
content,
|
||||
@@ -288,6 +290,16 @@ function TextEditor({
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
const handleCheckboxToggle = useCallback(
|
||||
(checkboxIndex: number, checked: boolean) => {
|
||||
const toggled = toggleMarkdownCheckbox(contentRef.current, checkboxIndex, checked)
|
||||
if (toggled !== contentRef.current) {
|
||||
handleContentChange(toggled)
|
||||
}
|
||||
},
|
||||
[handleContentChange]
|
||||
)
|
||||
|
||||
const isStreaming = streamingContent !== undefined
|
||||
const revealedContent = useStreamingText(content, isStreaming)
|
||||
|
||||
@@ -390,10 +402,11 @@ function TextEditor({
|
||||
className={cn('min-w-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}
|
||||
>
|
||||
<PreviewPanel
|
||||
content={revealedContent}
|
||||
content={isStreaming ? revealedContent : content}
|
||||
mimeType={file.type}
|
||||
filename={file.name}
|
||||
isStreaming={isStreaming}
|
||||
onCheckboxToggle={canEdit && !isStreaming ? handleCheckboxToggle : undefined}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -402,7 +415,7 @@ function TextEditor({
|
||||
)
|
||||
}
|
||||
|
||||
function IframePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const IframePreview = memo(function IframePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
|
||||
|
||||
return (
|
||||
@@ -417,9 +430,9 @@ function IframePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
|
||||
|
||||
return (
|
||||
@@ -432,7 +445,7 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const pptxSlideCache = new Map<string, string[]>()
|
||||
|
||||
@@ -701,7 +714,19 @@ function PptxPreview({
|
||||
)
|
||||
}
|
||||
|
||||
function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked: boolean): string {
|
||||
let currentIndex = 0
|
||||
return markdown.replace(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm, (match, prefix: string) => {
|
||||
if (currentIndex++ !== targetIndex) return match
|
||||
return `${prefix}[${checked ? 'x' : ' '}]`
|
||||
})
|
||||
}
|
||||
|
||||
const UnsupportedPreview = memo(function UnsupportedPreview({
|
||||
file,
|
||||
}: {
|
||||
file: WorkspaceFileRecord
|
||||
}) {
|
||||
const ext = getFileExtension(file.name)
|
||||
|
||||
return (
|
||||
@@ -714,4 +739,4 @@ function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Checkbox } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { useAutoScroll } from '@/hooks/use-auto-scroll'
|
||||
@@ -40,23 +41,36 @@ interface PreviewPanelProps {
|
||||
mimeType: string | null
|
||||
filename: string
|
||||
isStreaming?: boolean
|
||||
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
|
||||
}
|
||||
|
||||
export function PreviewPanel({ content, mimeType, filename, isStreaming }: PreviewPanelProps) {
|
||||
export const PreviewPanel = memo(function PreviewPanel({
|
||||
content,
|
||||
mimeType,
|
||||
filename,
|
||||
isStreaming,
|
||||
onCheckboxToggle,
|
||||
}: PreviewPanelProps) {
|
||||
const previewType = resolvePreviewType(mimeType, filename)
|
||||
|
||||
if (previewType === 'markdown')
|
||||
return <MarkdownPreview content={content} isStreaming={isStreaming} />
|
||||
return (
|
||||
<MarkdownPreview
|
||||
content={content}
|
||||
isStreaming={isStreaming}
|
||||
onCheckboxToggle={onCheckboxToggle}
|
||||
/>
|
||||
)
|
||||
if (previewType === 'html') return <HtmlPreview content={content} />
|
||||
if (previewType === 'csv') return <CsvPreview content={content} />
|
||||
if (previewType === 'svg') return <SvgPreview content={content} />
|
||||
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
|
||||
|
||||
const PREVIEW_MARKDOWN_COMPONENTS = {
|
||||
const STATIC_MARKDOWN_COMPONENTS = {
|
||||
p: ({ children }: any) => (
|
||||
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
|
||||
{children}
|
||||
@@ -82,17 +96,6 @@ const PREVIEW_MARKDOWN_COMPONENTS = {
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
ul: ({ children }: any) => (
|
||||
<ul className='mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }: any) => (
|
||||
<ol className='mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }: any) => <li className='break-words leading-[1.6]'>{children}</li>,
|
||||
code: ({ inline, className, children, ...props }: any) => {
|
||||
const isInline = inline || !className?.includes('language-')
|
||||
|
||||
@@ -160,26 +163,110 @@ const PREVIEW_MARKDOWN_COMPONENTS = {
|
||||
td: ({ children }: any) => <td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>,
|
||||
}
|
||||
|
||||
function buildMarkdownComponents(
|
||||
checkboxCounterRef: React.MutableRefObject<number>,
|
||||
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
|
||||
) {
|
||||
const isInteractive = Boolean(onCheckboxToggle)
|
||||
|
||||
return {
|
||||
...STATIC_MARKDOWN_COMPONENTS,
|
||||
ul: ({ className, children }: any) => {
|
||||
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
|
||||
return (
|
||||
<ul
|
||||
className={cn(
|
||||
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
|
||||
isTaskList ? 'list-none pl-0' : 'list-disc pl-6'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
ol: ({ className, children }: any) => {
|
||||
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
|
||||
return (
|
||||
<ol
|
||||
className={cn(
|
||||
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
|
||||
isTaskList ? 'list-none pl-0' : 'list-decimal pl-6'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
},
|
||||
li: ({ className, children }: any) => {
|
||||
const isTaskItem = typeof className === 'string' && className.includes('task-list-item')
|
||||
if (isTaskItem) {
|
||||
return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
|
||||
}
|
||||
return <li className='break-words leading-[1.6]'>{children}</li>
|
||||
},
|
||||
input: ({ type, checked, ...props }: any) => {
|
||||
if (type !== 'checkbox') return <input type={type} checked={checked} {...props} />
|
||||
|
||||
const index = checkboxCounterRef.current++
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={checked ?? false}
|
||||
onCheckedChange={
|
||||
isInteractive
|
||||
? (newChecked) => onCheckboxToggle!(index, Boolean(newChecked))
|
||||
: undefined
|
||||
}
|
||||
disabled={!isInteractive}
|
||||
size='sm'
|
||||
className='mt-1 shrink-0'
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const MarkdownPreview = memo(function MarkdownPreview({
|
||||
content,
|
||||
isStreaming = false,
|
||||
onCheckboxToggle,
|
||||
}: {
|
||||
content: string
|
||||
isStreaming?: boolean
|
||||
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
|
||||
}) {
|
||||
const { ref: scrollRef } = useAutoScroll(isStreaming)
|
||||
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
|
||||
|
||||
const checkboxCounterRef = useRef(0)
|
||||
|
||||
const components = useMemo(
|
||||
() => buildMarkdownComponents(checkboxCounterRef, onCheckboxToggle),
|
||||
[onCheckboxToggle]
|
||||
)
|
||||
|
||||
checkboxCounterRef.current = 0
|
||||
|
||||
const committedMarkdown = useMemo(
|
||||
() =>
|
||||
committed ? (
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
|
||||
{committed}
|
||||
</ReactMarkdown>
|
||||
) : null,
|
||||
[committed]
|
||||
[committed, components]
|
||||
)
|
||||
|
||||
if (onCheckboxToggle) {
|
||||
return (
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
{committedMarkdown}
|
||||
@@ -188,7 +275,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
key={generation}
|
||||
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
|
||||
{incoming}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@@ -197,7 +284,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
)
|
||||
})
|
||||
|
||||
function HtmlPreview({ content }: { content: string }) {
|
||||
const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) {
|
||||
return (
|
||||
<div className='h-full overflow-hidden'>
|
||||
<iframe
|
||||
@@ -208,9 +295,9 @@ function HtmlPreview({ content }: { content: string }) {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function SvgPreview({ content }: { content: string }) {
|
||||
const SvgPreview = memo(function SvgPreview({ content }: { content: string }) {
|
||||
const wrappedContent = useMemo(
|
||||
() =>
|
||||
`<!DOCTYPE html><html><head><style>body{margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:transparent;}svg{max-width:100%;max-height:100vh;}</style></head><body>${content}</body></html>`,
|
||||
@@ -227,9 +314,9 @@ function SvgPreview({ content }: { content: string }) {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function CsvPreview({ content }: { content: string }) {
|
||||
const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
|
||||
const { headers, rows } = useMemo(() => parseCsv(content), [content])
|
||||
|
||||
if (headers.length === 0) {
|
||||
@@ -271,7 +358,7 @@ function CsvPreview({ content }: { content: string }) {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
|
||||
const lines = text.split('\n').filter((line) => line.trim().length > 0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -18,7 +19,7 @@ interface FilesListContextMenuProps {
|
||||
disableUpload?: boolean
|
||||
}
|
||||
|
||||
export function FilesListContextMenu({
|
||||
export const FilesListContextMenu = memo(function FilesListContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
@@ -64,4 +65,4 @@ export function FilesListContextMenu({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
HeaderAction,
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
SearchConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
InlineRenameInput,
|
||||
@@ -159,11 +160,29 @@ export function Files() {
|
||||
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('')
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setInputValue(value)
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
setDebouncedSearchTerm(value)
|
||||
}, 200)
|
||||
}, [])
|
||||
|
||||
const [creatingFile, setCreatingFile] = useState(false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>(() => {
|
||||
if (fileIdFromRoute) {
|
||||
const file = files.find((f) => f.id === fileIdFromRoute)
|
||||
if (file && isPreviewable(file)) return 'preview'
|
||||
return 'editor'
|
||||
}
|
||||
return 'preview'
|
||||
})
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [contextMenuFile, setContextMenuFile] = useState<WorkspaceFileRecord | null>(null)
|
||||
@@ -183,59 +202,105 @@ export function Files() {
|
||||
() => (fileIdFromRoute ? files.find((f) => f.id === fileIdFromRoute) : null),
|
||||
[fileIdFromRoute, files]
|
||||
)
|
||||
const selectedFileRef = useRef(selectedFile)
|
||||
selectedFileRef.current = selectedFile
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
if (!searchTerm) return files
|
||||
const q = searchTerm.toLowerCase()
|
||||
if (!debouncedSearchTerm) return files
|
||||
const q = debouncedSearchTerm.toLowerCase()
|
||||
return files.filter((f) => f.name.toLowerCase().includes(q))
|
||||
}, [files, searchTerm])
|
||||
}, [files, debouncedSearchTerm])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
filteredFiles.map((file) => {
|
||||
const Icon = getDocumentIcon(file.type || '', file.name)
|
||||
return {
|
||||
id: file.id,
|
||||
cells: {
|
||||
name: {
|
||||
icon: <Icon className='h-[14px] w-[14px]' />,
|
||||
label: file.name,
|
||||
content:
|
||||
listRename.editingId === file.id ? (
|
||||
<span className='flex min-w-0 items-center gap-3 font-medium text-[var(--text-body)] text-sm'>
|
||||
<span className='flex-shrink-0 text-[var(--text-icon)]'>
|
||||
<Icon className='h-[14px] w-[14px]' />
|
||||
</span>
|
||||
<InlineRenameInput
|
||||
value={listRename.editValue}
|
||||
onChange={listRename.setEditValue}
|
||||
onSubmit={listRename.submitRename}
|
||||
onCancel={listRename.cancelRename}
|
||||
/>
|
||||
</span>
|
||||
) : undefined,
|
||||
},
|
||||
size: {
|
||||
label: formatFileSize(file.size, { includeBytes: true }),
|
||||
},
|
||||
type: {
|
||||
icon: <Icon className='h-[14px] w-[14px]' />,
|
||||
label: formatFileType(file.type, file.name),
|
||||
},
|
||||
created: timeCell(file.uploadedAt),
|
||||
owner: ownerCell(file.uploadedBy, members),
|
||||
updated: timeCell(file.uploadedAt),
|
||||
},
|
||||
sortValues: {
|
||||
size: file.size,
|
||||
created: -new Date(file.uploadedAt).getTime(),
|
||||
updated: -new Date(file.uploadedAt).getTime(),
|
||||
},
|
||||
}
|
||||
}),
|
||||
[filteredFiles, members, listRename.editingId, listRename.editValue]
|
||||
const rowCacheRef = useRef(
|
||||
new Map<string, { row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }>()
|
||||
)
|
||||
|
||||
const baseRows: ResourceRow[] = useMemo(() => {
|
||||
const prevCache = rowCacheRef.current
|
||||
const nextCache = new Map<
|
||||
string,
|
||||
{ row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }
|
||||
>()
|
||||
|
||||
const result = filteredFiles.map((file) => {
|
||||
const cached = prevCache.get(file.id)
|
||||
if (cached && cached.file === file && cached.members === members) {
|
||||
nextCache.set(file.id, cached)
|
||||
return cached.row
|
||||
}
|
||||
const Icon = getDocumentIcon(file.type || '', file.name)
|
||||
const row: ResourceRow = {
|
||||
id: file.id,
|
||||
cells: {
|
||||
name: {
|
||||
icon: <Icon className='h-[14px] w-[14px]' />,
|
||||
label: file.name,
|
||||
},
|
||||
size: {
|
||||
label: formatFileSize(file.size, { includeBytes: true }),
|
||||
},
|
||||
type: {
|
||||
icon: <Icon className='h-[14px] w-[14px]' />,
|
||||
label: formatFileType(file.type, file.name),
|
||||
},
|
||||
created: timeCell(file.uploadedAt),
|
||||
owner: ownerCell(file.uploadedBy, members),
|
||||
updated: timeCell(file.uploadedAt),
|
||||
},
|
||||
sortValues: {
|
||||
size: file.size,
|
||||
created: -new Date(file.uploadedAt).getTime(),
|
||||
updated: -new Date(file.uploadedAt).getTime(),
|
||||
},
|
||||
}
|
||||
nextCache.set(file.id, { row, file, members })
|
||||
return row
|
||||
})
|
||||
|
||||
rowCacheRef.current = nextCache
|
||||
return result
|
||||
}, [filteredFiles, members])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(() => {
|
||||
if (!listRename.editingId) return baseRows
|
||||
return baseRows.map((row) => {
|
||||
if (row.id !== listRename.editingId) return row
|
||||
const file = filteredFiles.find((f) => f.id === row.id)
|
||||
if (!file) return row
|
||||
const Icon = getDocumentIcon(file.type || '', file.name)
|
||||
return {
|
||||
...row,
|
||||
cells: {
|
||||
...row.cells,
|
||||
name: {
|
||||
...row.cells.name,
|
||||
content: (
|
||||
<span className='flex min-w-0 items-center gap-3 font-medium text-[var(--text-body)] text-sm'>
|
||||
<span className='flex-shrink-0 text-[var(--text-icon)]'>
|
||||
<Icon className='h-[14px] w-[14px]' />
|
||||
</span>
|
||||
<InlineRenameInput
|
||||
value={listRename.editValue}
|
||||
onChange={listRename.setEditValue}
|
||||
onSubmit={listRename.submitRename}
|
||||
onCancel={listRename.cancelRename}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [
|
||||
baseRows,
|
||||
listRename.editingId,
|
||||
listRename.editValue,
|
||||
listRename.setEditValue,
|
||||
listRename.submitRename,
|
||||
listRename.cancelRename,
|
||||
filteredFiles,
|
||||
])
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const list = e.target.files
|
||||
@@ -288,8 +353,13 @@ export function Files() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const deleteTargetFileRef = useRef(deleteTargetFile)
|
||||
deleteTargetFileRef.current = deleteTargetFile
|
||||
const fileIdFromRouteRef = useRef(fileIdFromRoute)
|
||||
fileIdFromRouteRef.current = fileIdFromRoute
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
const target = deleteTargetFile
|
||||
const target = deleteTargetFileRef.current
|
||||
if (!target) return
|
||||
|
||||
try {
|
||||
@@ -299,7 +369,7 @@ export function Files() {
|
||||
})
|
||||
setShowDeleteConfirm(false)
|
||||
setDeleteTargetFile(null)
|
||||
if (fileIdFromRoute === target.id) {
|
||||
if (fileIdFromRouteRef.current === target.id) {
|
||||
setIsDirty(false)
|
||||
setSaveStatus('idle')
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
@@ -307,36 +377,44 @@ export function Files() {
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete file:', err)
|
||||
}
|
||||
}, [deleteTargetFile, workspaceId, fileIdFromRoute, router])
|
||||
}, [workspaceId, router])
|
||||
|
||||
const isDirtyRef = useRef(isDirty)
|
||||
isDirtyRef.current = isDirty
|
||||
const saveStatusRef = useRef(saveStatus)
|
||||
saveStatusRef.current = saveStatus
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!saveRef.current || !isDirty || saveStatus === 'saving') return
|
||||
if (!saveRef.current || !isDirtyRef.current || saveStatusRef.current === 'saving') return
|
||||
await saveRef.current()
|
||||
}, [isDirty, saveStatus])
|
||||
}, [])
|
||||
|
||||
const handleBackAttempt = useCallback(() => {
|
||||
if (isDirty) {
|
||||
if (isDirtyRef.current) {
|
||||
setShowUnsavedChangesAlert(true)
|
||||
} else {
|
||||
setPreviewMode('editor')
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
}
|
||||
}, [isDirty, router, workspaceId])
|
||||
}, [router, workspaceId])
|
||||
|
||||
const handleStartHeaderRename = useCallback(() => {
|
||||
if (selectedFile) headerRename.startRename(selectedFile.id, selectedFile.name)
|
||||
}, [selectedFile, headerRename.startRename])
|
||||
const file = selectedFileRef.current
|
||||
if (file) headerRename.startRename(file.id, file.name)
|
||||
}, [headerRename.startRename])
|
||||
|
||||
const handleDownloadSelected = useCallback(() => {
|
||||
if (selectedFile) handleDownload(selectedFile)
|
||||
}, [selectedFile, handleDownload])
|
||||
const file = selectedFileRef.current
|
||||
if (file) handleDownload(file)
|
||||
}, [handleDownload])
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
if (selectedFile) {
|
||||
setDeleteTargetFile(selectedFile)
|
||||
const file = selectedFileRef.current
|
||||
if (file) {
|
||||
setDeleteTargetFile(file)
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
}, [selectedFile])
|
||||
}, [])
|
||||
|
||||
const fileDetailBreadcrumbs = useMemo(
|
||||
() =>
|
||||
@@ -379,9 +457,6 @@ export function Files() {
|
||||
handleBackAttempt,
|
||||
headerRename.editingId,
|
||||
headerRename.editValue,
|
||||
headerRename.setEditValue,
|
||||
headerRename.submitRename,
|
||||
headerRename.cancelRename,
|
||||
handleStartHeaderRename,
|
||||
handleDownloadSelected,
|
||||
handleDeleteSelected,
|
||||
@@ -396,12 +471,15 @@ export function Files() {
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
}, [router, workspaceId])
|
||||
|
||||
const creatingFileRef = useRef(creatingFile)
|
||||
creatingFileRef.current = creatingFile
|
||||
|
||||
const handleCreateFile = useCallback(async () => {
|
||||
if (creatingFile) return
|
||||
if (creatingFileRef.current) return
|
||||
setCreatingFile(true)
|
||||
|
||||
try {
|
||||
const existingNames = new Set(files.map((f) => f.name))
|
||||
const existingNames = new Set(filesRef.current.map((f) => f.name))
|
||||
let name = 'untitled.md'
|
||||
let counter = 1
|
||||
while (existingNames.has(name)) {
|
||||
@@ -423,42 +501,49 @@ export function Files() {
|
||||
} finally {
|
||||
setCreatingFile(false)
|
||||
}
|
||||
}, [creatingFile, files, workspaceId, router])
|
||||
}, [workspaceId, router])
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
const file = files.find((f) => f.id === rowId)
|
||||
const file = filesRef.current.find((f) => f.id === rowId)
|
||||
if (file) {
|
||||
setContextMenuFile(file)
|
||||
openContextMenu(e)
|
||||
}
|
||||
},
|
||||
[files, openContextMenu]
|
||||
[openContextMenu]
|
||||
)
|
||||
|
||||
const contextMenuFileRef = useRef(contextMenuFile)
|
||||
contextMenuFileRef.current = contextMenuFile
|
||||
|
||||
const handleContextMenuOpen = useCallback(() => {
|
||||
if (!contextMenuFile) return
|
||||
router.push(`/workspace/${workspaceId}/files/${contextMenuFile.id}`)
|
||||
const file = contextMenuFileRef.current
|
||||
if (!file) return
|
||||
router.push(`/workspace/${workspaceId}/files/${file.id}`)
|
||||
closeContextMenu()
|
||||
}, [contextMenuFile, closeContextMenu, router, workspaceId])
|
||||
}, [closeContextMenu, router, workspaceId])
|
||||
|
||||
const handleContextMenuDownload = useCallback(() => {
|
||||
if (!contextMenuFile) return
|
||||
handleDownload(contextMenuFile)
|
||||
const file = contextMenuFileRef.current
|
||||
if (!file) return
|
||||
handleDownload(file)
|
||||
closeContextMenu()
|
||||
}, [contextMenuFile, handleDownload, closeContextMenu])
|
||||
}, [handleDownload, closeContextMenu])
|
||||
|
||||
const handleContextMenuRename = useCallback(() => {
|
||||
if (contextMenuFile) listRename.startRename(contextMenuFile.id, contextMenuFile.name)
|
||||
const file = contextMenuFileRef.current
|
||||
if (file) listRename.startRename(file.id, file.name)
|
||||
closeContextMenu()
|
||||
}, [contextMenuFile, listRename.startRename, closeContextMenu])
|
||||
}, [listRename.startRename, closeContextMenu])
|
||||
|
||||
const handleContextMenuDelete = useCallback(() => {
|
||||
if (!contextMenuFile) return
|
||||
setDeleteTargetFile(contextMenuFile)
|
||||
const file = contextMenuFileRef.current
|
||||
if (!file) return
|
||||
setDeleteTargetFile(file)
|
||||
setShowDeleteConfirm(true)
|
||||
closeContextMenu()
|
||||
}, [contextMenuFile, closeContextMenu])
|
||||
}, [closeContextMenu])
|
||||
|
||||
const handleContentContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -479,41 +564,46 @@ export function Files() {
|
||||
closeListContextMenu()
|
||||
}, [closeListContextMenu])
|
||||
|
||||
useEffect(() => {
|
||||
const prevFileIdRef = useRef(fileIdFromRoute)
|
||||
if (fileIdFromRoute !== prevFileIdRef.current) {
|
||||
prevFileIdRef.current = fileIdFromRoute
|
||||
const isJustCreated =
|
||||
fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute
|
||||
if (justCreatedFileIdRef.current && !isJustCreated) {
|
||||
justCreatedFileIdRef.current = null
|
||||
}
|
||||
if (isJustCreated) {
|
||||
setPreviewMode('editor')
|
||||
} else {
|
||||
const file = fileIdFromRoute ? filesRef.current.find((f) => f.id === fileIdFromRoute) : null
|
||||
const canPreview = file ? isPreviewable(file) : false
|
||||
setPreviewMode(canPreview ? 'preview' : 'editor')
|
||||
const nextMode: PreviewMode = isJustCreated
|
||||
? 'editor'
|
||||
: (() => {
|
||||
const file = fileIdFromRoute
|
||||
? filesRef.current.find((f) => f.id === fileIdFromRoute)
|
||||
: null
|
||||
return file && isPreviewable(file) ? 'preview' : 'editor'
|
||||
})()
|
||||
if (nextMode !== previewMode) {
|
||||
setPreviewMode(nextMode)
|
||||
}
|
||||
}, [fileIdFromRoute])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFile) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!fileIdFromRouteRef.current) return
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedFile, handleSave])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (!isDirtyRef.current) return
|
||||
e.preventDefault()
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [isDirty])
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
}, [handleSave])
|
||||
|
||||
const handleCyclePreviewMode = useCallback(() => {
|
||||
setPreviewMode((prev) => {
|
||||
@@ -592,27 +682,92 @@ export function Files() {
|
||||
selectedFile,
|
||||
saveStatus,
|
||||
previewMode,
|
||||
isDirty,
|
||||
handleCyclePreviewMode,
|
||||
handleTogglePreview,
|
||||
handleSave,
|
||||
isDirty,
|
||||
handleDownloadSelected,
|
||||
handleDeleteSelected,
|
||||
])
|
||||
|
||||
/** Stable refs for values used in callbacks to avoid dependency churn */
|
||||
const listRenameRef = useRef(listRename)
|
||||
listRenameRef.current = listRename
|
||||
const headerRenameRef = useRef(headerRename)
|
||||
headerRenameRef.current = headerRename
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(id: string) => {
|
||||
if (listRenameRef.current.editingId !== id && !headerRenameRef.current.editingId) {
|
||||
router.push(`/workspace/${workspaceId}/files/${id}`)
|
||||
}
|
||||
},
|
||||
[router, workspaceId]
|
||||
)
|
||||
|
||||
const handleUploadClick = useCallback(() => {
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const canEdit = userPermissions.canEdit === true
|
||||
|
||||
const handleSearchClearAll = useCallback(() => {
|
||||
handleSearchChange('')
|
||||
}, [handleSearchChange])
|
||||
|
||||
const searchConfig: SearchConfig = useMemo(
|
||||
() => ({
|
||||
value: inputValue,
|
||||
onChange: handleSearchChange,
|
||||
onClearAll: handleSearchClearAll,
|
||||
placeholder: 'Search files...',
|
||||
}),
|
||||
[inputValue, handleSearchChange, handleSearchClearAll]
|
||||
)
|
||||
|
||||
const createConfig = useMemo(
|
||||
() => ({
|
||||
label: 'New file',
|
||||
onClick: handleCreateFile,
|
||||
disabled: uploading || creatingFile || !canEdit,
|
||||
}),
|
||||
[handleCreateFile, uploading, creatingFile, canEdit]
|
||||
)
|
||||
|
||||
const uploadButtonLabel = useMemo(
|
||||
() =>
|
||||
uploading && uploadProgress.total > 0
|
||||
? `${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: uploading
|
||||
? 'Uploading...'
|
||||
: 'Upload',
|
||||
[uploading, uploadProgress.completed, uploadProgress.total]
|
||||
)
|
||||
|
||||
const headerActionsConfig = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: uploadButtonLabel,
|
||||
icon: Upload,
|
||||
onClick: handleUploadClick,
|
||||
},
|
||||
],
|
||||
[uploadButtonLabel, handleUploadClick]
|
||||
)
|
||||
|
||||
const handleNavigateToFiles = useCallback(() => {
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
}, [router, workspaceId])
|
||||
|
||||
const loadingBreadcrumbs = useMemo(
|
||||
() => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }],
|
||||
[handleNavigateToFiles]
|
||||
)
|
||||
|
||||
if (fileIdFromRoute && !selectedFile) {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<ResourceHeader
|
||||
icon={FilesIcon}
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: 'Files',
|
||||
onClick: () => router.push(`/workspace/${workspaceId}/files`),
|
||||
},
|
||||
{ label: '...' },
|
||||
]}
|
||||
/>
|
||||
<ResourceHeader icon={FilesIcon} breadcrumbs={loadingBreadcrumbs} />
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<Skeleton className='h-[16px] w-[200px]' />
|
||||
</div>
|
||||
@@ -633,7 +788,7 @@ export function Files() {
|
||||
key={selectedFile.id}
|
||||
file={selectedFile}
|
||||
workspaceId={workspaceId}
|
||||
canEdit={userPermissions.canEdit === true}
|
||||
canEdit={canEdit}
|
||||
previewMode={previewMode}
|
||||
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
|
||||
onDirtyChange={setIsDirty}
|
||||
@@ -672,43 +827,18 @@ export function Files() {
|
||||
)
|
||||
}
|
||||
|
||||
const uploadButtonLabel =
|
||||
uploading && uploadProgress.total > 0
|
||||
? `${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: uploading
|
||||
? 'Uploading...'
|
||||
: 'Upload'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Resource
|
||||
icon={FilesIcon}
|
||||
title='Files'
|
||||
create={{
|
||||
label: 'New file',
|
||||
onClick: handleCreateFile,
|
||||
disabled: uploading || creatingFile || userPermissions.canEdit !== true,
|
||||
}}
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search files...',
|
||||
}}
|
||||
create={createConfig}
|
||||
search={searchConfig}
|
||||
defaultSort='created'
|
||||
headerActions={[
|
||||
{
|
||||
label: uploadButtonLabel,
|
||||
icon: Upload,
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
},
|
||||
]}
|
||||
headerActions={headerActionsConfig}
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
onRowClick={(id) => {
|
||||
if (listRename.editingId !== id && !headerRename.editingId) {
|
||||
router.push(`/workspace/${workspaceId}/files/${id}`)
|
||||
}
|
||||
}}
|
||||
onRowClick={handleRowClick}
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
isLoading={isLoading}
|
||||
onContextMenu={handleContentContextMenu}
|
||||
@@ -720,58 +850,20 @@ export function Files() {
|
||||
onClose={closeListContextMenu}
|
||||
onCreateFile={handleCreateFile}
|
||||
onUploadFile={handleListUploadFile}
|
||||
disableCreate={uploading || creatingFile || userPermissions.canEdit !== true}
|
||||
disableUpload={uploading || userPermissions.canEdit !== true}
|
||||
disableCreate={uploading || creatingFile || !canEdit}
|
||||
disableUpload={uploading || !canEdit}
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
open={isContextMenuOpen}
|
||||
onOpenChange={(open) => !open && closeContextMenu()}
|
||||
modal={false}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${contextMenuPosition.x}px`,
|
||||
top: `${contextMenuPosition.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onSelect={handleContextMenuOpen}>
|
||||
<Eye />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleContextMenuDownload}>
|
||||
<Download />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
{userPermissions.canEdit === true && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleContextMenuRename}>
|
||||
<Pencil />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleContextMenuDelete}>
|
||||
<Trash />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FileRowContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
onClose={closeContextMenu}
|
||||
onOpen={handleContextMenuOpen}
|
||||
onDownload={handleContextMenuDownload}
|
||||
onRename={handleContextMenuRename}
|
||||
onDelete={handleContextMenuDelete}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
|
||||
<DeleteConfirmModal
|
||||
open={showDeleteConfirm}
|
||||
@@ -794,6 +886,75 @@ export function Files() {
|
||||
)
|
||||
}
|
||||
|
||||
interface FileRowContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
onOpen: () => void
|
||||
onDownload: () => void
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
const FileRowContextMenu = memo(function FileRowContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
onOpen,
|
||||
onDownload,
|
||||
onRename,
|
||||
onDelete,
|
||||
canEdit,
|
||||
}: FileRowContextMenuProps) {
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onSelect={onOpen}>
|
||||
<Eye />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onDownload}>
|
||||
<Download />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
{canEdit && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onRename}>
|
||||
<Pencil />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onDelete}>
|
||||
<Trash />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -802,7 +963,7 @@ interface DeleteConfirmModalProps {
|
||||
isPending: boolean
|
||||
}
|
||||
|
||||
function DeleteConfirmModal({
|
||||
const DeleteConfirmModal = memo(function DeleteConfirmModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileName,
|
||||
@@ -833,4 +994,4 @@ function DeleteConfirmModal({
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -82,6 +82,7 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName | 'mothership', IconC
|
||||
create_job: Calendar,
|
||||
manage_job: Calendar,
|
||||
update_job_history: Calendar,
|
||||
job_respond: Calendar,
|
||||
// Management
|
||||
manage_mcp_tool: Settings,
|
||||
manage_skill: Asterisk,
|
||||
|
||||
@@ -52,7 +52,7 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='mr-[0px] h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
@@ -72,7 +72,16 @@ function FileDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
const DocIcon = getDocumentIcon('', item.name)
|
||||
return (
|
||||
<>
|
||||
<DocIcon className='mr-2 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<DocIcon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate'>{item.name}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon: ElementType }) {
|
||||
return (
|
||||
<>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate'>{item.name}</span>
|
||||
</>
|
||||
)
|
||||
@@ -104,7 +113,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
|
||||
renderTabIcon: (_resource, className) => (
|
||||
<TableIcon className={cn(className, 'text-[var(--text-icon)]')} />
|
||||
),
|
||||
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
|
||||
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={TableIcon} />,
|
||||
},
|
||||
file: {
|
||||
type: 'file',
|
||||
@@ -123,7 +132,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
|
||||
renderTabIcon: (_resource, className) => (
|
||||
<Database className={cn(className, 'text-[var(--text-icon)]')} />
|
||||
),
|
||||
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
|
||||
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Database} />,
|
||||
},
|
||||
} as const
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export type WindowWithSpeech = Window & {
|
||||
}
|
||||
|
||||
export interface PlusMenuHandle {
|
||||
open: () => void
|
||||
open: (anchor?: { left: number; top: number }) => void
|
||||
}
|
||||
|
||||
export const TEXTAREA_BASE_CLASSES = cn(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Paperclip } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Plus, Sim } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
|
||||
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
|
||||
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
|
||||
@@ -37,24 +36,24 @@ export const PlusMenuDropdown = React.memo(
|
||||
) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const activeIndexRef = useRef(activeIndex)
|
||||
const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
activeIndexRef.current = activeIndex
|
||||
}, [activeIndex])
|
||||
const doOpen = useCallback((anchor?: { left: number; top: number }) => {
|
||||
if (anchor) {
|
||||
setAnchorPos(anchor)
|
||||
} else {
|
||||
const rect = buttonRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
setAnchorPos({ left: rect.left, top: rect.top })
|
||||
}
|
||||
setOpen(true)
|
||||
setSearch('')
|
||||
}, [])
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open: () => {
|
||||
setOpen(true)
|
||||
setSearch('')
|
||||
setActiveIndex(0)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
React.useImperativeHandle(ref, () => ({ open: doOpen }), [doOpen])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const q = search.toLowerCase().trim()
|
||||
@@ -69,7 +68,6 @@ export const PlusMenuDropdown = React.memo(
|
||||
onResourceSelect(resource)
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
setActiveIndex(0)
|
||||
},
|
||||
[onResourceSelect]
|
||||
)
|
||||
@@ -79,32 +77,37 @@ export const PlusMenuDropdown = React.memo(
|
||||
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const items = filteredItemsRef.current
|
||||
if (!items) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActiveIndex((prev) => Math.min(prev + 1, items.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0))
|
||||
const firstItem = contentRef.current?.querySelector<HTMLElement>('[role="menuitem"]')
|
||||
firstItem?.focus()
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const idx = activeIndexRef.current
|
||||
if (items.length > 0 && items[idx]) {
|
||||
const { type, item } = items[idx]
|
||||
handleSelect({ type, id: item.id, title: item.name })
|
||||
}
|
||||
const first = filteredItemsRef.current?.[0]
|
||||
if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name })
|
||||
}
|
||||
},
|
||||
[handleSelect]
|
||||
)
|
||||
|
||||
const handleContentKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
const items = Array.from(
|
||||
contentRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []
|
||||
)
|
||||
if (items[0] && items[0] === document.activeElement) {
|
||||
e.preventDefault()
|
||||
searchRef.current?.focus()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (!isOpen) {
|
||||
setSearch('')
|
||||
setActiveIndex(0)
|
||||
setAnchorPos(null)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
@@ -126,126 +129,138 @@ export const PlusMenuDropdown = React.memo(
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
|
||||
title='Add attachments or resources'
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: anchorPos?.left ?? 0,
|
||||
top: anchorPos?.top ?? 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
ref={contentRef}
|
||||
align='start'
|
||||
side='top'
|
||||
sideOffset={8}
|
||||
className='flex w-[240px] flex-col overflow-hidden'
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
onKeyDown={handleContentKeyDown}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='top'
|
||||
sideOffset={8}
|
||||
className='flex w-[240px] flex-col overflow-hidden'
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
<DropdownMenuSearchInput
|
||||
placeholder='Search resources...'
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setActiveIndex(0)
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{filteredItems ? (
|
||||
filteredItems.length > 0 ? (
|
||||
filteredItems.map(({ type, item }, index) => {
|
||||
const config = getResourceConfig(type)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${type}:${item.id}`}
|
||||
className={cn(index === activeIndex && 'bg-[var(--surface-active)]')}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => {
|
||||
handleSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
<span className='ml-auto pl-2 text-[11px] text-[var(--text-tertiary)]'>
|
||||
{config.label}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})
|
||||
<DropdownMenuSearchInput
|
||||
ref={searchRef}
|
||||
placeholder='Search resources...'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{filteredItems ? (
|
||||
filteredItems.length > 0 ? (
|
||||
filteredItems.map(({ type, item }, index) => {
|
||||
const config = getResourceConfig(type)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${type}:${item.id}`}
|
||||
onClick={() => {
|
||||
handleSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
<span className='ml-auto pl-2 text-[11px] text-[var(--text-tertiary)]'>
|
||||
{config.label}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className='px-2 py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
No results
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className='px-2 py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
No results
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onFileSelect()
|
||||
}}
|
||||
>
|
||||
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
<span>Attachments</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
|
||||
<span>Workspace</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{availableResources.map(({ type, items }) => {
|
||||
if (items.length === 0) return null
|
||||
const config = getResourceConfig(type)
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<DropdownMenuSub key={type}>
|
||||
<DropdownMenuSubTrigger>
|
||||
{type === 'workflow' ? (
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: '#808080',
|
||||
borderColor: '#80808060',
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Icon className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
<span>{config.label}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
handleSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onFileSelect()
|
||||
}}
|
||||
>
|
||||
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
<span>Attachments</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
|
||||
<span>Workspace</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{availableResources.map(({ type, items }) => {
|
||||
if (items.length === 0) return null
|
||||
const config = getResourceConfig(type)
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<DropdownMenuSub key={type}>
|
||||
<DropdownMenuSubTrigger>
|
||||
{type === 'workflow' ? (
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: '#808080',
|
||||
borderColor: '#80808060',
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Icon className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
<span>{config.label}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
handleSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type='button'
|
||||
onClick={() => doOpen()}
|
||||
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
|
||||
title='Add attachments or resources'
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -50,6 +50,50 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
function getCaretAnchor(
|
||||
textarea: HTMLTextAreaElement,
|
||||
caretPos: number
|
||||
): { left: number; top: number } {
|
||||
const textareaRect = textarea.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(textarea)
|
||||
|
||||
const mirror = document.createElement('div')
|
||||
mirror.style.position = 'absolute'
|
||||
mirror.style.top = '0'
|
||||
mirror.style.left = '0'
|
||||
mirror.style.visibility = 'hidden'
|
||||
mirror.style.whiteSpace = 'pre-wrap'
|
||||
mirror.style.overflowWrap = 'break-word'
|
||||
mirror.style.font = style.font
|
||||
mirror.style.padding = style.padding
|
||||
mirror.style.border = style.border
|
||||
mirror.style.width = style.width
|
||||
mirror.style.lineHeight = style.lineHeight
|
||||
mirror.style.boxSizing = style.boxSizing
|
||||
mirror.style.letterSpacing = style.letterSpacing
|
||||
mirror.style.textTransform = style.textTransform
|
||||
mirror.style.textIndent = style.textIndent
|
||||
mirror.style.textAlign = style.textAlign
|
||||
mirror.textContent = textarea.value.substring(0, caretPos)
|
||||
|
||||
const marker = document.createElement('span')
|
||||
marker.style.display = 'inline-block'
|
||||
marker.style.width = '0px'
|
||||
marker.style.padding = '0'
|
||||
marker.style.border = '0'
|
||||
mirror.appendChild(marker)
|
||||
|
||||
document.body.appendChild(mirror)
|
||||
const markerRect = marker.getBoundingClientRect()
|
||||
const mirrorRect = mirror.getBoundingClientRect()
|
||||
document.body.removeChild(mirror)
|
||||
|
||||
return {
|
||||
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft,
|
||||
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop,
|
||||
}
|
||||
}
|
||||
|
||||
interface UserInputProps {
|
||||
defaultValue?: string
|
||||
editValue?: string
|
||||
@@ -486,7 +530,8 @@ export function UserInput({
|
||||
const adjusted = `${before}${after}`
|
||||
setValue(adjusted)
|
||||
atInsertPosRef.current = caret - 1
|
||||
plusMenuRef.current?.open()
|
||||
const anchor = getCaretAnchor(e.target, caret - 1)
|
||||
plusMenuRef.current?.open(anchor)
|
||||
restartRecognition(adjusted)
|
||||
return
|
||||
}
|
||||
@@ -522,6 +567,28 @@ export function UserInput({
|
||||
[isInitialView]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
const imageFiles: File[] = []
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile()
|
||||
if (file) imageFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFiles.length === 0) return
|
||||
|
||||
e.preventDefault()
|
||||
const dt = new DataTransfer()
|
||||
for (const file of imageFiles) {
|
||||
dt.items.add(file)
|
||||
}
|
||||
filesRef.current.processFiles(dt.files)
|
||||
}, [])
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.scrollTop = e.currentTarget.scrollTop
|
||||
@@ -661,6 +728,7 @@ export function UserInput({
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCut={mentionTokensWithContext.handleCut}
|
||||
onSelect={handleSelectAdjust}
|
||||
onMouseUp={handleSelectAdjust}
|
||||
|
||||
@@ -54,6 +54,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
description,
|
||||
color,
|
||||
workspaceId,
|
||||
deduplicate: true,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ export type MothershipToolName =
|
||||
| 'create_job'
|
||||
| 'complete_job'
|
||||
| 'update_job_history'
|
||||
| 'job_respond'
|
||||
| 'download_to_workspace_file'
|
||||
| 'materialize_file'
|
||||
| 'context_write'
|
||||
@@ -393,6 +394,7 @@ export const TOOL_UI_METADATA: Record<MothershipToolName, ToolUIMetadata> = {
|
||||
create_job: { title: 'Creating job', phaseLabel: 'Resource', phase: 'resource' },
|
||||
manage_job: { title: 'Updating job', phaseLabel: 'Management', phase: 'management' },
|
||||
update_job_history: { title: 'Updating job', phaseLabel: 'Management', phase: 'management' },
|
||||
job_respond: { title: 'Explaining job scheduled', phaseLabel: 'Execution', phase: 'execution' },
|
||||
// Management
|
||||
manage_mcp_tool: { title: 'Updating integration', phaseLabel: 'Management', phase: 'management' },
|
||||
manage_skill: { title: 'Updating skill', phaseLabel: 'Management', phase: 'management' },
|
||||
|
||||
@@ -1138,9 +1138,12 @@ export function Document({
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{effectiveDocumentName}
|
||||
</span>
|
||||
? This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
|
||||
chunk
|
||||
{documentData?.chunkCount === 1 ? '' : 's'} within it.{' '}
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
|
||||
chunk
|
||||
{documentData?.chunkCount === 1 ? '' : 's'} within it.
|
||||
</span>{' '}
|
||||
{documentData?.connectorId ? (
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This document is synced from a connector. Deleting it will permanently exclude it
|
||||
|
||||
@@ -1106,8 +1106,10 @@ export function KnowledgeBase({
|
||||
<p className='text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
|
||||
The knowledge base and all {pagination.total} document
|
||||
{pagination.total === 1 ? '' : 's'} within it will be removed.{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
The knowledge base and all {pagination.total} document
|
||||
{pagination.total === 1 ? '' : 's'} within it will be removed.
|
||||
</span>{' '}
|
||||
<span className='text-[var(--text-tertiary)]'>
|
||||
You can restore it from Recently Deleted in Settings.
|
||||
</span>
|
||||
@@ -1147,7 +1149,9 @@ export function KnowledgeBase({
|
||||
it from future syncs. To temporarily hide it from search, disable it instead.
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently delete the document.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
@@ -1177,7 +1181,10 @@ export function KnowledgeBase({
|
||||
<p className='text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete {selectedDocuments.size} document
|
||||
{selectedDocuments.size === 1 ? '' : 's'}?{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently delete the selected document
|
||||
{selectedDocuments.size === 1 ? '' : 's'}.
|
||||
</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -373,7 +373,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
<div className='flex flex-col gap-3'>
|
||||
{/* Auth: API key input or OAuth credential selection */}
|
||||
{isApiKeyMode ? (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>
|
||||
{connectorConfig.auth.mode === 'apiKey' && connectorConfig.auth.label
|
||||
? connectorConfig.auth.label
|
||||
@@ -394,7 +394,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Account</Label>
|
||||
{credentialsLoading ? (
|
||||
<div className='flex items-center gap-2 text-[var(--text-muted)] text-small'>
|
||||
@@ -442,7 +442,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
canonicalId && (canonicalGroups.get(canonicalId)?.length ?? 0) === 2
|
||||
|
||||
return (
|
||||
<div key={field.id} className='flex flex-col gap-1'>
|
||||
<div key={field.id} className='flex flex-col gap-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label>
|
||||
{field.title}
|
||||
@@ -507,7 +507,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
|
||||
{/* Tag definitions (opt-out) */}
|
||||
{connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Metadata Tags</Label>
|
||||
{connectorConfig.tagDefinitions.map((tagDef) => (
|
||||
<div
|
||||
@@ -550,7 +550,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
)}
|
||||
|
||||
{/* Sync interval */}
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Sync Frequency</Label>
|
||||
<ButtonGroup
|
||||
value={String(syncInterval)}
|
||||
|
||||
@@ -416,9 +416,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<ModalBody>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete the "{selectedTag?.displayName}" tag? This will
|
||||
remove this tag from {selectedTagUsage?.documentCount || 0} document
|
||||
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.{' '}
|
||||
Are you sure you want to delete the "{selectedTag?.displayName}" tag?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will remove this tag from {selectedTagUsage?.documentCount || 0} document
|
||||
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.
|
||||
</span>{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
@@ -73,12 +74,32 @@ export function ConnectorsSection({
|
||||
isLoading,
|
||||
canEdit,
|
||||
}: ConnectorsSectionProps) {
|
||||
const { mutate: triggerSync, isPending: isSyncing } = useTriggerSync()
|
||||
const { mutate: updateConnector, isPending: isUpdating } = useUpdateConnector()
|
||||
const { mutate: triggerSync } = useTriggerSync()
|
||||
const { mutate: updateConnector } = useUpdateConnector()
|
||||
const { mutate: deleteConnector, isPending: isDeleting } = useDeleteConnector()
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
|
||||
const [deleteDocuments, setDeleteDocuments] = useState(false)
|
||||
|
||||
const closeDeleteModal = useCallback(() => {
|
||||
setDeleteTarget(null)
|
||||
setDeleteDocuments(false)
|
||||
}, [])
|
||||
const [editingConnector, setEditingConnector] = useState<ConnectorData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [syncingIds, setSyncingIds] = useState<Set<string>>(() => new Set())
|
||||
const [updatingIds, setUpdatingIds] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const addToSet = useCallback((setter: typeof setSyncingIds, id: string) => {
|
||||
setter((prev) => new Set(prev).add(id))
|
||||
}, [])
|
||||
|
||||
const removeFromSet = useCallback((setter: typeof setSyncingIds, id: string) => {
|
||||
setter((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const syncTriggeredAt = useRef<Record<string, number>>({})
|
||||
const cooldownTimers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
|
||||
@@ -103,6 +124,7 @@ export function ConnectorsSection({
|
||||
if (isSyncOnCooldown(connectorId)) return
|
||||
|
||||
syncTriggeredAt.current[connectorId] = Date.now()
|
||||
addToSet(setSyncingIds, connectorId)
|
||||
|
||||
triggerSync(
|
||||
{ knowledgeBaseId, connectorId },
|
||||
@@ -121,10 +143,35 @@ export function ConnectorsSection({
|
||||
delete syncTriggeredAt.current[connectorId]
|
||||
forceUpdate((n) => n + 1)
|
||||
},
|
||||
onSettled: () => removeFromSet(setSyncingIds, connectorId),
|
||||
}
|
||||
)
|
||||
},
|
||||
[knowledgeBaseId, triggerSync, isSyncOnCooldown]
|
||||
[knowledgeBaseId, triggerSync, isSyncOnCooldown, addToSet, removeFromSet]
|
||||
)
|
||||
|
||||
const handleTogglePause = useCallback(
|
||||
(connector: ConnectorData) => {
|
||||
addToSet(setUpdatingIds, connector.id)
|
||||
updateConnector(
|
||||
{
|
||||
knowledgeBaseId,
|
||||
connectorId: connector.id,
|
||||
updates: {
|
||||
status: connector.status === 'paused' ? 'active' : 'paused',
|
||||
},
|
||||
},
|
||||
{
|
||||
onSettled: () => removeFromSet(setUpdatingIds, connector.id),
|
||||
onSuccess: () => setError(null),
|
||||
onError: (err) => {
|
||||
logger.error('Toggle pause failed', { error: err.message })
|
||||
setError(err.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[knowledgeBaseId, updateConnector, addToSet, removeFromSet]
|
||||
)
|
||||
|
||||
if (connectors.length === 0 && !canEdit && !isLoading) return null
|
||||
@@ -163,28 +210,11 @@ export function ConnectorsSection({
|
||||
workspaceId={workspaceId}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
canEdit={canEdit}
|
||||
isSyncing={isSyncing}
|
||||
isUpdating={isUpdating}
|
||||
isSyncPending={syncingIds.has(connector.id)}
|
||||
isUpdating={updatingIds.has(connector.id)}
|
||||
syncCooldown={isSyncOnCooldown(connector.id)}
|
||||
onSync={() => handleSync(connector.id)}
|
||||
onTogglePause={() =>
|
||||
updateConnector(
|
||||
{
|
||||
knowledgeBaseId,
|
||||
connectorId: connector.id,
|
||||
updates: {
|
||||
status: connector.status === 'paused' ? 'active' : 'paused',
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => setError(null),
|
||||
onError: (err) => {
|
||||
logger.error('Toggle pause failed', { error: err.message })
|
||||
setError(err.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
onTogglePause={() => handleTogglePause(connector)}
|
||||
onEdit={() => setEditingConnector(connector)}
|
||||
onDelete={() => setDeleteTarget(connector.id)}
|
||||
/>
|
||||
@@ -201,17 +231,30 @@ export function ConnectorsSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal open={deleteTarget !== null} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<Modal open={deleteTarget !== null} onOpenChange={closeDeleteModal}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Connector</ModalHeader>
|
||||
<ModalHeader>Remove Connector</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[var(--text-secondary)] text-sm'>
|
||||
Are you sure you want to remove this connected source? Documents already synced will
|
||||
remain in the knowledge base.
|
||||
This will disconnect the source and stop future syncs. Documents already synced will
|
||||
remain in the knowledge base unless you choose to delete them.
|
||||
</p>
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<Checkbox
|
||||
id='delete-docs'
|
||||
checked={deleteDocuments}
|
||||
onCheckedChange={(checked) => setDeleteDocuments(checked === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor='delete-docs'
|
||||
className='cursor-pointer text-[var(--text-secondary)] text-sm'
|
||||
>
|
||||
Also delete all synced documents
|
||||
</label>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setDeleteTarget(null)} disabled={isDeleting}>
|
||||
<Button variant='default' onClick={closeDeleteModal} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -220,23 +263,23 @@ export function ConnectorsSection({
|
||||
onClick={() => {
|
||||
if (deleteTarget) {
|
||||
deleteConnector(
|
||||
{ knowledgeBaseId, connectorId: deleteTarget },
|
||||
{ knowledgeBaseId, connectorId: deleteTarget, deleteDocuments },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setError(null)
|
||||
setDeleteTarget(null)
|
||||
closeDeleteModal()
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error('Delete connector failed', { error: err.message })
|
||||
setError(err.message)
|
||||
setDeleteTarget(null)
|
||||
closeDeleteModal()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
{isDeleting ? 'Removing...' : 'Remove'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
@@ -250,7 +293,7 @@ interface ConnectorCardProps {
|
||||
workspaceId: string
|
||||
knowledgeBaseId: string
|
||||
canEdit: boolean
|
||||
isSyncing: boolean
|
||||
isSyncPending: boolean
|
||||
isUpdating: boolean
|
||||
syncCooldown: boolean
|
||||
onSync: () => void
|
||||
@@ -264,7 +307,7 @@ function ConnectorCard({
|
||||
workspaceId,
|
||||
knowledgeBaseId,
|
||||
canEdit,
|
||||
isSyncing,
|
||||
isSyncPending,
|
||||
isUpdating,
|
||||
syncCooldown,
|
||||
onSync,
|
||||
@@ -306,13 +349,13 @@ function ConnectorCard({
|
||||
{Icon && <Icon className='h-5 w-5 flex-shrink-0' />}
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-[var(--text-primary)] text-small'>
|
||||
<span className='flex items-center gap-1.5 font-medium text-[var(--text-primary)] text-small'>
|
||||
{connectorDef?.name || connector.connectorType}
|
||||
{(isSyncPending || connector.status === 'syncing') && (
|
||||
<Loader2 className='h-3 w-3 animate-spin text-[var(--text-muted)]' />
|
||||
)}
|
||||
</span>
|
||||
<Badge variant={statusConfig.variant} className='text-micro'>
|
||||
{connector.status === 'syncing' && (
|
||||
<Loader2 className='mr-1 h-3 w-3 animate-spin' />
|
||||
)}
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -356,7 +399,7 @@ function ConnectorCard({
|
||||
variant='ghost'
|
||||
className='h-7 w-7 p-0'
|
||||
onClick={onSync}
|
||||
disabled={connector.status === 'syncing' || isSyncing || syncCooldown}
|
||||
disabled={connector.status === 'syncing' || isSyncPending || syncCooldown}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
|
||||
@@ -198,7 +198,7 @@ function SettingsTab({
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
{connectorConfig?.configFields.map((field) => (
|
||||
<div key={field.id} className='flex flex-col gap-1'>
|
||||
<div key={field.id} className='flex flex-col gap-2'>
|
||||
<Label>
|
||||
{field.title}
|
||||
{field.required && <span className='ml-0.5 text-[var(--text-error)]'>*</span>}
|
||||
@@ -227,7 +227,7 @@ function SettingsTab({
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Sync Frequency</Label>
|
||||
<ButtonGroup
|
||||
value={String(syncInterval)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, RotateCcw, X } from 'lucide-react'
|
||||
@@ -78,7 +78,10 @@ interface SubmitStatus {
|
||||
message: string
|
||||
}
|
||||
|
||||
export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
export const CreateBaseModal = memo(function CreateBaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CreateBaseModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
@@ -543,4 +546,4 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
|
||||
interface DeleteKnowledgeBaseModalProps {
|
||||
@@ -29,7 +30,7 @@ interface DeleteKnowledgeBaseModalProps {
|
||||
* Delete confirmation modal for knowledge base items.
|
||||
* Displays a warning message and confirmation buttons.
|
||||
*/
|
||||
export function DeleteKnowledgeBaseModal({
|
||||
export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
@@ -46,10 +47,17 @@ export function DeleteKnowledgeBaseModal({
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
|
||||
All associated documents, chunks, and embeddings will be removed.
|
||||
<span className='text-[var(--text-error)]'>
|
||||
All associated documents, chunks, and embeddings will be removed.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
'Are you sure you want to delete this knowledge base? All associated documents, chunks, and embeddings will be removed.'
|
||||
<>
|
||||
Are you sure you want to delete this knowledge base?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
All associated documents, chunks, and embeddings will be removed.
|
||||
</span>
|
||||
</>
|
||||
)}{' '}
|
||||
<span className='text-[var(--text-tertiary)]'>
|
||||
You can restore it from Recently Deleted in Settings.
|
||||
@@ -67,4 +75,4 @@ export function DeleteKnowledgeBaseModal({
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useForm } from 'react-hook-form'
|
||||
@@ -43,7 +43,7 @@ type FormValues = z.infer<typeof FormSchema>
|
||||
/**
|
||||
* Modal for editing knowledge base name and description
|
||||
*/
|
||||
export function EditKnowledgeBaseModal({
|
||||
export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
knowledgeBaseId,
|
||||
@@ -172,4 +172,4 @@ export function EditKnowledgeBaseModal({
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -30,7 +31,7 @@ interface KnowledgeBaseContextMenuProps {
|
||||
* Context menu component for knowledge base cards.
|
||||
* Displays open in new tab, view tags, edit, and delete options.
|
||||
*/
|
||||
export function KnowledgeBaseContextMenu({
|
||||
export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
@@ -114,4 +115,4 @@ export function KnowledgeBaseContextMenu({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -20,7 +21,7 @@ interface KnowledgeListContextMenuProps {
|
||||
* Context menu component for the knowledge base list page.
|
||||
* Displays "Add knowledge base" option when right-clicking on empty space.
|
||||
*/
|
||||
export function KnowledgeListContextMenu({
|
||||
export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
@@ -58,4 +59,4 @@ export function KnowledgeListContextMenu({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { Database } from '@/components/emcn/icons'
|
||||
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
|
||||
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
|
||||
import type {
|
||||
CreateAction,
|
||||
ResourceCell,
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
SearchConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
|
||||
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import {
|
||||
@@ -18,10 +25,10 @@ import {
|
||||
import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
|
||||
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
|
||||
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
const logger = createLogger('Knowledge')
|
||||
|
||||
@@ -33,11 +40,48 @@ const COLUMNS: ResourceColumn[] = [
|
||||
{ id: 'name', header: 'Name' },
|
||||
{ id: 'documents', header: 'Documents' },
|
||||
{ id: 'tokens', header: 'Tokens' },
|
||||
{ id: 'connectors', header: 'Connectors' },
|
||||
{ id: 'created', header: 'Created' },
|
||||
{ id: 'owner', header: 'Owner' },
|
||||
{ id: 'updated', header: 'Last Updated' },
|
||||
]
|
||||
|
||||
const DATABASE_ICON = <Database className='h-[14px] w-[14px]' />
|
||||
|
||||
function connectorCell(connectorTypes?: string[]): ResourceCell {
|
||||
if (!connectorTypes || connectorTypes.length === 0) {
|
||||
return { label: '—' }
|
||||
}
|
||||
|
||||
const entries = connectorTypes
|
||||
.map((type) => ({ type, def: CONNECTOR_REGISTRY[type] }))
|
||||
.filter((e): e is { type: string; def: NonNullable<(typeof CONNECTOR_REGISTRY)[string]> } =>
|
||||
Boolean(e.def?.icon)
|
||||
)
|
||||
|
||||
if (entries.length === 0) return { label: '—' }
|
||||
|
||||
return {
|
||||
content: (
|
||||
<div className='flex items-center gap-1'>
|
||||
{entries.map(({ type, def }) => {
|
||||
const Icon = def.icon
|
||||
return (
|
||||
<Tooltip.Root key={type}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='flex-shrink-0'>
|
||||
<Icon className='h-3.5 w-3.5' />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{def.name}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function Knowledge() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
@@ -54,8 +98,22 @@ export function Knowledge() {
|
||||
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
|
||||
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const [searchInputValue, setSearchInputValue] = useState('')
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchInputValue(value)
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
setDebouncedSearchQuery(value)
|
||||
}, 300)
|
||||
}, [])
|
||||
|
||||
const handleSearchClearAll = useCallback(() => {
|
||||
handleSearchChange('')
|
||||
}, [handleSearchChange])
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
|
||||
const [activeKnowledgeBase, setActiveKnowledgeBase] = useState<KnowledgeBaseWithDocCount | null>(
|
||||
@@ -69,7 +127,6 @@ export function Knowledge() {
|
||||
const {
|
||||
isOpen: isListContextMenuOpen,
|
||||
position: listContextMenuPosition,
|
||||
menuRef: listMenuRef,
|
||||
handleContextMenu: handleListContextMenu,
|
||||
closeMenu: closeListContextMenu,
|
||||
} = useContextMenu()
|
||||
@@ -77,11 +134,19 @@ export function Knowledge() {
|
||||
const {
|
||||
isOpen: isRowContextMenuOpen,
|
||||
position: rowContextMenuPosition,
|
||||
menuRef: rowMenuRef,
|
||||
handleContextMenu: handleRowCtxMenu,
|
||||
closeMenu: closeRowContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const isRowContextMenuOpenRef = useRef(isRowContextMenuOpen)
|
||||
isRowContextMenuOpenRef.current = isRowContextMenuOpen
|
||||
|
||||
const knowledgeBasesRef = useRef(knowledgeBases)
|
||||
knowledgeBasesRef.current = knowledgeBases
|
||||
|
||||
const activeKnowledgeBaseRef = useRef(activeKnowledgeBase)
|
||||
activeKnowledgeBaseRef.current = activeKnowledgeBase
|
||||
|
||||
const handleContentContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
@@ -96,7 +161,7 @@ export function Knowledge() {
|
||||
[handleListContextMenu]
|
||||
)
|
||||
|
||||
const handleAddKnowledgeBase = useCallback(() => {
|
||||
const handleOpenCreateModal = useCallback(() => {
|
||||
setIsCreateModalOpen(true)
|
||||
}, [])
|
||||
|
||||
@@ -132,7 +197,7 @@ export function Knowledge() {
|
||||
id: kb.id,
|
||||
cells: {
|
||||
name: {
|
||||
icon: <Database className='h-[14px] w-[14px]' />,
|
||||
icon: DATABASE_ICON,
|
||||
label: kb.name,
|
||||
},
|
||||
documents: {
|
||||
@@ -141,6 +206,7 @@ export function Knowledge() {
|
||||
tokens: {
|
||||
label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0',
|
||||
},
|
||||
connectors: connectorCell(kb.connectorTypes),
|
||||
created: timeCell(kb.createdAt),
|
||||
owner: ownerCell(kb.userId, members),
|
||||
updated: timeCell(kb.updatedAt),
|
||||
@@ -148,6 +214,7 @@ export function Knowledge() {
|
||||
sortValues: {
|
||||
documents: kbWithCount.docCount || 0,
|
||||
tokens: kb.tokenCount || 0,
|
||||
connectors: kb.connectorTypes?.length || 0,
|
||||
created: -new Date(kb.createdAt).getTime(),
|
||||
updated: -new Date(kb.updatedAt).getTime(),
|
||||
},
|
||||
@@ -158,51 +225,98 @@ export function Knowledge() {
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(rowId: string) => {
|
||||
if (isRowContextMenuOpen) return
|
||||
const kb = knowledgeBases.find((k) => k.id === rowId)
|
||||
if (isRowContextMenuOpenRef.current) return
|
||||
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId)
|
||||
if (!kb) return
|
||||
const urlParams = new URLSearchParams({ kbName: kb.name })
|
||||
router.push(`/workspace/${workspaceId}/knowledge/${rowId}?${urlParams.toString()}`)
|
||||
},
|
||||
[isRowContextMenuOpen, knowledgeBases, router, workspaceId]
|
||||
[router, workspaceId]
|
||||
)
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
const kb = knowledgeBases.find((k) => k.id === rowId) as KnowledgeBaseWithDocCount | undefined
|
||||
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) as
|
||||
| KnowledgeBaseWithDocCount
|
||||
| undefined
|
||||
setActiveKnowledgeBase(kb ?? null)
|
||||
handleRowCtxMenu(e)
|
||||
},
|
||||
[knowledgeBases, handleRowCtxMenu]
|
||||
[handleRowCtxMenu]
|
||||
)
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!activeKnowledgeBase) return
|
||||
const kb = activeKnowledgeBaseRef.current
|
||||
if (!kb) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await handleDeleteKnowledgeBase(activeKnowledgeBase.id)
|
||||
await handleDeleteKnowledgeBase(kb.id)
|
||||
setIsDeleteModalOpen(false)
|
||||
setActiveKnowledgeBase(null)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [activeKnowledgeBase, handleDeleteKnowledgeBase])
|
||||
}, [handleDeleteKnowledgeBase])
|
||||
|
||||
const handleCloseDeleteModal = useCallback(() => {
|
||||
setIsDeleteModalOpen(false)
|
||||
setActiveKnowledgeBase(null)
|
||||
}, [])
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
const kb = activeKnowledgeBaseRef.current
|
||||
if (!kb) return
|
||||
const urlParams = new URLSearchParams({ kbName: kb.name })
|
||||
window.open(`/workspace/${workspaceId}/knowledge/${kb.id}?${urlParams.toString()}`, '_blank')
|
||||
}, [workspaceId])
|
||||
|
||||
const handleViewTags = useCallback(() => {
|
||||
setIsTagsModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleCopyId = useCallback(() => {
|
||||
const kb = activeKnowledgeBaseRef.current
|
||||
if (kb) {
|
||||
navigator.clipboard.writeText(kb.id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setIsEditModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setIsDeleteModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const canEdit = userPermissions.canEdit === true
|
||||
|
||||
const createAction: CreateAction = useMemo(
|
||||
() => ({
|
||||
label: 'New base',
|
||||
onClick: handleOpenCreateModal,
|
||||
disabled: !canEdit,
|
||||
}),
|
||||
[handleOpenCreateModal, canEdit]
|
||||
)
|
||||
|
||||
const searchConfig: SearchConfig = useMemo(
|
||||
() => ({
|
||||
value: searchInputValue,
|
||||
onChange: handleSearchChange,
|
||||
onClearAll: handleSearchClearAll,
|
||||
placeholder: 'Search knowledge bases...',
|
||||
}),
|
||||
[searchInputValue, handleSearchChange, handleSearchClearAll]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Resource
|
||||
icon={Database}
|
||||
title='Knowledge Base'
|
||||
create={{
|
||||
label: 'New base',
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
disabled: userPermissions.canEdit !== true,
|
||||
}}
|
||||
search={{
|
||||
value: searchQuery,
|
||||
onChange: setSearchQuery,
|
||||
placeholder: 'Search knowledge bases...',
|
||||
}}
|
||||
create={createAction}
|
||||
search={searchConfig}
|
||||
defaultSort='created'
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
@@ -216,8 +330,8 @@ export function Knowledge() {
|
||||
isOpen={isListContextMenuOpen}
|
||||
position={listContextMenuPosition}
|
||||
onClose={closeListContextMenu}
|
||||
onAddKnowledgeBase={handleAddKnowledgeBase}
|
||||
disableAdd={userPermissions.canEdit !== true}
|
||||
onAddKnowledgeBase={handleOpenCreateModal}
|
||||
disableAdd={!canEdit}
|
||||
/>
|
||||
|
||||
{activeKnowledgeBase && (
|
||||
@@ -225,23 +339,17 @@ export function Knowledge() {
|
||||
isOpen={isRowContextMenuOpen}
|
||||
position={rowContextMenuPosition}
|
||||
onClose={closeRowContextMenu}
|
||||
onOpenInNewTab={() => {
|
||||
const urlParams = new URLSearchParams({ kbName: activeKnowledgeBase.name })
|
||||
window.open(
|
||||
`/workspace/${workspaceId}/knowledge/${activeKnowledgeBase.id}?${urlParams.toString()}`,
|
||||
'_blank'
|
||||
)
|
||||
}}
|
||||
onViewTags={() => setIsTagsModalOpen(true)}
|
||||
onCopyId={() => navigator.clipboard.writeText(activeKnowledgeBase.id)}
|
||||
onEdit={() => setIsEditModalOpen(true)}
|
||||
onDelete={() => setIsDeleteModalOpen(true)}
|
||||
onOpenInNewTab={handleOpenInNewTab}
|
||||
onViewTags={handleViewTags}
|
||||
onCopyId={handleCopyId}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
showOpenInNewTab
|
||||
showViewTags
|
||||
showEdit
|
||||
showDelete
|
||||
disableEdit={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
disableEdit={!canEdit}
|
||||
disableDelete={!canEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -259,10 +367,7 @@ export function Knowledge() {
|
||||
{activeKnowledgeBase && (
|
||||
<DeleteKnowledgeBaseModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => {
|
||||
setIsDeleteModalOpen(false)
|
||||
setActiveKnowledgeBase(null)
|
||||
}}
|
||||
onClose={handleCloseDeleteModal}
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
knowledgeBaseName={activeKnowledgeBase.name}
|
||||
|
||||
@@ -1268,7 +1268,9 @@ export const NotificationSettings = memo(function NotificationSettings({
|
||||
<ModalHeader>Delete Notification</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[var(--text-secondary)] text-caption'>
|
||||
This will permanently remove the notification and stop all deliveries.{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently remove the notification and stop all deliveries.
|
||||
</span>{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -57,14 +57,26 @@ function parseShortcut(shortcut: string): ParsedShortcut {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a KeyboardEvent.code value to the logical key name used in shortcut definitions.
|
||||
* Needed for international keyboard layouts where e.key may produce unexpected characters
|
||||
* (e.g. macOS Option+letter yields 'å' instead of 'a', dead keys yield 'Dead').
|
||||
*/
|
||||
function codeToKey(code: string): string | undefined {
|
||||
if (code.startsWith('Key')) return code.slice(3).toLowerCase()
|
||||
if (code.startsWith('Digit')) return code.slice(5)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
|
||||
const isMac = isMacPlatform()
|
||||
const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false)
|
||||
const expectedMeta = parsed.meta || (parsed.mod ? isMac : false)
|
||||
const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key
|
||||
const keyMatches = eventKey === parsed.key || codeToKey(e.code) === parsed.key
|
||||
|
||||
return (
|
||||
eventKey === parsed.key &&
|
||||
keyMatches &&
|
||||
!!e.ctrlKey === !!expectedCtrl &&
|
||||
!!e.metaKey === !!expectedMeta &&
|
||||
!!e.shiftKey === !!parsed.shift &&
|
||||
|
||||
@@ -203,3 +203,35 @@ export function useUserPermissionsContext(): WorkspaceUserPermissions & {
|
||||
const { userPermissions } = useWorkspacePermissionsContext()
|
||||
return userPermissions
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight permissions provider for sandbox/academy contexts.
|
||||
* Grants full edit access without any API calls or workspace dependencies.
|
||||
*/
|
||||
export function SandboxWorkspacePermissionsProvider({ children }: { children: React.ReactNode }) {
|
||||
const sandboxPermissions = useMemo(
|
||||
(): WorkspacePermissionsContextType => ({
|
||||
workspacePermissions: null,
|
||||
permissionsLoading: false,
|
||||
permissionsError: null,
|
||||
updatePermissions: () => {},
|
||||
refetchPermissions: async () => {},
|
||||
userPermissions: {
|
||||
canRead: true,
|
||||
canEdit: true,
|
||||
canAdmin: false,
|
||||
userPermissions: 'write',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isOfflineMode: false,
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkspacePermissionsContext.Provider value={sandboxPermissions}>
|
||||
{children}
|
||||
</WorkspacePermissionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -371,8 +371,10 @@ export function ApiKeys() {
|
||||
<ModalBody>
|
||||
<p className='text-[var(--text-secondary)]'>
|
||||
Deleting{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{deleteKey?.name}</span> will
|
||||
immediately revoke access for any integrations using it.{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{deleteKey?.name}</span>{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
will immediately revoke access for any integrations using it.
|
||||
</span>{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -404,7 +404,10 @@ export function BYOK() {
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{PROVIDERS.find((p) => p.id === deleteConfirmProvider)?.name}
|
||||
</span>{' '}
|
||||
API key? This workspace will revert to using platform hosted keys.
|
||||
API key?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This workspace will revert to using platform hosted keys.
|
||||
</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -366,7 +366,9 @@ export function Copilot() {
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{deleteKey?.name || 'Unnamed Key'}
|
||||
</span>{' '}
|
||||
will immediately revoke access for any integrations using it.{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
will immediately revoke access for any integrations using it.
|
||||
</span>{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
Input as EmcnInput,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { FormField } from '@/app/workspace/[workspaceId]/settings/components/mcp/components'
|
||||
import { useCreateWorkflowMcpServer } from '@/hooks/queries/workflow-mcp-servers'
|
||||
|
||||
const logger = createLogger('CreateWorkflowMcpServerModal')
|
||||
|
||||
const INITIAL_FORM_DATA: { name: string; description: string; isPublic: boolean } = {
|
||||
name: '',
|
||||
description: '',
|
||||
isPublic: false,
|
||||
}
|
||||
|
||||
interface CreateWorkflowMcpServerModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workspaceId: string
|
||||
workflowOptions?: ComboboxOption[]
|
||||
isLoadingWorkflows?: boolean
|
||||
}
|
||||
|
||||
export function CreateWorkflowMcpServerModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
workflowOptions,
|
||||
isLoadingWorkflows = false,
|
||||
}: CreateWorkflowMcpServerModalProps) {
|
||||
const createServerMutation = useCreateWorkflowMcpServer()
|
||||
|
||||
const [formData, setFormData] = useState({ ...INITIAL_FORM_DATA })
|
||||
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
|
||||
|
||||
const isFormValid = formData.name.trim().length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData({ ...INITIAL_FORM_DATA })
|
||||
setSelectedWorkflowIds([])
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleCreateServer = useCallback(async () => {
|
||||
if (!formData.name.trim()) return
|
||||
|
||||
try {
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
isPublic: formData.isPublic,
|
||||
workflowIds: selectedWorkflowIds.length > 0 ? selectedWorkflowIds : undefined,
|
||||
})
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
logger.error('Failed to create server:', err)
|
||||
}
|
||||
}, [formData, selectedWorkflowIds, workspaceId, onOpenChange])
|
||||
|
||||
const showWorkflows = workflowOptions !== undefined
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Add New MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<FormField label='Server Name'>
|
||||
<EmcnInput
|
||||
placeholder='e.g., My MCP Server'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className='h-9'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Description'>
|
||||
<Textarea
|
||||
placeholder='Describe what this MCP server does (optional)'
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className='min-h-[60px] resize-none'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{showWorkflows && (
|
||||
<FormField label='Workflows'>
|
||||
<Combobox
|
||||
options={workflowOptions ?? []}
|
||||
multiSelect
|
||||
multiSelectValues={selectedWorkflowIds}
|
||||
onMultiSelectChange={setSelectedWorkflowIds}
|
||||
placeholder='Select workflows...'
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
isLoading={isLoadingWorkflows}
|
||||
disabled={createServerMutation.isPending}
|
||||
emptyMessage='No deployed workflows available'
|
||||
overlayContent={
|
||||
selectedWorkflowIds.length > 0 ? (
|
||||
<span className='text-[var(--text-primary)]'>
|
||||
{selectedWorkflowIds.length} workflow
|
||||
{selectedWorkflowIds.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField label='Access'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<ButtonGroup
|
||||
value={formData.isPublic ? 'public' : 'private'}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, isPublic: value === 'public' })
|
||||
}
|
||||
>
|
||||
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
|
||||
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
{formData.isPublic && (
|
||||
<span className='text-[var(--text-muted)] text-xs'>
|
||||
No authentication required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateServer}
|
||||
disabled={!isFormValid || createServerMutation.isPending}
|
||||
variant='primary'
|
||||
>
|
||||
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,6 @@ import { useApiKeys } from '@/hooks/queries/api-keys'
|
||||
import { useCreateMcpServer } from '@/hooks/queries/mcp'
|
||||
import {
|
||||
useAddWorkflowMcpTool,
|
||||
useCreateWorkflowMcpServer,
|
||||
useDeleteWorkflowMcpServer,
|
||||
useDeleteWorkflowMcpTool,
|
||||
useDeployedWorkflows,
|
||||
@@ -49,6 +48,7 @@ import {
|
||||
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
|
||||
import { CreateApiKeyModal } from '../api-keys/components'
|
||||
import { FormField, McpServerSkeleton } from '../mcp/components'
|
||||
import { CreateWorkflowMcpServerModal } from './create-workflow-mcp-server-modal'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServers')
|
||||
|
||||
@@ -955,13 +955,10 @@ export function WorkflowMcpServers() {
|
||||
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
|
||||
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
|
||||
useDeployedWorkflows(workspaceId)
|
||||
const createServerMutation = useCreateWorkflowMcpServer()
|
||||
const deleteServerMutation = useDeleteWorkflowMcpServer()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [formData, setFormData] = useState({ name: '', description: '', isPublic: false })
|
||||
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
|
||||
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
|
||||
const [deletingServers, setDeletingServers] = useState<Set<string>>(() => new Set())
|
||||
@@ -979,29 +976,6 @@ export function WorkflowMcpServers() {
|
||||
}))
|
||||
}, [deployedWorkflows])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({ name: '', description: '', isPublic: false })
|
||||
setSelectedWorkflowIds([])
|
||||
setShowAddModal(false)
|
||||
}, [])
|
||||
|
||||
const handleCreateServer = async () => {
|
||||
if (!formData.name.trim()) return
|
||||
|
||||
try {
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
isPublic: formData.isPublic,
|
||||
workflowIds: selectedWorkflowIds.length > 0 ? selectedWorkflowIds : undefined,
|
||||
})
|
||||
resetForm()
|
||||
} catch (err) {
|
||||
logger.error('Failed to create server:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteServer = async () => {
|
||||
if (!serverToDelete) return
|
||||
|
||||
@@ -1026,7 +1000,6 @@ export function WorkflowMcpServers() {
|
||||
|
||||
const hasServers = servers.length > 0
|
||||
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && hasServers
|
||||
const isFormValid = formData.name.trim().length > 0
|
||||
|
||||
if (selectedServerId) {
|
||||
return (
|
||||
@@ -1123,86 +1096,13 @@ export function WorkflowMcpServers() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={showAddModal} onOpenChange={(open) => !open && resetForm()}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Add New MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<FormField label='Server Name'>
|
||||
<EmcnInput
|
||||
placeholder='e.g., My MCP Server'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className='h-9'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Description'>
|
||||
<Textarea
|
||||
placeholder='Describe what this MCP server does (optional)'
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className='min-h-[60px] resize-none'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Workflows'>
|
||||
<Combobox
|
||||
options={workflowOptions}
|
||||
multiSelect
|
||||
multiSelectValues={selectedWorkflowIds}
|
||||
onMultiSelectChange={setSelectedWorkflowIds}
|
||||
placeholder='Select workflows...'
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
isLoading={isLoadingWorkflows}
|
||||
disabled={createServerMutation.isPending}
|
||||
emptyMessage='No deployed workflows available'
|
||||
overlayContent={
|
||||
selectedWorkflowIds.length > 0 ? (
|
||||
<span className='text-[var(--text-primary)]'>
|
||||
{selectedWorkflowIds.length} workflow
|
||||
{selectedWorkflowIds.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Access'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<ButtonGroup
|
||||
value={formData.isPublic ? 'public' : 'private'}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, isPublic: value === 'public' })
|
||||
}
|
||||
>
|
||||
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
|
||||
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
{formData.isPublic && (
|
||||
<span className='text-[var(--text-muted)] text-xs'>
|
||||
No authentication required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateServer}
|
||||
disabled={!isFormValid || createServerMutation.isPending}
|
||||
variant='primary'
|
||||
>
|
||||
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<CreateWorkflowMcpServerModal
|
||||
open={showAddModal}
|
||||
onOpenChange={setShowAddModal}
|
||||
workspaceId={workspaceId}
|
||||
workflowOptions={workflowOptions}
|
||||
isLoadingWorkflows={isLoadingWorkflows}
|
||||
/>
|
||||
|
||||
<Modal open={!!serverToDelete} onOpenChange={(open) => !open && setServerToDelete(null)}>
|
||||
<ModalContent size='sm'>
|
||||
|
||||
@@ -164,8 +164,10 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
|
||||
)}
|
||||
<p className='text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
{isSingleRow ? 'this row' : `these ${deleteCount} rows`}? This will permanently remove
|
||||
all data in {isSingleRow ? 'this row' : 'these rows'}.{' '}
|
||||
{isSingleRow ? 'this row' : `these ${deleteCount} rows`}?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently remove all data in {isSingleRow ? 'this row' : 'these rows'}.
|
||||
</span>{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -1809,6 +1809,9 @@ export function Table({
|
||||
<p className='text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{tableData?.name}</span>?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
All {tableData?.rowCount ?? 0} rows will be removed.
|
||||
</span>{' '}
|
||||
<span className='text-[var(--text-tertiary)]'>
|
||||
You can restore it from Recently Deleted in Settings.
|
||||
</span>
|
||||
@@ -1845,8 +1848,10 @@ export function Table({
|
||||
<ModalBody>
|
||||
<p className='text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{deletingColumn}</span>? This
|
||||
will remove all data in this column.{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{deletingColumn}</span>?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will remove all data in this column.
|
||||
</span>{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -320,8 +320,10 @@ export function Tables() {
|
||||
<ModalBody>
|
||||
<p className='text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{activeTable?.name}</span>?
|
||||
All {activeTable?.rowCount} rows will be removed.{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{activeTable?.name}</span>?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
All {activeTable?.rowCount} rows will be removed.
|
||||
</span>{' '}
|
||||
<span className='text-[var(--text-tertiary)]'>
|
||||
You can restore it from Recently Deleted in Settings.
|
||||
</span>
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { GlobalCommand } from '@/app/workspace/[workspaceId]/providers/glob
|
||||
export type CommandId =
|
||||
| 'accept-diff-changes'
|
||||
| 'add-agent'
|
||||
| 'add-workflow'
|
||||
| 'add-task'
|
||||
// | 'goto-templates'
|
||||
| 'goto-logs'
|
||||
| 'open-search'
|
||||
@@ -52,6 +54,16 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
|
||||
shortcut: 'Mod+Shift+A',
|
||||
allowInEditable: true,
|
||||
},
|
||||
'add-workflow': {
|
||||
id: 'add-workflow',
|
||||
shortcut: 'Mod+Shift+P',
|
||||
allowInEditable: false,
|
||||
},
|
||||
'add-task': {
|
||||
id: 'add-task',
|
||||
shortcut: 'Mod+Shift+K',
|
||||
allowInEditable: false,
|
||||
},
|
||||
// 'goto-templates': {
|
||||
// id: 'goto-templates',
|
||||
// shortcut: 'Mod+Y',
|
||||
|
||||
@@ -929,7 +929,7 @@ export function Chat() {
|
||||
>
|
||||
{shouldShowConfigureStartInputsButton && (
|
||||
<div
|
||||
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-7)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]'
|
||||
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-active)]'
|
||||
title='Add chat inputs to Start block'
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -320,5 +320,6 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
clearAttachedFiles,
|
||||
processFiles,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -883,7 +883,7 @@ console.log(data);`
|
||||
</p>
|
||||
{missingFields.any && (
|
||||
<div
|
||||
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-7)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]'
|
||||
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-active)]'
|
||||
title='Add required A2A input fields to Start block'
|
||||
onClick={handleAddA2AInputs}
|
||||
>
|
||||
|
||||
@@ -17,8 +17,7 @@ import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-to
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
|
||||
import { useAllowedMcpDomains, useCreateMcpServer } from '@/hooks/queries/mcp'
|
||||
import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/create-workflow-mcp-server-modal'
|
||||
import {
|
||||
useAddWorkflowMcpTool,
|
||||
useDeleteWorkflowMcpTool,
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
type WorkflowMcpServer,
|
||||
type WorkflowMcpTool,
|
||||
} from '@/hooks/queries/workflow-mcp-servers'
|
||||
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -102,11 +100,7 @@ export function McpDeploy({
|
||||
}: McpDeployProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [showMcpModal, setShowMcpModal] = useState(false)
|
||||
|
||||
const createMcpServer = useCreateMcpServer()
|
||||
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
const { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
|
||||
const addToolMutation = useAddWorkflowMcpTool()
|
||||
@@ -473,22 +467,16 @@ export function McpDeploy({
|
||||
<>
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
Create an MCP Server in Settings → MCP Servers first.
|
||||
Create an MCP Server to expose your workflows as tools.
|
||||
</p>
|
||||
<Button variant='tertiary' onClick={() => setShowMcpModal(true)}>
|
||||
<Button variant='tertiary' onClick={() => setShowCreateModal(true)}>
|
||||
Create MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
<McpServerFormModal
|
||||
open={showMcpModal}
|
||||
onOpenChange={setShowMcpModal}
|
||||
mode='add'
|
||||
onSubmit={async (config) => {
|
||||
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
|
||||
}}
|
||||
<CreateWorkflowMcpServerModal
|
||||
open={showCreateModal}
|
||||
onOpenChange={setShowCreateModal}
|
||||
workspaceId={workspaceId}
|
||||
availableEnvVars={availableEnvVars}
|
||||
allowedMcpDomains={allowedMcpDomains}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -280,7 +280,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()}>
|
||||
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()} colorScheme='inverted'>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className={cn('pointer-events-none', className)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user