Compare commits

...

15 Commits

Author SHA1 Message Date
yyh
2f081fa6fa refactor(skill-editor): adopt 4-generic StateCreator pattern for type-safe cross-slice access
Use explicit StateCreator<FullStore, [], [], SliceType> pattern instead of
StateCreator<SliceType> for all skill-editor slices. This enables:
- Type-safe cross-slice state access via get()
- Explicit type contracts instead of relying on spread args behavior
- Better maintainability following Lobe-chat's proven pattern

Extract all type definitions to types.ts to avoid circular dependencies.
2026-01-18 13:24:34 +08:00
yyh
3b27d9e819 refactor(skill-editor): remove type assertions by using spread args pattern
Replace explicit parameter destructuring with spread args pattern to
eliminate `as unknown as` type assertions when composing sub-slices.
This aligns with the pattern used in the main workflow store.
2026-01-18 13:11:06 +08:00
yyh
c0a76220dd fix(skill-editor): resolve React Compiler memoization warnings
Consolidate file type derivations into a single useMemo with stable
dependencies (currentFileNode?.name and currentFileNode?.extension)
to help React Compiler track stability.

Extract originalContent as a separate variable to avoid property access
in useCallback dependencies, which caused Compiler to infer broader
dependencies than specified.
2026-01-17 22:01:33 +08:00
yyh
9d04fb4992 fix(skill-editor): resolve React Compiler memoization warnings
Wrap isEditable in useMemo to help React Compiler track its stability
and preserve memoization for callbacks that depend on it. Also replace
Record<string, any> with Record<string, unknown> to satisfy no-explicit-any.
2026-01-17 21:51:25 +08:00
yyh
02fcf33067 fix(skill-editor): remove unnecessary store subscriptions in tool-picker-block
Move activeTabId and fileMetadata reads from selector subscriptions to
getState() calls inside the callback. These values were only used in the
insertTools callback, not for rendering, causing unnecessary re-renders
when they changed.
2026-01-17 21:47:31 +08:00
yyh
bbf1247f80 fix(skill-editor): compare content with original to determine dirty state
Previously, any edit would mark the file as dirty even if the content
was restored to its original state. Now we compare against the original
content and clear the dirty flag when they match.
2026-01-17 17:52:00 +08:00
yyh
b82b73ef94 refactor(skill-editor): split slice into separate files for better organization
Split the monolithic skill-editor-slice.ts into a dedicated directory with
individual slice files (tab, file-tree, dirty, metadata, file-operations-menu)
to improve maintainability and code organization.
2026-01-17 17:28:25 +08:00
yyh
15d6f60f25 Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox 2026-01-17 17:03:32 +08:00
yyh
ad8c5f5452 perf: lazy load SkillMain component using next/dynamic
Reduce initial bundle size by dynamically importing SkillMain
component. This prevents loading the entire Skill module (including
Monaco and Lexical editors) when users only access the Graph view.
2026-01-16 21:31:56 +08:00
Harry
721d82b91a refactor(sandbox): modify sandbox provider configuration by adding 'configure_type' column and updating unique constraints 2026-01-16 19:02:16 +08:00
Joel
d542a74733 feat: panel ui 2026-01-16 18:39:13 +08:00
가은 정
fad6fa141d chore: improve accessibility for learn more link (#31120)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Co-authored-by: khmandarrin <jeong-ga-eun@jeong-ga-eun-ui-MacBookAir.local>
2026-01-16 18:12:07 +08:00
Pádraic Slattery
30821fd26c chore: Update outdated GitHub Actions versions (#31114) 2026-01-16 17:56:55 +08:00
Xiangxuan Qu
1a9fdd9a65 refactor: migrate tag list API query parameters to Pydantic (#31097)
Co-authored-by: fghpdf <fghpdf@users.noreply.github.com>
2026-01-16 17:49:52 +08:00
Stream
de610cbf39 fix: call get_text_content() instead of casting to str (#31121)
Signed-off-by: Stream <Stream_2@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-16 18:41:00 +09:00
26 changed files with 551 additions and 409 deletions

View File

@@ -16,14 +16,14 @@ jobs:
- name: Check Docker Compose inputs
id: docker-compose-changes
uses: tj-actions/changed-files@v46
uses: tj-actions/changed-files@v47
with:
files: |
docker/generate_docker_compose
docker/.env.example
docker/docker-compose-template.yaml
docker/docker-compose.yaml
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: "3.11"

View File

@@ -112,7 +112,7 @@ jobs:
context: "web"
steps:
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-${{ matrix.context }}-*

View File

@@ -19,7 +19,7 @@ jobs:
github.event.workflow_run.head_branch == 'deploy/agent-dev'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v0.1.8
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.AGENT_DEV_SSH_HOST }}
username: ${{ secrets.SSH_USER }}

View File

@@ -16,7 +16,7 @@ jobs:
github.event.workflow_run.head_branch == 'deploy/dev'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v0.1.8
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}

View File

@@ -20,7 +20,7 @@ jobs:
)
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v0.1.8
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HITL_SSH_HOST }}
username: ${{ secrets.SSH_USER }}

View File

