From 75f232d8328a0bc448f41c9e40eeade9f4c1b020 Mon Sep 17 00:00:00 2001 From: baonudesifeizhai Date: Sun, 29 Jun 2025 00:57:40 -0400 Subject: [PATCH] feat: Add prompt template feature --- .gitignore | 28 +++ README.md | 233 ++++++++++++++++++ api/controllers/console/__init__.py | 2 +- api/controllers/console/prompt_template.py | 84 +++++++ api/cookies.txt | 4 + api/core/workflow/nodes/llm/entities.py | 1 + api/core/workflow/nodes/llm/node.py | 23 ++ ...34e79_feat_add_prompt_management_tables.py | 66 +++++ ...add_model_name_and_model_parameters_to_.py | 35 +++ api/models/__init__.py | 3 + api/models/prompt_template.py | 96 ++++++++ api/print_routes.py | 4 + api/services/prompt_template_service.py | 163 ++++++++++++ .../config/users.d/custom_users_config.xml | 17 -- .../volumes/oceanbase/init.d/vec_memory.sql | 1 - .../opensearch/opensearch_dashboards.yml | 222 ----------------- docker/volumes/sandbox/conf/config.yaml | 14 -- .../volumes/sandbox/conf/config.yaml.example | 35 --- .../dependencies/python-requirements.txt | 0 package.json | 5 + .../prompt-templates/Container.tsx | 95 +++++++ .../(commonLayout)/prompt-templates/Form.tsx | 187 ++++++++++++++ .../prompt-templates/NewTemplateModal.tsx | 93 +++++++ .../prompt-templates/[id]/Container.tsx | 73 ++++++ .../prompt-templates/[id]/page.tsx | 11 + .../prompt-templates/new/Container.tsx | 46 ++++ .../prompt-templates/new/page.tsx | 11 + .../(commonLayout)/prompt-templates/page.tsx | 7 + web/app/components/header/index.tsx | 3 +- .../header/prompt-templates-nav/index.tsx | 26 ++ web/models/prompt-template.ts | 24 ++ web/service/prompt-template.ts | 22 ++ 32 files changed, 1343 insertions(+), 291 deletions(-) create mode 100644 api/controllers/console/prompt_template.py create mode 100644 api/cookies.txt create mode 100644 api/migrations/versions/2025_06_27_2008-e3845da34e79_feat_add_prompt_management_tables.py create mode 100644 api/migrations/versions/2025_06_28_1607-090bb5fe6078_add_model_name_and_model_parameters_to_.py create mode 100644 api/models/prompt_template.py create mode 100644 api/print_routes.py create mode 100644 api/services/prompt_template_service.py delete mode 100644 docker/volumes/myscale/config/users.d/custom_users_config.xml delete mode 100644 docker/volumes/oceanbase/init.d/vec_memory.sql delete mode 100644 docker/volumes/opensearch/opensearch_dashboards.yml delete mode 100644 docker/volumes/sandbox/conf/config.yaml delete mode 100644 docker/volumes/sandbox/conf/config.yaml.example delete mode 100644 docker/volumes/sandbox/dependencies/python-requirements.txt create mode 100644 package.json create mode 100644 web/app/(commonLayout)/prompt-templates/Container.tsx create mode 100644 web/app/(commonLayout)/prompt-templates/Form.tsx create mode 100644 web/app/(commonLayout)/prompt-templates/NewTemplateModal.tsx create mode 100644 web/app/(commonLayout)/prompt-templates/[id]/Container.tsx create mode 100644 web/app/(commonLayout)/prompt-templates/[id]/page.tsx create mode 100644 web/app/(commonLayout)/prompt-templates/new/Container.tsx create mode 100644 web/app/(commonLayout)/prompt-templates/new/page.tsx create mode 100644 web/app/(commonLayout)/prompt-templates/page.tsx create mode 100644 web/app/components/header/prompt-templates-nav/index.tsx create mode 100644 web/models/prompt-template.ts create mode 100644 web/service/prompt-template.ts diff --git a/.gitignore b/.gitignore index 8f82bea00d..406d66851c 100644 --- a/.gitignore +++ b/.gitignore @@ -214,3 +214,31 @@ mise.toml # AI Assistant .roo/ + +# Node.js +node_modules/ +npm-debug.log +yarn-error.log +package-lock.json +pnpm-lock.yaml + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +pip-cache/ +.env + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index 1dc7e2dd98..cae0cc5de7 100644 --- a/README.md +++ b/README.md @@ -266,3 +266,236 @@ To protect your privacy, please avoid posting security issues on GitHub. Instead ## License This repository is available under the [Dify Open Source License](LICENSE), which is essentially Apache 2.0 with a few additional restrictions. + +# Dify 多智能体记忆问题复现方案 + +## 问题概述 + +根据 [GitHub Issue #21640](https://github.com/langgenius/dify/issues/21640),在多智能体聊天流程系统中存在记忆隔离问题: + +- 每个智能体都有记忆功能(10条消息) +- 当对话流程切换到不同智能体时,新智能体会看到来自其他智能体的聊天历史 +- 这导致智能体产生"幻觉"(hallucination),回答不准确 + +## 复现方法 + +### 方法1:手动复现(推荐) + +1. **登录Dify控制台** + - 访问您的Dify实例 + - 登录管理员账户 + +2. **创建工作流应用** + - 创建新的工作流应用 + - 选择"工作流"模式 + +3. **添加节点** + ``` + Start节点 → 问题分类器 → Agent A (技术) / Agent B (商业) → End节点 + ``` + +4. **配置智能体** + - **Agent A**: + - 系统提示词:"你是Agent A,专门处理技术问题" + - 启用记忆:窗口大小10 + - **Agent B**: + - 系统提示词:"你是Agent B,专门处理商业问题" + - 启用记忆:窗口大小10 + +5. **配置路由逻辑** + - 使用问题分类器根据内容类型路由 + - 技术问题 → Agent A + - 商业问题 → Agent B + +6. **测试场景** + ``` + 用户: "如何配置Docker容器?" → Agent A回答 + 用户: "什么是Kubernetes?" → Agent A回答 + 用户: "如何制定营销策略?" → Agent B回答(可能引用技术内容) + 用户: "如何提高客户满意度?" → Agent B回答(可能引用技术内容) + ``` + +7. **观察问题** + - 检查Agent B的回答是否包含Docker、Kubernetes等技术相关内容 + - 如果包含,说明存在记忆隔离问题 + +### 方法2:使用复现脚本 + +1. **配置脚本** + ```bash + # 编辑 reproduction_script.py + DIFY_BASE_URL = "http://your-dify-instance.com" + DIFY_API_KEY = "your_api_key_here" + ``` + +2. **运行脚本** + ```bash + python reproduction_script.py + ``` + +3. **分析结果** + - 脚本会自动检测Agent B回答中的技术关键词 + - 如果发现技术相关内容,说明存在记忆隔离问题 + +## 问题分析 + +### 当前实现机制 + +1. **记忆存储**:所有节点共享同一个对话ID +2. **记忆获取**:基于整个对话获取历史消息 +3. **记忆配置**:每个节点可独立配置,但都基于同一对话 + +### 核心问题 + +```python +# 当前记忆获取逻辑 (api/core/workflow/nodes/llm/llm_utils.py) +def fetch_memory(variable_pool, app_id, node_data_memory, model_instance): + # 获取对话ID + conversation_id = variable_pool.get(["sys", "CONVERSATION_ID"]) + + # 基于整个对话获取记忆 - 问题所在 + conversation = session.scalar(stmt) + memory = TokenBufferMemory(conversation=conversation, model_instance=model_instance) +``` + +## 解决方案 + +### 方案1:节点特定记忆(推荐) + +1. **修改MemoryConfig** + ```python + class MemoryConfig(BaseModel): + window: WindowConfig + role_prefix: Optional[RolePrefix] = None + query_prompt_template: Optional[str] = None + # 新增:节点特定记忆开关 + node_specific_memory: bool = False + ``` + +2. **修改记忆获取逻辑** + ```python + def fetch_memory(variable_pool, app_id, node_data_memory, model_instance, node_id): + # 如果启用节点特定记忆,只获取该节点的历史 + if node_data_memory and node_data_memory.node_specific_memory: + # 过滤该节点的消息历史 + pass + ``` + +3. **前端UI修改** + - 在记忆配置中添加"节点特定记忆"开关 + - 提供用户友好的说明文字 + +### 方案2:记忆过滤 + +1. **数据库层面过滤** + - 在Message表中添加node_id字段 + - 根据节点ID过滤历史消息 + +2. **应用层面过滤** + - 在记忆获取时添加节点过滤条件 + - 只包含特定节点参与的消息 + +### 方案3:记忆上下文切换 + +1. **动态记忆管理** + - 在节点切换时调整记忆上下文 + - 根据节点类型选择性地包含历史 + +## 实施步骤 + +### 后端修改 + +1. **修改数据模型** + ```sql + -- 可选:在Message表中添加node_id字段 + ALTER TABLE messages ADD COLUMN node_id VARCHAR(255); + CREATE INDEX idx_messages_node_id ON messages(node_id); + ``` + +2. **修改核心代码** + - `api/core/prompt/entities/advanced_prompt_entities.py` + - `api/core/memory/token_buffer_memory.py` + - `api/core/workflow/nodes/llm/llm_utils.py` + +3. **添加测试用例** + - 单元测试验证记忆隔离功能 + - 集成测试验证多智能体场景 + +### 前端修改 + +1. **UI组件更新** + - `web/app/components/workflow/nodes/_base/components/memory-config.tsx` + - 添加节点特定记忆开关 + +2. **国际化支持** + - 在 `web/i18n/zh-Hans/workflow.ts` 中添加相关文本 + +3. **类型定义更新** + - 更新TypeScript类型定义 + +## 测试验证 + +### 功能测试 + +1. **记忆隔离测试** + - 验证节点特定记忆开关是否正常工作 + - 验证不同节点的记忆是否隔离 + +2. **多智能体测试** + - 测试技术Agent和商业Agent的记忆隔离 + - 验证回答的准确性和相关性 + +### 性能测试 + +1. **记忆过滤性能** + - 验证记忆过滤对性能的影响 + - 确保在大规模对话中的稳定性 + +2. **数据库查询优化** + - 验证节点过滤查询的性能 + - 确保索引的有效性 + +### 用户体验测试 + +1. **功能易用性** + - 验证用户是否能够理解和使用新功能 + - 确保配置界面的友好性 + +2. **向后兼容性** + - 验证现有工作流是否正常工作 + - 确保默认行为保持不变 + +## 预期效果 + +### 问题解决 + +1. **消除幻觉**:Agent B不再看到Agent A的技术对话历史 +2. **提高准确性**:每个智能体只基于自己的专业领域回答 +3. **改善用户体验**:多智能体对话更加准确和连贯 + +### 功能增强 + +1. **灵活配置**:用户可以选择是否启用节点特定记忆 +2. **向后兼容**:现有工作流无需修改即可正常工作 +3. **性能优化**:通过记忆过滤减少不必要的上下文 + +## 相关文件 + +- `reproduction_steps.md` - 详细的复现步骤 +- `implementation_example.py` - 实现示例代码 +- `reproduction_script.py` - 自动化复现脚本 + +## 贡献指南 + +如果您想为这个功能做出贡献: + +1. Fork Dify仓库 +2. 创建功能分支 +3. 实现节点特定记忆功能 +4. 添加测试用例 +5. 提交Pull Request + +## 联系方式 + +- GitHub Issue: [#21640](https://github.com/langgenius/dify/issues/21640) +- 项目主页: [https://github.com/langgenius/dify](https://github.com/langgenius/dify) diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index dbdcdc46ce..4062261e20 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -43,7 +43,7 @@ api.add_resource(AppImportConfirmApi, "/apps/imports//confirm" api.add_resource(AppImportCheckDependenciesApi, "/apps/imports//check-dependencies") # Import other controllers -from . import admin, apikey, extension, feature, ping, setup, version +from . import admin, apikey, extension, feature, ping, prompt_template, setup, version # Import app controllers from .app import ( diff --git a/api/controllers/console/prompt_template.py b/api/controllers/console/prompt_template.py new file mode 100644 index 0000000000..575105746c --- /dev/null +++ b/api/controllers/console/prompt_template.py @@ -0,0 +1,84 @@ +from flask_restful import Resource, reqparse +from werkzeug.exceptions import NotFound + +from controllers.console import api +from controllers.console.wraps import account_initialization_required, setup_required +from libs.login import login_required +from services.prompt_template_service import PromptTemplateService + + +class PromptTemplateListApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + templates = PromptTemplateService.get_prompt_templates() + # Manual serialization is no longer needed with Flask-RESTful + return [template.to_dict() for template in templates] + + @setup_required + @login_required + @account_initialization_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument('name', type=str, required=True, help='Name is required') + parser.add_argument('mode', type=str, required=True, help='Mode is required') + parser.add_argument('prompt_content', type=str, required=True, help='Prompt content is required.') + parser.add_argument('description', type=str, required=False) + parser.add_argument('tags', type=list, location='json') + parser.add_argument('model_name', type=str, required=False, location='json') + parser.add_argument('model_parameters', type=dict, required=False, location='json') + args = parser.parse_args() + + template = PromptTemplateService.create_prompt_template(**args) + return template.to_dict(), 201 + +api.add_resource(PromptTemplateListApi, '/prompt-templates') + +class PromptTemplateApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, template_id: str): + """ + Get a single prompt template. + """ + template = PromptTemplateService.get_prompt_template(template_id) + return template.to_dict() + + @setup_required + @login_required + @account_initialization_required + def put(self, template_id: str): + """ + Update a prompt template. + """ + parser = reqparse.RequestParser() + parser.add_argument('name', type=str, required=True, help='Name is required') + parser.add_argument('mode', type=str, required=True, help='Mode is required') + parser.add_argument('prompt_content', type=str, required=True, help='Prompt content is required') + parser.add_argument('description', type=str, required=False) + parser.add_argument('tags', type=list, location='json') + parser.add_argument('model_name', type=str, required=False, location='json') + parser.add_argument('model_parameters', type=dict, required=False, location='json') + args = parser.parse_args() + + template = PromptTemplateService.update_prompt_template(template_id=template_id, **args) + return template.to_dict() + + @setup_required + @login_required + @account_initialization_required + def delete(self, template_id: str): + """ + Delete a prompt template. + """ + try: + PromptTemplateService.delete_prompt_template(template_id) + except NotFound: + # According to REST principles, DELETE should be idempotent. + # If the resource is already gone, we can consider the operation successful. + pass + return '', 204 + +api.add_resource(PromptTemplateApi, '/prompt-templates/') \ No newline at end of file diff --git a/api/cookies.txt b/api/cookies.txt new file mode 100644 index 0000000000..c31d9899c3 --- /dev/null +++ b/api/cookies.txt @@ -0,0 +1,4 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + diff --git a/api/core/workflow/nodes/llm/entities.py b/api/core/workflow/nodes/llm/entities.py index 36d0688807..d1b3e1903d 100644 --- a/api/core/workflow/nodes/llm/entities.py +++ b/api/core/workflow/nodes/llm/entities.py @@ -60,6 +60,7 @@ class LLMNodeCompletionModelPromptTemplate(CompletionModelPromptTemplate): class LLMNodeData(BaseNodeData): model: ModelConfig + prompt_template_id: Optional[str] = None prompt_template: Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate prompt_config: PromptConfig = Field(default_factory=PromptConfig) memory: Optional[MemoryConfig] = None diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index b5225ce548..cb0920c563 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -68,6 +68,7 @@ from core.workflow.nodes.event import ( RunStreamChunkEvent, ) from core.workflow.utils.variable_template_parser import VariableTemplateParser +from services.prompt_template_service import PromptTemplateService from . import llm_utils from .entities import ( @@ -151,6 +152,28 @@ class LLMNode(BaseNode[LLMNodeData]): variable_pool = self.graph_runtime_state.variable_pool try: + # If a prompt template is used, load it and override node data + if self.node_data.prompt_template_id: + prompt_template_entity = PromptTemplateService.get_prompt_template_for_workflow( + template_id=self.node_data.prompt_template_id, + tenant_id=self.tenant_id + ) + latest_version = prompt_template_entity.get_latest_version() + + if latest_version: + # Override prompt_template based on its type + if isinstance(self.node_data.prompt_template, LLMNodeCompletionModelPromptTemplate): + self.node_data.prompt_template.text = latest_version.prompt_text + elif isinstance(self.node_data.prompt_template, list) and self.node_data.prompt_template: + # For chat mode, typically we modify the last user message or a specific system message. + # Here, for simplicity, we'll assume the main prompt is the first message. + self.node_data.prompt_template[0].text = latest_version.prompt_text + + # Override model settings + if latest_version.model_name and latest_version.model_parameters: + self.node_data.model.name = latest_version.model_name + self.node_data.model.completion_params = latest_version.model_parameters + # init messages template self.node_data.prompt_template = self._transform_chat_messages(self.node_data.prompt_template) diff --git a/api/migrations/versions/2025_06_27_2008-e3845da34e79_feat_add_prompt_management_tables.py b/api/migrations/versions/2025_06_27_2008-e3845da34e79_feat_add_prompt_management_tables.py new file mode 100644 index 0000000000..d8cf50821a --- /dev/null +++ b/api/migrations/versions/2025_06_27_2008-e3845da34e79_feat_add_prompt_management_tables.py @@ -0,0 +1,66 @@ +"""feat: add prompt management tables + +Revision ID: e3845da34e79 +Revises: 0ab65e1cc7fa +Create Date: 2025-06-27 20:08:44.738741 + +""" +from alembic import op +import models as models +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'e3845da34e79' +down_revision = '0ab65e1cc7fa' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('prompt_templates', + sa.Column('id', sa.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('mode', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('tags', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='prompt_template_pkey') + ) + with op.batch_alter_table('prompt_templates', schema=None) as batch_op: + batch_op.create_index('prompt_template_tenant_id_idx', ['tenant_id'], unique=False) + + op.create_table('prompt_versions', + sa.Column('id', sa.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('prompt_template_id', sa.UUID(), nullable=False), + sa.Column('version', sa.String(length=255), nullable=False), + sa.Column('prompt_text', sa.Text(), nullable=False), + sa.Column('variables', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('model_settings', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('created_by', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.ForeignKeyConstraint(['prompt_template_id'], ['prompt_templates.id'], name=op.f('prompt_versions_prompt_template_id_fkey')), + sa.PrimaryKeyConstraint('id', name='prompt_version_pkey') + ) + with op.batch_alter_table('prompt_versions', schema=None) as batch_op: + batch_op.create_index('prompt_version_template_id_idx', ['prompt_template_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('prompt_versions', schema=None) as batch_op: + batch_op.drop_index('prompt_version_template_id_idx') + + op.drop_table('prompt_versions') + with op.batch_alter_table('prompt_templates', schema=None) as batch_op: + batch_op.drop_index('prompt_template_tenant_id_idx') + + op.drop_table('prompt_templates') + # ### end Alembic commands ### diff --git a/api/migrations/versions/2025_06_28_1607-090bb5fe6078_add_model_name_and_model_parameters_to_.py b/api/migrations/versions/2025_06_28_1607-090bb5fe6078_add_model_name_and_model_parameters_to_.py new file mode 100644 index 0000000000..1583e76dc0 --- /dev/null +++ b/api/migrations/versions/2025_06_28_1607-090bb5fe6078_add_model_name_and_model_parameters_to_.py @@ -0,0 +1,35 @@ +"""add model_name and model_parameters to prompt_version + +Revision ID: 090bb5fe6078 +Revises: e3845da34e79 +Create Date: 2025-06-28 16:07:29.626618 + +""" +from alembic import op +import models as models +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '090bb5fe6078' +down_revision = 'e3845da34e79' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('prompt_versions', schema=None) as batch_op: + batch_op.add_column(sa.Column('model_name', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('model_parameters', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('prompt_versions', schema=None) as batch_op: + batch_op.drop_column('model_parameters') + batch_op.drop_column('model_name') + + # ### end Alembic commands ### diff --git a/api/models/__init__.py b/api/models/__init__.py index 83b50eb099..70f9dd3d01 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -56,6 +56,7 @@ from .model import ( TraceAppConfig, UploadFile, ) +from .prompt_template import PromptTemplate, PromptVersion from .provider import ( LoadBalancingModelConfig, Provider, @@ -140,6 +141,8 @@ __all__ = [ "MessageFile", "OperationLog", "PinnedConversation", + "PromptTemplate", + "PromptVersion", "Provider", "ProviderModel", "ProviderModelSetting", diff --git a/api/models/prompt_template.py b/api/models/prompt_template.py new file mode 100644 index 0000000000..c425081e6c --- /dev/null +++ b/api/models/prompt_template.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from sqlalchemy import desc +from sqlalchemy.dialects.postgresql import JSONB, UUID + +from .engine import db + +# from .account import Account <- This is the source of the circular import + + +class PromptTemplate(db.Model): + __tablename__ = 'prompt_templates' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='prompt_template_pkey'), + db.Index('prompt_template_tenant_id_idx', 'tenant_id'), + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + id = db.Column(UUID, primary_key=True, server_default=db.text('uuid_generate_v4()')) + tenant_id = db.Column(UUID, nullable=False) + name = db.Column(db.String(255), nullable=False) + mode = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text, nullable=True) + tags = db.Column(JSONB, nullable=True) + + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, + server_default=db.text('CURRENT_TIMESTAMP(0)'), + onupdate=db.text('CURRENT_TIMESTAMP(0)')) + + # Relationships + versions = db.relationship('PromptVersion', back_populates='prompt_template', + cascade="all, delete-orphan", lazy='dynamic') + + def get_latest_version(self): + """ + Fetches the most recent version of the prompt template. + """ + return self.versions.order_by(desc(PromptVersion.created_at)).first() + + def to_dict(self): + latest_version = self.get_latest_version() + model_settings = None + if latest_version: + model_settings = { + "model_name": latest_version.model_name, + "parameters": latest_version.model_parameters + } + + return { + 'id': str(self.id), + 'name': self.name, + 'mode': self.mode, + 'description': self.description, + 'tags': self.tags, + 'prompt_content': latest_version.prompt_text if latest_version else None, + 'model_settings': model_settings, + 'created_at': self.created_at.isoformat(), + 'updated_at': self.updated_at.isoformat(), + } + + def __repr__(self): + return f'' + + +class PromptVersion(db.Model): + __tablename__ = 'prompt_versions' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='prompt_version_pkey'), + db.Index('prompt_version_template_id_idx', 'prompt_template_id'), + ) + + id = db.Column(UUID, primary_key=True, server_default=db.text('uuid_generate_v4()')) + prompt_template_id = db.Column(UUID, db.ForeignKey('prompt_templates.id'), nullable=False) + version = db.Column(db.String(255), nullable=False, default='1.0') + prompt_text = db.Column(db.Text, nullable=False) + variables = db.Column(JSONB, nullable=True) + model_name = db.Column(db.String(255), nullable=True) + model_parameters = db.Column(JSONB, nullable=True) + model_settings = db.Column(JSONB, nullable=True) + + status = db.Column(db.String(50), nullable=False, default='draft') # e.g., draft, published, archived + + created_by = db.Column(UUID, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, + server_default=db.text('CURRENT_TIMESTAMP(0)'), + onupdate=db.text('CURRENT_TIMESTAMP(0)')) + + # Relationships + prompt_template = db.relationship('PromptTemplate', back_populates='versions') + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/api/print_routes.py b/api/print_routes.py new file mode 100644 index 0000000000..671c7a472f --- /dev/null +++ b/api/print_routes.py @@ -0,0 +1,4 @@ +from app import create_app + +app = create_app() +print(app.url_map) diff --git a/api/services/prompt_template_service.py b/api/services/prompt_template_service.py new file mode 100644 index 0000000000..3be446b3ce --- /dev/null +++ b/api/services/prompt_template_service.py @@ -0,0 +1,163 @@ +import logging +from typing import Optional +from uuid import UUID + +from flask_login import current_user +from werkzeug.exceptions import NotFound + +from models import db +from models.prompt_template import PromptTemplate, PromptVersion + + +class PromptTemplateService: + + @staticmethod + def get_prompt_templates(): + """ + Get all prompt templates for the current tenant. + """ + if not current_user.is_authenticated: + raise NotFound("User not authenticated.") + return db.session.query(PromptTemplate).filter_by(tenant_id=current_user.current_tenant_id).all() + + @staticmethod + def create_prompt_template( + name: str, + mode: str, + prompt_content: str, + description: Optional[str] = None, + tags: Optional[list] = None, + model_name: Optional[str] = None, + model_parameters: Optional[dict] = None, + ): + """ + Create a new prompt template and its initial version. + """ + logging.info(f"Attempting to create prompt template '{name}' for tenant {current_user.current_tenant_id}") + try: + if not current_user.is_authenticated: + logging.error("User not authenticated during prompt template creation.") + raise NotFound("User not authenticated.") + + template = PromptTemplate( + name=name, + mode=mode, + description=description, + tags=tags, + tenant_id=current_user.current_tenant_id, + ) + + initial_version = PromptVersion( + prompt_template=template, + prompt_text=prompt_content, + model_name=model_name, + model_parameters=model_parameters, + created_by=UUID(current_user.id), + ) + + db.session.add(template) + db.session.add(initial_version) + db.session.commit() + logging.info(f"Successfully created prompt template with id {template.id}") + + return template + except Exception as e: + logging.error(f"Error creating prompt template '{name}': {e}", exc_info=True) + db.session.rollback() + raise + + @staticmethod + def get_prompt_template(template_id: str): + """ + Get a specific prompt template by ID for the current tenant. + """ + if not current_user.is_authenticated: + raise NotFound("User not authenticated.") + + template = ( + db.session.query(PromptTemplate) + .filter_by(id=template_id, tenant_id=current_user.current_tenant_id) + .first() + ) + if not template: + raise NotFound(f"Prompt template with id {template_id} not found.") + return template + + @staticmethod + def get_prompt_template_for_workflow(template_id: str, tenant_id: str): + """ + Get a specific prompt template by ID for a specific tenant. + This method is designed for workflow context where current_user is not available. + """ + template = ( + db.session.query(PromptTemplate) + .filter_by(id=template_id, tenant_id=tenant_id) + .first() + ) + if not template: + raise NotFound(f"Prompt template with id {template_id} not found for the given tenant.") + return template + + @staticmethod + def update_prompt_template( + template_id: str, + name: str, + mode: str, + prompt_content: str, + description: Optional[str] = None, + tags: Optional[list] = None, + model_name: Optional[str] = None, + model_parameters: Optional[dict] = None, + ): + """ + Update a prompt template and create a new version if content changes. + """ + if not current_user.is_authenticated: + raise NotFound("User not authenticated.") + + template = PromptTemplateService.get_prompt_template(template_id) + if not template: + raise NotFound(f"Prompt template with id {template_id} not found.") + + # Update the main template fields + template.name = name + template.mode = mode + template.description = description + template.tags = tags + + latest_version = template.get_latest_version() + + # Create a new version only if the prompt content or model settings have changed + if (latest_version.prompt_text != prompt_content or + latest_version.model_name != model_name or + latest_version.model_parameters != model_parameters): + new_version = PromptVersion( + prompt_template=template, + prompt_text=prompt_content, + model_name=model_name, + model_parameters=model_parameters, + created_by=UUID(current_user.id), + ) + db.session.add(new_version) + + db.session.commit() + return template + + @staticmethod + def delete_prompt_template(template_id: str): + """ + Delete a specific prompt template by ID for the current tenant. + """ + if not current_user.is_authenticated: + raise NotFound("User not authenticated.") + + template = ( + db.session.query(PromptTemplate) + .filter_by(id=template_id, tenant_id=current_user.current_tenant_id) + .first() + ) + if not template: + raise NotFound(f"Prompt template with id {template_id} not found.") + + db.session.delete(template) + db.session.commit() \ No newline at end of file diff --git a/docker/volumes/myscale/config/users.d/custom_users_config.xml b/docker/volumes/myscale/config/users.d/custom_users_config.xml deleted file mode 100644 index b46e73a0e9..0000000000 --- a/docker/volumes/myscale/config/users.d/custom_users_config.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - ::1 - 127.0.0.1 - 10.0.0.0/8 - 172.16.0.0/12 - 192.168.0.0/16 - - default - default - 1 - - - diff --git a/docker/volumes/oceanbase/init.d/vec_memory.sql b/docker/volumes/oceanbase/init.d/vec_memory.sql deleted file mode 100644 index 0d859e5f7c..0000000000 --- a/docker/volumes/oceanbase/init.d/vec_memory.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER SYSTEM SET ob_vector_memory_limit_percentage = 30; diff --git a/docker/volumes/opensearch/opensearch_dashboards.yml b/docker/volumes/opensearch/opensearch_dashboards.yml deleted file mode 100644 index f50d63bbb9..0000000000 --- a/docker/volumes/opensearch/opensearch_dashboards.yml +++ /dev/null @@ -1,222 +0,0 @@ ---- -# Copyright OpenSearch Contributors -# SPDX-License-Identifier: Apache-2.0 - -# Description: -# Default configuration for OpenSearch Dashboards - -# OpenSearch Dashboards is served by a back end server. This setting specifies the port to use. -# server.port: 5601 - -# Specifies the address to which the OpenSearch Dashboards server will bind. IP addresses and host names are both valid values. -# The default is 'localhost', which usually means remote machines will not be able to connect. -# To allow connections from remote users, set this parameter to a non-loopback address. -# server.host: "localhost" - -# Enables you to specify a path to mount OpenSearch Dashboards at if you are running behind a proxy. -# Use the `server.rewriteBasePath` setting to tell OpenSearch Dashboards if it should remove the basePath -# from requests it receives, and to prevent a deprecation warning at startup. -# This setting cannot end in a slash. -# server.basePath: "" - -# Specifies whether OpenSearch Dashboards should rewrite requests that are prefixed with -# `server.basePath` or require that they are rewritten by your reverse proxy. -# server.rewriteBasePath: false - -# The maximum payload size in bytes for incoming server requests. -# server.maxPayloadBytes: 1048576 - -# The OpenSearch Dashboards server's name. This is used for display purposes. -# server.name: "your-hostname" - -# The URLs of the OpenSearch instances to use for all your queries. -# opensearch.hosts: ["http://localhost:9200"] - -# OpenSearch Dashboards uses an index in OpenSearch to store saved searches, visualizations and -# dashboards. OpenSearch Dashboards creates a new index if the index doesn't already exist. -# opensearchDashboards.index: ".opensearch_dashboards" - -# The default application to load. -# opensearchDashboards.defaultAppId: "home" - -# Setting for an optimized healthcheck that only uses the local OpenSearch node to do Dashboards healthcheck. -# This settings should be used for large clusters or for clusters with ingest heavy nodes. -# It allows Dashboards to only healthcheck using the local OpenSearch node rather than fan out requests across all nodes. -# -# It requires the user to create an OpenSearch node attribute with the same name as the value used in the setting -# This node attribute should assign all nodes of the same cluster an integer value that increments with each new cluster that is spun up -# e.g. in opensearch.yml file you would set the value to a setting using node.attr.cluster_id: -# Should only be enabled if there is a corresponding node attribute created in your OpenSearch config that matches the value here -# opensearch.optimizedHealthcheckId: "cluster_id" - -# If your OpenSearch is protected with basic authentication, these settings provide -# the username and password that the OpenSearch Dashboards server uses to perform maintenance on the OpenSearch Dashboards -# index at startup. Your OpenSearch Dashboards users still need to authenticate with OpenSearch, which -# is proxied through the OpenSearch Dashboards server. -# opensearch.username: "opensearch_dashboards_system" -# opensearch.password: "pass" - -# Enables SSL and paths to the PEM-format SSL certificate and SSL key files, respectively. -# These settings enable SSL for outgoing requests from the OpenSearch Dashboards server to the browser. -# server.ssl.enabled: false -# server.ssl.certificate: /path/to/your/server.crt -# server.ssl.key: /path/to/your/server.key - -# Optional settings that provide the paths to the PEM-format SSL certificate and key files. -# These files are used to verify the identity of OpenSearch Dashboards to OpenSearch and are required when -# xpack.security.http.ssl.client_authentication in OpenSearch is set to required. -# opensearch.ssl.certificate: /path/to/your/client.crt -# opensearch.ssl.key: /path/to/your/client.key - -# Optional setting that enables you to specify a path to the PEM file for the certificate -# authority for your OpenSearch instance. -# opensearch.ssl.certificateAuthorities: [ "/path/to/your/CA.pem" ] - -# To disregard the validity of SSL certificates, change this setting's value to 'none'. -# opensearch.ssl.verificationMode: full - -# Time in milliseconds to wait for OpenSearch to respond to pings. Defaults to the value of -# the opensearch.requestTimeout setting. -# opensearch.pingTimeout: 1500 - -# Time in milliseconds to wait for responses from the back end or OpenSearch. This value -# must be a positive integer. -# opensearch.requestTimeout: 30000 - -# List of OpenSearch Dashboards client-side headers to send to OpenSearch. To send *no* client-side -# headers, set this value to [] (an empty list). -# opensearch.requestHeadersWhitelist: [ authorization ] - -# Header names and values that are sent to OpenSearch. Any custom headers cannot be overwritten -# by client-side headers, regardless of the opensearch.requestHeadersWhitelist configuration. -# opensearch.customHeaders: {} - -# Time in milliseconds for OpenSearch to wait for responses from shards. Set to 0 to disable. -# opensearch.shardTimeout: 30000 - -# Logs queries sent to OpenSearch. Requires logging.verbose set to true. -# opensearch.logQueries: false - -# Specifies the path where OpenSearch Dashboards creates the process ID file. -# pid.file: /var/run/opensearchDashboards.pid - -# Enables you to specify a file where OpenSearch Dashboards stores log output. -# logging.dest: stdout - -# Set the value of this setting to true to suppress all logging output. -# logging.silent: false - -# Set the value of this setting to true to suppress all logging output other than error messages. -# logging.quiet: false - -# Set the value of this setting to true to log all events, including system usage information -# and all requests. -# logging.verbose: false - -# Set the interval in milliseconds to sample system and process performance -# metrics. Minimum is 100ms. Defaults to 5000. -# ops.interval: 5000 - -# Specifies locale to be used for all localizable strings, dates and number formats. -# Supported languages are the following: English - en , by default , Chinese - zh-CN . -# i18n.locale: "en" - -# Set the allowlist to check input graphite Url. Allowlist is the default check list. -# vis_type_timeline.graphiteAllowedUrls: ['https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite'] - -# Set the blocklist to check input graphite Url. Blocklist is an IP list. -# Below is an example for reference -# vis_type_timeline.graphiteBlockedIPs: [ -# //Loopback -# '127.0.0.0/8', -# '::1/128', -# //Link-local Address for IPv6 -# 'fe80::/10', -# //Private IP address for IPv4 -# '10.0.0.0/8', -# '172.16.0.0/12', -# '192.168.0.0/16', -# //Unique local address (ULA) -# 'fc00::/7', -# //Reserved IP address -# '0.0.0.0/8', -# '100.64.0.0/10', -# '192.0.0.0/24', -# '192.0.2.0/24', -# '198.18.0.0/15', -# '192.88.99.0/24', -# '198.51.100.0/24', -# '203.0.113.0/24', -# '224.0.0.0/4', -# '240.0.0.0/4', -# '255.255.255.255/32', -# '::/128', -# '2001:db8::/32', -# 'ff00::/8', -# ] -# vis_type_timeline.graphiteBlockedIPs: [] - -# opensearchDashboards.branding: -# logo: -# defaultUrl: "" -# darkModeUrl: "" -# mark: -# defaultUrl: "" -# darkModeUrl: "" -# loadingLogo: -# defaultUrl: "" -# darkModeUrl: "" -# faviconUrl: "" -# applicationTitle: "" - -# Set the value of this setting to true to capture region blocked warnings and errors -# for your map rendering services. -# map.showRegionBlockedWarning: false% - -# Set the value of this setting to false to suppress search usage telemetry -# for reducing the load of OpenSearch cluster. -# data.search.usageTelemetry.enabled: false - -# 2.4 renames 'wizard.enabled: false' to 'vis_builder.enabled: false' -# Set the value of this setting to false to disable VisBuilder -# functionality in Visualization. -# vis_builder.enabled: false - -# 2.4 New Experimental Feature -# Set the value of this setting to true to enable the experimental multiple data source -# support feature. Use with caution. -# data_source.enabled: false -# Set the value of these settings to customize crypto materials to encryption saved credentials -# in data sources. -# data_source.encryption.wrappingKeyName: 'changeme' -# data_source.encryption.wrappingKeyNamespace: 'changeme' -# data_source.encryption.wrappingKey: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] - -# 2.6 New ML Commons Dashboards Feature -# Set the value of this setting to true to enable the ml commons dashboards -# ml_commons_dashboards.enabled: false - -# 2.12 New experimental Assistant Dashboards Feature -# Set the value of this setting to true to enable the assistant dashboards -# assistant.chat.enabled: false - -# 2.13 New Query Assistant Feature -# Set the value of this setting to false to disable the query assistant -# observability.query_assist.enabled: false - -# 2.14 Enable Ui Metric Collectors in Usage Collector -# Set the value of this setting to true to enable UI Metric collections -# usageCollection.uiMetric.enabled: false - -opensearch.hosts: [https://localhost:9200] -opensearch.ssl.verificationMode: none -opensearch.username: admin -opensearch.password: 'Qazwsxedc!@#123' -opensearch.requestHeadersWhitelist: [authorization, securitytenant] - -opensearch_security.multitenancy.enabled: true -opensearch_security.multitenancy.tenants.preferred: [Private, Global] -opensearch_security.readonly_mode.roles: [kibana_read_only] -# Use this setting if you are running opensearch-dashboards without https -opensearch_security.cookie.secure: false -server.host: '0.0.0.0' diff --git a/docker/volumes/sandbox/conf/config.yaml b/docker/volumes/sandbox/conf/config.yaml deleted file mode 100644 index 8c1a1deb54..0000000000 --- a/docker/volumes/sandbox/conf/config.yaml +++ /dev/null @@ -1,14 +0,0 @@ -app: - port: 8194 - debug: True - key: dify-sandbox -max_workers: 4 -max_requests: 50 -worker_timeout: 5 -python_path: /usr/local/bin/python3 -enable_network: True # please make sure there is no network risk in your environment -allowed_syscalls: # please leave it empty if you have no idea how seccomp works -proxy: - socks5: '' - http: '' - https: '' diff --git a/docker/volumes/sandbox/conf/config.yaml.example b/docker/volumes/sandbox/conf/config.yaml.example deleted file mode 100644 index f92c19e51a..0000000000 --- a/docker/volumes/sandbox/conf/config.yaml.example +++ /dev/null @@ -1,35 +0,0 @@ -app: - port: 8194 - debug: True - key: dify-sandbox -max_workers: 4 -max_requests: 50 -worker_timeout: 5 -python_path: /usr/local/bin/python3 -python_lib_path: - - /usr/local/lib/python3.10 - - /usr/lib/python3.10 - - /usr/lib/python3 - - /usr/lib/x86_64-linux-gnu - - /etc/ssl/certs/ca-certificates.crt - - /etc/nsswitch.conf - - /etc/hosts - - /etc/resolv.conf - - /run/systemd/resolve/stub-resolv.conf - - /run/resolvconf/resolv.conf - - /etc/localtime - - /usr/share/zoneinfo - - /etc/timezone - # add more paths if needed -python_pip_mirror_url: https://pypi.tuna.tsinghua.edu.cn/simple -nodejs_path: /usr/local/bin/node -enable_network: True -allowed_syscalls: - - 1 - - 2 - - 3 - # add all the syscalls which you require -proxy: - socks5: '' - http: '' - https: '' diff --git a/docker/volumes/sandbox/dependencies/python-requirements.txt b/docker/volumes/sandbox/dependencies/python-requirements.txt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/package.json b/package.json new file mode 100644 index 0000000000..c4a6474840 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "date-fns": "^4.1.0" + } +} diff --git a/web/app/(commonLayout)/prompt-templates/Container.tsx b/web/app/(commonLayout)/prompt-templates/Container.tsx new file mode 100644 index 0000000000..b10e0fa443 --- /dev/null +++ b/web/app/(commonLayout)/prompt-templates/Container.tsx @@ -0,0 +1,95 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { useRouter } from 'next/navigation' +import { fetchPromptTemplates, deletePromptTemplate } from '@/service/prompt-template' +import type { PromptTemplate } from '@/models/prompt-template' +import Button from '@/app/components/base/button' +import NewTemplateModal from './NewTemplateModal' +import { TrashIcon } from '@heroicons/react/24/outline' + +const Container = () => { + const router = useRouter() + const [templates, setTemplates] = useState([]) + const [showNewModal, setShowNewModal] = useState(false) + + const fetchTemplates = useCallback(() => { + fetchPromptTemplates().then(res => { + // res may be an array or an object with a data property + const templateList = Array.isArray(res) ? res : res.data + setTemplates(templateList || []) + }) + }, []) + + useEffect(() => { + fetchTemplates() + }, [fetchTemplates]) + + const handleSuccess = () => { + fetchTemplates() + } + + const handleDelete = async (e: React.MouseEvent, id: string) => { + e.stopPropagation() // Prevent row click event + if (window.confirm('Are you sure you want to delete this template?')) { + try { + await deletePromptTemplate(id) + fetchTemplates() + } catch (e: any) { + alert(`Failed to delete template: ${e.message}`) + } + } + } + + const handleRowClick = (id: string) => { + router.push(`/prompt-templates/${id}`) + } + + return ( + <> +
+
+

