mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a54dcbe949 | ||
|
|
c2b12cf21f | ||
|
|
4a9439e952 | ||
|
|
893e322a49 | ||
|
|
b0cb95be2f | ||
|
|
0b9019d9a2 | ||
|
|
6d00d6bf2c | ||
|
|
3267d8cc24 | ||
|
|
2e69f85364 | ||
|
|
57e5bac121 | ||
|
|
8ce0299400 | ||
|
|
a0796f088b | ||
|
|
98fe4cd40b | ||
|
|
34d210c66c | ||
|
|
2334f2dca4 | ||
|
|
65fc138bfc |
@@ -99,8 +99,6 @@ function SignupFormContent({
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
|
||||
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const redirectUrl = useMemo(
|
||||
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
|
||||
@@ -258,27 +256,14 @@ function SignupFormContent({
|
||||
let token: string | undefined
|
||||
const widget = turnstileRef.current
|
||||
if (turnstileSiteKey && widget) {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
widget.reset()
|
||||
token = await Promise.race([
|
||||
new Promise<string>((resolve, reject) => {
|
||||
captchaResolveRef.current = resolve
|
||||
captchaRejectRef.current = reject
|
||||
widget.execute()
|
||||
}),
|
||||
new Promise<string>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
|
||||
}),
|
||||
])
|
||||
widget.execute()
|
||||
token = await widget.getResponsePromise()
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
captchaResolveRef.current = null
|
||||
captchaRejectRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,10 +520,7 @@ function SignupFormContent({
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={(token) => captchaResolveRef.current?.(token)}
|
||||
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
|
||||
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
|
||||
options={{ execution: 'execute' }}
|
||||
options={{ execution: 'execute', appearance: 'execute' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
formatUpdatedAt,
|
||||
getEffectiveMaxOutputTokens,
|
||||
getModelBySlug,
|
||||
getPricingBounds,
|
||||
getProviderBySlug,
|
||||
@@ -198,7 +199,8 @@ export default async function ModelPage({
|
||||
</div>
|
||||
|
||||
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
{model.summary} {model.bestFor}
|
||||
{model.summary}
|
||||
{model.bestFor ? ` ${model.bestFor}` : ''}
|
||||
</p>
|
||||
|
||||
<div className='mt-8 flex flex-wrap gap-3'>
|
||||
@@ -229,13 +231,11 @@ export default async function ModelPage({
|
||||
? `${formatPrice(model.pricing.cachedInput)}/1M`
|
||||
: 'N/A'
|
||||
}
|
||||
compact
|
||||
/>
|
||||
<StatCard label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
|
||||
<StatCard
|
||||
label='Context window'
|
||||
value={model.contextWindow ? formatTokenCount(model.contextWindow) : 'Unknown'}
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -280,12 +280,12 @@ export default async function ModelPage({
|
||||
label='Max output'
|
||||
value={
|
||||
model.capabilities.maxOutputTokens
|
||||
? `${formatTokenCount(model.capabilities.maxOutputTokens)} tokens`
|
||||
: 'Standard defaults'
|
||||
? `${formatTokenCount(getEffectiveMaxOutputTokens(model.capabilities))} tokens`
|
||||
: 'Not published'
|
||||
}
|
||||
/>
|
||||
<DetailItem label='Provider' value={provider.name} />
|
||||
<DetailItem label='Best for' value={model.bestFor} />
|
||||
{model.bestFor ? <DetailItem label='Best for' value={model.bestFor} /> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
49
apps/sim/app/(landing)/models/utils.test.ts
Normal file
49
apps/sim/app/(landing)/models/utils.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildModelCapabilityFacts, getEffectiveMaxOutputTokens, getModelBySlug } from './utils'
|
||||
|
||||
describe('model catalog capability facts', () => {
|
||||
it.concurrent(
|
||||
'shows structured outputs support and published max output tokens for gpt-4o',
|
||||
() => {
|
||||
const model = getModelBySlug('openai', 'gpt-4o')
|
||||
|
||||
expect(model).not.toBeNull()
|
||||
expect(model).toBeDefined()
|
||||
|
||||
const capabilityFacts = buildModelCapabilityFacts(model!)
|
||||
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
|
||||
const maxOutputTokens = capabilityFacts.find((fact) => fact.label === 'Max output tokens')
|
||||
|
||||
expect(getEffectiveMaxOutputTokens(model!.capabilities)).toBe(16384)
|
||||
expect(structuredOutputs?.value).toBe('Supported')
|
||||
expect(maxOutputTokens?.value).toBe('16k')
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('preserves native structured outputs labeling for claude models', () => {
|
||||
const model = getModelBySlug('anthropic', 'claude-sonnet-4-6')
|
||||
|
||||
expect(model).not.toBeNull()
|
||||
expect(model).toBeDefined()
|
||||
|
||||
const capabilityFacts = buildModelCapabilityFacts(model!)
|
||||
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
|
||||
|
||||
expect(structuredOutputs?.value).toBe('Supported (native)')
|
||||
})
|
||||
|
||||
it.concurrent('does not invent a max output token limit when one is not published', () => {
|
||||
expect(getEffectiveMaxOutputTokens({})).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('keeps best-for copy for clearly differentiated models only', () => {
|
||||
const researchModel = getModelBySlug('google', 'deep-research-pro-preview-12-2025')
|
||||
const generalModel = getModelBySlug('xai', 'grok-4-latest')
|
||||
|
||||
expect(researchModel).not.toBeNull()
|
||||
expect(generalModel).not.toBeNull()
|
||||
|
||||
expect(researchModel?.bestFor).toContain('research workflows')
|
||||
expect(generalModel?.bestFor).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -112,7 +112,7 @@ export interface CatalogModel {
|
||||
capabilities: ModelCapabilities
|
||||
capabilityTags: string[]
|
||||
summary: string
|
||||
bestFor: string
|
||||
bestFor?: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
@@ -190,6 +190,14 @@ export function formatCapabilityBoolean(
|
||||
return value ? positive : negative
|
||||
}
|
||||
|
||||
function supportsCatalogStructuredOutputs(capabilities: ModelCapabilities): boolean {
|
||||
return !capabilities.deepResearch
|
||||
}
|
||||
|
||||
export function getEffectiveMaxOutputTokens(capabilities: ModelCapabilities): number | null {
|
||||
return capabilities.maxOutputTokens ?? null
|
||||
}
|
||||
|
||||
function trimTrailingZeros(value: string): string {
|
||||
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
|
||||
}
|
||||
@@ -326,7 +334,7 @@ function buildCapabilityTags(capabilities: ModelCapabilities): string[] {
|
||||
tags.push('Tool choice')
|
||||
}
|
||||
|
||||
if (capabilities.nativeStructuredOutputs) {
|
||||
if (supportsCatalogStructuredOutputs(capabilities)) {
|
||||
tags.push('Structured outputs')
|
||||
}
|
||||
|
||||
@@ -365,7 +373,7 @@ function buildBestForLine(model: {
|
||||
pricing: PricingInfo
|
||||
capabilities: ModelCapabilities
|
||||
contextWindow: number | null
|
||||
}): string {
|
||||
}): string | null {
|
||||
const { pricing, capabilities, contextWindow } = model
|
||||
|
||||
if (capabilities.deepResearch) {
|
||||
@@ -376,10 +384,6 @@ function buildBestForLine(model: {
|
||||
return 'Best for reasoning-heavy tasks that need more deliberate model control.'
|
||||
}
|
||||
|
||||
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
|
||||
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
|
||||
}
|
||||
|
||||
if (contextWindow && contextWindow >= 1000000) {
|
||||
return 'Best for long-context retrieval, large documents, and high-memory workflows.'
|
||||
}
|
||||
@@ -388,7 +392,11 @@ function buildBestForLine(model: {
|
||||
return 'Best for production workflows that need reliable typed outputs.'
|
||||
}
|
||||
|
||||
return 'Best for general-purpose AI workflows inside Sim.'
|
||||
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
|
||||
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildModelSummary(
|
||||
@@ -437,6 +445,11 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
|
||||
const shortId = stripProviderPrefix(provider.id, model.id)
|
||||
const mergedCapabilities = { ...provider.capabilities, ...model.capabilities }
|
||||
const capabilityTags = buildCapabilityTags(mergedCapabilities)
|
||||
const bestFor = buildBestForLine({
|
||||
pricing: model.pricing,
|
||||
capabilities: mergedCapabilities,
|
||||
contextWindow: model.contextWindow ?? null,
|
||||
})
|
||||
const displayName = formatModelDisplayName(provider.id, model.id)
|
||||
const modelSlug = slugify(shortId)
|
||||
const href = `/models/${providerSlug}/${modelSlug}`
|
||||
@@ -461,11 +474,7 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
|
||||
model.contextWindow ?? null,
|
||||
capabilityTags
|
||||
),
|
||||
bestFor: buildBestForLine({
|
||||
pricing: model.pricing,
|
||||
capabilities: mergedCapabilities,
|
||||
contextWindow: model.contextWindow ?? null,
|
||||
}),
|
||||
...(bestFor ? { bestFor } : {}),
|
||||
searchText: [
|
||||
provider.name,
|
||||
providerDisplayName,
|
||||
@@ -683,6 +692,7 @@ export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel):
|
||||
|
||||
export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[] {
|
||||
const { capabilities } = model
|
||||
const supportsStructuredOutputs = supportsCatalogStructuredOutputs(capabilities)
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -711,7 +721,11 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
|
||||
},
|
||||
{
|
||||
label: 'Structured outputs',
|
||||
value: formatCapabilityBoolean(capabilities.nativeStructuredOutputs),
|
||||
value: supportsStructuredOutputs
|
||||
? capabilities.nativeStructuredOutputs
|
||||
? 'Supported (native)'
|
||||
: 'Supported'
|
||||
: 'Not supported',
|
||||
},
|
||||
{
|
||||
label: 'Tool choice',
|
||||
@@ -732,8 +746,8 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
|
||||
{
|
||||
label: 'Max output tokens',
|
||||
value: capabilities.maxOutputTokens
|
||||
? formatTokenCount(capabilities.maxOutputTokens)
|
||||
: 'Standard defaults',
|
||||
? formatTokenCount(getEffectiveMaxOutputTokens(capabilities))
|
||||
: 'Not published',
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -752,8 +766,8 @@ export function getProviderCapabilitySummary(provider: CatalogProvider): Capabil
|
||||
const reasoningCount = provider.models.filter(
|
||||
(model) => model.capabilities.reasoningEffort || model.capabilities.thinking
|
||||
).length
|
||||
const structuredCount = provider.models.filter(
|
||||
(model) => model.capabilities.nativeStructuredOutputs
|
||||
const structuredCount = provider.models.filter((model) =>
|
||||
supportsCatalogStructuredOutputs(model.capabilities)
|
||||
).length
|
||||
const deepResearchCount = provider.models.filter(
|
||||
(model) => model.capabilities.deepResearch
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* @see stores/constants.ts for the source of truth
|
||||
*/
|
||||
:root {
|
||||
--sidebar-width: 248px; /* SIDEBAR_WIDTH.DEFAULT */
|
||||
--sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */
|
||||
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
|
||||
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
|
||||
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
|
||||
|
||||
@@ -304,7 +304,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isDeployed: true,
|
||||
deploymentStatuses: { production: 'deployed' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -349,7 +348,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isDeployed: true,
|
||||
deploymentStatuses: { production: 'deployed' },
|
||||
lastSaved: 1640995200000,
|
||||
},
|
||||
},
|
||||
@@ -370,7 +368,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isDeployed: true,
|
||||
deploymentStatuses: { production: 'deployed' },
|
||||
lastSaved: 1640995200000,
|
||||
}),
|
||||
}
|
||||
@@ -473,7 +470,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
edges: undefined,
|
||||
loops: null,
|
||||
parallels: undefined,
|
||||
deploymentStatuses: null,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -508,7 +504,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isDeployed: false,
|
||||
deploymentStatuses: {},
|
||||
lastSaved: 1640995200000,
|
||||
})
|
||||
})
|
||||
@@ -768,10 +763,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
parallel1: { branches: ['branch1', 'branch2'] },
|
||||
},
|
||||
isDeployed: true,
|
||||
deploymentStatuses: {
|
||||
production: 'deployed',
|
||||
staging: 'pending',
|
||||
},
|
||||
deployedAt: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
}
|
||||
@@ -816,10 +807,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
parallel1: { branches: ['branch1', 'branch2'] },
|
||||
},
|
||||
isDeployed: true,
|
||||
deploymentStatuses: {
|
||||
production: 'deployed',
|
||||
staging: 'pending',
|
||||
},
|
||||
deployedAt: '2024-01-01T10:00:00.000Z',
|
||||
lastSaved: 1640995200000,
|
||||
})
|
||||
|
||||
@@ -82,7 +82,6 @@ export async function POST(request: NextRequest) {
|
||||
loops: checkpointState?.loops || {},
|
||||
parallels: checkpointState?.parallels || {},
|
||||
isDeployed: checkpointState?.isDeployed || false,
|
||||
deploymentStatuses: checkpointState?.deploymentStatuses || {},
|
||||
lastSaved: Date.now(),
|
||||
...(checkpointState?.deployedAt &&
|
||||
checkpointState.deployedAt !== null &&
|
||||
|
||||
@@ -79,7 +79,6 @@ export async function POST(
|
||||
loops: deployedState.loops || {},
|
||||
parallels: deployedState.parallels || {},
|
||||
lastSaved: Date.now(),
|
||||
deploymentStatuses: deployedState.deploymentStatuses || {},
|
||||
})
|
||||
|
||||
if (!saveResult.success) {
|
||||
|
||||
@@ -89,7 +89,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const finalWorkflowData = {
|
||||
...workflowData,
|
||||
state: {
|
||||
deploymentStatuses: {},
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
@@ -115,7 +114,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const emptyWorkflowData = {
|
||||
...workflowData,
|
||||
state: {
|
||||
deploymentStatuses: {},
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
|
||||
@@ -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 { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
|
||||
const logger = createLogger('WorkflowVariablesAPI')
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
}
|
||||
|
||||
// Sidebar width
|
||||
var defaultSidebarWidth = '248px';
|
||||
try {
|
||||
var stored = localStorage.getItem('sidebar-state');
|
||||
if (stored) {
|
||||
@@ -108,11 +109,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
document.documentElement.style.setProperty('--sidebar-width', width + 'px');
|
||||
} else if (width > maxSidebarWidth) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', maxSidebarWidth + 'px');
|
||||
} else {
|
||||
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback handled by CSS defaults
|
||||
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
|
||||
}
|
||||
|
||||
// Panel width and active tab
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
export async function GET() {
|
||||
export function GET() {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const llmsContent = `# Sim
|
||||
const content = `# Sim
|
||||
|
||||
> Sim is 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.
|
||||
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect integrations and LLMs to deploy and orchestrate agentic workflows.
|
||||
|
||||
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. SOC2 compliant.
|
||||
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. It supports both product discovery pages and deeper technical documentation.
|
||||
|
||||
## Core Pages
|
||||
## Preferred URLs
|
||||
|
||||
- [Homepage](${baseUrl}): Product overview, features, and pricing
|
||||
- [Homepage](${baseUrl}): Product overview and primary entry point
|
||||
- [Integrations directory](${baseUrl}/integrations): Public catalog of integrations and automation capabilities
|
||||
- [Models directory](${baseUrl}/models): Public catalog of AI models, pricing, context windows, and capabilities
|
||||
- [Blog](${baseUrl}/blog): Announcements, guides, and product context
|
||||
- [Changelog](${baseUrl}/changelog): Product updates and release notes
|
||||
- [Sim Blog](${baseUrl}/blog): Announcements, insights, and guides
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Documentation](https://docs.sim.ai): Complete guides and API reference
|
||||
- [Quickstart](https://docs.sim.ai/quickstart): Get started in 5 minutes
|
||||
- [API Reference](https://docs.sim.ai/api): REST API documentation
|
||||
- [Documentation](https://docs.sim.ai): Product guides and technical reference
|
||||
- [Quickstart](https://docs.sim.ai/quickstart): Fastest path to getting started
|
||||
- [API Reference](https://docs.sim.ai/api): API documentation
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Workspace**: Container for workflows, data sources, and executions
|
||||
- **Workflow**: Directed graph of blocks defining an agentic process
|
||||
- **Block**: Individual step (LLM call, tool call, HTTP request, code execution)
|
||||
- **Block**: Individual step such as an LLM call, tool call, HTTP request, or code execution
|
||||
- **Trigger**: Event or schedule that initiates workflow execution
|
||||
- **Execution**: A single run of a workflow with logs and outputs
|
||||
- **Knowledge Base**: Vector-indexed document store for retrieval-augmented generation
|
||||
- **Knowledge Base**: Document store used for retrieval-augmented generation
|
||||
|
||||
## Capabilities
|
||||
|
||||
- AI agent creation and deployment
|
||||
- Agentic workflow orchestration
|
||||
- 1,000+ integrations (Slack, Gmail, Notion, Airtable, databases, and more)
|
||||
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI, Perplexity)
|
||||
- Knowledge base creation with retrieval-augmented generation (RAG)
|
||||
- Integrations across business tools, databases, and communication platforms
|
||||
- Multi-model LLM orchestration
|
||||
- Knowledge bases and retrieval-augmented generation
|
||||
- Table creation and management
|
||||
- Document creation and processing
|
||||
- Scheduled and webhook-triggered executions
|
||||
@@ -45,24 +47,19 @@ Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over
|
||||
|
||||
- AI agent deployment and orchestration
|
||||
- Knowledge bases and RAG pipelines
|
||||
- Document creation and processing
|
||||
- Customer support automation
|
||||
- Internal operations (sales, marketing, legal, finance)
|
||||
- Internal operations workflows across sales, marketing, legal, and finance
|
||||
|
||||
## Links
|
||||
## Additional Links
|
||||
|
||||
- [GitHub Repository](https://github.com/simstudioai/sim): Open-source codebase
|
||||
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with 100,000+ builders
|
||||
- [X/Twitter](https://x.com/simdotai): Product updates and announcements
|
||||
|
||||
## Optional
|
||||
|
||||
- [Careers](https://jobs.ashbyhq.com/sim): Join the Sim team
|
||||
- [Docs](https://docs.sim.ai): Canonical documentation source
|
||||
- [Terms of Service](${baseUrl}/terms): Legal terms
|
||||
- [Privacy Policy](${baseUrl}/privacy): Data handling practices
|
||||
- [Sitemap](${baseUrl}/sitemap.xml): Public URL inventory
|
||||
`
|
||||
|
||||
return new Response(llmsContent, {
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
|
||||
|
||||
@@ -8,6 +8,34 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const now = new Date()
|
||||
const integrationPages: MetadataRoute.Sitemap = integrations.map((integration) => ({
|
||||
url: `${baseUrl}/integrations/${integration.slug}`,
|
||||
lastModified: now,
|
||||
}))
|
||||
const modelHubPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: `${baseUrl}/integrations`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/models`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/partners`,
|
||||
lastModified: now,
|
||||
},
|
||||
]
|
||||
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
url: `${baseUrl}${provider.href}`,
|
||||
lastModified: new Date(
|
||||
Math.max(...provider.models.map((model) => new Date(model.pricing.updatedAt).getTime()))
|
||||
),
|
||||
}))
|
||||
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
|
||||
url: `${baseUrl}${model.href}`,
|
||||
lastModified: new Date(model.pricing.updatedAt),
|
||||
}))
|
||||
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
@@ -26,14 +54,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// url: `${baseUrl}/templates`,
|
||||
// lastModified: now,
|
||||
// },
|
||||
{
|
||||
url: `${baseUrl}/integrations`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/models`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/changelog`,
|
||||
lastModified: now,
|
||||
@@ -54,20 +74,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
lastModified: new Date(p.updated ?? p.date),
|
||||
}))
|
||||
|
||||
const integrationPages: MetadataRoute.Sitemap = integrations.map((i) => ({
|
||||
url: `${baseUrl}/integrations/${i.slug}`,
|
||||
lastModified: now,
|
||||
}))
|
||||
|
||||
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
url: `${baseUrl}${provider.href}`,
|
||||
lastModified: now,
|
||||
}))
|
||||
|
||||
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
|
||||
url: `${baseUrl}${model.href}`,
|
||||
lastModified: new Date(model.pricing.updatedAt),
|
||||
}))
|
||||
|
||||
return [...staticPages, ...blogPages, ...integrationPages, ...providerPages, ...modelPages]
|
||||
return [
|
||||
...staticPages,
|
||||
...modelHubPages,
|
||||
...integrationPages,
|
||||
...providerPages,
|
||||
...modelPages,
|
||||
...blogPages,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -108,8 +108,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
lastUpdate: input.lastUpdate,
|
||||
metadata: input.metadata,
|
||||
variables: input.variables,
|
||||
deploymentStatuses: input.deploymentStatuses,
|
||||
needsRedeployment: input.needsRedeployment,
|
||||
dragStartPosition: input.dragStartPosition ?? null,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, Copy, Ellipsis, Hash } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Button,
|
||||
Check,
|
||||
Copy,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
} from '@/components/emcn'
|
||||
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
|
||||
|
||||
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
|
||||
|
||||
function toPlainText(raw: string): string {
|
||||
return (
|
||||
raw
|
||||
// Strip special tags and their contents
|
||||
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
|
||||
// Strip markdown
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/`{3}[\s\S]*?`{3}/g, '')
|
||||
.replace(/`(.+?)`/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^[>\-*]\s+/gm, '')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
||||
// Normalize whitespace
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
)
|
||||
}
|
||||
|
||||
const ICON_CLASS = 'h-[14px] w-[14px]'
|
||||
const BUTTON_CLASS =
|
||||
'flex h-[26px] w-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string
|
||||
requestId?: string
|
||||
chatId?: string
|
||||
userQuery?: string
|
||||
}
|
||||
|
||||
export function MessageActions({ content, requestId }: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState<'message' | 'request' | null>(null)
|
||||
export function MessageActions({ content, chatId, userQuery }: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
|
||||
const [feedbackText, setFeedbackText] = useState('')
|
||||
const resetTimeoutRef = useRef<number | null>(null)
|
||||
const submitFeedback = useSubmitCopilotFeedback()
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -26,59 +63,119 @@ export function MessageActions({ content, requestId }: MessageActionsProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyToClipboard = useCallback(async (text: string, type: 'message' | 'request') => {
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (!content) return
|
||||
const text = toPlainText(content)
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(type)
|
||||
setCopied(true)
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current)
|
||||
}
|
||||
resetTimeoutRef.current = window.setTimeout(() => setCopied(null), 1500)
|
||||
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}, [content])
|
||||
|
||||
const handleFeedbackClick = useCallback(
|
||||
(type: 'up' | 'down') => {
|
||||
if (chatId && userQuery) {
|
||||
setPendingFeedback(type)
|
||||
setFeedbackText('')
|
||||
}
|
||||
},
|
||||
[chatId, userQuery]
|
||||
)
|
||||
|
||||
const handleSubmitFeedback = useCallback(() => {
|
||||
if (!pendingFeedback || !chatId || !userQuery) return
|
||||
const text = feedbackText.trim()
|
||||
if (!text) {
|
||||
setPendingFeedback(null)
|
||||
setFeedbackText('')
|
||||
return
|
||||
}
|
||||
submitFeedback.mutate({
|
||||
chatId,
|
||||
userQuery,
|
||||
agentResponse: content,
|
||||
isPositiveFeedback: pendingFeedback === 'up',
|
||||
feedback: text,
|
||||
})
|
||||
setPendingFeedback(null)
|
||||
setFeedbackText('')
|
||||
}, [pendingFeedback, chatId, userQuery, content, feedbackText])
|
||||
|
||||
const handleModalClose = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setPendingFeedback(null)
|
||||
setFeedbackText('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!content && !requestId) {
|
||||
return null
|
||||
}
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<>
|
||||
<div className='flex items-center gap-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='More options'
|
||||
className='flex h-5 w-5 items-center justify-center rounded-sm text-[var(--text-icon)] opacity-0 transition-colors transition-opacity hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-primary)] focus-visible:opacity-100 focus-visible:outline-none group-hover/msg:opacity-100 data-[state=open]:opacity-100'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label='Copy message'
|
||||
onClick={copyToClipboard}
|
||||
className={BUTTON_CLASS}
|
||||
>
|
||||
<Ellipsis className='h-3 w-3' strokeWidth={2} />
|
||||
{copied ? <Check className={ICON_CLASS} /> : <Copy className={ICON_CLASS} />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' side='top' sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
disabled={!content}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation()
|
||||
void copyToClipboard(content, 'message')
|
||||
}}
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Like'
|
||||
onClick={() => handleFeedbackClick('up')}
|
||||
className={BUTTON_CLASS}
|
||||
>
|
||||
{copied === 'message' ? <Check /> : <Copy />}
|
||||
<span>Copy Message</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!requestId}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation()
|
||||
if (requestId) {
|
||||
void copyToClipboard(requestId, 'request')
|
||||
}
|
||||
}}
|
||||
<ThumbsUp className={ICON_CLASS} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Dislike'
|
||||
onClick={() => handleFeedbackClick('down')}
|
||||
className={BUTTON_CLASS}
|
||||
>
|
||||
{copied === 'request' ? <Check /> : <Hash />}
|
||||
<span>Copy Request ID</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ThumbsDown className={ICON_CLASS} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Modal open={pendingFeedback !== null} onOpenChange={handleModalClose}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Give feedback</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{pendingFeedback === 'up' ? 'What did you like?' : 'What could be improved?'}
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder={
|
||||
pendingFeedback === 'up'
|
||||
? 'Tell us what was helpful...'
|
||||
: 'Tell us what went wrong...'
|
||||
}
|
||||
value={feedbackText}
|
||||
onChange={(e) => setFeedbackText(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => handleModalClose(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' onClick={handleSubmitFeedback}>
|
||||
Submit
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
@@ -59,8 +59,8 @@ type OAuthModalConnectProps = OAuthModalBaseProps & {
|
||||
workspaceId: string
|
||||
credentialCount: number
|
||||
} & (
|
||||
| { workflowId: string; knowledgeBaseId?: never }
|
||||
| { workflowId?: never; knowledgeBaseId: string }
|
||||
| { workflowId: string; knowledgeBaseId?: never; connectorType?: never }
|
||||
| { workflowId?: never; knowledgeBaseId: string; connectorType?: string }
|
||||
)
|
||||
|
||||
interface OAuthModalReauthorizeProps extends OAuthModalBaseProps {
|
||||
@@ -81,6 +81,7 @@ export function OAuthModal(props: OAuthModalProps) {
|
||||
const workspaceId = isConnect ? props.workspaceId : ''
|
||||
const workflowId = isConnect ? props.workflowId : undefined
|
||||
const knowledgeBaseId = isConnect ? props.knowledgeBaseId : undefined
|
||||
const connectorType = isConnect ? props.connectorType : undefined
|
||||
const toolName = !isConnect ? props.toolName : ''
|
||||
const requiredScopes = !isConnect ? (props.requiredScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
|
||||
const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
|
||||
@@ -172,7 +173,7 @@ export function OAuthModal(props: OAuthModalProps) {
|
||||
}
|
||||
|
||||
const returnContext: OAuthReturnContext = knowledgeBaseId
|
||||
? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId }
|
||||
? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId, connectorType }
|
||||
: { ...baseContext, origin: 'workflow' as const, workflowId: workflowId! }
|
||||
|
||||
writeOAuthReturnContext(returnContext)
|
||||
@@ -205,7 +206,11 @@ export function OAuthModal(props: OAuthModalProps) {
|
||||
return
|
||||
}
|
||||
|
||||
await client.oauth2.link({ providerId, callbackURL: window.location.href })
|
||||
const callbackURL = new URL(window.location.href)
|
||||
if (connectorType) {
|
||||
callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType)
|
||||
}
|
||||
await client.oauth2.link({ providerId, callbackURL: callbackURL.toString() })
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
logger.error('Failed to initiate OAuth connection', { error: err })
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export default function FileViewLoading() {
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg)]'>
|
||||
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -473,9 +473,9 @@ function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
|
||||
const detail = data.code ? `${data.message} (${data.code})` : data.message
|
||||
|
||||
return (
|
||||
<span className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
|
||||
<p className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
|
||||
{detail}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ interface MothershipChatProps {
|
||||
onSendQueuedMessage: (id: string) => Promise<void>
|
||||
onEditQueuedMessage: (id: string) => void
|
||||
userId?: string
|
||||
chatId?: string
|
||||
onContextAdd?: (context: ChatContext) => void
|
||||
editValue?: string
|
||||
onEditValueConsumed?: () => void
|
||||
@@ -53,7 +54,7 @@ const LAYOUT_STYLES = {
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-3',
|
||||
attachmentWidth: 'max-w-[70%]',
|
||||
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
|
||||
assistantRow: 'group/msg relative pb-5',
|
||||
assistantRow: 'group/msg',
|
||||
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
|
||||
footerInner: 'mx-auto max-w-[42rem]',
|
||||
},
|
||||
@@ -63,7 +64,7 @@ const LAYOUT_STYLES = {
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-2',
|
||||
attachmentWidth: 'max-w-[85%]',
|
||||
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
|
||||
assistantRow: 'group/msg relative pb-3',
|
||||
assistantRow: 'group/msg',
|
||||
footer: 'flex-shrink-0 px-3 pb-3',
|
||||
footerInner: '',
|
||||
},
|
||||
@@ -80,6 +81,7 @@ export function MothershipChat({
|
||||
onSendQueuedMessage,
|
||||
onEditQueuedMessage,
|
||||
userId,
|
||||
chatId,
|
||||
onContextAdd,
|
||||
editValue,
|
||||
onEditValueConsumed,
|
||||
@@ -147,20 +149,28 @@ export function MothershipChat({
|
||||
}
|
||||
|
||||
const isLastMessage = index === messages.length - 1
|
||||
const precedingUserMsg = [...messages]
|
||||
.slice(0, index)
|
||||
.reverse()
|
||||
.find((m) => m.role === 'user')
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={styles.assistantRow}>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? onSubmit : undefined}
|
||||
/>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='mt-2.5'>
|
||||
<MessageActions
|
||||
content={msg.content}
|
||||
chatId={chatId}
|
||||
userQuery={precedingUserMsg?.content}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -115,7 +115,7 @@ export const MothershipView = memo(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
|
||||
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]',
|
||||
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-1/2 border-l',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -348,6 +348,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
onSendQueuedMessage={sendNow}
|
||||
onEditQueuedMessage={handleEditQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
chatId={resolvedChatId}
|
||||
onContextAdd={handleContextAdd}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
|
||||
@@ -1407,17 +1407,6 @@ export function useChat(
|
||||
const output = tc.result?.output as Record<string, unknown> | undefined
|
||||
const deployedWorkflowId = (output?.workflowId as string) ?? undefined
|
||||
if (deployedWorkflowId && typeof output?.isDeployed === 'boolean') {
|
||||
const isDeployed = output.isDeployed as boolean
|
||||
const serverDeployedAt = output.deployedAt
|
||||
? new Date(output.deployedAt as string)
|
||||
: undefined
|
||||
useWorkflowRegistry
|
||||
.getState()
|
||||
.setDeploymentStatus(
|
||||
deployedWorkflowId,
|
||||
isDeployed,
|
||||
isDeployed ? (serverDeployedAt ?? new Date()) : undefined
|
||||
)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.info(deployedWorkflowId),
|
||||
})
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_LINE_COUNT = 4
|
||||
|
||||
export default function HomeLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-[var(--bg)]'>
|
||||
<div className='min-h-0 flex-1 overflow-hidden px-6 py-4'>
|
||||
<div className='mx-auto max-w-[42rem] space-y-[10px] pt-3'>
|
||||
{Array.from({ length: SKELETON_LINE_COUNT }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[16px]' style={{ width: `${120 + (i % 4) * 48}px` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
|
||||
<div className='mx-auto max-w-[42rem]'>
|
||||
<Skeleton className='h-[48px] w-full rounded-[12px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { format } from 'date-fns'
|
||||
import { AlertCircle, Loader2, Pencil, Plus, Tag, X } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import {
|
||||
Badge,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { Database, DatabaseX } from '@/components/emcn/icons'
|
||||
import { SearchHighlight } from '@/components/ui/search-highlight'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state'
|
||||
import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants'
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
|
||||
@@ -192,6 +193,10 @@ export function KnowledgeBase({
|
||||
}: KnowledgeBaseProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = propWorkspaceId || (params.workspaceId as string)
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const addConnectorParam = searchParams.get(ADD_CONNECTOR_SEARCH_PARAM)
|
||||
const posthog = usePostHog()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -278,7 +283,29 @@ export function KnowledgeBase({
|
||||
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
|
||||
const [showAddConnectorModal, setShowAddConnectorModal] = useState(false)
|
||||
const showAddConnectorModal = addConnectorParam != null
|
||||
const searchParamsRef = useRef(searchParams)
|
||||
searchParamsRef.current = searchParams
|
||||
const updateAddConnectorParam = useCallback(
|
||||
(value: string | null) => {
|
||||
const current = searchParamsRef.current
|
||||
const currentValue = current.get(ADD_CONNECTOR_SEARCH_PARAM)
|
||||
if (value === currentValue || (value === null && currentValue === null)) return
|
||||
const next = new URLSearchParams(current.toString())
|
||||
if (value === null) {
|
||||
next.delete(ADD_CONNECTOR_SEARCH_PARAM)
|
||||
} else {
|
||||
next.set(ADD_CONNECTOR_SEARCH_PARAM, value)
|
||||
}
|
||||
const qs = next.toString()
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false })
|
||||
},
|
||||
[pathname, router]
|
||||
)
|
||||
const setShowAddConnectorModal = useCallback(
|
||||
(open: boolean) => updateAddConnectorParam(open ? '' : null),
|
||||
[updateAddConnectorParam]
|
||||
)
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
@@ -340,8 +367,6 @@ export function KnowledgeBase({
|
||||
prevHadSyncingRef.current = hasSyncingConnectors
|
||||
}, [hasSyncingConnectors, refreshKnowledgeBase, refreshDocuments])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base'
|
||||
const error = knowledgeBaseError || documentsError
|
||||
|
||||
@@ -1254,7 +1279,13 @@ export function KnowledgeBase({
|
||||
/>
|
||||
|
||||
{showAddConnectorModal && (
|
||||
<AddConnectorModal open onOpenChange={setShowAddConnectorModal} knowledgeBaseId={id} />
|
||||
<AddConnectorModal
|
||||
open
|
||||
onOpenChange={setShowAddConnectorModal}
|
||||
onConnectorTypeChange={updateAddConnectorParam}
|
||||
knowledgeBaseId={id}
|
||||
initialConnectorType={addConnectorParam || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{documentToRename && (
|
||||
|
||||
@@ -44,14 +44,22 @@ const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
|
||||
interface AddConnectorModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConnectorTypeChange?: (connectorType: string | null) => void
|
||||
knowledgeBaseId: string
|
||||
initialConnectorType?: string | null
|
||||
}
|
||||
|
||||
type Step = 'select-type' | 'configure'
|
||||
|
||||
export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddConnectorModalProps) {
|
||||
const [step, setStep] = useState<Step>('select-type')
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null)
|
||||
export function AddConnectorModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConnectorTypeChange,
|
||||
knowledgeBaseId,
|
||||
initialConnectorType,
|
||||
}: AddConnectorModalProps) {
|
||||
const [step, setStep] = useState<Step>(() => (initialConnectorType ? 'configure' : 'select-type'))
|
||||
const [selectedType, setSelectedType] = useState<string | null>(initialConnectorType ?? null)
|
||||
const [sourceConfig, setSourceConfig] = useState<Record<string, string>>({})
|
||||
const [syncInterval, setSyncInterval] = useState(1440)
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
|
||||
@@ -151,6 +159,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
setError(null)
|
||||
setSearchTerm('')
|
||||
setStep('configure')
|
||||
onConnectorTypeChange?.(type)
|
||||
}
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
@@ -286,7 +295,10 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='mr-2 h-6 w-6 p-0'
|
||||
onClick={() => setStep('select-type')}
|
||||
onClick={() => {
|
||||
setStep('select-type')
|
||||
onConnectorTypeChange?.('')
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className='h-4 w-4' />
|
||||
</Button>
|
||||
@@ -565,6 +577,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
workspaceId={workspaceId}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
credentialCount={credentials.length}
|
||||
connectorType={selectedType ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export default function TaskLoading() {
|
||||
return (
|
||||
<div className='flex h-full bg-[var(--bg)]'>
|
||||
<div className='flex h-full min-w-0 flex-1 flex-col'>
|
||||
<div className='flex min-h-0 flex-1 items-center justify-center'>
|
||||
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -111,8 +111,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
lastUpdate: input.lastUpdate,
|
||||
metadata: input.metadata,
|
||||
variables: input.variables,
|
||||
deploymentStatuses: input.deploymentStatuses,
|
||||
needsRedeployment: input.needsRedeployment,
|
||||
dragStartPosition: input.dragStartPosition ?? null,
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ import { useWorkflowMap } from '@/hooks/queries/workflows'
|
||||
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
@@ -90,10 +89,7 @@ export function DeployModal({
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
const deploymentStatus = useWorkflowRegistry((state) =>
|
||||
state.getWorkflowDeploymentStatus(workflowId)
|
||||
)
|
||||
const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp
|
||||
const isDeployed = isDeployedProp
|
||||
const { data: workflowMap = {} } = useWorkflowMap(workspaceId)
|
||||
const workflowMetadata = workflowId ? workflowMap[workflowId] : undefined
|
||||
const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null
|
||||
@@ -381,8 +377,6 @@ export function DeployModal({
|
||||
|
||||
invalidateDeploymentQueries(queryClient, workflowId)
|
||||
|
||||
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
|
||||
|
||||
if (chatSuccessTimeoutRef.current) {
|
||||
clearTimeout(chatSuccessTimeoutRef.current)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useDeployment,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
import { useDeployedWorkflowState } from '@/hooks/queries/deployments'
|
||||
import { useDeployedWorkflowState, useDeploymentInfo } from '@/hooks/queries/deployments'
|
||||
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -25,10 +25,10 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
|
||||
const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading'
|
||||
const { hasBlocks } = useCurrentWorkflow()
|
||||
|
||||
const deploymentStatus = useWorkflowRegistry((state) =>
|
||||
state.getWorkflowDeploymentStatus(activeWorkflowId)
|
||||
)
|
||||
const isDeployed = deploymentStatus?.isDeployed || false
|
||||
const { data: deploymentInfo } = useDeploymentInfo(activeWorkflowId, {
|
||||
enabled: !isRegistryLoading,
|
||||
})
|
||||
const isDeployed = deploymentInfo?.isDeployed ?? false
|
||||
|
||||
const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading
|
||||
const {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
|
||||
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
@@ -31,8 +31,8 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import type { Variable } from '@/stores/panel'
|
||||
import { useVariablesStore } from '@/stores/panel'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import type { Variable } from '@/stores/panel'
|
||||
import { useVariablesStore } from '@/stores/panel'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
|
||||
interface VariableAssignment {
|
||||
id: string
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useParams } from 'next/navigation'
|
||||
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
|
||||
import { usePersonalEnvironment } from '@/hooks/queries/environment'
|
||||
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useDependsOnGate } from './use-depends-on-gate'
|
||||
import { useSubBlockValue } from './use-sub-block-value'
|
||||
@@ -32,7 +32,7 @@ export function useSelectorSetup(
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const workflowId = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
|
||||
const envVariables = useEnvironmentStore((s) => s.variables)
|
||||
const { data: envVariables = {} } = usePersonalEnvironment()
|
||||
|
||||
const { finalDisabled, dependencyValues, canonicalIndex } = useDependsOnGate(
|
||||
blockId,
|
||||
|
||||
@@ -63,7 +63,8 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import type { ChatContext, PanelTab } from '@/stores/panel'
|
||||
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
|
||||
import { usePanelStore } from '@/stores/panel'
|
||||
import { useVariablesModalStore } from '@/stores/variables/modal'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { captureBaselineSnapshot } from '@/stores/workflow-diff/utils'
|
||||
@@ -205,7 +206,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
setIsChatOpen: state.setIsChatOpen,
|
||||
}))
|
||||
)
|
||||
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore(
|
||||
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesModalStore(
|
||||
useShallow((state) => ({
|
||||
isOpen: state.isOpen,
|
||||
setIsOpen: state.setIsOpen,
|
||||
@@ -410,6 +411,17 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
setHasHydrated(true)
|
||||
}, [setHasHydrated])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const message = (e as CustomEvent<{ message: string }>).detail?.message
|
||||
if (!message) return
|
||||
setActiveTab('copilot')
|
||||
copilotSendMessage(message)
|
||||
}
|
||||
window.addEventListener('mothership-send-message', handler)
|
||||
return () => window.removeEventListener('mothership-send-message', handler)
|
||||
}, [setActiveTab, copilotSendMessage])
|
||||
|
||||
/**
|
||||
* Handles tab click events
|
||||
*/
|
||||
@@ -482,7 +494,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
throw new Error('No workflow state found')
|
||||
}
|
||||
|
||||
const workflowVariables = usePanelVariablesStore
|
||||
const workflowVariables = useVariablesStore
|
||||
.getState()
|
||||
.getVariablesByWorkflowId(activeWorkflowId)
|
||||
|
||||
@@ -827,6 +839,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
onSendQueuedMessage={copilotSendNow}
|
||||
onEditQueuedMessage={handleCopilotEditQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
chatId={copilotResolvedChatId}
|
||||
editValue={copilotEditingInputValue}
|
||||
onEditValueConsumed={clearCopilotEditingValue}
|
||||
layout='copilot-view'
|
||||
|
||||
@@ -27,15 +27,15 @@ import {
|
||||
usePreventZoom,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
|
||||
import {
|
||||
getVariablesPosition,
|
||||
MAX_VARIABLES_HEIGHT,
|
||||
MAX_VARIABLES_WIDTH,
|
||||
MIN_VARIABLES_HEIGHT,
|
||||
MIN_VARIABLES_WIDTH,
|
||||
useVariablesStore,
|
||||
} from '@/stores/variables/store'
|
||||
useVariablesModalStore,
|
||||
} from '@/stores/variables/modal'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -96,7 +96,7 @@ export function Variables() {
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
|
||||
const { isOpen, position, width, height, setIsOpen, setPosition, setDimensions } =
|
||||
useVariablesStore(
|
||||
useVariablesModalStore(
|
||||
useShallow((s) => ({
|
||||
isOpen: s.isOpen,
|
||||
position: s.position,
|
||||
@@ -108,7 +108,7 @@ export function Variables() {
|
||||
}))
|
||||
)
|
||||
|
||||
const variables = usePanelVariablesStore((s) => s.variables)
|
||||
const variables = useVariablesStore((s) => s.variables)
|
||||
|
||||
const { collaborativeUpdateVariable, collaborativeAddVariable, collaborativeDeleteVariable } =
|
||||
useCollaborativeWorkflow()
|
||||
|
||||
@@ -48,7 +48,7 @@ import { useSkills } from '@/hooks/queries/skills'
|
||||
import { useTablesList } from '@/hooks/queries/tables'
|
||||
import { useWorkflowMap } from '@/hooks/queries/workflows'
|
||||
import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
|
||||
import { useVariablesStore } from '@/stores/panel'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from 'react'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import type { DeploymentStatus } from '@/stores/workflows/registry/types'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
@@ -16,8 +15,6 @@ export interface CurrentWorkflow {
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
lastSaved?: number
|
||||
deploymentStatuses?: Record<string, DeploymentStatus>
|
||||
needsRedeployment?: boolean
|
||||
|
||||
// Mode information
|
||||
isDiffMode: boolean
|
||||
@@ -48,8 +45,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
|
||||
loops: state.loops,
|
||||
parallels: state.parallels,
|
||||
lastSaved: state.lastSaved,
|
||||
deploymentStatuses: state.deploymentStatuses,
|
||||
needsRedeployment: state.needsRedeployment,
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -78,8 +73,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
|
||||
loops: activeWorkflow.loops || {},
|
||||
parallels: activeWorkflow.parallels || {},
|
||||
lastSaved: activeWorkflow.lastSaved,
|
||||
deploymentStatuses: activeWorkflow.deploymentStatuses,
|
||||
needsRedeployment: activeWorkflow.needsRedeployment,
|
||||
|
||||
// Mode information - update to reflect ready state
|
||||
isDiffMode: hasActiveDiff && isShowingDiff,
|
||||
|
||||
@@ -36,8 +36,6 @@ import { useExecutionStream } from '@/hooks/use-execution-stream'
|
||||
import { WorkflowValidationError } from '@/serializer'
|
||||
import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useVariablesStore } from '@/stores/panel'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import {
|
||||
clearExecutionPointer,
|
||||
consolePersistence,
|
||||
@@ -45,6 +43,7 @@ import {
|
||||
saveExecutionPointer,
|
||||
useTerminalConsoleStore,
|
||||
} from '@/stores/terminal'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
@@ -120,7 +119,6 @@ export function useWorkflowExecution() {
|
||||
}))
|
||||
)
|
||||
const hasHydrated = useTerminalConsoleStore((s) => s._hasHydrated)
|
||||
const getAllVariables = useEnvironmentStore((s) => s.getAllVariables)
|
||||
const { getVariablesByWorkflowId, variables } = useVariablesStore(
|
||||
useShallow((s) => ({
|
||||
getVariablesByWorkflowId: s.getVariablesByWorkflowId,
|
||||
@@ -744,7 +742,6 @@ export function useWorkflowExecution() {
|
||||
activeWorkflowId,
|
||||
currentWorkflow,
|
||||
toggleConsole,
|
||||
getAllVariables,
|
||||
getVariablesByWorkflowId,
|
||||
setIsExecuting,
|
||||
setIsDebugging,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export default function WorkflowLoading() {
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='relative flex h-full w-full flex-1 items-center justify-center bg-[var(--bg)]'>
|
||||
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -124,8 +124,7 @@ export async function applyAutoLayoutAndUpdateStore(
|
||||
try {
|
||||
useWorkflowStore.getState().updateLastSaved()
|
||||
|
||||
const { deploymentStatuses, needsRedeployment, dragStartPosition, ...stateToSave } =
|
||||
newWorkflowState
|
||||
const { dragStartPosition, ...stateToSave } = newWorkflowState
|
||||
|
||||
const cleanedWorkflowState = {
|
||||
...stateToSave,
|
||||
|
||||
@@ -85,7 +85,7 @@ import { useSearchModalStore } from '@/stores/modals/search/store'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { useVariablesModalStore } from '@/stores/variables/modal'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils'
|
||||
@@ -265,7 +265,7 @@ const WorkflowContent = React.memo(
|
||||
const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance, {
|
||||
embedded,
|
||||
})
|
||||
const { emitCursorUpdate } = useSocket()
|
||||
const { emitCursorUpdate, joinWorkflow, leaveWorkflow } = useSocket()
|
||||
useDynamicHandleRefresh()
|
||||
|
||||
const workspaceId = propWorkspaceId || (params.workspaceId as string)
|
||||
@@ -273,6 +273,14 @@ const WorkflowContent = React.memo(
|
||||
|
||||
const addNotification = useNotificationStore((state) => state.addNotification)
|
||||
|
||||
useEffect(() => {
|
||||
if (!embedded || !workflowIdParam) return
|
||||
joinWorkflow(workflowIdParam)
|
||||
return () => {
|
||||
leaveWorkflow()
|
||||
}
|
||||
}, [embedded, workflowIdParam, joinWorkflow, leaveWorkflow])
|
||||
|
||||
useOAuthReturnForWorkflow(workflowIdParam)
|
||||
|
||||
const {
|
||||
@@ -337,7 +345,7 @@ const WorkflowContent = React.memo(
|
||||
autoConnectRef.current = isAutoConnectEnabled
|
||||
|
||||
// Panel open states for context menu
|
||||
const isVariablesOpen = useVariablesStore((state) => state.isOpen)
|
||||
const isVariablesOpen = useVariablesModalStore((state) => state.isOpen)
|
||||
const isChatOpen = useChatStore((state) => state.isChatOpen)
|
||||
|
||||
const snapGrid: [number, number] = useMemo(
|
||||
@@ -1374,7 +1382,7 @@ const WorkflowContent = React.memo(
|
||||
}, [router, workspaceId, workflowIdParam])
|
||||
|
||||
const handleContextToggleVariables = useCallback(() => {
|
||||
const { isOpen, setIsOpen } = useVariablesStore.getState()
|
||||
const { isOpen, setIsOpen } = useVariablesModalStore.getState()
|
||||
setIsOpen(!isOpen)
|
||||
}, [])
|
||||
|
||||
@@ -2144,12 +2152,9 @@ const WorkflowContent = React.memo(
|
||||
|
||||
const handleCanvasPointerMove = useCallback(
|
||||
(event: React.PointerEvent<Element>) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const bounds = target.getBoundingClientRect()
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX - bounds.left,
|
||||
y: event.clientY - bounds.top,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
|
||||
emitCursorUpdate(position)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils
|
||||
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
/** Execution status for blocks in preview mode */
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Button,
|
||||
Combobox,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
@@ -432,7 +431,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label htmlFor='type'>Request</Label>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>Request</p>
|
||||
<Combobox
|
||||
id='type'
|
||||
options={REQUEST_TYPE_OPTIONS}
|
||||
@@ -447,7 +446,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label htmlFor='subject'>Subject</Label>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>Subject</p>
|
||||
<Input
|
||||
id='subject'
|
||||
placeholder='Brief description of your request'
|
||||
@@ -457,7 +456,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label htmlFor='message'>Message</Label>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>Message</p>
|
||||
<Textarea
|
||||
id='message'
|
||||
placeholder='Please provide details about your request...'
|
||||
@@ -468,7 +467,9 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Attach Images (Optional)</Label>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
Attach Images (Optional)
|
||||
</p>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
@@ -505,7 +506,9 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Uploaded Images</Label>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
Uploaded Images
|
||||
</p>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
|
||||
@@ -343,7 +343,11 @@ export function SearchModal({
|
||||
'-translate-x-1/2 fixed top-[15%] z-50 w-[500px] rounded-xl border-[4px] border-black/[0.06] bg-[var(--bg)] shadow-[0_24px_80px_-16px_rgba(0,0,0,0.15)] dark:border-white/[0.06] dark:shadow-[0_24px_80px_-16px_rgba(0,0,0,0.4)]',
|
||||
open ? 'visible opacity-100' : 'invisible opacity-0'
|
||||
)}
|
||||
style={{ left: 'calc(var(--sidebar-width) / 2 + 50%)' }}
|
||||
style={{
|
||||
left: isOnWorkflowPage
|
||||
? 'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)'
|
||||
: 'calc(var(--sidebar-width) / 2 + 50%)',
|
||||
}}
|
||||
>
|
||||
<Command label='Search' shouldFilter={false}>
|
||||
<div className='mx-2 mt-2 mb-1 flex items-center gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 dark:bg-[var(--surface-4)]'>
|
||||
|
||||
@@ -316,6 +316,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const posthog = usePostHog()
|
||||
const { data: sessionData, isPending: sessionLoading } = useSession()
|
||||
@@ -894,6 +895,9 @@ export const Sidebar = memo(function Sidebar() {
|
||||
container.addEventListener('scroll', updateScrollState, { passive: true })
|
||||
const observer = new ResizeObserver(updateScrollState)
|
||||
observer.observe(container)
|
||||
if (scrollContentRef.current) {
|
||||
observer.observe(scrollContentRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateScrollState)
|
||||
@@ -1336,275 +1340,286 @@ export const Sidebar = memo(function Sidebar() {
|
||||
!hasOverflowTop && 'border-transparent'
|
||||
)}
|
||||
>
|
||||
<div className='tasks-section flex flex-shrink-0 flex-col' data-tour='nav-tasks'>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
|
||||
{!isCollapsed && (
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
onClick={handleNewTask}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Shortcut keys={isMac ? '⌘⇧K' : 'Ctrl+Shift+K'}>
|
||||
New task
|
||||
</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<div ref={scrollContentRef} className='flex flex-col'>
|
||||
<div
|
||||
className='tasks-section flex flex-shrink-0 flex-col'
|
||||
data-tour='nav-tasks'
|
||||
>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>
|
||||
All tasks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<CollapsedSidebarMenu
|
||||
icon={tasksCollapsedIcon}
|
||||
hover={tasksHover}
|
||||
ariaLabel='Tasks'
|
||||
className='mt-1.5'
|
||||
primaryAction={tasksPrimaryAction}
|
||||
>
|
||||
{tasksLoading ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<Loader className='h-[14px] w-[14px]' animate />
|
||||
Loading...
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<CollapsedTaskFlyoutItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isCurrentRoute={task.id !== 'new' && pathname === task.href}
|
||||
isMenuOpen={menuOpenTaskId === task.id}
|
||||
isEditing={task.id === taskFlyoutRename.editingId}
|
||||
editValue={taskFlyoutRename.value}
|
||||
inputRef={taskFlyoutRename.inputRef}
|
||||
isRenaming={taskFlyoutRename.isSaving}
|
||||
onEditValueChange={taskFlyoutRename.setValue}
|
||||
onEditKeyDown={taskFlyoutRename.handleKeyDown}
|
||||
onEditBlur={handleTaskRenameBlur}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
onMoreClick={handleTaskMoreClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CollapsedSidebarMenu>
|
||||
) : (
|
||||
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
|
||||
{tasksLoading ? (
|
||||
<SidebarItemSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{tasks.slice(0, visibleTaskCount).map((task) => {
|
||||
const isCurrentRoute = task.id !== 'new' && pathname === task.href
|
||||
const isRenaming = taskFlyoutRename.editingId === task.id
|
||||
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg bg-[var(--surface-active)] px-2 text-sm'
|
||||
>
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
ref={taskFlyoutRename.inputRef}
|
||||
value={taskFlyoutRename.value}
|
||||
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
|
||||
onKeyDown={taskFlyoutRename.handleKeyDown}
|
||||
onBlur={handleTaskRenameBlur}
|
||||
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarTaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isCurrentRoute={isCurrentRoute}
|
||||
isSelected={isSelected}
|
||||
isActive={!!task.isActive}
|
||||
isUnread={!!task.isUnread}
|
||||
isMenuOpen={menuOpenTaskId === task.id}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
onMultiSelectClick={handleTaskClick}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
onMoreClick={handleTaskMoreClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{tasks.length > visibleTaskCount && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSeeMoreTasks}
|
||||
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
|
||||
<span className='font-base'>See more</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='workflows-section relative mt-3.5 flex flex-col'
|
||||
data-tour='nav-workflows'
|
||||
>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>Workflows</div>
|
||||
{!isCollapsed && (
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<DropdownMenu>
|
||||
{!isCollapsed && (
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{isImporting || isCreatingFolder ? (
|
||||
<Loader className='h-[16px] w-[16px]' animate />
|
||||
) : (
|
||||
<MoreHorizontal className='h-[16px] w-[16px]' />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
onClick={handleNewTask}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>More actions</p>
|
||||
<Tooltip.Shortcut keys={isMac ? '⌘⇧K' : 'Ctrl+Shift+K'}>
|
||||
New task
|
||||
</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
sideOffset={8}
|
||||
className='min-w-[160px]'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleImportWorkflow}
|
||||
disabled={!canEdit || isImporting}
|
||||
>
|
||||
<Download />
|
||||
{isImporting ? 'Importing...' : 'Import workflow'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleCreateFolder}
|
||||
disabled={!canEdit || isCreatingFolder}
|
||||
>
|
||||
<FolderPlus />
|
||||
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
onClick={handleCreateWorkflow}
|
||||
disabled={isCreatingWorkflow || !canEdit}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{isCreatingWorkflow ? (
|
||||
<p>Creating workflow...</p>
|
||||
) : (
|
||||
<Tooltip.Shortcut keys={isMac ? '⌘⇧P' : 'Ctrl+Shift+P'}>
|
||||
New workflow
|
||||
</Tooltip.Shortcut>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<CollapsedSidebarMenu
|
||||
icon={tasksCollapsedIcon}
|
||||
hover={tasksHover}
|
||||
ariaLabel='Tasks'
|
||||
className='mt-1.5'
|
||||
primaryAction={tasksPrimaryAction}
|
||||
>
|
||||
{tasksLoading ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<Loader className='h-[14px] w-[14px]' animate />
|
||||
Loading...
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<CollapsedTaskFlyoutItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isCurrentRoute={task.id !== 'new' && pathname === task.href}
|
||||
isMenuOpen={menuOpenTaskId === task.id}
|
||||
isEditing={task.id === taskFlyoutRename.editingId}
|
||||
editValue={taskFlyoutRename.value}
|
||||
inputRef={taskFlyoutRename.inputRef}
|
||||
isRenaming={taskFlyoutRename.isSaving}
|
||||
onEditValueChange={taskFlyoutRename.setValue}
|
||||
onEditKeyDown={taskFlyoutRename.handleKeyDown}
|
||||
onEditBlur={handleTaskRenameBlur}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
onMoreClick={handleTaskMoreClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CollapsedSidebarMenu>
|
||||
) : (
|
||||
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
|
||||
{tasksLoading ? (
|
||||
<SidebarItemSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{tasks.slice(0, visibleTaskCount).map((task) => {
|
||||
const isCurrentRoute = task.id !== 'new' && pathname === task.href
|
||||
const isRenaming = taskFlyoutRename.editingId === task.id
|
||||
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg bg-[var(--surface-active)] px-2 text-sm'
|
||||
>
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
ref={taskFlyoutRename.inputRef}
|
||||
value={taskFlyoutRename.value}
|
||||
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
|
||||
onKeyDown={taskFlyoutRename.handleKeyDown}
|
||||
onBlur={handleTaskRenameBlur}
|
||||
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarTaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isCurrentRoute={isCurrentRoute}
|
||||
isSelected={isSelected}
|
||||
isActive={!!task.isActive}
|
||||
isUnread={!!task.isUnread}
|
||||
isMenuOpen={menuOpenTaskId === task.id}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
onMultiSelectClick={handleTaskClick}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
onMoreClick={handleTaskMoreClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{tasks.length > visibleTaskCount && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSeeMoreTasks}
|
||||
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
|
||||
<span className='font-base'>See more</span>
|
||||
</button>
|
||||
)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<CollapsedSidebarMenu
|
||||
icon={workflowsCollapsedIcon}
|
||||
hover={workflowsHover}
|
||||
ariaLabel='Workflows'
|
||||
className='mt-1.5'
|
||||
primaryAction={workflowsPrimaryAction}
|
||||
>
|
||||
{workflowsLoading && regularWorkflows.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<Loader className='h-[14px] w-[14px]' animate />
|
||||
Loading...
|
||||
</DropdownMenuItem>
|
||||
) : regularWorkflows.length === 0 ? (
|
||||
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<CollapsedFolderItems
|
||||
nodes={folderTree}
|
||||
workflowsByFolder={workflowsByFolder}
|
||||
workspaceId={workspaceId}
|
||||
currentWorkflowId={workflowId}
|
||||
editingWorkflowId={workflowFlyoutRename.editingId}
|
||||
editingValue={workflowFlyoutRename.value}
|
||||
editInputRef={workflowFlyoutRename.inputRef}
|
||||
isRenamingWorkflow={workflowFlyoutRename.isSaving}
|
||||
onEditValueChange={workflowFlyoutRename.setValue}
|
||||
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
|
||||
onEditBlur={handleWorkflowRenameBlur}
|
||||
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
|
||||
onWorkflowRename={handleCollapsedWorkflowRename}
|
||||
canRenameWorkflow={canEdit}
|
||||
/>
|
||||
{(workflowsByFolder.root || []).map((workflow) => (
|
||||
<CollapsedWorkflowFlyoutItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
href={`/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
isCurrentRoute={workflow.id === workflowId}
|
||||
isEditing={workflow.id === workflowFlyoutRename.editingId}
|
||||
editValue={workflowFlyoutRename.value}
|
||||
inputRef={workflowFlyoutRename.inputRef}
|
||||
isRenaming={workflowFlyoutRename.isSaving}
|
||||
|
||||
<div
|
||||
className='workflows-section relative mt-3.5 flex flex-col'
|
||||
data-tour='nav-workflows'
|
||||
>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>
|
||||
Workflows
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<DropdownMenu>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{isImporting || isCreatingFolder ? (
|
||||
<Loader className='h-[16px] w-[16px]' animate />
|
||||
) : (
|
||||
<MoreHorizontal className='h-[16px] w-[16px]' />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>More actions</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
sideOffset={8}
|
||||
className='min-w-[160px]'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleImportWorkflow}
|
||||
disabled={!canEdit || isImporting}
|
||||
>
|
||||
<Download />
|
||||
{isImporting ? 'Importing...' : 'Import workflow'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleCreateFolder}
|
||||
disabled={!canEdit || isCreatingFolder}
|
||||
>
|
||||
<FolderPlus />
|
||||
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
onClick={handleCreateWorkflow}
|
||||
disabled={isCreatingWorkflow || !canEdit}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{isCreatingWorkflow ? (
|
||||
<p>Creating workflow...</p>
|
||||
) : (
|
||||
<Tooltip.Shortcut keys={isMac ? '⌘⇧P' : 'Ctrl+Shift+P'}>
|
||||
New workflow
|
||||
</Tooltip.Shortcut>
|
||||
)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<CollapsedSidebarMenu
|
||||
icon={workflowsCollapsedIcon}
|
||||
hover={workflowsHover}
|
||||
ariaLabel='Workflows'
|
||||
className='mt-1.5'
|
||||
primaryAction={workflowsPrimaryAction}
|
||||
>
|
||||
{workflowsLoading && regularWorkflows.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<Loader className='h-[14px] w-[14px]' animate />
|
||||
Loading...
|
||||
</DropdownMenuItem>
|
||||
) : regularWorkflows.length === 0 ? (
|
||||
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<CollapsedFolderItems
|
||||
nodes={folderTree}
|
||||
workflowsByFolder={workflowsByFolder}
|
||||
workspaceId={workspaceId}
|
||||
currentWorkflowId={workflowId}
|
||||
editingWorkflowId={workflowFlyoutRename.editingId}
|
||||
editingValue={workflowFlyoutRename.value}
|
||||
editInputRef={workflowFlyoutRename.inputRef}
|
||||
isRenamingWorkflow={workflowFlyoutRename.isSaving}
|
||||
onEditValueChange={workflowFlyoutRename.setValue}
|
||||
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
|
||||
onEditBlur={handleWorkflowRenameBlur}
|
||||
onOpenInNewTab={() => handleCollapsedWorkflowOpenInNewTab(workflow)}
|
||||
onRename={() => handleCollapsedWorkflowRename(workflow)}
|
||||
canRename={canEdit}
|
||||
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
|
||||
onWorkflowRename={handleCollapsedWorkflowRename}
|
||||
canRenameWorkflow={canEdit}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CollapsedSidebarMenu>
|
||||
) : (
|
||||
<div className='mt-1.5 px-2'>
|
||||
{workflowsLoading && regularWorkflows.length === 0 && (
|
||||
<SidebarItemSkeleton />
|
||||
)}
|
||||
<WorkflowList
|
||||
workspaceId={workspaceId}
|
||||
workflowId={workflowId}
|
||||
regularWorkflows={regularWorkflows}
|
||||
isLoading={isLoading}
|
||||
canReorder={canEdit}
|
||||
handleFileChange={handleImportFileChange}
|
||||
fileInputRef={fileInputRef}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(workflowsByFolder.root || []).map((workflow) => (
|
||||
<CollapsedWorkflowFlyoutItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
href={`/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
isCurrentRoute={workflow.id === workflowId}
|
||||
isEditing={workflow.id === workflowFlyoutRename.editingId}
|
||||
editValue={workflowFlyoutRename.value}
|
||||
inputRef={workflowFlyoutRename.inputRef}
|
||||
isRenaming={workflowFlyoutRename.isSaving}
|
||||
onEditValueChange={workflowFlyoutRename.setValue}
|
||||
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
|
||||
onEditBlur={handleWorkflowRenameBlur}
|
||||
onOpenInNewTab={() =>
|
||||
handleCollapsedWorkflowOpenInNewTab(workflow)
|
||||
}
|
||||
onRename={() => handleCollapsedWorkflowRename(workflow)}
|
||||
canRename={canEdit}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CollapsedSidebarMenu>
|
||||
) : (
|
||||
<div className='mt-1.5 px-2'>
|
||||
{workflowsLoading && regularWorkflows.length === 0 && (
|
||||
<SidebarItemSkeleton />
|
||||
)}
|
||||
<WorkflowList
|
||||
workspaceId={workspaceId}
|
||||
workflowId={workflowId}
|
||||
regularWorkflows={regularWorkflows}
|
||||
isLoading={isLoading}
|
||||
canReorder={canEdit}
|
||||
handleFileChange={handleImportFileChange}
|
||||
fileInputRef={fileInputRef}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export default function WorkflowsLoading() {
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='relative flex h-full w-full flex-1 items-center justify-center bg-[var(--bg)]'>
|
||||
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -90,6 +90,7 @@ interface SocketContextType {
|
||||
onSelectionUpdate: (handler: (data: any) => void) => void
|
||||
onWorkflowDeleted: (handler: (data: any) => void) => void
|
||||
onWorkflowReverted: (handler: (data: any) => void) => void
|
||||
onWorkflowUpdated: (handler: (data: any) => void) => void
|
||||
onOperationConfirmed: (handler: (data: any) => void) => void
|
||||
onOperationFailed: (handler: (data: any) => void) => void
|
||||
}
|
||||
@@ -118,6 +119,7 @@ const SocketContext = createContext<SocketContextType>({
|
||||
onSelectionUpdate: () => {},
|
||||
onWorkflowDeleted: () => {},
|
||||
onWorkflowReverted: () => {},
|
||||
onWorkflowUpdated: () => {},
|
||||
onOperationConfirmed: () => {},
|
||||
onOperationFailed: () => {},
|
||||
})
|
||||
@@ -155,6 +157,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
selectionUpdate?: (data: any) => void
|
||||
workflowDeleted?: (data: any) => void
|
||||
workflowReverted?: (data: any) => void
|
||||
workflowUpdated?: (data: any) => void
|
||||
operationConfirmed?: (data: any) => void
|
||||
operationFailed?: (data: any) => void
|
||||
}>({})
|
||||
@@ -334,7 +337,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
socketInstance.on('join-workflow-success', ({ workflowId, presenceUsers }) => {
|
||||
isRejoiningRef.current = false
|
||||
// Ignore stale success responses from previous navigation
|
||||
if (workflowId !== urlWorkflowIdRef.current) {
|
||||
if (urlWorkflowIdRef.current && workflowId !== urlWorkflowIdRef.current) {
|
||||
logger.debug(`Ignoring stale join-workflow-success for ${workflowId}`)
|
||||
return
|
||||
}
|
||||
@@ -382,6 +385,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
eventHandlers.current.workflowReverted?.(data)
|
||||
})
|
||||
|
||||
socketInstance.on('workflow-updated', (data) => {
|
||||
logger.info(`Workflow ${data.workflowId} has been updated externally`)
|
||||
eventHandlers.current.workflowUpdated?.(data)
|
||||
})
|
||||
|
||||
const rehydrateWorkflowStores = async (workflowId: string, workflowState: any) => {
|
||||
const [
|
||||
{ useOperationQueueStore },
|
||||
@@ -424,7 +432,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
loops: workflowState.loops || {},
|
||||
parallels: workflowState.parallels || {},
|
||||
lastSaved: workflowState.lastSaved || Date.now(),
|
||||
deploymentStatuses: workflowState.deploymentStatuses || {},
|
||||
})
|
||||
|
||||
useSubBlockStore.setState((state: any) => ({
|
||||
@@ -804,6 +811,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
eventHandlers.current.workflowReverted = handler
|
||||
}, [])
|
||||
|
||||
const onWorkflowUpdated = useCallback((handler: (data: any) => void) => {
|
||||
eventHandlers.current.workflowUpdated = handler
|
||||
}, [])
|
||||
|
||||
const onOperationConfirmed = useCallback((handler: (data: any) => void) => {
|
||||
eventHandlers.current.operationConfirmed = handler
|
||||
}, [])
|
||||
@@ -837,6 +848,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
onSelectionUpdate,
|
||||
onWorkflowDeleted,
|
||||
onWorkflowReverted,
|
||||
onWorkflowUpdated,
|
||||
onOperationConfirmed,
|
||||
onOperationFailed,
|
||||
}),
|
||||
@@ -864,6 +876,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
onSelectionUpdate,
|
||||
onWorkflowDeleted,
|
||||
onWorkflowReverted,
|
||||
onWorkflowUpdated,
|
||||
onOperationConfirmed,
|
||||
onOperationFailed,
|
||||
]
|
||||
|
||||
@@ -40,6 +40,7 @@ import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import { X } from 'lucide-react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { Button } from '../button/button'
|
||||
|
||||
@@ -50,13 +51,6 @@ import { Button } from '../button/button'
|
||||
const ANIMATION_CLASSES =
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'
|
||||
|
||||
/**
|
||||
* Modal content animation classes.
|
||||
* We keep only the slide animations (no zoom) to stabilize positioning while avoiding scale effects.
|
||||
*/
|
||||
const CONTENT_ANIMATION_CLASSES =
|
||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[50%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[50%] motion-reduce:animate-none'
|
||||
|
||||
/**
|
||||
* Root modal component. Manages open state.
|
||||
*/
|
||||
@@ -145,6 +139,8 @@ const ModalContent = React.forwardRef<
|
||||
ModalContentProps
|
||||
>(({ className, children, showClose = true, size = 'md', style, ...props }, ref) => {
|
||||
const [isInteractionReady, setIsInteractionReady] = React.useState(false)
|
||||
const pathname = usePathname()
|
||||
const isWorkflowPage = pathname?.includes('/w/') ?? false
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setIsInteractionReady(true), 100)
|
||||
@@ -157,14 +153,15 @@ const ModalContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
ANIMATION_CLASSES,
|
||||
CONTENT_ANIMATION_CLASSES,
|
||||
'fixed top-[50%] z-[var(--z-modal)] flex max-h-[84vh] translate-x-[-50%] translate-y-[-50%] flex-col overflow-hidden rounded-xl bg-[var(--bg)] text-small ring-1 ring-foreground/10 duration-200',
|
||||
MODAL_SIZES[size],
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
left: '50%',
|
||||
left: isWorkflowPage
|
||||
? // --panel-width is always the rendered panel width on /w/ routes (panel is never hidden/collapsed)
|
||||
'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)'
|
||||
: 'calc(var(--sidebar-width) / 2 + 50%)',
|
||||
...style,
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
|
||||
@@ -72,6 +72,8 @@ export { Table } from './table'
|
||||
export { TableX } from './table-x'
|
||||
export { TagIcon } from './tag'
|
||||
export { TerminalWindow } from './terminal-window'
|
||||
export { ThumbsDown } from './thumbs-down'
|
||||
export { ThumbsUp } from './thumbs-up'
|
||||
export { Trash } from './trash'
|
||||
export { TrashOutline } from './trash-outline'
|
||||
export { Trash2 } from './trash2'
|
||||
|
||||
28
apps/sim/components/emcn/icons/thumbs-down.tsx
Normal file
28
apps/sim/components/emcn/icons/thumbs-down.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* ThumbsDown icon component
|
||||
* @param props - SVG properties including className, fill, etc.
|
||||
*/
|
||||
export function ThumbsDown(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='-1 -2 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.75'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
aria-hidden='true'
|
||||
{...props}
|
||||
>
|
||||
<g transform='scale(1,-1) translate(0,-20)'>
|
||||
<path d='M6 8v12' />
|
||||
<path d='M14 3.88L13 8h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 16.5 20H3a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L11 0a3.13 3.13 0 0 1 3 3.88Z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
26
apps/sim/components/emcn/icons/thumbs-up.tsx
Normal file
26
apps/sim/components/emcn/icons/thumbs-up.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* ThumbsUp icon component
|
||||
* @param props - SVG properties including className, fill, etc.
|
||||
*/
|
||||
export function ThumbsUp(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='-1 -2 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.75'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
aria-hidden='true'
|
||||
{...props}
|
||||
>
|
||||
<path d='M6 8v12' />
|
||||
<path d='M14 3.88L13 8h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 16.5 20H3a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L11 0a3.13 3.13 0 0 1 3 3.88Z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
39
apps/sim/hooks/queries/copilot-feedback.ts
Normal file
39
apps/sim/hooks/queries/copilot-feedback.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
const logger = createLogger('CopilotFeedbackMutation')
|
||||
|
||||
interface SubmitFeedbackVariables {
|
||||
chatId: string
|
||||
userQuery: string
|
||||
agentResponse: string
|
||||
isPositiveFeedback: boolean
|
||||
feedback?: string
|
||||
}
|
||||
|
||||
interface SubmitFeedbackResponse {
|
||||
success: boolean
|
||||
feedbackId: string
|
||||
}
|
||||
|
||||
export function useSubmitCopilotFeedback() {
|
||||
return useMutation({
|
||||
mutationFn: async (variables: SubmitFeedbackVariables): Promise<SubmitFeedbackResponse> => {
|
||||
const response = await fetch('/api/copilot/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(variables),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Failed to submit feedback')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to submit copilot feedback:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state'
|
||||
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('DeploymentQueries')
|
||||
@@ -321,7 +320,6 @@ interface DeployWorkflowResult {
|
||||
*/
|
||||
export function useDeployWorkflow() {
|
||||
const queryClient = useQueryClient()
|
||||
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
@@ -351,18 +349,12 @@ export function useDeployWorkflow() {
|
||||
warnings: data.warnings,
|
||||
}
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
logger.info('Workflow deployed successfully', { workflowId: variables.workflowId })
|
||||
|
||||
setDeploymentStatus(
|
||||
variables.workflowId,
|
||||
data.isDeployed,
|
||||
data.deployedAt ? new Date(data.deployedAt) : undefined,
|
||||
data.apiKey
|
||||
)
|
||||
|
||||
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(variables.workflowId, false)
|
||||
|
||||
onSettled: (_data, error, variables) => {
|
||||
if (error) {
|
||||
logger.error('Failed to deploy workflow', { error })
|
||||
} else {
|
||||
logger.info('Workflow deployed successfully', { workflowId: variables.workflowId })
|
||||
}
|
||||
return Promise.all([
|
||||
invalidateDeploymentQueries(queryClient, variables.workflowId),
|
||||
queryClient.invalidateQueries({
|
||||
@@ -370,9 +362,6 @@ export function useDeployWorkflow() {
|
||||
}),
|
||||
])
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to deploy workflow', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -389,7 +378,6 @@ interface UndeployWorkflowVariables {
|
||||
*/
|
||||
export function useUndeployWorkflow() {
|
||||
const queryClient = useQueryClient()
|
||||
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workflowId }: UndeployWorkflowVariables): Promise<void> => {
|
||||
@@ -402,11 +390,12 @@ export function useUndeployWorkflow() {
|
||||
throw new Error(errorData.error || 'Failed to undeploy workflow')
|
||||
}
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
logger.info('Workflow undeployed successfully', { workflowId: variables.workflowId })
|
||||
|
||||
setDeploymentStatus(variables.workflowId, false)
|
||||
|
||||
onSettled: (_data, error, variables) => {
|
||||
if (error) {
|
||||
logger.error('Failed to undeploy workflow', { error })
|
||||
} else {
|
||||
logger.info('Workflow undeployed successfully', { workflowId: variables.workflowId })
|
||||
}
|
||||
return Promise.all([
|
||||
invalidateDeploymentQueries(queryClient, variables.workflowId),
|
||||
queryClient.invalidateQueries({
|
||||
@@ -414,9 +403,6 @@ export function useUndeployWorkflow() {
|
||||
}),
|
||||
])
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to undeploy workflow', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -613,7 +599,6 @@ interface ActivateVersionResult {
|
||||
*/
|
||||
export function useActivateDeploymentVersion() {
|
||||
const queryClient = useQueryClient()
|
||||
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
@@ -663,20 +648,13 @@ export function useActivateDeploymentVersion() {
|
||||
)
|
||||
}
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
logger.info('Deployment version activated', {
|
||||
workflowId: variables.workflowId,
|
||||
version: variables.version,
|
||||
})
|
||||
|
||||
setDeploymentStatus(
|
||||
variables.workflowId,
|
||||
true,
|
||||
data.deployedAt ? new Date(data.deployedAt) : undefined,
|
||||
data.apiKey
|
||||
)
|
||||
},
|
||||
onSettled: (_, __, variables) => {
|
||||
onSettled: (_data, error, variables) => {
|
||||
if (!error) {
|
||||
logger.info('Deployment version activated', {
|
||||
workflowId: variables.workflowId,
|
||||
version: variables.version,
|
||||
})
|
||||
}
|
||||
return invalidateDeploymentQueries(queryClient, variables.workflowId)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { WorkspaceEnvironmentData } from '@/lib/environment/api'
|
||||
@@ -6,7 +5,6 @@ import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/envir
|
||||
import { workspaceCredentialKeys } from '@/hooks/queries/credentials'
|
||||
import { API_ENDPOINTS } from '@/stores/constants'
|
||||
import type { EnvironmentVariable } from '@/stores/settings/environment'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
|
||||
export type { WorkspaceEnvironmentData } from '@/lib/environment/api'
|
||||
export type { EnvironmentVariable } from '@/stores/settings/environment'
|
||||
@@ -22,29 +20,16 @@ export const environmentKeys = {
|
||||
workspace: (workspaceId: string) => [...environmentKeys.all, 'workspace', workspaceId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment Variable Types
|
||||
*/
|
||||
/**
|
||||
* Hook to fetch personal environment variables
|
||||
*/
|
||||
export function usePersonalEnvironment() {
|
||||
const setVariables = useEnvironmentStore((state) => state.setVariables)
|
||||
|
||||
const query = useQuery({
|
||||
return useQuery({
|
||||
queryKey: environmentKeys.personal(),
|
||||
queryFn: ({ signal }) => fetchPersonalEnvironment(signal),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
setVariables(query.data)
|
||||
}
|
||||
}, [query.data, setVariables])
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getQueryDataMock } = vi.hoisted(() => ({
|
||||
getQueryDataMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/_shell/providers/get-query-client', () => ({
|
||||
getQueryClient: vi.fn(() => ({
|
||||
getQueryData: getQueryDataMock,
|
||||
})),
|
||||
}))
|
||||
|
||||
import { getCustomTool, getCustomTools } from '@/hooks/queries/utils/custom-tool-cache'
|
||||
|
||||
describe('custom tool cache helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('reads workspace-scoped custom tools from the cache', () => {
|
||||
const tools = [{ id: 'tool-1', title: 'Weather', schema: {}, code: '', workspaceId: 'ws-1' }]
|
||||
getQueryDataMock.mockReturnValue(tools)
|
||||
|
||||
expect(getCustomTools('ws-1')).toBe(tools)
|
||||
expect(getQueryDataMock).toHaveBeenCalledWith(['customTools', 'list', 'ws-1'])
|
||||
})
|
||||
|
||||
it('resolves custom tools by id or title', () => {
|
||||
getQueryDataMock.mockReturnValue([
|
||||
{ id: 'tool-1', title: 'Weather', schema: {}, code: '', workspaceId: 'ws-1' },
|
||||
])
|
||||
|
||||
expect(getCustomTool('tool-1', 'ws-1')?.title).toBe('Weather')
|
||||
expect(getCustomTool('Weather', 'ws-1')?.id).toBe('tool-1')
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import type { CustomToolDefinition } from '@/hooks/queries/custom-tools'
|
||||
import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys'
|
||||
|
||||
/**
|
||||
* Reads custom tools for a workspace directly from the React Query cache.
|
||||
*/
|
||||
export function getCustomTools(workspaceId: string): CustomToolDefinition[] {
|
||||
return (
|
||||
getQueryClient().getQueryData<CustomToolDefinition[]>(customToolsKeys.list(workspaceId)) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a custom tool from the cache by id or title.
|
||||
*/
|
||||
export function getCustomTool(
|
||||
identifier: string,
|
||||
workspaceId: string
|
||||
): CustomToolDefinition | undefined {
|
||||
const tools = getCustomTools(workspaceId)
|
||||
return tools.find((tool) => tool.id === identifier || tool.title === identifier)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
|
||||
import { usePersonalEnvironment } from '@/hooks/queries/environment'
|
||||
import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry'
|
||||
import type { SelectorKey, SelectorOption, SelectorQueryArgs } from '@/hooks/selectors/types'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
|
||||
interface SelectorHookArgs extends Omit<SelectorQueryArgs, 'key'> {
|
||||
search?: string
|
||||
@@ -31,7 +31,7 @@ export function useSelectorOptionDetail(
|
||||
key: SelectorKey,
|
||||
args: SelectorHookArgs & { detailId?: string }
|
||||
) {
|
||||
const envVariables = useEnvironmentStore((s) => s.variables)
|
||||
const { data: envVariables = {} } = usePersonalEnvironment()
|
||||
const definition = getSelectorDefinition(key)
|
||||
|
||||
const resolvedDetailId = useMemo(() => {
|
||||
|
||||
@@ -19,8 +19,9 @@ import {
|
||||
} from '@/socket/constants'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store'
|
||||
import { usePanelEditorStore, useVariablesStore } from '@/stores/panel'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { useCodeUndoRedoStore, useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -122,6 +123,7 @@ export function useCollaborativeWorkflow() {
|
||||
onVariableUpdate,
|
||||
onWorkflowDeleted,
|
||||
onWorkflowReverted,
|
||||
onWorkflowUpdated,
|
||||
onOperationConfirmed,
|
||||
onOperationFailed,
|
||||
} = useSocket()
|
||||
@@ -536,82 +538,99 @@ export function useCollaborativeWorkflow() {
|
||||
}
|
||||
}
|
||||
|
||||
const reloadWorkflowFromApi = async (workflowId: string, reason: string): Promise<boolean> => {
|
||||
const response = await fetch(`/api/workflows/${workflowId}`)
|
||||
if (!response.ok) {
|
||||
logger.error(`Failed to fetch workflow data after ${reason}: ${response.statusText}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const responseData = await response.json()
|
||||
const workflowData = responseData.data
|
||||
|
||||
if (!workflowData?.state) {
|
||||
logger.error(`No state found in workflow data after ${reason}`, { workflowData })
|
||||
return false
|
||||
}
|
||||
|
||||
isApplyingRemoteChange.current = true
|
||||
try {
|
||||
useWorkflowStore.getState().replaceWorkflowState({
|
||||
blocks: workflowData.state.blocks || {},
|
||||
edges: workflowData.state.edges || [],
|
||||
loops: workflowData.state.loops || {},
|
||||
parallels: workflowData.state.parallels || {},
|
||||
lastSaved: workflowData.state.lastSaved || Date.now(),
|
||||
})
|
||||
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(workflowData.state.blocks || {}).forEach(([blockId, block]) => {
|
||||
const blockState = block as any
|
||||
subblockValues[blockId] = {}
|
||||
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
|
||||
subblockValues[blockId][subblockId] = (subblock as any).value
|
||||
})
|
||||
})
|
||||
|
||||
useSubBlockStore.setState((state: any) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[workflowId]: subblockValues,
|
||||
},
|
||||
}))
|
||||
|
||||
const graph = {
|
||||
blocksById: workflowData.state.blocks || {},
|
||||
edgesById: Object.fromEntries(
|
||||
(workflowData.state.edges || []).map((e: any) => [e.id, e])
|
||||
),
|
||||
}
|
||||
|
||||
const undoRedoStore = useUndoRedoStore.getState()
|
||||
const stackKeys = Object.keys(undoRedoStore.stacks)
|
||||
stackKeys.forEach((key) => {
|
||||
const [wfId, userId] = key.split(':')
|
||||
if (wfId === workflowId) {
|
||||
undoRedoStore.pruneInvalidEntries(wfId, userId, graph)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`Successfully reloaded workflow state after ${reason}`, { workflowId })
|
||||
return true
|
||||
} finally {
|
||||
isApplyingRemoteChange.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleWorkflowReverted = async (data: any) => {
|
||||
const { workflowId } = data
|
||||
logger.info(`Workflow ${workflowId} has been reverted to deployed state`)
|
||||
|
||||
// If the reverted workflow is the currently active one, reload the workflow state
|
||||
if (activeWorkflowId === workflowId) {
|
||||
logger.info(`Currently active workflow ${workflowId} was reverted, reloading state`)
|
||||
if (activeWorkflowId !== workflowId) return
|
||||
|
||||
try {
|
||||
// Fetch the updated workflow state from the server (which loads from normalized tables)
|
||||
const response = await fetch(`/api/workflows/${workflowId}`)
|
||||
if (response.ok) {
|
||||
const responseData = await response.json()
|
||||
const workflowData = responseData.data
|
||||
try {
|
||||
await reloadWorkflowFromApi(workflowId, 'revert')
|
||||
} catch (error) {
|
||||
logger.error('Error reloading workflow state after revert:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowData?.state) {
|
||||
// Update the workflow store with the reverted state
|
||||
isApplyingRemoteChange.current = true
|
||||
try {
|
||||
// Update the main workflow state using the API response
|
||||
useWorkflowStore.getState().replaceWorkflowState({
|
||||
blocks: workflowData.state.blocks || {},
|
||||
edges: workflowData.state.edges || [],
|
||||
loops: workflowData.state.loops || {},
|
||||
parallels: workflowData.state.parallels || {},
|
||||
lastSaved: workflowData.state.lastSaved || Date.now(),
|
||||
deploymentStatuses: workflowData.state.deploymentStatuses || {},
|
||||
})
|
||||
const handleWorkflowUpdated = async (data: any) => {
|
||||
const { workflowId } = data
|
||||
logger.info(`Workflow ${workflowId} has been updated externally`)
|
||||
|
||||
// Update subblock store with reverted values
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(workflowData.state.blocks || {}).forEach(([blockId, block]) => {
|
||||
const blockState = block as any
|
||||
subblockValues[blockId] = {}
|
||||
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
|
||||
subblockValues[blockId][subblockId] = (subblock as any).value
|
||||
})
|
||||
})
|
||||
if (activeWorkflowId !== workflowId) return
|
||||
|
||||
// Update subblock store for this workflow
|
||||
useSubBlockStore.setState((state: any) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[workflowId]: subblockValues,
|
||||
},
|
||||
}))
|
||||
const { hasActiveDiff } = useWorkflowDiffStore.getState()
|
||||
if (hasActiveDiff) {
|
||||
logger.info('Skipping workflow-updated: active diff in progress', { workflowId })
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Successfully loaded reverted workflow state for ${workflowId}`)
|
||||
|
||||
const graph = {
|
||||
blocksById: workflowData.state.blocks || {},
|
||||
edgesById: Object.fromEntries(
|
||||
(workflowData.state.edges || []).map((e: any) => [e.id, e])
|
||||
),
|
||||
}
|
||||
|
||||
const undoRedoStore = useUndoRedoStore.getState()
|
||||
const stackKeys = Object.keys(undoRedoStore.stacks)
|
||||
stackKeys.forEach((key) => {
|
||||
const [wfId, userId] = key.split(':')
|
||||
if (wfId === workflowId) {
|
||||
undoRedoStore.pruneInvalidEntries(wfId, userId, graph)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
isApplyingRemoteChange.current = false
|
||||
}
|
||||
} else {
|
||||
logger.error('No state found in workflow data after revert', { workflowData })
|
||||
}
|
||||
} else {
|
||||
logger.error(`Failed to fetch workflow data after revert: ${response.statusText}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error reloading workflow state after revert:', error)
|
||||
}
|
||||
try {
|
||||
await reloadWorkflowFromApi(workflowId, 'external update')
|
||||
} catch (error) {
|
||||
logger.error('Error reloading workflow state after external update:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +652,7 @@ export function useCollaborativeWorkflow() {
|
||||
onVariableUpdate(handleVariableUpdate)
|
||||
onWorkflowDeleted(handleWorkflowDeleted)
|
||||
onWorkflowReverted(handleWorkflowReverted)
|
||||
onWorkflowUpdated(handleWorkflowUpdated)
|
||||
onOperationConfirmed(handleOperationConfirmed)
|
||||
onOperationFailed(handleOperationFailed)
|
||||
}, [
|
||||
@@ -641,6 +661,7 @@ export function useCollaborativeWorkflow() {
|
||||
onVariableUpdate,
|
||||
onWorkflowDeleted,
|
||||
onWorkflowReverted,
|
||||
onWorkflowUpdated,
|
||||
onOperationConfirmed,
|
||||
onOperationFailed,
|
||||
activeWorkflowId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { toast } from '@/components/emcn'
|
||||
import {
|
||||
ADD_CONNECTOR_SEARCH_PARAM,
|
||||
consumeOAuthReturnContext,
|
||||
type OAuthReturnContext,
|
||||
readOAuthReturnContext,
|
||||
@@ -98,7 +99,11 @@ export function useOAuthReturnRouter() {
|
||||
try {
|
||||
sessionStorage.removeItem(SETTINGS_RETURN_URL_KEY)
|
||||
} catch {}
|
||||
router.replace(`/workspace/${workspaceId}/knowledge/${ctx.knowledgeBaseId}`)
|
||||
const kbUrl = `/workspace/${workspaceId}/knowledge/${ctx.knowledgeBaseId}`
|
||||
const connectorParam = ctx.connectorType
|
||||
? `?${ADD_CONNECTOR_SEARCH_PARAM}=${encodeURIComponent(ctx.connectorType)}`
|
||||
: ''
|
||||
router.replace(`${kbUrl}${connectorParam}`)
|
||||
return
|
||||
}
|
||||
}, [router, workspaceId])
|
||||
|
||||
@@ -40,7 +40,11 @@ import {
|
||||
XCircle,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { getCustomTool } from '@/hooks/queries/utils/custom-tool-cache'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import type { CustomToolDefinition } from '@/hooks/queries/custom-tools'
|
||||
import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments'
|
||||
import { deploymentKeys } from '@/hooks/queries/deployments'
|
||||
import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys'
|
||||
import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -445,7 +449,8 @@ const META_deploy_api: ToolMetadata = {
|
||||
|
||||
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
||||
const isAlreadyDeployed = workflowId
|
||||
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
|
||||
? (getQueryClient().getQueryData<WorkflowDeploymentInfo>(deploymentKeys.info(workflowId))
|
||||
?.isDeployed ?? false)
|
||||
: false
|
||||
|
||||
let actionText = action
|
||||
@@ -1053,7 +1058,11 @@ const META_manage_custom_tool: ToolMetadata = {
|
||||
let toolName = params?.schema?.function?.name
|
||||
if (!toolName && params?.toolId && workspaceId) {
|
||||
try {
|
||||
const tool = getCustomTool(params.toolId, workspaceId)
|
||||
const tools =
|
||||
getQueryClient().getQueryData<CustomToolDefinition[]>(
|
||||
customToolsKeys.list(workspaceId)
|
||||
) ?? []
|
||||
const tool = tools.find((t) => t.id === params.toolId || t.title === params.toolId)
|
||||
toolName = tool?.schema?.function?.name
|
||||
} catch {
|
||||
// Ignore errors accessing cache
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type BaseServerTool,
|
||||
type ServerToolContext,
|
||||
} from '@/lib/copilot/tools/server/base-tool'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { applyTargetedLayout, getTargetedLayoutImpact } from '@/lib/workflows/autolayout'
|
||||
import {
|
||||
DEFAULT_HORIZONTAL_SPACING,
|
||||
@@ -287,6 +288,18 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
|
||||
|
||||
logger.info('Workflow state persisted to database', { workflowId })
|
||||
|
||||
const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
|
||||
fetch(`${socketUrl}/api/workflow-updated`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': env.INTERNAL_API_SECRET,
|
||||
},
|
||||
body: JSON.stringify({ workflowId }),
|
||||
}).catch((error) => {
|
||||
logger.warn('Failed to notify socket server of workflow update', { workflowId, error })
|
||||
})
|
||||
|
||||
const sanitizationWarnings = validation.warnings.length > 0 ? validation.warnings : undefined
|
||||
|
||||
return {
|
||||
|
||||
@@ -91,6 +91,8 @@ export function clearPendingCredentialCreateRequest() {
|
||||
window.sessionStorage.removeItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY)
|
||||
}
|
||||
|
||||
export const ADD_CONNECTOR_SEARCH_PARAM = 'addConnector' as const
|
||||
|
||||
const OAUTH_RETURN_CONTEXT_KEY = 'sim.oauth-return-context'
|
||||
|
||||
export type OAuthReturnOrigin = 'workflow' | 'integrations' | 'kb-connectors'
|
||||
@@ -116,6 +118,7 @@ interface OAuthReturnIntegrations extends OAuthReturnBase {
|
||||
interface OAuthReturnKBConnectors extends OAuthReturnBase {
|
||||
origin: 'kb-connectors'
|
||||
knowledgeBaseId: string
|
||||
connectorType?: string
|
||||
}
|
||||
|
||||
export type OAuthReturnContext =
|
||||
|
||||
@@ -2,10 +2,9 @@ import type { Edge } from 'reactflow'
|
||||
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
|
||||
import type { ParentIteration, SerializableExecutionState } from '@/executor/execution/types'
|
||||
import type { BlockLog, NormalizedBlockOutput } from '@/executor/types'
|
||||
import type { DeploymentStatus } from '@/stores/workflows/registry/types'
|
||||
import type { Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export type { WorkflowState, Loop, Parallel, DeploymentStatus }
|
||||
export type { WorkflowState, Loop, Parallel }
|
||||
export type WorkflowEdge = Edge
|
||||
export type { NormalizedBlockOutput, BlockLog }
|
||||
|
||||
|
||||
@@ -123,8 +123,6 @@ export function buildDefaultWorkflowArtifacts(): DefaultWorkflowArtifacts {
|
||||
loops: {},
|
||||
parallels: {},
|
||||
lastSaved: Date.now(),
|
||||
deploymentStatuses: {},
|
||||
needsRedeployment: false,
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -123,6 +123,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
|
||||
requestId: 'req-1',
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'user-1',
|
||||
workflowUserId: 'workflow-owner',
|
||||
workspaceId: 'workspace-1',
|
||||
triggerType: 'api',
|
||||
executionId: 'execution-1',
|
||||
@@ -755,4 +756,92 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
|
||||
expect(safeCompleteWithErrorMock).not.toHaveBeenCalled()
|
||||
expect(wasExecutionFinalizedByCore(envError, 'execution-no-log-start')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses sessionUserId for env resolution when isClientSession is true', async () => {
|
||||
const snapshot = {
|
||||
...createSnapshot(),
|
||||
metadata: {
|
||||
...createSnapshot().metadata,
|
||||
isClientSession: true,
|
||||
sessionUserId: 'session-user',
|
||||
workflowUserId: 'workflow-owner',
|
||||
},
|
||||
}
|
||||
|
||||
getPersonalAndWorkspaceEnvMock.mockResolvedValue({
|
||||
personalEncrypted: {},
|
||||
workspaceEncrypted: {},
|
||||
personalDecrypted: {},
|
||||
workspaceDecrypted: {},
|
||||
})
|
||||
safeStartMock.mockResolvedValue(true)
|
||||
executorExecuteMock.mockResolvedValue({
|
||||
output: { done: true },
|
||||
logs: [],
|
||||
metadata: { duration: 123, startTime: 'start', endTime: 'end' },
|
||||
})
|
||||
|
||||
await executeWorkflowCore({
|
||||
snapshot: snapshot as any,
|
||||
callbacks: {},
|
||||
loggingSession: loggingSession as any,
|
||||
})
|
||||
|
||||
expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('session-user', 'workspace-1')
|
||||
})
|
||||
|
||||
it('uses workflowUserId for env resolution in server-side execution', async () => {
|
||||
const snapshot = {
|
||||
...createSnapshot(),
|
||||
metadata: {
|
||||
...createSnapshot().metadata,
|
||||
isClientSession: false,
|
||||
sessionUserId: undefined,
|
||||
workflowUserId: 'workflow-owner',
|
||||
userId: 'billing-actor',
|
||||
},
|
||||
}
|
||||
|
||||
getPersonalAndWorkspaceEnvMock.mockResolvedValue({
|
||||
personalEncrypted: {},
|
||||
workspaceEncrypted: {},
|
||||
personalDecrypted: {},
|
||||
workspaceDecrypted: {},
|
||||
})
|
||||
safeStartMock.mockResolvedValue(true)
|
||||
executorExecuteMock.mockResolvedValue({
|
||||
output: { done: true },
|
||||
logs: [],
|
||||
metadata: { duration: 123, startTime: 'start', endTime: 'end' },
|
||||
})
|
||||
|
||||
await executeWorkflowCore({
|
||||
snapshot: snapshot as any,
|
||||
callbacks: {},
|
||||
loggingSession: loggingSession as any,
|
||||
})
|
||||
|
||||
expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('workflow-owner', 'workspace-1')
|
||||
})
|
||||
|
||||
it('throws when workflowUserId is missing in server-side execution', async () => {
|
||||
const snapshot = {
|
||||
...createSnapshot(),
|
||||
metadata: {
|
||||
...createSnapshot().metadata,
|
||||
isClientSession: false,
|
||||
sessionUserId: undefined,
|
||||
workflowUserId: undefined,
|
||||
userId: 'billing-actor',
|
||||
},
|
||||
}
|
||||
|
||||
await expect(
|
||||
executeWorkflowCore({
|
||||
snapshot: snapshot as any,
|
||||
callbacks: {},
|
||||
loggingSession: loggingSession as any,
|
||||
})
|
||||
).rejects.toThrow('Missing workflowUserId in execution metadata')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -325,10 +325,13 @@ export async function executeWorkflowCore(
|
||||
|
||||
const mergedStates = mergeSubblockStateWithValues(blocks)
|
||||
|
||||
const personalEnvUserId = metadata.sessionUserId || metadata.userId
|
||||
const personalEnvUserId =
|
||||
metadata.isClientSession && metadata.sessionUserId
|
||||
? metadata.sessionUserId
|
||||
: metadata.workflowUserId
|
||||
|
||||
if (!personalEnvUserId) {
|
||||
throw new Error('Missing execution actor for environment resolution')
|
||||
throw new Error('Missing workflowUserId in execution metadata')
|
||||
}
|
||||
|
||||
const { personalEncrypted, workspaceEncrypted, personalDecrypted, workspaceDecrypted } =
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
deduplicateWorkflowName,
|
||||
} from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateHelper')
|
||||
|
||||
@@ -985,7 +985,6 @@ describe('Database Helpers', () => {
|
||||
edges: loadedState!.edges,
|
||||
loops: {},
|
||||
parallels: {},
|
||||
deploymentStatuses: {},
|
||||
}
|
||||
|
||||
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { VariableType } from '@/stores/panel/variables/types'
|
||||
import type { VariableType } from '@/stores/variables/types'
|
||||
|
||||
/**
|
||||
* Central manager for handling all variable-related operations.
|
||||
|
||||
@@ -271,6 +271,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -290,6 +291,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -324,6 +326,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -342,6 +345,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -360,6 +364,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -373,6 +378,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -449,6 +455,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
reasoningEffort: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 100000,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
},
|
||||
@@ -463,6 +470,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -509,7 +517,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
nativeStructuredOutputs: true,
|
||||
maxOutputTokens: 128000,
|
||||
maxOutputTokens: 64000,
|
||||
thinking: {
|
||||
levels: ['low', 'medium', 'high', 'max'],
|
||||
default: 'high',
|
||||
@@ -741,6 +749,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -759,6 +768,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -777,6 +787,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -795,6 +806,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -813,6 +825,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -831,6 +844,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -1067,6 +1081,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
levels: ['minimal', 'low', 'medium', 'high'],
|
||||
default: 'high',
|
||||
},
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1084,6 +1099,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
levels: ['minimal', 'low', 'medium', 'high'],
|
||||
default: 'minimal',
|
||||
},
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1101,6 +1117,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
levels: ['minimal', 'low', 'medium', 'high'],
|
||||
default: 'high',
|
||||
},
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
@@ -1114,6 +1131,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1127,6 +1145,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1140,6 +1159,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1153,6 +1173,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1165,6 +1186,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1178,6 +1200,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
capabilities: {
|
||||
deepResearch: true,
|
||||
memory: false,
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
@@ -2094,6 +2117,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
nativeStructuredOutputs: true,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
},
|
||||
@@ -2107,6 +2131,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
nativeStructuredOutputs: true,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
},
|
||||
@@ -2120,6 +2145,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
nativeStructuredOutputs: true,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
},
|
||||
@@ -2133,6 +2159,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
nativeStructuredOutputs: true,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
},
|
||||
@@ -2337,6 +2364,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
maxOutputTokens: 32768,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -2373,6 +2401,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -2385,6 +2414,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
maxOutputTokens: 40000,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -2397,6 +2427,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -2409,6 +2440,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -2421,6 +2453,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -2863,13 +2896,17 @@ export function getModelsWithoutMemory(): string[] {
|
||||
export function getMaxOutputTokensForModel(modelId: string): number {
|
||||
const normalizedModelId = modelId.toLowerCase()
|
||||
const STANDARD_MAX_OUTPUT_TOKENS = 4096
|
||||
const allModels = Object.values(PROVIDER_DEFINITIONS).flatMap((provider) => provider.models)
|
||||
|
||||
for (const provider of Object.values(PROVIDER_DEFINITIONS)) {
|
||||
for (const model of provider.models) {
|
||||
const baseModelId = model.id.toLowerCase()
|
||||
if (normalizedModelId === baseModelId || normalizedModelId.startsWith(`${baseModelId}-`)) {
|
||||
return model.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
const exactMatch = allModels.find((model) => model.id.toLowerCase() === normalizedModelId)
|
||||
if (exactMatch) {
|
||||
return exactMatch.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
|
||||
for (const model of allModels) {
|
||||
const baseModelId = model.id.toLowerCase()
|
||||
if (normalizedModelId.startsWith(`${baseModelId}-`)) {
|
||||
return model.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -664,6 +664,45 @@ describe('Model Capabilities', () => {
|
||||
|
||||
describe('Max Output Tokens', () => {
|
||||
describe('getMaxOutputTokensForModel', () => {
|
||||
it.concurrent('should return published max for OpenAI GPT-4o', () => {
|
||||
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(16384)
|
||||
})
|
||||
|
||||
it.concurrent('should return published max for OpenAI GPT-5.1', () => {
|
||||
expect(getMaxOutputTokensForModel('gpt-5.1')).toBe(128000)
|
||||
})
|
||||
|
||||
it.concurrent('should return published max for OpenAI GPT-5 Chat', () => {
|
||||
expect(getMaxOutputTokensForModel('gpt-5-chat-latest')).toBe(16384)
|
||||
})
|
||||
|
||||
it.concurrent('should return published max for OpenAI o1', () => {
|
||||
expect(getMaxOutputTokensForModel('o1')).toBe(100000)
|
||||
})
|
||||
|
||||
it.concurrent('should return updated max for Claude Sonnet 4.6', () => {
|
||||
expect(getMaxOutputTokensForModel('claude-sonnet-4-6')).toBe(64000)
|
||||
})
|
||||
|
||||
it.concurrent('should return published max for Gemini 2.5 Pro', () => {
|
||||
expect(getMaxOutputTokensForModel('gemini-2.5-pro')).toBe(65536)
|
||||
})
|
||||
|
||||
it.concurrent('should return published max for Azure GPT-5.2', () => {
|
||||
expect(getMaxOutputTokensForModel('azure/gpt-5.2')).toBe(128000)
|
||||
})
|
||||
|
||||
it.concurrent('should return standard default for models without maxOutputTokens', () => {
|
||||
expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(4096)
|
||||
expect(getMaxOutputTokensForModel('grok-4-latest')).toBe(4096)
|
||||
})
|
||||
|
||||
it.concurrent('should return published max for Bedrock Claude Opus 4.1', () => {
|
||||
expect(getMaxOutputTokensForModel('bedrock/anthropic.claude-opus-4-1-20250805-v1:0')).toBe(
|
||||
64000
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should return correct max for Claude Opus 4.6', () => {
|
||||
expect(getMaxOutputTokensForModel('claude-opus-4-6')).toBe(128000)
|
||||
})
|
||||
@@ -676,10 +715,6 @@ describe('Max Output Tokens', () => {
|
||||
expect(getMaxOutputTokensForModel('claude-opus-4-1')).toBe(32000)
|
||||
})
|
||||
|
||||
it.concurrent('should return standard default for models without maxOutputTokens', () => {
|
||||
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(4096)
|
||||
})
|
||||
|
||||
it.concurrent('should return standard default for unknown models', () => {
|
||||
expect(getMaxOutputTokensForModel('unknown-model')).toBe(4096)
|
||||
})
|
||||
|
||||
@@ -186,7 +186,6 @@ export async function getWorkflowState(workflowId: string) {
|
||||
|
||||
if (normalizedData) {
|
||||
const finalState = {
|
||||
deploymentStatuses: {},
|
||||
hasActiveWebhook: false,
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { environmentKeys } from '@/hooks/queries/environment'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useVariablesStore } from '@/stores/panel'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -12,198 +11,13 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('Stores')
|
||||
|
||||
// Track initialization state
|
||||
let isInitializing = false
|
||||
let appFullyInitialized = false
|
||||
let dataInitialized = false // Flag for actual data loading completion
|
||||
|
||||
/**
|
||||
* Initialize the application state and sync system
|
||||
* localStorage persistence has been removed - relies on DB and Zustand stores only
|
||||
* Reset all Zustand stores and React Query caches to initial state.
|
||||
*/
|
||||
async function initializeApplication(): Promise<void> {
|
||||
if (typeof window === 'undefined' || isInitializing) return
|
||||
|
||||
isInitializing = true
|
||||
appFullyInitialized = false
|
||||
|
||||
// Track initialization start time
|
||||
const initStartTime = Date.now()
|
||||
|
||||
try {
|
||||
// Load environment variables directly from DB
|
||||
await useEnvironmentStore.getState().loadEnvironmentVariables()
|
||||
|
||||
// Mark data as initialized only after sync managers have loaded data from DB
|
||||
dataInitialized = true
|
||||
|
||||
// Log initialization timing information
|
||||
const initDuration = Date.now() - initStartTime
|
||||
logger.info(`Application initialization completed in ${initDuration}ms`)
|
||||
|
||||
// Mark application as fully initialized
|
||||
appFullyInitialized = true
|
||||
} catch (error) {
|
||||
logger.error('Error during application initialization:', { error })
|
||||
// Still mark as initialized to prevent being stuck in initializing state
|
||||
appFullyInitialized = true
|
||||
// But don't mark data as initialized on error
|
||||
dataInitialized = false
|
||||
} finally {
|
||||
isInitializing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if application is fully initialized
|
||||
*/
|
||||
export function isAppInitialized(): boolean {
|
||||
return appFullyInitialized
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if data has been loaded from the database
|
||||
* This should be checked before any sync operations
|
||||
*/
|
||||
export function isDataInitialized(): boolean {
|
||||
return dataInitialized
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle application cleanup before unload
|
||||
*/
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent): void {
|
||||
// Check if we're on an authentication page and skip confirmation if we are
|
||||
if (typeof window !== 'undefined') {
|
||||
const path = window.location.pathname
|
||||
// Skip confirmation for auth-related pages
|
||||
if (
|
||||
path === '/login' ||
|
||||
path === '/signup' ||
|
||||
path === '/reset-password' ||
|
||||
path === '/verify'
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Standard beforeunload pattern
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up sync system
|
||||
*/
|
||||
function cleanupApplication(): void {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
// Note: No sync managers to dispose - Socket.IO handles cleanup
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all user data when signing out
|
||||
* localStorage persistence has been removed
|
||||
*/
|
||||
export async function clearUserData(): Promise<void> {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
// Note: No sync managers to dispose - Socket.IO handles cleanup
|
||||
|
||||
// Reset all stores to their initial state
|
||||
resetAllStores()
|
||||
|
||||
// Clear localStorage except for essential app settings (minimal usage)
|
||||
const keysToKeep = ['next-favicon', 'theme']
|
||||
const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key))
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key))
|
||||
|
||||
// Reset application initialization state
|
||||
appFullyInitialized = false
|
||||
dataInitialized = false
|
||||
|
||||
logger.info('User data cleared successfully')
|
||||
} catch (error) {
|
||||
logger.error('Error clearing user data:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage application lifecycle
|
||||
*/
|
||||
export function useAppInitialization() {
|
||||
useEffect(() => {
|
||||
// Use Promise to handle async initialization
|
||||
initializeApplication()
|
||||
|
||||
return () => {
|
||||
cleanupApplication()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to reinitialize the application after successful login
|
||||
* Use this in the login success handler or post-login page
|
||||
*/
|
||||
export function useLoginInitialization() {
|
||||
useEffect(() => {
|
||||
reinitializeAfterLogin()
|
||||
}, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize the application after login
|
||||
* This ensures we load fresh data from the database for the new user
|
||||
*/
|
||||
export async function reinitializeAfterLogin(): Promise<void> {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
// Reset application initialization state
|
||||
appFullyInitialized = false
|
||||
dataInitialized = false
|
||||
|
||||
// Note: No sync managers to dispose - Socket.IO handles cleanup
|
||||
|
||||
// Clean existing state to avoid stale data
|
||||
resetAllStores()
|
||||
|
||||
// Reset initialization flags to force a fresh load
|
||||
isInitializing = false
|
||||
|
||||
// Reinitialize the application
|
||||
await initializeApplication()
|
||||
|
||||
logger.info('Application reinitialized after login')
|
||||
} catch (error) {
|
||||
logger.error('Error reinitializing application:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize immediately when imported on client
|
||||
if (typeof window !== 'undefined') {
|
||||
initializeApplication()
|
||||
}
|
||||
|
||||
// Export all stores
|
||||
export {
|
||||
useWorkflowStore,
|
||||
useWorkflowRegistry,
|
||||
useEnvironmentStore,
|
||||
useExecutionStore,
|
||||
useTerminalConsoleStore,
|
||||
useVariablesStore,
|
||||
useSubBlockStore,
|
||||
}
|
||||
|
||||
// Helper function to reset all stores
|
||||
export const resetAllStores = () => {
|
||||
// Reset all stores to initial state
|
||||
useWorkflowRegistry.setState({
|
||||
activeWorkflowId: null,
|
||||
error: null,
|
||||
deploymentStatuses: {},
|
||||
hydration: {
|
||||
phase: 'idle',
|
||||
workspaceId: null,
|
||||
@@ -214,7 +28,7 @@ export const resetAllStores = () => {
|
||||
})
|
||||
useWorkflowStore.getState().clear()
|
||||
useSubBlockStore.getState().clear()
|
||||
useEnvironmentStore.getState().reset()
|
||||
getQueryClient().removeQueries({ queryKey: environmentKeys.all })
|
||||
useExecutionStore.getState().reset()
|
||||
useTerminalConsoleStore.setState({
|
||||
workflowEntries: {},
|
||||
@@ -223,21 +37,24 @@ export const resetAllStores = () => {
|
||||
isOpen: false,
|
||||
})
|
||||
consolePersistence.persist()
|
||||
// Custom tools are managed by React Query cache, not a Zustand store
|
||||
// Variables store has no tracking to reset; registry hydrates
|
||||
}
|
||||
|
||||
// Helper function to log all store states
|
||||
export const logAllStores = () => {
|
||||
const state = {
|
||||
workflow: useWorkflowStore.getState(),
|
||||
workflowRegistry: useWorkflowRegistry.getState(),
|
||||
environment: useEnvironmentStore.getState(),
|
||||
execution: useExecutionStore.getState(),
|
||||
console: useTerminalConsoleStore.getState(),
|
||||
subBlock: useSubBlockStore.getState(),
|
||||
variables: useVariablesStore.getState(),
|
||||
/**
|
||||
* Clear all user data when signing out.
|
||||
*/
|
||||
export async function clearUserData(): Promise<void> {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
resetAllStores()
|
||||
|
||||
// Clear localStorage except for essential app settings
|
||||
const keysToKeep = ['next-favicon', 'theme']
|
||||
const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key))
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key))
|
||||
|
||||
logger.info('User data cleared successfully')
|
||||
} catch (error) {
|
||||
logger.error('Error clearing user data:', { error })
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -9,6 +9,3 @@ export { usePanelStore } from './store'
|
||||
// Toolbar
|
||||
export { useToolbarStore } from './toolbar'
|
||||
export type { ChatContext, PanelState, PanelTab } from './types'
|
||||
export type { Variable, VariablesStore, VariableType } from './variables'
|
||||
// Variables
|
||||
export { useVariablesStore } from './variables'
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useVariablesStore } from './store'
|
||||
export type { Variable, VariablesStore, VariableType } from './types'
|
||||
@@ -1,290 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import JSON5 from 'json5'
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
import type { Variable, VariablesStore } from '@/stores/panel/variables/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
const logger = createLogger('VariablesStore')
|
||||
|
||||
function validateVariable(variable: Variable): string | undefined {
|
||||
try {
|
||||
switch (variable.type) {
|
||||
case 'number':
|
||||
if (Number.isNaN(Number(variable.value))) {
|
||||
return 'Not a valid number'
|
||||
}
|
||||
break
|
||||
case 'boolean':
|
||||
if (!/^(true|false)$/i.test(String(variable.value).trim())) {
|
||||
return 'Expected "true" or "false"'
|
||||
}
|
||||
break
|
||||
case 'object':
|
||||
try {
|
||||
const valueToEvaluate = String(variable.value).trim()
|
||||
|
||||
if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) {
|
||||
return 'Not a valid object format'
|
||||
}
|
||||
|
||||
const parsed = JSON5.parse(valueToEvaluate)
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return 'Not a valid object'
|
||||
}
|
||||
|
||||
return undefined
|
||||
} catch (e) {
|
||||
logger.error('Object parsing error:', e)
|
||||
return 'Invalid object syntax'
|
||||
}
|
||||
case 'array':
|
||||
try {
|
||||
const parsed = JSON5.parse(String(variable.value))
|
||||
if (!Array.isArray(parsed)) {
|
||||
return 'Not a valid array'
|
||||
}
|
||||
} catch {
|
||||
return 'Invalid array syntax'
|
||||
}
|
||||
break
|
||||
}
|
||||
return undefined
|
||||
} catch (e) {
|
||||
return e instanceof Error ? e.message : 'Invalid format'
|
||||
}
|
||||
}
|
||||
|
||||
function migrateStringToPlain(variable: Variable): Variable {
|
||||
if (variable.type !== 'string') {
|
||||
return variable
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...variable,
|
||||
type: 'plain' as const,
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
export const useVariablesStore = create<VariablesStore>()(
|
||||
devtools((set, get) => ({
|
||||
variables: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isEditing: null,
|
||||
|
||||
async loadForWorkflow(workflowId) {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(text || `Failed to load variables: ${res.statusText}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const variables = (data?.data as Record<string, Variable>) || {}
|
||||
set((state) => {
|
||||
const withoutWorkflow = Object.fromEntries(
|
||||
Object.entries(state.variables).filter(
|
||||
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
|
||||
)
|
||||
)
|
||||
return {
|
||||
variables: { ...withoutWorkflow, ...variables },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Unknown error'
|
||||
set({ isLoading: false, error: message })
|
||||
}
|
||||
},
|
||||
|
||||
addVariable: (variable, providedId?: string) => {
|
||||
const id = providedId || crypto.randomUUID()
|
||||
|
||||
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
|
||||
|
||||
if (!variable.name || /^variable\d+$/.test(variable.name)) {
|
||||
const existingNumbers = workflowVariables
|
||||
.map((v) => {
|
||||
const match = v.name.match(/^variable(\d+)$/)
|
||||
return match ? Number.parseInt(match[1]) : 0
|
||||
})
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
|
||||
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
|
||||
|
||||
variable.name = `variable${nextNumber}`
|
||||
}
|
||||
|
||||
let uniqueName = variable.name
|
||||
let nameIndex = 1
|
||||
|
||||
while (workflowVariables.some((v) => v.name === uniqueName)) {
|
||||
uniqueName = `${variable.name} (${nameIndex})`
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
if (variable.type === 'string') {
|
||||
variable.type = 'plain'
|
||||
}
|
||||
|
||||
const newVariable: Variable = {
|
||||
id,
|
||||
workflowId: variable.workflowId,
|
||||
name: uniqueName,
|
||||
type: variable.type,
|
||||
value: variable.value || '',
|
||||
validationError: undefined,
|
||||
}
|
||||
|
||||
const validationError = validateVariable(newVariable)
|
||||
if (validationError) {
|
||||
newVariable.validationError = validationError
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
variables: {
|
||||
...state.variables,
|
||||
[id]: newVariable,
|
||||
},
|
||||
}))
|
||||
|
||||
return id
|
||||
},
|
||||
|
||||
updateVariable: (id, update) => {
|
||||
set((state) => {
|
||||
if (!state.variables[id]) return state
|
||||
|
||||
if (update.name !== undefined) {
|
||||
const oldVariable = state.variables[id]
|
||||
const oldVariableName = oldVariable.name
|
||||
const newName = update.name.trim()
|
||||
|
||||
if (!newName) {
|
||||
update = { ...update }
|
||||
update.name = undefined
|
||||
} else if (newName !== oldVariableName) {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
const targetWorkflowId = oldVariable.workflowId
|
||||
|
||||
if (targetWorkflowId) {
|
||||
const workflowValues = subBlockStore.workflowValues[targetWorkflowId] || {}
|
||||
const updatedWorkflowValues = { ...workflowValues }
|
||||
const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> =
|
||||
[]
|
||||
|
||||
const oldVarName = normalizeName(oldVariableName)
|
||||
const newVarName = normalizeName(newName)
|
||||
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
|
||||
|
||||
const updateReferences = (value: any, pattern: RegExp, replacement: string): any => {
|
||||
if (typeof value === 'string') {
|
||||
return pattern.test(value) ? value.replace(pattern, replacement) : value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => updateReferences(item, pattern, replacement))
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const result = { ...value }
|
||||
for (const key in result) {
|
||||
result[key] = updateReferences(result[key], pattern, replacement)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
|
||||
Object.entries(blockValues as Record<string, any>).forEach(
|
||||
([subBlockId, value]) => {
|
||||
const updatedValue = updateReferences(value, regex, `<variable.${newVarName}>`)
|
||||
|
||||
if (JSON.stringify(updatedValue) !== JSON.stringify(value)) {
|
||||
if (!updatedWorkflowValues[blockId]) {
|
||||
updatedWorkflowValues[blockId] = { ...workflowValues[blockId] }
|
||||
}
|
||||
updatedWorkflowValues[blockId][subBlockId] = updatedValue
|
||||
changedSubBlocks.push({ blockId, subBlockId, value: updatedValue })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Update local state
|
||||
useSubBlockStore.setState({
|
||||
workflowValues: {
|
||||
...subBlockStore.workflowValues,
|
||||
[targetWorkflowId]: updatedWorkflowValues,
|
||||
},
|
||||
})
|
||||
|
||||
// Queue operations for persistence via socket
|
||||
const operationQueue = useOperationQueueStore.getState()
|
||||
|
||||
for (const { blockId, subBlockId, value } of changedSubBlocks) {
|
||||
operationQueue.addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'subblock-update',
|
||||
target: 'subblock',
|
||||
payload: { blockId, subblockId: subBlockId, value },
|
||||
},
|
||||
workflowId: targetWorkflowId,
|
||||
userId: 'system',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (update.type === 'string') {
|
||||
update = { ...update, type: 'plain' }
|
||||
}
|
||||
|
||||
const updatedVariable: Variable = {
|
||||
...state.variables[id],
|
||||
...update,
|
||||
validationError: undefined,
|
||||
}
|
||||
|
||||
if (update.type || update.value !== undefined) {
|
||||
updatedVariable.validationError = validateVariable(updatedVariable)
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...state.variables,
|
||||
[id]: updatedVariable,
|
||||
}
|
||||
|
||||
return { variables: updated }
|
||||
})
|
||||
},
|
||||
|
||||
deleteVariable: (id) => {
|
||||
set((state) => {
|
||||
if (!state.variables[id]) return state
|
||||
|
||||
const workflowId = state.variables[id].workflowId
|
||||
const { [id]: _, ...rest } = state.variables
|
||||
|
||||
return { variables: rest }
|
||||
})
|
||||
},
|
||||
|
||||
getVariablesByWorkflowId: (workflowId) => {
|
||||
return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId)
|
||||
},
|
||||
}))
|
||||
)
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Variable types supported in the application
|
||||
* Note: 'string' is deprecated - use 'plain' for text values instead
|
||||
*/
|
||||
export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string'
|
||||
|
||||
/**
|
||||
* Represents a workflow variable with workflow-specific naming
|
||||
* Variable names must be unique within each workflow
|
||||
*/
|
||||
export interface Variable {
|
||||
id: string
|
||||
workflowId: string
|
||||
name: string // Must be unique per workflow
|
||||
type: VariableType
|
||||
value: unknown
|
||||
validationError?: string // Tracks format validation errors
|
||||
}
|
||||
|
||||
export interface VariablesStore {
|
||||
variables: Record<string, Variable>
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
isEditing: string | null
|
||||
|
||||
/**
|
||||
* Loads variables for a specific workflow from the API and hydrates the store.
|
||||
*/
|
||||
loadForWorkflow: (workflowId: string) => Promise<void>
|
||||
|
||||
/**
|
||||
* Adds a new variable with automatic name uniqueness validation
|
||||
* If a variable with the same name exists, it will be suffixed with a number
|
||||
* Optionally accepts a predetermined ID for collaborative operations
|
||||
*/
|
||||
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
|
||||
|
||||
/**
|
||||
* Updates a variable, ensuring name remains unique within the workflow
|
||||
* If an updated name conflicts with existing ones, a numbered suffix is added
|
||||
*/
|
||||
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
|
||||
|
||||
deleteVariable: (id: string) => void
|
||||
|
||||
/**
|
||||
* Returns all variables for a specific workflow
|
||||
*/
|
||||
getVariablesByWorkflowId: (workflowId: string) => Variable[]
|
||||
}
|
||||
@@ -1,7 +1 @@
|
||||
export { useEnvironmentStore } from './store'
|
||||
export type {
|
||||
CachedWorkspaceEnvData,
|
||||
EnvironmentState,
|
||||
EnvironmentStore,
|
||||
EnvironmentVariable,
|
||||
} from './types'
|
||||
export type { CachedWorkspaceEnvData, EnvironmentState, EnvironmentVariable } from './types'
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { create } from 'zustand'
|
||||
import { fetchPersonalEnvironment } from '@/lib/environment/api'
|
||||
import type { EnvironmentStore, EnvironmentVariable } from './types'
|
||||
|
||||
const logger = createLogger('EnvironmentStore')
|
||||
|
||||
export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
|
||||
variables: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
loadEnvironmentVariables: async () => {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
const data = await fetchPersonalEnvironment()
|
||||
set({ variables: data, isLoading: false })
|
||||
} catch (error) {
|
||||
logger.error('Error loading environment variables:', { error })
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
isLoading: false,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
setVariables: (variables: Record<string, EnvironmentVariable>) => {
|
||||
set({ variables })
|
||||
},
|
||||
|
||||
getAllVariables: () => {
|
||||
return get().variables
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
variables: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
}))
|
||||
@@ -15,10 +15,3 @@ export interface EnvironmentState {
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface EnvironmentStore extends EnvironmentState {
|
||||
loadEnvironmentVariables: () => Promise<void>
|
||||
setVariables: (variables: Record<string, EnvironmentVariable>) => void
|
||||
getAllVariables: () => Record<string, EnvironmentVariable>
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
11
apps/sim/stores/variables/index.ts
Normal file
11
apps/sim/stores/variables/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
getDefaultVariablesDimensions,
|
||||
getVariablesPosition,
|
||||
MAX_VARIABLES_HEIGHT,
|
||||
MAX_VARIABLES_WIDTH,
|
||||
MIN_VARIABLES_HEIGHT,
|
||||
MIN_VARIABLES_WIDTH,
|
||||
useVariablesModalStore,
|
||||
} from './modal'
|
||||
export { useVariablesStore } from './store'
|
||||
export type { Variable, VariablesStore, VariableType } from './types'
|
||||
145
apps/sim/stores/variables/modal.ts
Normal file
145
apps/sim/stores/variables/modal.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import type {
|
||||
VariablesDimensions,
|
||||
VariablesModalStore,
|
||||
VariablesPosition,
|
||||
} from '@/stores/variables/types'
|
||||
|
||||
/**
|
||||
* Floating variables modal default dimensions.
|
||||
* Slightly larger than the chat modal for more comfortable editing.
|
||||
*/
|
||||
const DEFAULT_WIDTH = 320
|
||||
const DEFAULT_HEIGHT = 320
|
||||
|
||||
/**
|
||||
* Minimum and maximum modal dimensions.
|
||||
*/
|
||||
export const MIN_VARIABLES_WIDTH = DEFAULT_WIDTH
|
||||
export const MIN_VARIABLES_HEIGHT = DEFAULT_HEIGHT
|
||||
export const MAX_VARIABLES_WIDTH = 500
|
||||
export const MAX_VARIABLES_HEIGHT = 600
|
||||
|
||||
/** Inset gap between the viewport edge and the content window */
|
||||
const CONTENT_WINDOW_GAP = 8
|
||||
|
||||
/**
|
||||
* Compute a center-biased default position, factoring in current layout chrome
|
||||
* (sidebar, right panel, terminal) and content window inset.
|
||||
*/
|
||||
const calculateDefaultPosition = (): VariablesPosition => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { x: 100, y: 100 }
|
||||
}
|
||||
|
||||
const sidebarWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
|
||||
)
|
||||
const panelWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
|
||||
)
|
||||
const terminalHeight = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
|
||||
)
|
||||
|
||||
const availableWidth = window.innerWidth - sidebarWidth - CONTENT_WINDOW_GAP - panelWidth
|
||||
const availableHeight = window.innerHeight - CONTENT_WINDOW_GAP * 2 - terminalHeight
|
||||
const x = sidebarWidth + (availableWidth - DEFAULT_WIDTH) / 2
|
||||
const y = CONTENT_WINDOW_GAP + (availableHeight - DEFAULT_HEIGHT) / 2
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrain a position to the visible canvas, considering layout chrome.
|
||||
*/
|
||||
const constrainPosition = (
|
||||
position: VariablesPosition,
|
||||
width: number = DEFAULT_WIDTH,
|
||||
height: number = DEFAULT_HEIGHT
|
||||
): VariablesPosition => {
|
||||
if (typeof window === 'undefined') return position
|
||||
|
||||
const sidebarWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
|
||||
)
|
||||
const panelWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
|
||||
)
|
||||
const terminalHeight = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
|
||||
)
|
||||
|
||||
const minX = sidebarWidth
|
||||
const maxX = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - width
|
||||
const minY = CONTENT_WINDOW_GAP
|
||||
const maxY = window.innerHeight - CONTENT_WINDOW_GAP - terminalHeight - height
|
||||
|
||||
return {
|
||||
x: Math.max(minX, Math.min(maxX, position.x)),
|
||||
y: Math.max(minY, Math.min(maxY, position.y)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a valid, constrained position. If the stored one is off-bounds due to
|
||||
* layout changes, prefer a fresh default center position.
|
||||
*/
|
||||
export const getVariablesPosition = (
|
||||
stored: VariablesPosition | null,
|
||||
width: number = DEFAULT_WIDTH,
|
||||
height: number = DEFAULT_HEIGHT
|
||||
): VariablesPosition => {
|
||||
if (!stored) return calculateDefaultPosition()
|
||||
const constrained = constrainPosition(stored, width, height)
|
||||
const deltaX = Math.abs(constrained.x - stored.x)
|
||||
const deltaY = Math.abs(constrained.y - stored.y)
|
||||
if (deltaX > 100 || deltaY > 100) return calculateDefaultPosition()
|
||||
return constrained
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-only store for the floating variables modal.
|
||||
* Variable data lives in the variables data store (`@/stores/variables/store`).
|
||||
*/
|
||||
export const useVariablesModalStore = create<VariablesModalStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
isOpen: false,
|
||||
position: null,
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
|
||||
setIsOpen: (open) => set({ isOpen: open }),
|
||||
setPosition: (position) => set({ position }),
|
||||
setDimensions: (dimensions) =>
|
||||
set({
|
||||
width: Math.max(MIN_VARIABLES_WIDTH, Math.min(MAX_VARIABLES_WIDTH, dimensions.width)),
|
||||
height: Math.max(
|
||||
MIN_VARIABLES_HEIGHT,
|
||||
Math.min(MAX_VARIABLES_HEIGHT, dimensions.height)
|
||||
),
|
||||
}),
|
||||
resetPosition: () => set({ position: null }),
|
||||
}),
|
||||
{
|
||||
name: 'variables-modal-store',
|
||||
partialize: (state) => ({
|
||||
position: state.position,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
}),
|
||||
}
|
||||
),
|
||||
{ name: 'variables-modal-store' }
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Get default floating variables modal dimensions.
|
||||
*/
|
||||
export const getDefaultVariablesDimensions = (): VariablesDimensions => ({
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
})
|
||||
@@ -1,145 +1,47 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import JSON5 from 'json5'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import type {
|
||||
Variable,
|
||||
VariablesDimensions,
|
||||
VariablesPosition,
|
||||
VariablesStore,
|
||||
VariableType,
|
||||
} from '@/stores/variables/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
import type { Variable, VariablesStore } from '@/stores/variables/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
const logger = createLogger('VariablesModalStore')
|
||||
const logger = createLogger('VariablesStore')
|
||||
|
||||
/**
|
||||
* Floating variables modal default dimensions.
|
||||
* Slightly larger than the chat modal for more comfortable editing.
|
||||
*/
|
||||
const DEFAULT_WIDTH = 320
|
||||
const DEFAULT_HEIGHT = 320
|
||||
|
||||
/**
|
||||
* Minimum and maximum modal dimensions.
|
||||
* Kept in sync with the chat modal experience.
|
||||
*/
|
||||
export const MIN_VARIABLES_WIDTH = DEFAULT_WIDTH
|
||||
export const MIN_VARIABLES_HEIGHT = DEFAULT_HEIGHT
|
||||
export const MAX_VARIABLES_WIDTH = 500
|
||||
export const MAX_VARIABLES_HEIGHT = 600
|
||||
|
||||
/** Inset gap between the viewport edge and the content window */
|
||||
const CONTENT_WINDOW_GAP = 8
|
||||
|
||||
/**
|
||||
* Compute a center-biased default position, factoring in current layout chrome
|
||||
* (sidebar, right panel, terminal) and content window inset.
|
||||
*/
|
||||
const calculateDefaultPosition = (): VariablesPosition => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { x: 100, y: 100 }
|
||||
}
|
||||
|
||||
const sidebarWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
|
||||
)
|
||||
const panelWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
|
||||
)
|
||||
const terminalHeight = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
|
||||
)
|
||||
|
||||
const availableWidth = window.innerWidth - sidebarWidth - CONTENT_WINDOW_GAP - panelWidth
|
||||
const availableHeight = window.innerHeight - CONTENT_WINDOW_GAP * 2 - terminalHeight
|
||||
const x = sidebarWidth + (availableWidth - DEFAULT_WIDTH) / 2
|
||||
const y = CONTENT_WINDOW_GAP + (availableHeight - DEFAULT_HEIGHT) / 2
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrain a position to the visible canvas, considering layout chrome.
|
||||
*/
|
||||
const constrainPosition = (
|
||||
position: VariablesPosition,
|
||||
width: number = DEFAULT_WIDTH,
|
||||
height: number = DEFAULT_HEIGHT
|
||||
): VariablesPosition => {
|
||||
if (typeof window === 'undefined') return position
|
||||
|
||||
const sidebarWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
|
||||
)
|
||||
const panelWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
|
||||
)
|
||||
const terminalHeight = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
|
||||
)
|
||||
|
||||
const minX = sidebarWidth
|
||||
const maxX = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - width
|
||||
const minY = CONTENT_WINDOW_GAP
|
||||
const maxY = window.innerHeight - CONTENT_WINDOW_GAP - terminalHeight - height
|
||||
|
||||
return {
|
||||
x: Math.max(minX, Math.min(maxX, position.x)),
|
||||
y: Math.max(minY, Math.min(maxY, position.y)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a valid, constrained position. If the stored one is off-bounds due to
|
||||
* layout changes, prefer a fresh default center position.
|
||||
*/
|
||||
export const getVariablesPosition = (
|
||||
stored: VariablesPosition | null,
|
||||
width: number = DEFAULT_WIDTH,
|
||||
height: number = DEFAULT_HEIGHT
|
||||
): VariablesPosition => {
|
||||
if (!stored) return calculateDefaultPosition()
|
||||
const constrained = constrainPosition(stored, width, height)
|
||||
const deltaX = Math.abs(constrained.x - stored.x)
|
||||
const deltaY = Math.abs(constrained.y - stored.y)
|
||||
if (deltaX > 100 || deltaY > 100) return calculateDefaultPosition()
|
||||
return constrained
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a variable's value given its type. Returns an error message or undefined.
|
||||
*/
|
||||
function validateVariable(variable: Variable): string | undefined {
|
||||
try {
|
||||
switch (variable.type) {
|
||||
case 'number': {
|
||||
return Number.isNaN(Number(variable.value)) ? 'Not a valid number' : undefined
|
||||
}
|
||||
case 'boolean': {
|
||||
return !/^(true|false)$/i.test(String(variable.value).trim())
|
||||
? 'Expected "true" or "false"'
|
||||
: undefined
|
||||
}
|
||||
case 'object': {
|
||||
case 'number':
|
||||
if (Number.isNaN(Number(variable.value))) {
|
||||
return 'Not a valid number'
|
||||
}
|
||||
break
|
||||
case 'boolean':
|
||||
if (!/^(true|false)$/i.test(String(variable.value).trim())) {
|
||||
return 'Expected "true" or "false"'
|
||||
}
|
||||
break
|
||||
case 'object':
|
||||
try {
|
||||
const valueToEvaluate = String(variable.value).trim()
|
||||
|
||||
if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) {
|
||||
return 'Not a valid object format'
|
||||
}
|
||||
|
||||
const parsed = JSON5.parse(valueToEvaluate)
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return 'Not a valid object'
|
||||
}
|
||||
|
||||
return undefined
|
||||
} catch (e) {
|
||||
logger.error('Object parsing error:', e)
|
||||
return 'Invalid object syntax'
|
||||
}
|
||||
}
|
||||
case 'array': {
|
||||
case 'array':
|
||||
try {
|
||||
const parsed = JSON5.parse(String(variable.value))
|
||||
if (!Array.isArray(parsed)) {
|
||||
@@ -148,257 +50,199 @@ function validateVariable(variable: Variable): string | undefined {
|
||||
} catch {
|
||||
return 'Invalid array syntax'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
break
|
||||
}
|
||||
return undefined
|
||||
} catch (e) {
|
||||
return e instanceof Error ? e.message : 'Invalid format'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate deprecated type 'string' -> 'plain'.
|
||||
*/
|
||||
function migrateStringToPlain(variable: Variable): Variable {
|
||||
if (variable.type !== 'string') return variable
|
||||
return { ...variable, type: 'plain' as const }
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating Variables modal + Variables data store.
|
||||
*/
|
||||
export const useVariablesStore = create<VariablesStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// UI
|
||||
isOpen: false,
|
||||
position: null,
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
devtools((set, get) => ({
|
||||
variables: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isEditing: null,
|
||||
|
||||
setIsOpen: (open) => set({ isOpen: open }),
|
||||
setPosition: (position) => set({ position }),
|
||||
setDimensions: (dimensions) =>
|
||||
set({
|
||||
width: Math.max(MIN_VARIABLES_WIDTH, Math.min(MAX_VARIABLES_WIDTH, dimensions.width)),
|
||||
height: Math.max(
|
||||
MIN_VARIABLES_HEIGHT,
|
||||
Math.min(MAX_VARIABLES_HEIGHT, dimensions.height)
|
||||
),
|
||||
}),
|
||||
resetPosition: () => set({ position: null }),
|
||||
addVariable: (variable, providedId?: string) => {
|
||||
const id = providedId || crypto.randomUUID()
|
||||
|
||||
// Data
|
||||
variables: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
|
||||
|
||||
async loadForWorkflow(workflowId) {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(text || `Failed to load variables: ${res.statusText}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const variables = (data?.data as Record<string, Variable>) || {}
|
||||
// Migrate any deprecated types and merge into store (remove other workflow entries)
|
||||
const migrated: Record<string, Variable> = Object.fromEntries(
|
||||
Object.entries(variables).map(([id, v]) => [id, migrateStringToPlain(v)])
|
||||
)
|
||||
set((state) => {
|
||||
const withoutThisWorkflow = Object.fromEntries(
|
||||
Object.entries(state.variables).filter(
|
||||
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
|
||||
)
|
||||
)
|
||||
return {
|
||||
variables: { ...withoutThisWorkflow, ...migrated },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Unknown error'
|
||||
set({ isLoading: false, error: message })
|
||||
}
|
||||
},
|
||||
|
||||
addVariable: (variable, providedId) => {
|
||||
const id = providedId || uuidv4()
|
||||
const state = get()
|
||||
|
||||
const workflowVariables = state
|
||||
.getVariablesByWorkflowId(variable.workflowId)
|
||||
.map((v) => ({ id: v.id, name: v.name }))
|
||||
|
||||
// Default naming: variableN
|
||||
if (!variable.name || /^variable\d+$/.test(variable.name)) {
|
||||
const existingNumbers = workflowVariables
|
||||
.map((v) => {
|
||||
const match = v.name.match(/^variable(\d+)$/)
|
||||
return match ? Number.parseInt(match[1]) : 0
|
||||
})
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
|
||||
variable.name = `variable${nextNumber}`
|
||||
}
|
||||
|
||||
// Ensure uniqueness
|
||||
let uniqueName = variable.name
|
||||
let nameIndex = 1
|
||||
while (workflowVariables.some((v) => v.name === uniqueName)) {
|
||||
uniqueName = `${variable.name} (${nameIndex})`
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
if (variable.type === 'string') {
|
||||
variable.type = 'plain'
|
||||
}
|
||||
|
||||
const newVariable: Variable = {
|
||||
id,
|
||||
workflowId: variable.workflowId,
|
||||
name: uniqueName,
|
||||
type: variable.type,
|
||||
value: variable.value ?? '',
|
||||
validationError: undefined,
|
||||
}
|
||||
|
||||
const validationError = validateVariable(newVariable)
|
||||
if (validationError) {
|
||||
newVariable.validationError = validationError
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
variables: {
|
||||
...state.variables,
|
||||
[id]: newVariable,
|
||||
},
|
||||
}))
|
||||
|
||||
return id
|
||||
},
|
||||
|
||||
updateVariable: (id, update) => {
|
||||
set((state) => {
|
||||
const existing = state.variables[id]
|
||||
if (!existing) return state
|
||||
|
||||
// Handle name changes: keep references in sync across workflow values
|
||||
if (update.name !== undefined) {
|
||||
const oldVariableName = existing.name
|
||||
const newName = String(update.name).trim()
|
||||
|
||||
if (!newName) {
|
||||
update = { ...update, name: undefined }
|
||||
} else if (newName !== oldVariableName) {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
|
||||
if (activeWorkflowId) {
|
||||
const workflowValues = subBlockStore.workflowValues[activeWorkflowId] || {}
|
||||
const updatedWorkflowValues = { ...workflowValues }
|
||||
|
||||
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
|
||||
Object.entries(blockValues as Record<string, any>).forEach(
|
||||
([subBlockId, value]) => {
|
||||
const oldVarName = normalizeName(oldVariableName)
|
||||
const newVarName = normalizeName(newName)
|
||||
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
|
||||
|
||||
updatedWorkflowValues[blockId][subBlockId] = updateReferences(
|
||||
value,
|
||||
regex,
|
||||
`<variable.${newVarName}>`
|
||||
)
|
||||
|
||||
function updateReferences(
|
||||
val: any,
|
||||
refRegex: RegExp,
|
||||
replacement: string
|
||||
): any {
|
||||
if (typeof val === 'string') {
|
||||
return refRegex.test(val) ? val.replace(refRegex, replacement) : val
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
return val.map((item) => updateReferences(item, refRegex, replacement))
|
||||
}
|
||||
if (val !== null && typeof val === 'object') {
|
||||
const result: Record<string, any> = { ...val }
|
||||
for (const key in result) {
|
||||
result[key] = updateReferences(result[key], refRegex, replacement)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return val
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
useSubBlockStore.setState({
|
||||
workflowValues: {
|
||||
...subBlockStore.workflowValues,
|
||||
[activeWorkflowId]: updatedWorkflowValues,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deprecated -> new type migration
|
||||
if (update.type === 'string') {
|
||||
update = { ...update, type: 'plain' as VariableType }
|
||||
}
|
||||
|
||||
const updated: Variable = {
|
||||
...existing,
|
||||
...update,
|
||||
validationError: undefined,
|
||||
}
|
||||
|
||||
// Validate only when type or value changed
|
||||
if (update.type || update.value !== undefined) {
|
||||
updated.validationError = validateVariable(updated)
|
||||
}
|
||||
|
||||
return {
|
||||
variables: {
|
||||
...state.variables,
|
||||
[id]: updated,
|
||||
},
|
||||
}
|
||||
if (!variable.name || /^variable\d+$/.test(variable.name)) {
|
||||
const existingNumbers = workflowVariables
|
||||
.map((v) => {
|
||||
const match = v.name.match(/^variable(\d+)$/)
|
||||
return match ? Number.parseInt(match[1]) : 0
|
||||
})
|
||||
},
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
|
||||
deleteVariable: (id) => {
|
||||
set((state) => {
|
||||
if (!state.variables[id]) return state
|
||||
const { [id]: _deleted, ...rest } = state.variables
|
||||
return { variables: rest }
|
||||
})
|
||||
},
|
||||
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
|
||||
|
||||
getVariablesByWorkflowId: (workflowId) => {
|
||||
return Object.values(get().variables).filter((v) => v.workflowId === workflowId)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'variables-modal-store',
|
||||
variable.name = `variable${nextNumber}`
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Get default floating variables modal dimensions.
|
||||
*/
|
||||
export const getDefaultVariablesDimensions = (): VariablesDimensions => ({
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
})
|
||||
let uniqueName = variable.name
|
||||
let nameIndex = 1
|
||||
|
||||
while (workflowVariables.some((v) => v.name === uniqueName)) {
|
||||
uniqueName = `${variable.name} (${nameIndex})`
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
if (variable.type === 'string') {
|
||||
variable.type = 'plain'
|
||||
}
|
||||
|
||||
const newVariable: Variable = {
|
||||
id,
|
||||
workflowId: variable.workflowId,
|
||||
name: uniqueName,
|
||||
type: variable.type,
|
||||
value: variable.value || '',
|
||||
validationError: undefined,
|
||||
}
|
||||
|
||||
const validationError = validateVariable(newVariable)
|
||||
if (validationError) {
|
||||
newVariable.validationError = validationError
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
variables: {
|
||||
...state.variables,
|
||||
[id]: newVariable,
|
||||
},
|
||||
}))
|
||||
|
||||
return id
|
||||
},
|
||||
|
||||
updateVariable: (id, update) => {
|
||||
set((state) => {
|
||||
if (!state.variables[id]) return state
|
||||
|
||||
if (update.name !== undefined) {
|
||||
const oldVariable = state.variables[id]
|
||||
const oldVariableName = oldVariable.name
|
||||
const newName = update.name.trim()
|
||||
|
||||
if (!newName) {
|
||||
update = { ...update }
|
||||
update.name = undefined
|
||||
} else if (newName !== oldVariableName) {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
const targetWorkflowId = oldVariable.workflowId
|
||||
|
||||
if (targetWorkflowId) {
|
||||
const workflowValues = subBlockStore.workflowValues[targetWorkflowId] || {}
|
||||
const updatedWorkflowValues = { ...workflowValues }
|
||||
const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> =
|
||||
[]
|
||||
|
||||
const oldVarName = normalizeName(oldVariableName)
|
||||
const newVarName = normalizeName(newName)
|
||||
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
|
||||
|
||||
const updateReferences = (value: any, pattern: RegExp, replacement: string): any => {
|
||||
if (typeof value === 'string') {
|
||||
return pattern.test(value) ? value.replace(pattern, replacement) : value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => updateReferences(item, pattern, replacement))
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const result = { ...value }
|
||||
for (const key in result) {
|
||||
result[key] = updateReferences(result[key], pattern, replacement)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
|
||||
Object.entries(blockValues as Record<string, any>).forEach(
|
||||
([subBlockId, value]) => {
|
||||
const updatedValue = updateReferences(value, regex, `<variable.${newVarName}>`)
|
||||
|
||||
if (JSON.stringify(updatedValue) !== JSON.stringify(value)) {
|
||||
if (!updatedWorkflowValues[blockId]) {
|
||||
updatedWorkflowValues[blockId] = { ...workflowValues[blockId] }
|
||||
}
|
||||
updatedWorkflowValues[blockId][subBlockId] = updatedValue
|
||||
changedSubBlocks.push({ blockId, subBlockId, value: updatedValue })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Update local state
|
||||
useSubBlockStore.setState({
|
||||
workflowValues: {
|
||||
...subBlockStore.workflowValues,
|
||||
[targetWorkflowId]: updatedWorkflowValues,
|
||||
},
|
||||
})
|
||||
|
||||
// Queue operations for persistence via socket
|
||||
const operationQueue = useOperationQueueStore.getState()
|
||||
|
||||
for (const { blockId, subBlockId, value } of changedSubBlocks) {
|
||||
operationQueue.addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'subblock-update',
|
||||
target: 'subblock',
|
||||
payload: { blockId, subblockId: subBlockId, value },
|
||||
},
|
||||
workflowId: targetWorkflowId,
|
||||
userId: 'system',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (update.type === 'string') {
|
||||
update = { ...update, type: 'plain' }
|
||||
}
|
||||
|
||||
const updatedVariable: Variable = {
|
||||
...state.variables[id],
|
||||
...update,
|
||||
validationError: undefined,
|
||||
}
|
||||
|
||||
if (update.type || update.value !== undefined) {
|
||||
updatedVariable.validationError = validateVariable(updatedVariable)
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...state.variables,
|
||||
[id]: updatedVariable,
|
||||
}
|
||||
|
||||
return { variables: updated }
|
||||
})
|
||||
},
|
||||
|
||||
deleteVariable: (id) => {
|
||||
set((state) => {
|
||||
if (!state.variables[id]) return state
|
||||
|
||||
const { [id]: _, ...rest } = state.variables
|
||||
|
||||
return { variables: rest }
|
||||
})
|
||||
},
|
||||
|
||||
getVariablesByWorkflowId: (workflowId) => {
|
||||
return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId)
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -1,19 +1,47 @@
|
||||
/**
|
||||
* Variable types supported by the variables modal/editor.
|
||||
* Note: 'string' is deprecated. Use 'plain' for freeform text values instead.
|
||||
* Variable types supported in the application
|
||||
* Note: 'string' is deprecated - use 'plain' for text values instead
|
||||
*/
|
||||
export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string'
|
||||
|
||||
/**
|
||||
* Workflow-scoped variable model.
|
||||
* Represents a workflow variable with workflow-specific naming
|
||||
* Variable names must be unique within each workflow
|
||||
*/
|
||||
export interface Variable {
|
||||
id: string
|
||||
workflowId: string
|
||||
name: string
|
||||
name: string // Must be unique per workflow
|
||||
type: VariableType
|
||||
value: unknown
|
||||
validationError?: string
|
||||
validationError?: string // Tracks format validation errors
|
||||
}
|
||||
|
||||
export interface VariablesStore {
|
||||
variables: Record<string, Variable>
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
isEditing: string | null
|
||||
|
||||
/**
|
||||
* Adds a new variable with automatic name uniqueness validation
|
||||
* If a variable with the same name exists, it will be suffixed with a number
|
||||
* Optionally accepts a predetermined ID for collaborative operations
|
||||
*/
|
||||
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
|
||||
|
||||
/**
|
||||
* Updates a variable, ensuring name remains unique within the workflow
|
||||
* If an updated name conflicts with existing ones, a numbered suffix is added
|
||||
*/
|
||||
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
|
||||
|
||||
deleteVariable: (id: string) => void
|
||||
|
||||
/**
|
||||
* Returns all variables for a specific workflow
|
||||
*/
|
||||
getVariablesByWorkflowId: (workflowId: string) => Variable[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,11 +61,10 @@ export interface VariablesDimensions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Public store interface for variables editor/modal.
|
||||
* Combines UI state of the floating modal and the variables data/actions.
|
||||
* UI-only store interface for the floating variables modal.
|
||||
* Variable data lives in the variables data store (`@/stores/variables/store`).
|
||||
*/
|
||||
export interface VariablesStore {
|
||||
// UI State
|
||||
export interface VariablesModalStore {
|
||||
isOpen: boolean
|
||||
position: VariablesPosition | null
|
||||
width: number
|
||||
@@ -46,16 +73,4 @@ export interface VariablesStore {
|
||||
setPosition: (position: VariablesPosition) => void
|
||||
setDimensions: (dimensions: VariablesDimensions) => void
|
||||
resetPosition: () => void
|
||||
|
||||
// Data
|
||||
variables: Record<string, Variable>
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
loadForWorkflow: (workflowId: string) => Promise<void>
|
||||
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
|
||||
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
|
||||
deleteVariable: (id: string) => void
|
||||
getVariablesByWorkflowId: (workflowId: string) => Variable[]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('Workflows')
|
||||
|
||||
@@ -30,9 +30,6 @@ export function getWorkflowWithValues(workflowId: string, workspaceId: string) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get deployment status from registry
|
||||
const deploymentStatus = useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)
|
||||
|
||||
// Use the current state from the store (only available for active workflow)
|
||||
const workflowState: WorkflowState = useWorkflowStore.getState().getWorkflowState()
|
||||
|
||||
@@ -52,110 +49,10 @@ export function getWorkflowWithValues(workflowId: string, workspaceId: string) {
|
||||
loops: workflowState.loops,
|
||||
parallels: workflowState.parallels,
|
||||
lastSaved: workflowState.lastSaved,
|
||||
// Get deployment fields from registry for API compatibility
|
||||
isDeployed: deploymentStatus?.isDeployed || false,
|
||||
deployedAt: deploymentStatus?.deployedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific block with its subblock values merged in
|
||||
* @param blockId ID of the block to retrieve
|
||||
* @returns The block with merged subblock values or null if not found
|
||||
*/
|
||||
export function getBlockWithValues(blockId: string): BlockState | null {
|
||||
const workflowState = useWorkflowStore.getState()
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
|
||||
if (!activeWorkflowId || !workflowState.blocks[blockId]) return null
|
||||
|
||||
const mergedBlocks = mergeSubblockState(workflowState.blocks, activeWorkflowId, blockId)
|
||||
return mergedBlocks[blockId] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all workflows with their values merged
|
||||
* Note: Since localStorage has been removed, this only includes the active workflow state
|
||||
* @param workspaceId Workspace containing the workflow metadata
|
||||
* @returns An object containing workflows, with state only for the active workflow
|
||||
*/
|
||||
export function getAllWorkflowsWithValues(workspaceId: string) {
|
||||
const workflows = getWorkflows(workspaceId)
|
||||
const result: Record<
|
||||
string,
|
||||
{
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
color: string
|
||||
folderId?: string | null
|
||||
workspaceId?: string
|
||||
apiKey?: string
|
||||
state: WorkflowState & { isDeployed: boolean; deployedAt?: Date }
|
||||
}
|
||||
> = {}
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
const currentState = useWorkflowStore.getState()
|
||||
|
||||
// Only sync the active workflow to ensure we always send valid state data
|
||||
const activeMetadata = activeWorkflowId
|
||||
? workflows.find((w) => w.id === activeWorkflowId)
|
||||
: undefined
|
||||
if (activeWorkflowId && activeMetadata) {
|
||||
const metadata = activeMetadata
|
||||
|
||||
// Get deployment status from registry
|
||||
const deploymentStatus = useWorkflowRegistry
|
||||
.getState()
|
||||
.getWorkflowDeploymentStatus(activeWorkflowId)
|
||||
|
||||
// Ensure state has all required fields for Zod validation
|
||||
const workflowState: WorkflowState = {
|
||||
...useWorkflowStore.getState().getWorkflowState(),
|
||||
// Ensure fallback values for safer handling
|
||||
blocks: currentState.blocks || {},
|
||||
edges: currentState.edges || [],
|
||||
loops: currentState.loops || {},
|
||||
parallels: currentState.parallels || {},
|
||||
lastSaved: currentState.lastSaved || Date.now(),
|
||||
}
|
||||
|
||||
// Merge the subblock values for this specific workflow
|
||||
const mergedBlocks = mergeSubblockState(workflowState.blocks, activeWorkflowId)
|
||||
|
||||
// Include the API key in the state if it exists in the deployment status
|
||||
const apiKey = deploymentStatus?.apiKey
|
||||
|
||||
result[activeWorkflowId] = {
|
||||
id: activeWorkflowId,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
color: metadata.color || '#3972F6',
|
||||
folderId: metadata.folderId,
|
||||
state: {
|
||||
blocks: mergedBlocks,
|
||||
edges: workflowState.edges,
|
||||
loops: workflowState.loops,
|
||||
parallels: workflowState.parallels,
|
||||
lastSaved: workflowState.lastSaved,
|
||||
// Get deployment fields from registry for API compatibility
|
||||
isDeployed: deploymentStatus?.isDeployed || false,
|
||||
deployedAt: deploymentStatus?.deployedAt,
|
||||
},
|
||||
// Include API key if available
|
||||
apiKey,
|
||||
}
|
||||
|
||||
// Only include workspaceId if it's not null/undefined
|
||||
if (metadata.workspaceId) {
|
||||
result[activeWorkflowId].workspaceId = metadata.workspaceId
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
export type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
export { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
@@ -3,14 +3,12 @@ import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments'
|
||||
import { deploymentKeys } from '@/hooks/queries/deployments'
|
||||
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
import type {
|
||||
DeploymentStatus,
|
||||
HydrationState,
|
||||
WorkflowRegistry,
|
||||
} from '@/stores/workflows/registry/types'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import type { HydrationState, WorkflowRegistry } from '@/stores/workflows/registry/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -34,7 +32,6 @@ function resetWorkflowStores() {
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
deploymentStatuses: {},
|
||||
lastSaved: Date.now(),
|
||||
})
|
||||
|
||||
@@ -48,7 +45,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
(set, get) => ({
|
||||
activeWorkflowId: null,
|
||||
error: null,
|
||||
deploymentStatuses: {},
|
||||
hydration: initialHydration,
|
||||
clipboard: null,
|
||||
pendingSelection: null,
|
||||
@@ -61,7 +57,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
|
||||
set({
|
||||
activeWorkflowId: null,
|
||||
deploymentStatuses: {},
|
||||
error: null,
|
||||
hydration: {
|
||||
phase: 'idle',
|
||||
@@ -73,74 +68,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
})
|
||||
},
|
||||
|
||||
getWorkflowDeploymentStatus: (workflowId: string | null): DeploymentStatus | null => {
|
||||
if (!workflowId) {
|
||||
workflowId = get().activeWorkflowId
|
||||
if (!workflowId) return null
|
||||
}
|
||||
|
||||
const { deploymentStatuses = {} } = get()
|
||||
|
||||
if (deploymentStatuses[workflowId]) {
|
||||
return deploymentStatuses[workflowId]
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
setDeploymentStatus: (
|
||||
workflowId: string | null,
|
||||
isDeployed: boolean,
|
||||
deployedAt?: Date,
|
||||
apiKey?: string
|
||||
) => {
|
||||
if (!workflowId) {
|
||||
workflowId = get().activeWorkflowId
|
||||
if (!workflowId) return
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
deploymentStatuses: {
|
||||
...state.deploymentStatuses,
|
||||
[workflowId as string]: {
|
||||
isDeployed,
|
||||
deployedAt: deployedAt || (isDeployed ? new Date() : undefined),
|
||||
apiKey,
|
||||
needsRedeployment: isDeployed
|
||||
? false
|
||||
: (state.deploymentStatuses?.[workflowId as string]?.needsRedeployment ?? false),
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
setWorkflowNeedsRedeployment: (workflowId: string | null, needsRedeployment: boolean) => {
|
||||
if (!workflowId) {
|
||||
workflowId = get().activeWorkflowId
|
||||
if (!workflowId) return
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const deploymentStatuses = state.deploymentStatuses || {}
|
||||
const currentStatus = deploymentStatuses[workflowId as string] || { isDeployed: false }
|
||||
|
||||
return {
|
||||
deploymentStatuses: {
|
||||
...deploymentStatuses,
|
||||
[workflowId as string]: {
|
||||
...currentStatus,
|
||||
needsRedeployment,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const { activeWorkflowId } = get()
|
||||
if (workflowId === activeWorkflowId) {
|
||||
useWorkflowStore.getState().setNeedsRedeploymentFlag(needsRedeployment)
|
||||
}
|
||||
},
|
||||
|
||||
loadWorkflowState: async (workflowId: string) => {
|
||||
const workspaceId = get().hydration.workspaceId
|
||||
if (!workspaceId) {
|
||||
@@ -170,20 +97,19 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
}
|
||||
|
||||
const workflowData = (await response.json()).data
|
||||
const nextDeploymentStatuses =
|
||||
workflowData?.isDeployed || workflowData?.deployedAt
|
||||
? {
|
||||
...get().deploymentStatuses,
|
||||
[workflowId]: {
|
||||
isDeployed: workflowData.isDeployed || false,
|
||||
deployedAt: workflowData.deployedAt
|
||||
? new Date(workflowData.deployedAt)
|
||||
: undefined,
|
||||
apiKey: workflowData.apiKey || undefined,
|
||||
needsRedeployment: false,
|
||||
},
|
||||
}
|
||||
: get().deploymentStatuses
|
||||
|
||||
if (workflowData?.isDeployed !== undefined) {
|
||||
getQueryClient().setQueryData<WorkflowDeploymentInfo>(
|
||||
deploymentKeys.info(workflowId),
|
||||
(prev) => ({
|
||||
isDeployed: workflowData.isDeployed ?? false,
|
||||
deployedAt: workflowData.deployedAt ?? null,
|
||||
apiKey: workflowData.apiKey ?? prev?.apiKey ?? null,
|
||||
needsRedeployment: prev?.needsRedeployment ?? false,
|
||||
isPublicApi: prev?.isPublicApi ?? false,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
let workflowState: WorkflowState
|
||||
|
||||
@@ -195,7 +121,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
loops: workflowData.state.loops || {},
|
||||
parallels: workflowData.state.parallels || {},
|
||||
lastSaved: Date.now(),
|
||||
deploymentStatuses: nextDeploymentStatuses,
|
||||
}
|
||||
} else {
|
||||
workflowState = {
|
||||
@@ -204,7 +129,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
deploymentStatuses: nextDeploymentStatuses,
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
@@ -250,7 +174,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
set((state) => ({
|
||||
activeWorkflowId: workflowId,
|
||||
error: null,
|
||||
deploymentStatuses: nextDeploymentStatuses,
|
||||
hydration: {
|
||||
phase: 'ready',
|
||||
workspaceId: state.hydration.workspaceId,
|
||||
@@ -367,7 +290,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
|
||||
set({
|
||||
activeWorkflowId: null,
|
||||
deploymentStatuses: {},
|
||||
error: null,
|
||||
hydration: initialHydration,
|
||||
clipboard: null,
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export interface DeploymentStatus {
|
||||
isDeployed: boolean
|
||||
deployedAt?: Date
|
||||
apiKey?: string
|
||||
needsRedeployment?: boolean
|
||||
}
|
||||
|
||||
export interface ClipboardData {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
@@ -45,7 +38,6 @@ export interface HydrationState {
|
||||
export interface WorkflowRegistryState {
|
||||
activeWorkflowId: string | null
|
||||
error: string | null
|
||||
deploymentStatuses: Record<string, DeploymentStatus>
|
||||
hydration: HydrationState
|
||||
clipboard: ClipboardData | null
|
||||
pendingSelection: string[] | null
|
||||
@@ -57,14 +49,6 @@ export interface WorkflowRegistryActions {
|
||||
switchToWorkspace: (id: string) => void
|
||||
markWorkflowCreating: (workflowId: string) => void
|
||||
markWorkflowCreated: (workflowId: string | null) => void
|
||||
getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null
|
||||
setDeploymentStatus: (
|
||||
workflowId: string | null,
|
||||
isDeployed: boolean,
|
||||
deployedAt?: Date,
|
||||
apiKey?: string
|
||||
) => void
|
||||
setWorkflowNeedsRedeployment: (workflowId: string | null, needsRedeployment: boolean) => void
|
||||
copyBlocks: (blockIds: string[]) => void
|
||||
preparePasteData: (positionOffset?: { x: number; y: number }) => {
|
||||
blocks: Record<string, BlockState>
|
||||
|
||||
@@ -107,8 +107,6 @@ const initialState = {
|
||||
loops: {},
|
||||
parallels: {},
|
||||
lastSaved: undefined,
|
||||
deploymentStatuses: {},
|
||||
needsRedeployment: false,
|
||||
}
|
||||
|
||||
export const useWorkflowStore = create<WorkflowStore>()(
|
||||
@@ -116,10 +114,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => {
|
||||
set({ needsRedeployment })
|
||||
},
|
||||
|
||||
setCurrentWorkflowId: (currentWorkflowId) => {
|
||||
set({ currentWorkflowId })
|
||||
},
|
||||
@@ -540,8 +534,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
loops: state.loops,
|
||||
parallels: state.parallels,
|
||||
lastSaved: state.lastSaved,
|
||||
deploymentStatuses: state.deploymentStatuses,
|
||||
needsRedeployment: state.needsRedeployment,
|
||||
}
|
||||
},
|
||||
replaceWorkflowState: (
|
||||
@@ -580,11 +572,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
edges: nextEdges,
|
||||
loops: nextLoops,
|
||||
parallels: nextParallels,
|
||||
deploymentStatuses: nextState.deploymentStatuses || state.deploymentStatuses,
|
||||
needsRedeployment:
|
||||
nextState.needsRedeployment !== undefined
|
||||
? nextState.needsRedeployment
|
||||
: state.needsRedeployment,
|
||||
lastSaved:
|
||||
options?.updateLastSaved === true
|
||||
? Date.now()
|
||||
@@ -1141,67 +1128,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
}))
|
||||
},
|
||||
|
||||
revertToDeployedState: async (deployedState: WorkflowState) => {
|
||||
const activeWorkflowId = get().currentWorkflowId
|
||||
|
||||
if (!activeWorkflowId) {
|
||||
logger.error('Cannot revert: no active workflow ID')
|
||||
return
|
||||
}
|
||||
|
||||
const deploymentStatus = get().deploymentStatuses?.[activeWorkflowId]
|
||||
|
||||
get().replaceWorkflowState({
|
||||
...deployedState,
|
||||
needsRedeployment: false,
|
||||
deploymentStatuses: {
|
||||
...get().deploymentStatuses,
|
||||
...(deploymentStatus ? { [activeWorkflowId]: deploymentStatus } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
const values: Record<string, Record<string, any>> = {}
|
||||
Object.entries(deployedState.blocks).forEach(([blockId, block]) => {
|
||||
values[blockId] = {}
|
||||
Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => {
|
||||
values[blockId][subBlockId] = subBlock.value
|
||||
})
|
||||
})
|
||||
|
||||
useSubBlockStore.setState({
|
||||
workflowValues: {
|
||||
...useSubBlockStore.getState().workflowValues,
|
||||
[activeWorkflowId]: values,
|
||||
},
|
||||
})
|
||||
|
||||
get().updateLastSaved()
|
||||
|
||||
// Call API to persist the revert to normalized tables
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workflows/${activeWorkflowId}/deployments/active/revert`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Failed to persist revert to deployed state:', errorData.error)
|
||||
// Don't throw error to avoid breaking the UI, but log it
|
||||
} else {
|
||||
logger.info('Successfully persisted revert to deployed state')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error calling revert to deployed API:', error)
|
||||
// Don't throw error to avoid breaking the UI
|
||||
}
|
||||
},
|
||||
|
||||
toggleBlockAdvancedMode: (id: string) => {
|
||||
const block = get().blocks[id]
|
||||
if (!block) return
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import type { OutputFieldDefinition, SubBlockType } from '@/blocks/types'
|
||||
import type { DeploymentStatus } from '@/stores/workflows/registry/types'
|
||||
|
||||
export const SUBFLOW_TYPES = {
|
||||
LOOP: 'loop',
|
||||
@@ -173,8 +172,6 @@ export interface WorkflowState {
|
||||
exportedAt?: string
|
||||
}
|
||||
variables?: Record<string, Variable>
|
||||
deploymentStatuses?: Record<string, DeploymentStatus>
|
||||
needsRedeployment?: boolean
|
||||
dragStartPosition?: DragStartPosition | null
|
||||
}
|
||||
|
||||
@@ -228,8 +225,6 @@ export interface WorkflowActions {
|
||||
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void
|
||||
generateLoopBlocks: () => Record<string, Loop>
|
||||
generateParallelBlocks: () => Record<string, Parallel>
|
||||
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void
|
||||
revertToDeployedState: (deployedState: WorkflowState) => void
|
||||
toggleBlockAdvancedMode: (id: string) => void
|
||||
setDragStartPosition: (position: DragStartPosition | null) => void
|
||||
getDragStartPosition: () => DragStartPosition | null
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
mockGetCustomToolById,
|
||||
mockListCustomTools,
|
||||
mockGetCustomToolByIdOrTitle,
|
||||
mockGenerateInternalToken,
|
||||
} = vi.hoisted(() => ({
|
||||
mockIsHosted: { value: false },
|
||||
mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record<string, string | undefined>,
|
||||
@@ -38,6 +39,7 @@ const {
|
||||
mockGetCustomToolById: vi.fn(),
|
||||
mockListCustomTools: vi.fn(),
|
||||
mockGetCustomToolByIdOrTitle: vi.fn(),
|
||||
mockGenerateInternalToken: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock feature flags
|
||||
@@ -65,6 +67,10 @@ vi.mock('@/lib/api-key/byok', () => ({
|
||||
getBYOKKey: (...args: unknown[]) => mockGetBYOKKey(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/internal', () => ({
|
||||
generateInternalToken: (...args: unknown[]) => mockGenerateInternalToken(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/usage-log', () => ({}))
|
||||
|
||||
vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({
|
||||
@@ -193,8 +199,8 @@ vi.mock('@/tools/registry', () => {
|
||||
return { tools: mockTools }
|
||||
})
|
||||
|
||||
// Mock custom tools - define mock data inside factory function
|
||||
vi.mock('@/hooks/queries/utils/custom-tool-cache', () => {
|
||||
// Mock query client for custom tool cache reads
|
||||
vi.mock('@/app/_shell/providers/get-query-client', () => {
|
||||
const mockCustomTool = {
|
||||
id: 'custom-tool-123',
|
||||
title: 'Custom Weather Tool',
|
||||
@@ -214,13 +220,12 @@ vi.mock('@/hooks/queries/utils/custom-tool-cache', () => {
|
||||
},
|
||||
}
|
||||
return {
|
||||
getCustomTool: (toolId: string) => {
|
||||
if (toolId === 'custom-tool-123') {
|
||||
return mockCustomTool
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
getCustomTools: () => [mockCustomTool],
|
||||
getQueryClient: () => ({
|
||||
getQueryData: (key: string[]) => {
|
||||
if (key[0] === 'customTools') return [mockCustomTool]
|
||||
return undefined
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1155,6 +1160,34 @@ describe('MCP Tool Execution', () => {
|
||||
expect(result.timing).toBeDefined()
|
||||
})
|
||||
|
||||
it('should embed userId in JWT when executionContext is undefined (agent block path)', async () => {
|
||||
mockGenerateInternalToken.mockResolvedValue('test-token')
|
||||
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
data: { output: { content: [{ type: 'text', text: 'OK' }] } },
|
||||
}),
|
||||
})),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
await executeTool('mcp-123-test_tool', {
|
||||
query: 'test',
|
||||
_context: {
|
||||
workspaceId: 'workspace-456',
|
||||
workflowId: 'workflow-789',
|
||||
userId: 'user-abc',
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-abc')
|
||||
})
|
||||
|
||||
describe('Tool request retries', () => {
|
||||
function makeJsonResponse(
|
||||
status: number,
|
||||
|
||||
@@ -1552,11 +1552,13 @@ async function executeMcpTool(
|
||||
|
||||
const baseUrl = getInternalApiBaseUrl()
|
||||
|
||||
const mcpScope = resolveToolScope(params, executionContext)
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
try {
|
||||
const internalToken = await generateInternalToken(executionContext?.userId)
|
||||
const internalToken = await generateInternalToken(mcpScope.userId)
|
||||
headers.Authorization = `Bearer ${internalToken}`
|
||||
} catch (error) {
|
||||
logger.error(`[${actualRequestId}] Failed to generate internal token:`, error)
|
||||
@@ -1587,8 +1589,6 @@ async function executeMcpTool(
|
||||
)
|
||||
}
|
||||
|
||||
const mcpScope = resolveToolScope(params, executionContext)
|
||||
|
||||
if (mcpScope.callChain && mcpScope.callChain.length > 0) {
|
||||
headers[SIM_VIA_HEADER] = serializeCallChain(mcpScope.callChain)
|
||||
}
|
||||
|
||||
@@ -21,24 +21,23 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({
|
||||
secureFetchWithPinnedIP: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/settings/environment', () => {
|
||||
const mockStore = {
|
||||
getAllVariables: vi.fn().mockReturnValue({
|
||||
API_KEY: { value: 'mock-api-key' },
|
||||
BASE_URL: { value: 'https://example.com' },
|
||||
}),
|
||||
}
|
||||
const { mockGetQueryData } = vi.hoisted(() => ({
|
||||
mockGetQueryData: vi.fn(),
|
||||
}))
|
||||
|
||||
return {
|
||||
useEnvironmentStore: {
|
||||
getState: vi.fn().mockImplementation(() => mockStore),
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/app/_shell/providers/get-query-client', () => ({
|
||||
getQueryClient: () => ({
|
||||
getQueryData: mockGetQueryData,
|
||||
}),
|
||||
}))
|
||||
|
||||
const originalWindow = global.window
|
||||
beforeEach(() => {
|
||||
global.window = {} as any
|
||||
mockGetQueryData.mockReturnValue({
|
||||
API_KEY: { key: 'API_KEY', value: 'mock-api-key' },
|
||||
BASE_URL: { key: 'BASE_URL', value: 'https://example.com' },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -651,15 +650,8 @@ describe('createParamSchema', () => {
|
||||
})
|
||||
|
||||
describe('getClientEnvVars', () => {
|
||||
it.concurrent('should return environment variables from store in browser environment', () => {
|
||||
const mockStoreGetter = () => ({
|
||||
getAllVariables: () => ({
|
||||
API_KEY: { value: 'mock-api-key' },
|
||||
BASE_URL: { value: 'https://example.com' },
|
||||
}),
|
||||
})
|
||||
|
||||
const result = getClientEnvVars(mockStoreGetter)
|
||||
it('should return environment variables from React Query cache in browser environment', () => {
|
||||
const result = getClientEnvVars()
|
||||
|
||||
expect(result).toEqual({
|
||||
API_KEY: 'mock-api-key',
|
||||
@@ -667,7 +659,7 @@ describe('getClientEnvVars', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should return empty object in server environment', () => {
|
||||
it('should return empty object in server environment', () => {
|
||||
global.window = undefined as any
|
||||
|
||||
const result = getClientEnvVars()
|
||||
@@ -677,7 +669,7 @@ describe('getClientEnvVars', () => {
|
||||
})
|
||||
|
||||
describe('createCustomToolRequestBody', () => {
|
||||
it.concurrent('should create request body function for client-side execution', () => {
|
||||
it('should create request body function for client-side execution', () => {
|
||||
const customTool = {
|
||||
code: 'return a + b',
|
||||
schema: {
|
||||
@@ -687,14 +679,7 @@ describe('createCustomToolRequestBody', () => {
|
||||
},
|
||||
}
|
||||
|
||||
const mockStoreGetter = () => ({
|
||||
getAllVariables: () => ({
|
||||
API_KEY: { value: 'mock-api-key' },
|
||||
BASE_URL: { value: 'https://example.com' },
|
||||
}),
|
||||
})
|
||||
|
||||
const bodyFn = createCustomToolRequestBody(customTool, true, undefined, mockStoreGetter)
|
||||
const bodyFn = createCustomToolRequestBody(customTool, true)
|
||||
const result = bodyFn({ a: 5, b: 3 })
|
||||
|
||||
expect(result).toEqual({
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import type { CustomToolDefinition } from '@/hooks/queries/custom-tools'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { environmentKeys } from '@/hooks/queries/environment'
|
||||
import type { EnvironmentVariable } from '@/stores/settings/environment'
|
||||
import { tools } from '@/tools/registry'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
@@ -215,20 +217,20 @@ export function createParamSchema(customTool: any): Record<string, any> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables from store (client-side only)
|
||||
* @param getStore Optional function to get the store (useful for testing)
|
||||
* Get environment variables from React Query cache (client-side only)
|
||||
*/
|
||||
export function getClientEnvVars(getStore?: () => any): Record<string, string> {
|
||||
export function getClientEnvVars(): Record<string, string> {
|
||||
if (typeof window === 'undefined') return {}
|
||||
|
||||
try {
|
||||
// Allow injecting the store for testing
|
||||
const envStore = getStore ? getStore() : useEnvironmentStore.getState()
|
||||
const allEnvVars = envStore.getAllVariables()
|
||||
const allEnvVars =
|
||||
getQueryClient().getQueryData<Record<string, EnvironmentVariable>>(
|
||||
environmentKeys.personal()
|
||||
) ?? {}
|
||||
|
||||
// Convert environment variables to a simple key-value object
|
||||
return Object.entries(allEnvVars).reduce(
|
||||
(acc, [key, variable]: [string, any]) => {
|
||||
(acc, [key, variable]) => {
|
||||
acc[key] = variable.value
|
||||
return acc
|
||||
},
|
||||
@@ -245,20 +247,14 @@ export function getClientEnvVars(getStore?: () => any): Record<string, string> {
|
||||
* @param customTool The custom tool configuration
|
||||
* @param isClient Whether running on client side
|
||||
* @param workflowId Optional workflow ID for server-side
|
||||
* @param getStore Optional function to get the store (useful for testing)
|
||||
*/
|
||||
export function createCustomToolRequestBody(
|
||||
customTool: any,
|
||||
isClient = true,
|
||||
workflowId?: string,
|
||||
getStore?: () => any
|
||||
) {
|
||||
export function createCustomToolRequestBody(customTool: any, isClient = true, workflowId?: string) {
|
||||
return (params: Record<string, any>) => {
|
||||
// Get environment variables - try multiple sources in order of preference:
|
||||
// 1. envVars parameter (passed from provider/agent context)
|
||||
// 2. Client-side store (if running in browser)
|
||||
// 3. Empty object (fallback)
|
||||
const envVars = params.envVars || (isClient ? getClientEnvVars(getStore) : {})
|
||||
const envVars = params.envVars || (isClient ? getClientEnvVars() : {})
|
||||
|
||||
// Get workflow variables from params (passed from execution context)
|
||||
const workflowVariables = params.workflowVariables || {}
|
||||
|
||||
Reference in New Issue
Block a user