@@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@v5
- uses: actions/stale@v10
with:
days-before-issue-stale: 15
days-before-issue-close: 3

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -30,6 +30,11 @@ class TagBindingRemovePayload(BaseModel):
type: Literal["knowledge", "app"] | None = Field(default=None, description="Tag type")
class TagListQueryParam(BaseModel):
type: Literal["knowledge", "app", ""] = Field("", description="Tag type filter")
keyword: str | None = Field(None, description="Search keyword")
register_schema_models(
console_ns,
TagBasePayload,
@@ -43,12 +48,15 @@ class TagListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.doc(
params={"type": 'Tag type filter. Can be "knowledge" or "app".', "keyword": "Search keyword for tag name."}
)
@marshal_with(dataset_tag_fields)
def get(self):
_, current_tenant_id = current_account_with_tenant()
tag_type = request.args.get("type", type=str, default="")
keyword = request.args.get("keyword", default=None, type=str)
tags = TagService.get_tags(tag_type, current_tenant_id, keyword)
raw_args = request.args.to_dict()
param = TagListQueryParam.model_validate(raw_args)
tags = TagService.get_tags(param.type, current_tenant_id, param.keyword)
return tags, 200

View File

@@ -71,8 +71,8 @@ class LLMGenerator:
response: LLMResult = model_instance.invoke_llm(
prompt_messages=list(prompts), model_parameters={"max_tokens": 500, "temperature": 1}, stream=False
)
answer = cast(str, response.message.content)
if answer is None:
answer = response.message.get_text_content()
if answer == "":
return ""
try:
result_dict = json.loads(answer)
@@ -184,7 +184,7 @@ class LLMGenerator:
prompt_messages=list(prompt_messages), model_parameters=model_parameters, stream=False
)
rule_config["prompt"] = cast(str, response.message.content)
rule_config["prompt"] = response.message.get_text_content()
except InvokeError as e:
error = str(e)
@@ -237,13 +237,11 @@ class LLMGenerator:
return rule_config
rule_config["prompt"] = cast(str, prompt_content.message.content)
rule_config["prompt"] = prompt_content.message.get_text_content()
if not isinstance(prompt_content.message.content, str):
raise NotImplementedError("prompt content is not a string")
parameter_generate_prompt = parameter_template.format(
inputs={
"INPUT_TEXT": prompt_content.message.content,
"INPUT_TEXT": prompt_content.message.get_text_content(),
},
remove_template_variables=False,
)
@@ -253,7 +251,7 @@ class LLMGenerator:
statement_generate_prompt = statement_template.format(
inputs={
"TASK_DESCRIPTION": instruction,
"INPUT_TEXT": prompt_content.message.content,
"INPUT_TEXT": prompt_content.message.get_text_content(),
},
remove_template_variables=False,
)
@@ -263,7 +261,7 @@ class LLMGenerator:
parameter_content: LLMResult = model_instance.invoke_llm(
prompt_messages=list(parameter_messages), model_parameters=model_parameters, stream=False
)
rule_config["variables"] = re.findall(r'"\s*([^"]+)\s*"', cast(str, parameter_content.message.content))
rule_config["variables"] = re.findall(r'"\s*([^"]+)\s*"', parameter_content.message.get_text_content())
except InvokeError as e:
error = str(e)
error_step = "generate variables"
@@ -272,7 +270,7 @@ class LLMGenerator:
statement_content: LLMResult = model_instance.invoke_llm(
prompt_messages=list(statement_messages), model_parameters=model_parameters, stream=False
)
rule_config["opening_statement"] = cast(str, statement_content.message.content)
rule_config["opening_statement"] = statement_content.message.get_text_content()
except InvokeError as e:
error = str(e)
error_step = "generate conversation opener"
@@ -315,7 +313,7 @@ class LLMGenerator:
prompt_messages=list(prompt_messages), model_parameters=model_parameters, stream=False
)
generated_code = cast(str, response.message.content)
generated_code = response.message.get_text_content()
return {"code": generated_code, "language": code_language, "error": ""}
except InvokeError as e:
@@ -351,7 +349,7 @@ class LLMGenerator:
raise TypeError("Expected LLMResult when stream=False")
response = result
answer = cast(str, response.message.content)
answer = response.message.get_text_content()
return answer.strip()
@classmethod
@@ -375,10 +373,7 @@ class LLMGenerator:
prompt_messages=list(prompt_messages), model_parameters=model_parameters, stream=False
)
raw_content = response.message.content
if not isinstance(raw_content, str):
raise ValueError(f"LLM response content must be a string, got: {type(raw_content)}")
raw_content = response.message.get_text_content()
try:
parsed_content = json.loads(raw_content)

View File