Prompt Templates

+ +
+
+ + + + + + + + + + + {templates.map((template) => ( + handleRowClick(template.id)} className="cursor-pointer hover:bg-gray-50"> + + + + + + ))} + +
NameModelCreated At + Actions +
{template.name}{template.model_settings?.model_name}{new Date(template.created_at).toLocaleString()} + +
+
+
+ {showNewModal && ( + setShowNewModal(false)} + onSuccess={handleSuccess} + /> + )} + + ) +} + +export default Container \ No newline at end of file diff --git a/web/app/(commonLayout)/prompt-templates/Form.tsx b/web/app/(commonLayout)/prompt-templates/Form.tsx new file mode 100644 index 0000000000..8e91643f24 --- /dev/null +++ b/web/app/(commonLayout)/prompt-templates/Form.tsx @@ -0,0 +1,187 @@ +'use client' +import React, { useState } from 'react' +import type { PromptTemplate, PromptTemplateRequest } from '@/models/prompt-template' +import Input from '@/app/components/base/input' +import Textarea from '@/app/components/base/textarea' +import Button from '@/app/components/base/button' +import TagInput from '@/app/components/base/tag-input' +import Select from '@/app/components/base/select' +import { format as formatDate } from 'date-fns' +import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import Trigger from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger' + +type FormProps = { + type: 'create' | 'edit' + template?: PromptTemplate + templateId?: string + onSave: (data: PromptTemplateRequest) => void + onCancel: () => void +} + +const Form = ({ type, template, templateId, onSave, onCancel }: FormProps) => { + const [formData, setFormData] = useState({ + name: template?.name || '', + description: template?.description || '', + tags: template?.tags || [], + prompt_content: template?.prompt_content || '', + mode: template?.mode || 'completion', + model_name: template?.model_settings?.model_name || 'gpt-3.5-turbo', + provider: 'openai', // hardcode for now + model_parameters: template?.model_settings?.parameters || { temperature: 0.7 }, + }) + const [open, setOpen] = useState(false) + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData(prev => ({ ...prev, [name]: value })) + } + + const handleSelectChange = (item: {value: string | number}) => { + setFormData(prev => ({ ...prev, mode: item.value as string })) + } + + const handleTagsChange = (tags: string[]) => { + setFormData(prev => ({ ...prev, tags })) + } + + const handleModelChange = (model: { modelId: string; provider: string; }) => { + setFormData(prev => ({ + ...prev, + model_name: model.modelId, + provider: model.provider, + model_parameters: {}, + })) + } + + const handleModelParamsChange = (params: FormValue) => { + setFormData(prev => ({ ...prev, model_parameters: params })) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const { provider, ...restData } = formData + onSave(restData as PromptTemplateRequest) + } + + const title = type === 'create' ? 'Create Prompt Template' : 'Edit Prompt Template' + + return ( +
+

{title}

+
+ {type === 'edit' && ( +
+ +

{templateId}

+
+ )} + +
+ + +
+ +
+ +