mirror of
https://github.com/langgenius/dify.git
synced 2026-01-18 12:59:58 +00:00
Compare commits
15 Commits
deploy/age
...
feat/suppo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f081fa6fa | ||
|
|
3b27d9e819 | ||
|
|
c0a76220dd | ||
|
|
9d04fb4992 | ||
|
|
02fcf33067 | ||
|
|
bbf1247f80 | ||
|
|
b82b73ef94 | ||
|
|
15d6f60f25 | ||
|
|
ad8c5f5452 | ||
|
|
721d82b91a | ||
|
|
d542a74733 | ||
|
|
fad6fa141d | ||
|
|
30821fd26c | ||
|
|
1a9fdd9a65 | ||
|
|
de610cbf39 |
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
2
.github/workflows/build-push.yml
vendored
2
.github/workflows/build-push.yml
vendored
@@ -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 }}-*
|
||||
|
||||
2
.github/workflows/deploy-agent-dev.yml
vendored
2
.github/workflows/deploy-agent-dev.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/deploy-dev.yml
vendored
2
.github/workflows/deploy-dev.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/deploy-hitl.yml
vendored
2
.github/workflows/deploy-hitl.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/trigger-i18n-sync.yml
vendored
2
.github/workflows/trigger-i18n-sync.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ###
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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>,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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 })
|
||||
},
|
||||
})
|
||||
@@ -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]),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user