@@ -18,51 +18,18 @@ depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tenant_credit_pools',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
sa.Column('pool_type', sa.String(length=40), server_default='trial', nullable=False),
sa.Column('quota_limit', sa.BigInteger(), nullable=False),
sa.Column('quota_used', sa.BigInteger(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id', name='tenant_credit_pool_pkey')
)
with op.batch_alter_table('tenant_credit_pools', schema=None) as batch_op:
batch_op.create_index('tenant_credit_pool_pool_type_idx', ['pool_type'], unique=False)
batch_op.create_index('tenant_credit_pool_tenant_id_idx', ['tenant_id'], unique=False)
with op.batch_alter_table('messages', schema=None) as batch_op:
batch_op.create_index('message_created_at_id_idx', ['created_at', 'id'], unique=False)
with op.batch_alter_table('sandbox_providers', schema=None) as batch_op:
batch_op.add_column(sa.Column('configure_type', sa.String(length=20), server_default='user', nullable=False))
batch_op.drop_constraint(batch_op.f('unique_sandbox_provider_tenant_type'), type_='unique')
batch_op.create_unique_constraint('unique_sandbox_provider_tenant_type', ['tenant_id', 'provider_type', 'configure_type'])
with op.batch_alter_table('workflow_runs', schema=None) as batch_op:
batch_op.create_index('workflow_run_created_at_id_idx', ['created_at', 'id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('workflow_runs', schema=None) as batch_op:
batch_op.drop_index('workflow_run_created_at_id_idx')
with op.batch_alter_table('sandbox_providers', schema=None) as batch_op:
batch_op.drop_constraint('unique_sandbox_provider_tenant_type', type_='unique')
batch_op.create_unique_constraint(batch_op.f('unique_sandbox_provider_tenant_type'), ['tenant_id', 'provider_type'], postgresql_nulls_not_distinct=False)
batch_op.drop_column('configure_type')
with op.batch_alter_table('messages', schema=None) as batch_op:
batch_op.drop_index('message_created_at_id_idx')
with op.batch_alter_table('tenant_credit_pools', schema=None) as batch_op:
batch_op.drop_index('tenant_credit_pool_tenant_id_idx')
batch_op.drop_index('tenant_credit_pool_pool_type_idx')
op.drop_table('tenant_credit_pools')
# ### end Alembic commands ###

View File

@@ -65,15 +65,17 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
<div className="text-xs text-text-secondary">
{t('overview.disableTooltip.triggerMode', { ns: 'appOverview', feature: featureName })}
</div>
<div
className="cursor-pointer text-xs font-medium text-text-accent hover:underline"
<a
href={triggerDocUrl}
target="_blank"
rel="noopener noreferrer"
className="block cursor-pointer text-xs font-medium text-text-accent hover:underline"
onClick={(event) => {
event.stopPropagation()
window.open(triggerDocUrl, '_blank')
}}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</a>
</div>
), [t, triggerDocUrl])

View File

@@ -2,6 +2,7 @@
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store'
import dynamic from 'next/dynamic'
import { useSearchParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
import {
@@ -17,7 +18,6 @@ import WorkflowWithDefaultContext from '@/app/components/workflow'
import {
WorkflowContextProvider,
} from '@/app/components/workflow/context'
import SkillMain from '@/app/components/workflow/skill/main'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
import {
@@ -41,6 +41,10 @@ import {
import { parseAsViewType, WORKFLOW_VIEW_PARAM_KEY } from './search-params'
import { createWorkflowSlice } from './store/workflow/workflow-slice'
const SkillMain = dynamic(() => import('@/app/components/workflow/skill/main'), {
ssr: false,
})
const WorkflowAppWithAdditionalContext = () => {
const {
data,

View File

@@ -5,7 +5,6 @@ import type { ToolWithProvider } from '@/app/components/workflow/types'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { useSelectOrDelete } from '@/app/components/base/prompt-editor/hooks'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@@ -28,6 +27,7 @@ import { canFindTool } from '@/utils'
import { cn } from '@/utils/classnames'
import { basePath } from '@/utils/var'
import { DELETE_TOOL_BLOCK_COMMAND } from './index'
import ToolHeader from './tool-header'
type ToolBlockComponentProps = {
nodeKey: string
@@ -85,7 +85,6 @@ const ToolBlockComponent: FC<ToolBlockComponentProps> = ({
}) => {
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_TOOL_BLOCK_COMMAND)
const language = useGetLanguage()
const { t } = useTranslation()
const { theme } = useTheme()
const [isSettingOpen, setIsSettingOpen] = useState(false)
const [toolValue, setToolValue] = useState<ToolValue | null>(null)
@@ -127,6 +126,17 @@ const ToolBlockComponent: FC<ToolBlockComponentProps> = ({
}
}, [currentProvider, currentTool, language, tool])
const toolDescriptionText = useMemo(() => {
if (toolValue?.tool_description)
return toolValue.tool_description
if (currentTool?.description) {
return typeof currentTool.description === 'object'
? (currentTool.description?.[language] || '')
: (currentTool.description || '')
}
return ''
}, [currentTool?.description, language, toolValue?.tool_description])
const toolConfigFromMetadata = useMemo(() => {
if (!activeTabId)
return undefined
@@ -345,14 +355,19 @@ const ToolBlockComponent: FC<ToolBlockComponentProps> = ({
</span>
{portalContainer && isSettingOpen && createPortal(
<div
className="absolute right-4 top-4 z-[999]"
className="absolute bottom-4 right-4 top-4 z-[999]"
data-tool-setting-panel="true"
>
<div className={cn('relative max-h-[642px] min-h-20 w-[361px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-4 shadow-lg backdrop-blur-sm', 'overflow-y-auto pb-2')}>
<div className="system-xl-semibold px-4 pb-1 pt-3.5 text-text-primary">{t('detailPanel.toolSelector.toolSetting', { ns: 'plugin' })}</div>
<div className={cn('relative h-full min-h-20 w-[361px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-4 shadow-lg backdrop-blur-sm', 'overflow-y-auto pb-2')}>
{currentProvider && currentTool && toolValue && (
<>
<div className="px-4 pb-2 text-xs text-text-tertiary">{displayLabel}</div>
<ToolHeader
icon={resolvedIcon}
providerLabel={currentProvider.label?.[language] || currentProvider.name || provider}
toolLabel={toolValue.tool_label || displayLabel}
description={toolDescriptionText}
onClose={() => setIsSettingOpen(false)}
/>
<ToolAuthorizationSection
currentProvider={currentProvider}
credentialId={toolValue.credential_id}

View File

@@ -0,0 +1,100 @@
'use client'
import type { FC } from 'react'
import type { Emoji } from '@/app/components/tools/types'
import { RiBookOpenLine, RiCloseLine } from '@remixicon/react'
import AppIcon from '@/app/components/base/app-icon'
type ToolHeaderProps = {
icon: string | Emoji | undefined
providerLabel: string
toolLabel: string
description: string
onClose: () => void
}
const ToolHeader: FC<ToolHeaderProps> = ({
icon,
providerLabel,
toolLabel,
description,
onClose,
}) => {
const renderHeaderIcon = () => {
if (!icon)
return null
if (typeof icon === 'string') {
if (icon.startsWith('http') || icon.startsWith('/')) {
return (
<span className="flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-[6px] border border-divider-subtle bg-background-default-dodge">
<span
className="h-full w-full bg-cover bg-center"
style={{ backgroundImage: `url(${icon})` }}
/>
</span>
)
}
return (
<AppIcon
size="xs"
icon={icon}
className="!h-5 !w-5 shrink-0 !rounded-[6px] !border border-divider-subtle bg-background-default-dodge"
/>
)
}
return (
<AppIcon
size="xs"
icon={icon.content}
background={icon.background}
className="!h-5 !w-5 shrink-0 !rounded-[6px] !border border-divider-subtle bg-background-default-dodge"
/>
)
}
return (
<>
<div className="flex items-start gap-1 px-3 pb-2 pt-3">
<div className="flex flex-1 flex-col items-start">
<div className="flex items-center gap-1 rounded-md px-1 py-1">
{renderHeaderIcon()}
<span className="system-xs-medium text-text-tertiary">
{providerLabel}
</span>
</div>
</div>
<div className="flex items-center gap-1 pt-1">
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded-[6px] text-text-tertiary hover:bg-state-base-hover"
onClick={(event) => {
event.stopPropagation()
}}
>
<RiBookOpenLine className="h-4 w-4" />
</button>
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded-[6px] text-text-tertiary hover:bg-state-base-hover"
onClick={(event) => {
event.stopPropagation()
onClose()
}}
>
<RiCloseLine className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-1.5 px-3 pb-2">
<div className="system-md-semibold text-text-primary">
{toolLabel}
</div>
<div className="system-sm-regular mt-2.5 text-text-secondary">
{description}
</div>
</div>
</>
)
}
export default ToolHeader

View File

@@ -17,7 +17,7 @@ import { $splitNodeContainingQuery } from '@/app/components/base/prompt-editor/u
import { CollectionType } from '@/app/components/tools/types'
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { $createToolBlockNode } from './node'
class ToolPickerMenuOption extends MenuOption {
@@ -36,8 +36,6 @@ const ToolPickerBlock: FC<ToolPickerBlockProps> = ({ scope = 'all' }) => {
minLength: 0,
maxLength: 0,
})
const activeTabId = useStore(s => s.activeTabId)
const fileMetadata = useStore(s => s.fileMetadata)
const storeApi = useWorkflowStore()
const options = useMemo(() => [new ToolPickerMenuOption()], [])
@@ -73,11 +71,10 @@ const ToolPickerBlock: FC<ToolPickerBlockProps> = ({ scope = 'all' }) => {
$insertNodes(nodes)
})
const storeState = storeApi.getState()
const resolvedTabId = activeTabId || storeState.activeTabId
if (!resolvedTabId)
const { activeTabId, fileMetadata, setDraftMetadata, pinTab } = storeApi.getState()
if (!activeTabId)
return
const metadata = (storeState.fileMetadata.get(resolvedTabId) || {}) as Record<string, any>
const metadata = (fileMetadata.get(activeTabId) || {}) as Record<string, any>
const nextTools = { ...(metadata.tools || {}) } as Record<string, any>
toolEntries.forEach(({ configId, tool }) => {
const schemas = toolParametersToFormSchemas((tool.paramSchemas || []) as ToolParameter[])
@@ -91,12 +88,12 @@ const ToolPickerBlock: FC<ToolPickerBlockProps> = ({ scope = 'all' }) => {
configuration: { fields },
}
})
storeState.setDraftMetadata(resolvedTabId, {
setDraftMetadata(activeTabId, {
...metadata,
tools: nextTools,
})
storeState.pinTab(resolvedTabId)
}, [activeTabId, checkForTriggerMatch, editor, fileMetadata, storeApi])
pinTab(activeTabId)
}, [checkForTriggerMatch, editor, storeApi])
const renderMenu = useCallback((
anchorElementRef: React.RefObject<HTMLElement | null>,

View File

@@ -2,7 +2,7 @@
import type { NodeApi, TreeApi } from 'react-arborist'
import type { TreeNodeData } from '../type'
import type { OpensObject } from '@/app/components/workflow/store/workflow/skill-editor-slice'
import type { OpensObject } from '@/app/components/workflow/store/workflow/skill-editor/file-tree-slice'
import { RiDragDropLine } from '@remixicon/react'
import { useIsMutating } from '@tanstack/react-query'
import { useSize } from 'ahooks'

View File

@@ -42,13 +42,20 @@ const SkillDocEditor: FC = () => {
const { data: nodeMap } = useSkillAssetNodeMap()
const currentFileNode = activeTabId ? nodeMap?.get(activeTabId) : undefined
const fileExtension = getFileExtension(currentFileNode?.name, currentFileNode?.extension)
const isMarkdown = isMarkdownFile(fileExtension)
const isCodeOrText = isCodeOrTextFile(fileExtension)
const isImage = isImageFile(fileExtension)
const isVideo = isVideoFile(fileExtension)
const isOffice = isOfficeFile(fileExtension)
const isEditable = isMarkdown || isCodeOrText
const { isMarkdown, isCodeOrText, isImage, isVideo, isOffice, isEditable } = useMemo(() => {
const ext = getFileExtension(currentFileNode?.name, currentFileNode?.extension)
const markdown = isMarkdownFile(ext)
const codeOrText = isCodeOrTextFile(ext)
return {
isMarkdown: markdown,
isCodeOrText: codeOrText,
isImage: isImageFile(ext),
isVideo: isVideoFile(ext),
isOffice: isOfficeFile(ext),
isEditable: markdown || codeOrText,
}
}, [currentFileNode?.name, currentFileNode?.extension])
const {
data: fileContent,
@@ -58,14 +65,16 @@ const SkillDocEditor: FC = () => {
const updateContent = useUpdateAppAssetFileContent()
const originalContent = fileContent?.content ?? ''
const currentContent = useMemo(() => {
if (!activeTabId)
return ''
const draft = dirtyContents.get(activeTabId)
if (draft !== undefined)
return draft
return fileContent?.content ?? ''
}, [activeTabId, dirtyContents, fileContent?.content])
return originalContent
}, [activeTabId, dirtyContents, originalContent])
const currentMetadata = useMemo(() => {
if (!activeTabId)
@@ -78,7 +87,7 @@ const SkillDocEditor: FC = () => {
return
if (dirtyMetadataIds.has(activeTabId))
return
let nextMetadata: Record<string, any> = {}
let nextMetadata: Record<string, unknown> = {}
if (fileContent.metadata) {
if (typeof fileContent.metadata === 'string') {
try {
@@ -99,9 +108,15 @@ const SkillDocEditor: FC = () => {
const handleEditorChange = useCallback((value: string | undefined) => {
if (!activeTabId || !isEditable)
return
storeApi.getState().setDraftContent(activeTabId, value ?? '')
const newValue = value ?? ''
if (newValue === originalContent)
storeApi.getState().clearDraftContent(activeTabId)
else
storeApi.getState().setDraftContent(activeTabId, newValue)
storeApi.getState().pinTab(activeTabId)
}, [activeTabId, isEditable, storeApi])
}, [activeTabId, isEditable, originalContent, storeApi])
const handleSave = useCallback(async () => {
if (!activeTabId || !appId || !isEditable)
@@ -117,7 +132,7 @@ const SkillDocEditor: FC = () => {
appId,
nodeId: activeTabId,
payload: {
content: content ?? fileContent?.content ?? '',
content: content ?? originalContent,
...(currentMetadata ? { metadata: currentMetadata } : {}),
},
})
@@ -134,7 +149,7 @@ const SkillDocEditor: FC = () => {
message: String(error),
})
}
}, [activeTabId, appId, currentMetadata, dirtyContents, dirtyMetadataIds, fileContent?.content, isEditable, storeApi, t, updateContent])
}, [activeTabId, appId, currentMetadata, dirtyContents, dirtyMetadataIds, isEditable, originalContent, storeApi, t, updateContent])
useEffect(() => {
function handleKeyDown(e: KeyboardEvent): void {

View File

@@ -10,7 +10,7 @@ import type { HistorySliceShape } from './history-slice'
import type { LayoutSliceShape } from './layout-slice'
import type { NodeSliceShape } from './node-slice'
import type { PanelSliceShape } from './panel-slice'
import type { SkillEditorSliceShape } from './skill-editor-slice'
import type { SkillEditorSliceShape } from './skill-editor'
import type { ToolSliceShape } from './tool-slice'
import type { VersionSliceShape } from './version-slice'
import type { WorkflowDraftSliceShape } from './workflow-draft-slice'
@@ -32,7 +32,7 @@ import { createHistorySlice } from './history-slice'
import { createLayoutSlice } from './layout-slice'
import { createNodeSlice } from './node-slice'
import { createPanelSlice } from './panel-slice'
import { createSkillEditorSlice } from './skill-editor-slice'
import { createSkillEditorSlice } from './skill-editor'
import { createToolSlice } from './tool-slice'
import { createVersionSlice } from './version-slice'
import { createWorkflowDraftSlice } from './workflow-draft-slice'

View File

@@ -1,310 +0,0 @@
import type { StateCreator } from 'zustand'
export type OpenTabOptions = {
/** true = Pinned (permanent), false/undefined = Preview (temporary) */
pinned?: boolean
}
export type TabSliceShape = {
/** Ordered list of open tab file IDs */
openTabIds: string[]
/** Currently active tab file ID */
activeTabId: string | null
/** Current preview tab file ID (at most one) */
previewTabId: string | null
/** Open a file tab with optional pinned mode */
openTab: (fileId: string, options?: OpenTabOptions) => void
/** Close a tab */
closeTab: (fileId: string) => void
/** Activate an existing tab */
activateTab: (fileId: string) => void
/** Convert preview tab to pinned tab */
pinTab: (fileId: string) => void
/** Check if a tab is in preview mode */
isPreviewTab: (fileId: string) => boolean
}
const createTabSlice: StateCreator<TabSliceShape> = (set, get) => ({
openTabIds: [],
activeTabId: null,
previewTabId: null,
openTab: (fileId: string, options?: OpenTabOptions) => {
const { openTabIds, activeTabId, previewTabId } = get()
const isPinned = options?.pinned ?? false
if (openTabIds.includes(fileId)) {
if (isPinned && previewTabId === fileId)
set({ activeTabId: fileId, previewTabId: null })
else if (activeTabId !== fileId)
set({ activeTabId: fileId })
return
}
let newOpenTabIds = [...openTabIds]
if (!isPinned) {
if (previewTabId && openTabIds.includes(previewTabId))
newOpenTabIds = newOpenTabIds.filter(id => id !== previewTabId)
set({
openTabIds: [...newOpenTabIds, fileId],
activeTabId: fileId,
previewTabId: fileId,
})
}
else {
set({
openTabIds: [...newOpenTabIds, fileId],
activeTabId: fileId,
})
}
},
closeTab: (fileId: string) => {
const { openTabIds, activeTabId, previewTabId } = get()
const newOpenTabIds = openTabIds.filter(id => id !== fileId)
let newActiveTabId = activeTabId
if (activeTabId === fileId) {
const closedIndex = openTabIds.indexOf(fileId)
if (newOpenTabIds.length > 0)
newActiveTabId = newOpenTabIds[Math.min(closedIndex, newOpenTabIds.length - 1)]
else
newActiveTabId = null
}
const newPreviewTabId = previewTabId === fileId
? null
: (previewTabId && newOpenTabIds.includes(previewTabId) ? previewTabId : null)
set({
openTabIds: newOpenTabIds,
activeTabId: newActiveTabId,
previewTabId: newPreviewTabId,
})
},
activateTab: (fileId: string) => {
const { openTabIds } = get()
if (openTabIds.includes(fileId))
set({ activeTabId: fileId })
},
pinTab: (fileId: string) => {
const { previewTabId, openTabIds } = get()
if (!openTabIds.includes(fileId))
return
if (previewTabId === fileId)
set({ previewTabId: null })
},
isPreviewTab: (fileId: string) => {
return get().previewTabId === fileId
},
})
export type OpensObject = Record<string, boolean>
export type FileTreeSliceShape = {
expandedFolderIds: Set<string>
setExpandedFolderIds: (ids: Set<string>) => void
toggleFolder: (folderId: string) => void
revealFile: (ancestorFolderIds: string[]) => void
setExpandedFromOpens: (opens: OpensObject) => void
getOpensObject: () => OpensObject
}
const createFileTreeSlice: StateCreator<FileTreeSliceShape> = (set, get) => ({
expandedFolderIds: new Set<string>(),
setExpandedFolderIds: (ids: Set<string>) => {
set({ expandedFolderIds: ids })
},
toggleFolder: (folderId: string) => {
const { expandedFolderIds } = get()
const newSet = new Set(expandedFolderIds)
if (newSet.has(folderId))
newSet.delete(folderId)
else
newSet.add(folderId)
set({ expandedFolderIds: newSet })
},
revealFile: (ancestorFolderIds: string[]) => {
const { expandedFolderIds } = get()
const newSet = new Set(expandedFolderIds)
ancestorFolderIds.forEach(id => newSet.add(id))
set({ expandedFolderIds: newSet })
},
setExpandedFromOpens: (opens: OpensObject) => {
const newSet = new Set<string>(
Object.entries(opens)
.filter(([_, isOpen]) => isOpen)
.map(([id]) => id),
)
set({ expandedFolderIds: newSet })
},
getOpensObject: () => {
const { expandedFolderIds } = get()
return Object.fromEntries(
[...expandedFolderIds].map(id => [id, true]),
)
},
})
export type DirtySliceShape = {
dirtyContents: Map<string, string>
setDraftContent: (fileId: string, content: string) => void
clearDraftContent: (fileId: string) => void
isDirty: (fileId: string) => boolean
getDraftContent: (fileId: string) => string | undefined
}
const createDirtySlice: StateCreator<DirtySliceShape> = (set, get) => ({
dirtyContents: new Map<string, string>(),
setDraftContent: (fileId: string, content: string) => {
const { dirtyContents } = get()
const newMap = new Map(dirtyContents)
newMap.set(fileId, content)
set({ dirtyContents: newMap })
},
clearDraftContent: (fileId: string) => {
const { dirtyContents } = get()
const newMap = new Map(dirtyContents)
newMap.delete(fileId)
set({ dirtyContents: newMap })
},
isDirty: (fileId: string) => {
return get().dirtyContents.has(fileId)
},
getDraftContent: (fileId: string) => {
return get().dirtyContents.get(fileId)
},
})
export type MetadataSliceShape = {
fileMetadata: Map<string, Record<string, any>>
dirtyMetadataIds: Set<string>
setFileMetadata: (fileId: string, metadata: Record<string, any>) => void
setDraftMetadata: (fileId: string, metadata: Record<string, any>) => void
clearDraftMetadata: (fileId: string) => void
clearFileMetadata: (fileId: string) => void
isMetadataDirty: (fileId: string) => boolean
getFileMetadata: (fileId: string) => Record<string, any> | undefined
}
const createMetadataSlice: StateCreator<MetadataSliceShape> = (set, get) => ({
fileMetadata: new Map<string, Record<string, any>>(),
dirtyMetadataIds: new Set<string>(),
setFileMetadata: (fileId: string, metadata: Record<string, any>) => {
const { fileMetadata } = get()
const nextMap = new Map(fileMetadata)
if (metadata)
nextMap.set(fileId, metadata)
else
nextMap.delete(fileId)
set({ fileMetadata: nextMap })
},
setDraftMetadata: (fileId: string, metadata: Record<string, any>) => {
const { fileMetadata, dirtyMetadataIds } = get()
const nextMap = new Map(fileMetadata)
nextMap.set(fileId, metadata || {})
const nextDirty = new Set(dirtyMetadataIds)
nextDirty.add(fileId)
set({ fileMetadata: nextMap, dirtyMetadataIds: nextDirty })
},
clearDraftMetadata: (fileId: string) => {
const { dirtyMetadataIds } = get()
if (!dirtyMetadataIds.has(fileId))
return
const nextDirty = new Set(dirtyMetadataIds)
nextDirty.delete(fileId)
set({ dirtyMetadataIds: nextDirty })
},
clearFileMetadata: (fileId: string) => {
const { fileMetadata, dirtyMetadataIds } = get()
const nextMap = new Map(fileMetadata)
nextMap.delete(fileId)
const nextDirty = new Set(dirtyMetadataIds)
nextDirty.delete(fileId)
set({ fileMetadata: nextMap, dirtyMetadataIds: nextDirty })
},
isMetadataDirty: (fileId: string) => {
return get().dirtyMetadataIds.has(fileId)
},
getFileMetadata: (fileId: string) => {
return get().fileMetadata.get(fileId)
},
})
export type FileOperationsMenuSliceShape = {
contextMenu: {
top: number
left: number
nodeId: string
} | null
setContextMenu: (menu: FileOperationsMenuSliceShape['contextMenu']) => void
}
const createFileOperationsMenuSlice: StateCreator<FileOperationsMenuSliceShape> = set => ({
contextMenu: null,
setContextMenu: (contextMenu) => {
set({ contextMenu })
},
})
export type SkillEditorSliceShape
= TabSliceShape
& FileTreeSliceShape
& DirtySliceShape
& MetadataSliceShape
& FileOperationsMenuSliceShape
& {
resetSkillEditor: () => void
}
export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (set, get, store) => {
// Type assertion via unknown to allow composition with other slices in a larger store
// This is safe because all slice creators only use set/get for their own properties
const tabArgs = [set, get, store] as unknown as Parameters<StateCreator<TabSliceShape>>
const fileTreeArgs = [set, get, store] as unknown as Parameters<StateCreator<FileTreeSliceShape>>
const dirtyArgs = [set, get, store] as unknown as Parameters<StateCreator<DirtySliceShape>>
const metadataArgs = [set, get, store] as unknown as Parameters<StateCreator<MetadataSliceShape>>
const menuArgs = [set, get, store] as unknown as Parameters<StateCreator<FileOperationsMenuSliceShape>>
return {
...createTabSlice(...tabArgs),
...createFileTreeSlice(...fileTreeArgs),
...createDirtySlice(...dirtyArgs),
...createMetadataSlice(...metadataArgs),
...createFileOperationsMenuSlice(...menuArgs),
resetSkillEditor: () => {
set({
openTabIds: [],
activeTabId: null,
previewTabId: null,
expandedFolderIds: new Set<string>(),
dirtyContents: new Map<string, string>(),
fileMetadata: new Map<string, Record<string, any>>(),
dirtyMetadataIds: new Set<string>(),
contextMenu: null,
})
},
}
}

View File

@@ -0,0 +1,35 @@
import type { StateCreator } from 'zustand'
import type { DirtySliceShape, SkillEditorSliceShape } from './types'
export type { DirtySliceShape } from './types'
export const createDirtySlice: StateCreator<
SkillEditorSliceShape,
[],
[],
DirtySliceShape
> = (set, get) => ({
dirtyContents: new Map<string, string>(),
setDraftContent: (fileId: string, content: string) => {
const { dirtyContents } = get()
const newMap = new Map(dirtyContents)
newMap.set(fileId, content)
set({ dirtyContents: newMap })
},
clearDraftContent: (fileId: string) => {
const { dirtyContents } = get()
const newMap = new Map(dirtyContents)
newMap.delete(fileId)
set({ dirtyContents: newMap })
},
isDirty: (fileId: string) => {
return get().dirtyContents.has(fileId)
},
getDraftContent: (fileId: string) => {
return get().dirtyContents.get(fileId)
},
})

View File

@@ -0,0 +1,17 @@
import type { StateCreator } from 'zustand'
import type { FileOperationsMenuSliceShape, SkillEditorSliceShape } from './types'
export type { FileOperationsMenuSliceShape } from './types'
export const createFileOperationsMenuSlice: StateCreator<
SkillEditorSliceShape,
[],
[],
FileOperationsMenuSliceShape
> = set => ({
contextMenu: null,
setContextMenu: (contextMenu) => {
set({ contextMenu })
},
})

View File

@@ -0,0 +1,51 @@
import type { StateCreator } from 'zustand'
import type { FileTreeSliceShape, OpensObject, SkillEditorSliceShape } from './types'
export type { FileTreeSliceShape, OpensObject } from './types'
export const createFileTreeSlice: StateCreator<
SkillEditorSliceShape,
[],
[],
FileTreeSliceShape
> = (set, get) => ({
expandedFolderIds: new Set<string>(),
setExpandedFolderIds: (ids: Set<string>) => {
set({ expandedFolderIds: ids })
},
toggleFolder: (folderId: string) => {
const { expandedFolderIds } = get()
const newSet = new Set(expandedFolderIds)
if (newSet.has(folderId))
newSet.delete(folderId)
else
newSet.add(folderId)
set({ expandedFolderIds: newSet })
},
revealFile: (ancestorFolderIds: string[]) => {
const { expandedFolderIds } = get()
const newSet = new Set(expandedFolderIds)
ancestorFolderIds.forEach(id => newSet.add(id))
set({ expandedFolderIds: newSet })
},
setExpandedFromOpens: (opens: OpensObject) => {
const newSet = new Set<string>(
Object.entries(opens)
.filter(([_, isOpen]) => isOpen)
.map(([id]) => id),
)
set({ expandedFolderIds: newSet })
},
getOpensObject: () => {
const { expandedFolderIds } = get()
return Object.fromEntries(
[...expandedFolderIds].map(id => [id, true]),
)
},
})

View File

@@ -0,0 +1,36 @@
import type { StateCreator } from 'zustand'
import type { SkillEditorSliceShape } from './types'
import { createDirtySlice } from './dirty-slice'
import { createFileOperationsMenuSlice } from './file-operations-menu-slice'
import { createFileTreeSlice } from './file-tree-slice'
import { createMetadataSlice } from './metadata-slice'
import { createTabSlice } from './tab-slice'
export type { DirtySliceShape } from './dirty-slice'
export type { FileOperationsMenuSliceShape } from './file-operations-menu-slice'
export type { FileTreeSliceShape } from './file-tree-slice'
export type { MetadataSliceShape } from './metadata-slice'
export type { OpenTabOptions, TabSliceShape } from './tab-slice'
export type { SkillEditorSliceShape } from './types'
export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...args) => ({
...createTabSlice(...args),
...createFileTreeSlice(...args),
...createDirtySlice(...args),
...createMetadataSlice(...args),
...createFileOperationsMenuSlice(...args),
resetSkillEditor: () => {
const [set] = args
set({
openTabIds: [],
activeTabId: null,
previewTabId: null,
expandedFolderIds: new Set<string>(),
dirtyContents: new Map<string, string>(),
fileMetadata: new Map<string, Record<string, unknown>>(),
dirtyMetadataIds: new Set<string>(),
contextMenu: null,
})
},
})

View File

@@ -0,0 +1,59 @@
import type { StateCreator } from 'zustand'
import type { MetadataSliceShape, SkillEditorSliceShape } from './types'
export type { MetadataSliceShape } from './types'
export const createMetadataSlice: StateCreator<
SkillEditorSliceShape,
[],
[],
MetadataSliceShape
> = (set, get) => ({
fileMetadata: new Map<string, Record<string, unknown>>(),
dirtyMetadataIds: new Set<string>(),
setFileMetadata: (fileId: string, metadata: Record<string, unknown>) => {
const { fileMetadata } = get()
const nextMap = new Map(fileMetadata)
if (metadata)
nextMap.set(fileId, metadata)
else
nextMap.delete(fileId)
set({ fileMetadata: nextMap })
},
setDraftMetadata: (fileId: string, metadata: Record<string, unknown>) => {
const { fileMetadata, dirtyMetadataIds } = get()
const nextMap = new Map(fileMetadata)
nextMap.set(fileId, metadata || {})
const nextDirty = new Set(dirtyMetadataIds)
nextDirty.add(fileId)
set({ fileMetadata: nextMap, dirtyMetadataIds: nextDirty })
},
clearDraftMetadata: (fileId: string) => {
const { dirtyMetadataIds } = get()
if (!dirtyMetadataIds.has(fileId))
return
const nextDirty = new Set(dirtyMetadataIds)
nextDirty.delete(fileId)
set({ dirtyMetadataIds: nextDirty })
},
clearFileMetadata: (fileId: string) => {
const { fileMetadata, dirtyMetadataIds } = get()
const nextMap = new Map(fileMetadata)
nextMap.delete(fileId)
const nextDirty = new Set(dirtyMetadataIds)
nextDirty.delete(fileId)
set({ fileMetadata: nextMap, dirtyMetadataIds: nextDirty })
},
isMetadataDirty: (fileId: string) => {
return get().dirtyMetadataIds.has(fileId)
},
getFileMetadata: (fileId: string) => {
return get().fileMetadata.get(fileId)
},
})

View File

@@ -0,0 +1,88 @@
import type { StateCreator } from 'zustand'
import type { OpenTabOptions, SkillEditorSliceShape, TabSliceShape } from './types'
export type { OpenTabOptions, TabSliceShape } from './types'
export const createTabSlice: StateCreator<
SkillEditorSliceShape,
[],
[],
TabSliceShape
> = (set, get) => ({
openTabIds: [],
activeTabId: null,
previewTabId: null,
openTab: (fileId: string, options?: OpenTabOptions) => {
const { openTabIds, activeTabId, previewTabId } = get()
const isPinned = options?.pinned ?? false
if (openTabIds.includes(fileId)) {
if (isPinned && previewTabId === fileId)
set({ activeTabId: fileId, previewTabId: null })
else if (activeTabId !== fileId)
set({ activeTabId: fileId })
return
}
let newOpenTabIds = [...openTabIds]
if (!isPinned) {
if (previewTabId && openTabIds.includes(previewTabId))
newOpenTabIds = newOpenTabIds.filter(id => id !== previewTabId)
set({
openTabIds: [...newOpenTabIds, fileId],
activeTabId: fileId,
previewTabId: fileId,
})
}
else {
set({
openTabIds: [...newOpenTabIds, fileId],
activeTabId: fileId,
})
}
},
closeTab: (fileId: string) => {
const { openTabIds, activeTabId, previewTabId } = get()
const newOpenTabIds = openTabIds.filter(id => id !== fileId)
let newActiveTabId = activeTabId
if (activeTabId === fileId) {
const closedIndex = openTabIds.indexOf(fileId)
if (newOpenTabIds.length > 0)
newActiveTabId = newOpenTabIds[Math.min(closedIndex, newOpenTabIds.length - 1)]
else
newActiveTabId = null
}
let newPreviewTabId: string | null = null
if (previewTabId !== fileId && previewTabId && newOpenTabIds.includes(previewTabId))
newPreviewTabId = previewTabId
set({
openTabIds: newOpenTabIds,
activeTabId: newActiveTabId,
previewTabId: newPreviewTabId,
})
},
activateTab: (fileId: string) => {
const { openTabIds } = get()
if (openTabIds.includes(fileId))
set({ activeTabId: fileId })
},
pinTab: (fileId: string) => {
const { previewTabId, openTabIds } = get()
if (!openTabIds.includes(fileId))
return
if (previewTabId === fileId)
set({ previewTabId: null })
},
isPreviewTab: (fileId: string) => {
return get().previewTabId === fileId
},
})

View File

@@ -0,0 +1,63 @@
export type OpenTabOptions = {
pinned?: boolean
}
export type TabSliceShape = {
openTabIds: string[]
activeTabId: string | null
previewTabId: string | null
openTab: (fileId: string, options?: OpenTabOptions) => void
closeTab: (fileId: string) => void
activateTab: (fileId: string) => void
pinTab: (fileId: string) => void
isPreviewTab: (fileId: string) => boolean
}
export type OpensObject = Record<string, boolean>
export type FileTreeSliceShape = {
expandedFolderIds: Set<string>
setExpandedFolderIds: (ids: Set<string>) => void
toggleFolder: (folderId: string) => void
revealFile: (ancestorFolderIds: string[]) => void
setExpandedFromOpens: (opens: OpensObject) => void
getOpensObject: () => OpensObject
}
export type DirtySliceShape = {
dirtyContents: Map<string, string>
setDraftContent: (fileId: string, content: string) => void
clearDraftContent: (fileId: string) => void
isDirty: (fileId: string) => boolean
getDraftContent: (fileId: string) => string | undefined
}
export type MetadataSliceShape = {
fileMetadata: Map<string, Record<string, unknown>>
dirtyMetadataIds: Set<string>
setFileMetadata: (fileId: string, metadata: Record<string, unknown>) => void
setDraftMetadata: (fileId: string, metadata: Record<string, unknown>) => void
clearDraftMetadata: (fileId: string) => void
clearFileMetadata: (fileId: string) => void
isMetadataDirty: (fileId: string) => boolean
getFileMetadata: (fileId: string) => Record<string, unknown> | undefined
}
export type FileOperationsMenuSliceShape = {
contextMenu: {
top: number
left: number
nodeId: string
} | null
setContextMenu: (menu: FileOperationsMenuSliceShape['contextMenu']) => void
}
export type SkillEditorSliceShape
= TabSliceShape
& FileTreeSliceShape
& DirtySliceShape
& MetadataSliceShape
& FileOperationsMenuSliceShape
& {
resetSkillEditor: () => void
}