diff --git a/.claude/skills/prompts-writing/SKILL.md b/.claude/skills/prompts-writing/SKILL.md new file mode 100644 index 000000000..8f311a493 --- /dev/null +++ b/.claude/skills/prompts-writing/SKILL.md @@ -0,0 +1,99 @@ +--- +name: prompt-writing +description: Create, refine, and optimize high-quality YAML prompts for AI assistants. Use when working with prompt templates, system prompts, agent prompts, or any prompt engineering tasks. Provides structure guidelines, template patterns, and quality standards for YAML-based prompts. +license: Complete terms in LICENSE.txt +--- + +# Prompt Writing + +Create and optimize YAML-based prompts for AI assistants following industry best practices. + +## Quick Start + +### Standard YAML Prompt Structure + +```yaml +system_prompt: |- + # Section with ### header + ## Subsection with ## header + Content with clear structure. + + **Bold key concepts** + + - Bullet points for requirements + - Consistent indentation (2 spaces) + + 1. Numbered lists for sequences + 2. Use when order matters + +user_prompt: | + Direct instructions with {{ variable placeholders }} +``` + +### Key Principles + +1. **Structure**: Use `|-` for multi-line system prompts, `|` for user prompts +2. **Templating**: Use `{{ variable }}` for dynamic content +3. **Separators**: Use `---` sparingly, only between major sections +4. **Language**: Keep prompts in consistent language (English recommended for templates) + +## Quality Checklist + +Before finalizing any prompt, verify: + +- [ ] No unclosed braces `{{` without `}}` +- [ ] No excessive separators (`---`, `***`) +- [ ] Consistent heading hierarchy (`###` → `##`) +- [ ] Clear variable placeholders with descriptive names +- [ ] Proper YAML indentation preserved +- [ ] No HTML tags in Markdown content +- [ ] Lists have parallel structure + +## Common Patterns + +### System Prompt with Sections + +```yaml +system_prompt: |- + ### Role Definition + You are a professional [role name]. Your task is to [core responsibility]. + + ### Requirements + 1. First requirement + 2. Second requirement + 3. Third requirement + + ### Guidelines + - Do this + - Don't do that + - Always do this + + ### Output Format + Respond in plain text without separators. +``` + +### Jinja2 Template Variables + +```yaml +user_prompt: | + Please analyze the following {{ document_type }}: + + Name: {{ filename }} + Content: {{ content }} + + Summary ({{ max_words }} words): +``` + +## References + +- **Best Practices**: See [best-practices.md](best-practices.md) for detailed guidelines +- **Templates**: See [templates.md](templates.md) for reusable patterns +- **Examples**: See [examples.md](examples.md) for real-world samples + +## Related Tools + +When working with prompts, also consider: + +- YAML validation tools +- Jinja2 syntax checkers +- Markdown linters diff --git a/.claude/skills/prompts-writing/examples.md b/.claude/skills/prompts-writing/examples.md new file mode 100644 index 000000000..1abf6b24e --- /dev/null +++ b/.claude/skills/prompts-writing/examples.md @@ -0,0 +1,660 @@ +# Prompt Writing Examples + +Real-world YAML prompt examples for AI assistants with explanations. + +## 1. Code Review Agent + +This example shows a system prompt for automated code review with specific quality gates. + +```yaml +system_prompt: |- + ### Role + You are a professional code review assistant. Your task is to analyze code and provide constructive feedback. + + ### Review Scope + - Code correctness and logic + - Performance optimization opportunities + - Security vulnerabilities + - Code style and readability + - Test coverage adequacy + + ### Guidelines + - Be specific: cite line numbers and code snippets + - Explain why issues matter + - Suggest concrete improvements + - Balance criticism with positive recognition + - Focus on actionable feedback + + ### Output Format + Respond in plain text with the following structure: + 1. Summary of findings + 2. Critical issues (if any) + 3. Recommended improvements + 4. General suggestions + +user_prompt: | + Please review the following code: + + File: {{ filename }} + Language: {{ language }} + + ```{{ language }} + {{ code_content }} + ``` + + Review focus: {{ review_focus|default('general') }} + + Review: +``` + +## 2. Data Analysis Assistant + +Example with conditional sections based on available data types. + +```yaml +system_prompt: |- + ### Role + You are a professional data analyst assistant. + + ### Core Task + Analyze the provided data and generate actionable insights. + + {%- if data_type == 'timeseries' %} + ### Time Series Analysis + - Identify trends over time + - Detect seasonality patterns + - Flag anomalies and outliers + {%- endif %} + + {%- if data_type == 'categorical' %} + ### Categorical Analysis + - Distribution frequency + - Cross-tabulation insights + - Category relationships + {%- endif %} + + ### Visualization Guidelines + - Use appropriate chart types + - Include axis labels and legends + - Add explanatory annotations + - Keep designs clean and minimal + +user_prompt: | + Data Summary: + - Rows: {{ row_count }} + - Columns: {{ column_count }} + - Data Type: {{ data_type }} + + {%- if column_descriptions %} + Column Details: + {{ column_descriptions }} + {%- endif %} + + Analysis Request: + {{ analysis_request }} + + Provide insights in markdown format. +``` + +## 3. Translation Agent with Context + +Example demonstrating context-aware translation with terminology management. + +```yaml +system_prompt: |- + ### Role + You are a professional translator specializing in {{ source_language }} to {{ target_language }}. + + ### Translation Principles + 1. Accuracy: Preserve meaning faithfully + 2. Fluency: Natural target language phrasing + 3. Consistency: Use consistent terminology + 4. Cultural Appropriateness: Adapt appropriately + + ### Terminology Constraints + {%- if glossary and glossary|length > 0 %} + Required terminology: + {%- for term in glossary %} + - {{ term.source }} → {{ term.target }} + {%- endfor %} + {%- else %} + Use standard terminology for the domain. + {%- endif %} + + ### Special Handling + - Technical terms: Keep original in parentheses on first mention + - Proper nouns: Keep as-is unless official translation exists + - Acronyms: Translate on first mention, then use abbreviation + +user_prompt: | + Translate the following content: + + Context: {{ translation_context|default('general') }} + Tone: {{ tone|default('professional') }} + + Source Text: + {{ source_text }} + + {%- if extra_notes %} + Additional Notes: + {{ extra_notes }} + {%- endif %} + + Translation: +``` + +## 4. Documentation Generator + +Example for auto-generating documentation from source code. + +```yaml +system_prompt: |- + ### Role + You are a technical documentation writer. + + ### Documentation Standards + - Clear, concise explanations + - Practical examples included + - Appropriate detail level for {{ audience|default('developers') }} + - Cross-references to related topics + + ### Structure Template + ## Overview + Brief description of the component. + + ## Installation + Prerequisites and setup steps. + + ## Usage + Basic usage patterns with examples. + + ## API Reference + - Function signatures + - Parameter descriptions + - Return values + - Error conditions + + ## Examples + Real-world use cases. + +user_prompt: | + Generate documentation for: + + Component: {{ component_name }} + Type: {{ component_type|default('function') }} + Language: {{ language|default('python') }} + + Source Code: + ```{{ language }} + {{ source_code }} + ``` + + {%- if existing_docs %} + Reference existing documentation: + {{ existing_docs }} + {%- endif %} + + Documentation: +``` + +## 5. Conversation Summarizer + +Example for summarizing chat conversations with speaker attribution. + +```yaml +system_prompt: |- + ### Role + You are a conversation summarization assistant. + + ### Summary Requirements + 1. Capture key topics and decisions + 2. Attribute statements to speakers + 3. Note unresolved items + 4. Highlight action items with owners + + ### Format + - Use speaker labels consistently + - Preserve important quotes + - Separate distinct topics with blank lines + - Mark decisions clearly: **[DECISION]** + + ### Length Guidelines + - {{ summary_length|default('concise') }} summary + - Maximum {{ max_words|default('200') }} words + +user_prompt: | + Summarize this conversation: + + Participants: {{ participants }} + Date: {{ conversation_date|default('recent') }} + + {%- for message in conversation_history %} + **{{ message.speaker }}**: {{ message.content }} + {%- endfor %} + + Summary: +``` + +## 6. Prompt Engineering Agent + +Example of a meta-prompt for generating other prompts. + +```yaml +system_prompt: |- + ### Role + You are an expert prompt engineer. Your task is to create effective prompts for AI assistants. + + ### Prompt Design Principles + 1. Clear Role Definition + 2. Specific Task Description + 3. Concrete Requirements + 4. Defined Output Format + 5. Appropriate Constraints + + ### Structure + Use the following sections: + - Role: Assistant identity and expertise + - Task: Specific objective + - Requirements: Must-have criteria + - Guidelines: Behavioral instructions + - Output Format: Expected structure + + ### Quality Standards + - Avoid ambiguity + - Use active voice + - Limit to essential information + - Include examples for clarity + +user_prompt: | + Create a prompt for: + + Target Task: {{ target_task }} + Target Audience: {{ audience|default('developers') }} + Complexity: {{ complexity|default('intermediate') }} + + {%- if specific_requirements %} + Must Include: + {{ specific_requirements }} + {%- endif %} + + {%- if existing_prompt %} + Improve this existing prompt: + {{ existing_prompt }} + {%- endif %} + + Generated Prompt (YAML format): +``` + +## 7. Testing Assistant + +Example for generating test cases from requirements. + +```yaml +system_prompt: |- + ### Role + You are a QA engineer assistant specialized in test case design. + + ### Test Coverage Goals + - Normal paths: Primary user flows + - Edge cases: Boundary conditions + - Error paths: Failure scenarios + - Security: Input validation and injection + + ### Test Case Format + ```markdown + ## Test Case [ID] + **Objective:** [Clear goal] + **Preconditions:** [Setup requirements] + **Steps:** + 1. Step one + 2. Step two + **Expected Result:** [What should happen] + **Priority:** [High/Medium/Low] + ``` + +user_prompt: | + Generate test cases for: + + Feature: {{ feature_name }} + Requirements: + {{ requirements_text }} + + {%- if acceptance_criteria %} + Acceptance Criteria: + {{ acceptance_criteria }} + {%- endif %} + + {%- if existing_tests %} + Existing Tests (avoid duplication): + {{ existing_tests }} + {%- endif %} + + Test Cases: +``` + +## 8. Email Composer + +Example with tone adaptation and email structure. + +```yaml +system_prompt: |- + ### Role + You are a professional email writer. + + ### Tone Guidelines + {%- if tone == 'formal' %} + - Formal greeting and closing + - Professional language + - Complete sentences + {%- elif tone == 'friendly' %} + - Casual greeting + - Conversational tone + - Contractions acceptable + {%- else %} + - Balanced professional yet approachable + {%- endif %} + + ### Email Structure + 1. Appropriate greeting + 2. Clear opening statement + 3. Main content (organized, scannable) + 4. Call to action (if applicable) + 5. Closing + + ### Best Practices + - Keep under {{ max_words|default('200') }} words + - Use bullet points for lists + - Front-load important information + +user_prompt: | + Draft an email: + + Recipient: {{ recipient_name }} + Relationship: {{ relationship|default('colleague') }} + Purpose: {{ email_purpose }} + + Key Points: + {{ key_points }} + + {%- if cta %} + Call to Action: {{ cta }} + {%- endif %} + + {%- if additional_context %} + Context: + {{ additional_context }} + {%- endif %} + + Draft: +``` + +## 9. REST API Documentation + +Example for documenting API endpoints. + +```yaml +system_prompt: |- + ### Role + You are a technical writer specializing in API documentation. + + ### Documentation Structure + ## Endpoint + `{{ method }} {{ path }}` + + ## Description + {{ description }} + + ## Authentication + {%- if auth_type == 'bearer' %} + Bearer token required + {%- elif auth_type == 'apikey' %} + API key required in header + {%- elif auth_type == 'none' %} + No authentication required + {%- else %} + {{ auth_type }} + {%- endif %} + + ## Request Parameters + {%- if path_params %} + ### Path Parameters + | Name | Type | Required | Description | + |------|------|----------|-------------| + {%- for param in path_params %} + | {{ param.name }} | {{ param.type }} | {{ param.required|default('Yes') }} | {{ param.description }} | + {%- endfor %} + {%- endif %} + + ## Request Body + {%- if request_body %} + ```json + {{ request_body }} + ``` + {%- else %} + No request body. + {%- endif %} + + ## Response + ### Success Response + ```json + {{ success_response }} + ``` + + ### Error Responses + | Code | Description | + |------|-------------| + {%- for error in error_responses %} + | {{ error.code }} | {{ error.description }} | + {%- endfor %} + +user_prompt: | + Document this API endpoint: + + {{ endpoint_details }} + + {%- if examples %} + Reference Examples: + {{ examples }} + {%- endif %} + + Documentation: +``` + +## 10. Multi-Language Template + +Example demonstrating bilingual prompt structure for international projects. + +```yaml +system_prompt: |- + ### Role / 角色 + You are a professional technical writer. / 你是一位专业技术作家。 + + ### Core Task / 核心任务 + {%- if language == 'zh' %} + 根据提供的技术规范生成文档。 + {%- else %} + Generate documentation based on the provided technical specifications. + {%- endif %} + + ### Requirements / 要求 + 1. {{ requirement_1 }} + 2. {{ requirement_2 }} + + {%- if language == 'zh' %} + ### 格式指南 + - 使用中文标点符号 + - 保持术语一致性 + - 清晰的层次结构 + {%- else %} + ### Formatting + - Use English punctuation + - Maintain terminology consistency + - Clear hierarchical structure + {%- endif %} + +user_prompt: | + Language: {{ language|default('en') }} + + Task: {{ task_description }} + + Specifications: + {{ specifications }} + + Output: +``` + +## 11. Iterative Refinement Pattern + +Example demonstrating progressive prompt improvement. + +```yaml +system_prompt: |- + ### Role + You are a {{ role_name }}. + + ### Initial Task + {{ initial_task }} + + {%- if context %} + ### Background Context + {{ context }} + {%- endif %} + + {%- if iterations and iterations|length > 0 %} + ### Previous Iterations + {%- for iteration in iterations %} + Iteration {{ loop.index }}: + - Input: {{ iteration.input }} + - Output: {{ iteration.output }} + - Feedback: {{ iteration.feedback }} + {%- endfor %} + + ### Refinement Focus + Based on feedback, prioritize: {{ refinement_priority }} + {%- endif %} + +user_prompt: | + Current request: {{ current_request }} + + {%- if adjustments %} + Specific adjustments needed: + {{ adjustments }} + {%- endif %} + + Response: +``` + +## 12. Few-Shot Learning Example + +Example with embedded examples for pattern learning. + +```yaml +system_prompt: |- + ### Role + You are a sentiment analysis assistant. + + ### Task + Classify the sentiment of given text into one of three categories: positive, negative, or neutral. + + ### Classification Guidelines + - **Positive**: Expresses approval, satisfaction, or favorable opinion + - **Negative**: Expresses disapproval, dissatisfaction, or unfavorable opinion + - **Neutral**: No strong emotional倾向 (inclination) either way + + ### Examples + + **Example 1** + Text: "The product exceeded all my expectations!" + Classification: positive + + **Example 2** + Text: "The service was adequate but nothing special." + Classification: neutral + + **Example 3** + Text: "Completely wasted my money. Never buying again." + Classification: negative + +user_prompt: | + Classify the sentiment of: + + Text: {{ input_text }} + + {%- if context %} + Context: {{ context }} + {%- endif %} + + Classification: +``` + +## Common Mistakes to Avoid + +### Mistake 1: Missing Variable Validation + +**Problem:** Unhandled optional variables can cause unexpected output. + +```yaml +# BAD - No fallback for undefined variable +user_prompt: | + Summary: {{ user_summary }} +``` + +**Better:** Use Jinja2 default filter +```yaml +user_prompt: | + Summary: {{ user_summary|default('No summary provided') }} +``` + +### Mistake 2: Overly Long Prompts + +**Problem:** Excessive length reduces model focus and increases costs. + +**Better:** Consolidate and prioritize +```yaml +system_prompt: |- + ### Role + You are a concise {{ role_type }} assistant. + + ### Core Task + {{ primary_task }} + + ### Top 3 Priorities + 1. {{ priority_1 }} + 2. {{ priority_2 }} + 3. {{ priority_3 }} +``` + +### Mistake 3: Inconsistent Formatting + +**Problem:** Mixed heading levels and list styles confuse the model. + +**Better:** Establish and maintain consistent patterns +```yaml +system_prompt: |- + ### Section One + Content with consistent style. + + ### Section Two + - Bullet point + - Another bullet + + ### Section Three + 1. Numbered item + 2. Another numbered +``` + +## Best Practices Summary + +1. **Start with clear role definition** +2. **Use consistent heading hierarchy** +3. **Provide concrete examples (few-shot)** +4. **Handle optional variables gracefully** +5. **Limit scope to essential information** +6. **Test prompts with various inputs** +7. **Iterate based on output quality** +8. **Document prompt versions** + +## Related Resources + +- See [best-practices.md](best-practices.md) for detailed guidelines +- See [templates.md](templates.md) for reusable patterns diff --git a/.claude/skills/prompts-writing/references/best-practices.md b/.claude/skills/prompts-writing/references/best-practices.md new file mode 100644 index 000000000..516845e50 --- /dev/null +++ b/.claude/skills/prompts-writing/references/best-practices.md @@ -0,0 +1,367 @@ +# Prompt Writing Best Practices + +This document provides comprehensive guidelines for creating high-quality YAML prompts. + +## 1. YAML Syntax Fundamentals + +### Multi-line String Handling + +| Syntax | Use Case | Example | +|--------|----------|---------| +| `\|-` | System prompts (strips trailing newline) | `system_prompt: \|-` | +| `|` | User prompts (preserves newlines) | `user_prompt: \|` | +| `>` | Long single lines (rarely used) | `description: >` | + +### Indentation Rules + +- Use 2 spaces for indentation (no tabs) +- Nested structures under each field +- List items align at parent level + +```yaml +system_prompt: |- + ### Section Title + Content here. + + - List item 1 + Nested item (2 spaces) + - List item 2 +``` + +## 2. Structure Guidelines + +### Heading Hierarchy + +Use headings to create logical sections: + +```yaml +system_prompt: |- + ### Primary Section (most important) + Core role and primary responsibilities. + + ### Secondary Section + Additional requirements. + + ## Less Important Section + Background context. + + ### Specific Guidelines + - Concrete rules +``` + +**Rules:** +- Never skip heading levels (e.g., `###` to `#####`) +- Maximum heading depth: `####` for most prompts +- Use `###` for major sections, `####` for subsections + +### Separator Usage + +Separators (`---`, `***`) create visual clutter and should be avoided: + +**DO:** +```yaml +system_prompt: |- + ### Requirements + 1. Be concise + 2. Be clear + + ### Output Format + Plain text response. +``` + +**DON'T:** +```yaml +system_prompt: |- + ### Requirements + 1. Be concise + 2. Be clear + + --- + + ### Output Format + Plain text response. + + *** + + Additional notes. +``` + +**Exception:** Use `---` only when truly separating distinct document types or sections in complex templates. + +## 3. Writing Style + +### Sentence Structure + +**DO:** +- Use active voice: "You are a helpful assistant." +- Be direct: "Generate a summary." +- Keep sentences under 25 words. + +**DON'T:** +- Passive voice: "A summary should be generated by you." +- Vague instructions: "Maybe you could try to..." +- Run-on sentences. + +### Conciseness Principles + +**Before (verbose):** +``` +You are a document summarization assistant and your main job and responsibility is to read through the document that is provided to you and create a summary of it. You should make sure to... +``` + +**After (concise):** +``` +You are a professional document summarization assistant. Generate a concise summary based on the provided content. +``` + +### List Consistency + +**DO (all items parallel):** +```yaml +- Be accurate +- Be concise +- Be helpful +``` + +**DON'T (mixed structures):** +```yaml +- Be accurate +- Creating summaries +- You should be helpful +``` + +### Punctuation Rules + +| Element | Rule | Example | +|---------|------|---------| +| Lists | Period only if complex sentences | `- Item one` or `- First item. Second sentence.` | +| Headings | No period at end | `### Requirements` | +| Variables | Spaces around braces | `{{ filename }}` not `{{filename}}` | +| Code blocks | Language tag required | ```python | + +## 4. Variable Placeholder Standards + +### Naming Conventions + +Use descriptive, lowercase names with underscores: + +| Good | Bad | +|------|-----| +| `{{ document_title }}` | `{{ title }}` | +| `{{ max_word_count }}` | `{{ max }}` | +| `{{ user_query }}` | `{{ q }}` | + +### Variable Types + +```yaml +# String variables +user_prompt: | + Analyze: {{ document_content }} + +# Numeric variables with constraints +user_prompt: | + Summary ({{ max_words }} words maximum): + +# Optional variables (with Jinja2 default) +{{ time|default('current time') }} + +# Conditional variables +{%- if memory_list and memory_list|length > 0 %} + ### Contextual Memory + ... +{%- endif %} +``` + +## 5. Common Sections + +### Role Definition + +```yaml +system_prompt: |- + ### Role + You are a professional [domain] assistant specialized in [specific task]. +``` + +### Requirements + +```yaml + ### Requirements + 1. First requirement (most important) + 2. Second requirement + 3. Third requirement +``` + +### Guidelines + +```yaml + ### Guidelines + - Do this (positive instruction) + - Don't do that (negative instruction) + - Always do this (mandatory) +``` + +### Output Format + +```yaml + ### Output Format + Respond in plain text without: + - Separators (---, ***) + - HTML tags + - Special formatting +``` + +## 6. Anti-Patterns to Avoid + +### Anti-Pattern 1: Excessive Instructions + +```yaml +# BAD - Too many nested rules +system_prompt: |- + You are an assistant. Your name is X. You were created by Y. + You should always be helpful. Being helpful means you should: + 1. Greet the user + 2. Listen carefully + 3. Respond appropriately + ... +``` + +**Better:** +```yaml +system_prompt: |- + You are a helpful assistant specialized in {{ task_type }}. +``` + +### Anti-Pattern 2: Vague Instructions + +```yaml +# BAD - Not actionable +system_prompt: |- + You should do a good job at summarizing. Make sure it's good. +``` + +**Better:** +```yaml +system_prompt: |- + ### Task + Generate a {{ word_count }}-word summary that captures: + - Main topic + - Key arguments + - Supporting evidence +``` + +### Anti-Pattern 3: Mixed Languages + +```yaml +# BAD - Inconsistent language +system_prompt: |- + ### Role + You are a professional assistant. + ### 要求 + 保持简洁。 +``` + +**Better (choose one language):** +```yaml +system_prompt: |- + ### Role + You are a professional assistant. + ### Requirements + Keep responses concise. +``` + +### Anti-Pattern 4: Unbalanced Lists + +```yaml +# BAD - Missing items + - Do A + - Do B + +# Better - Complete list + - Do A + - Do B + - Do C +``` + +## 7. Language Guidelines + +### English Prompts + +- Use American or British English consistently +- Prefer simple vocabulary over complex terms +- Use "you" for direct addressing + +### Chinese Prompts + +- Use Simplified Chinese (简体中文) +- Follow Chinese punctuation standards +- Keep technical terms in English when appropriate + +### Mixed-Language Projects + +When maintaining both EN and ZH versions: + +```yaml +# Filename pattern: prompt_name_en.yaml +# Corresponding file: prompt_name_zh.yaml + +# Key terms translation: +# - "Requirements" → "要求" +# - "Guidelines" → "指南" +# - "Output Format" → "输出格式" +``` + +## 8. Quality Validation Checklist + +### Before Finalizing + +```markdown +□ All braces are balanced ({{ }}) +□ No trailing spaces at line ends +□ Consistent heading hierarchy +□ Parallel list structure +□ Proper YAML indentation (2 spaces) +□ No HTML tags in Markdown +□ Variables have descriptive names +□ Language is consistent throughout +□ No excessive separators +□ Sentence case for list items +``` + +### Automated Checks + +Consider using: +1. YAML linter (yamllint) +2. Jinja2 syntax validator +3. Markdown formatter + +## 9. Performance Considerations + +### Prompt Length + +- System prompts: 500-2000 tokens typical +- User prompts: 100-500 tokens typical +- Longer isn't always better—be concise + +### Token Efficiency + +- Avoid repetitive phrasing +- Use variables for repeated content +- Trim unnecessary sections + +## 10. Version Control + +### File Naming + +```yaml +# Format: {purpose}_{lang}.yaml +manager_system_prompt_template_en.yaml +manager_system_prompt_template_zh.yaml +document_summary_agent_en.yaml +``` + +### Changelog Practices + +When modifying prompts: +1. Update file in place +2. Document significant changes +3. Consider version history for major revisions diff --git a/.claude/skills/prompts-writing/references/templates.md b/.claude/skills/prompts-writing/references/templates.md new file mode 100644 index 000000000..934643954 --- /dev/null +++ b/.claude/skills/prompts-writing/references/templates.md @@ -0,0 +1,323 @@ +# Prompt Templates + +This document provides reusable template patterns for YAML-based prompts. + +## 1. Agent System Prompt Template + +```yaml +system_prompt: |- + ### Basic Information + You are {{APP_NAME}}, {{APP_DESCRIPTION}}, it is {{time|default('current time')}} now + + ### Core Responsibilities + {{ duty }} + + ### Principles + Legal Compliance: Strictly adhere to all laws and regulations; + Security Protection: Do not respond to dangerous requests; + Ethical Guidelines: Refuse harmful content. + + ### Execution Process + 1. Think: Analyze the task and plan approach + 2. Code: Execute using tools/agents + 3. Observe Results: Review and iterate + + ### Available Resources + {%- if tools and tools.values() | list %} + - Available tools: + {%- for tool in tools.values() %} + - {{ tool.name }}: {{ tool.description }} + {%- endfor %} + {%- else %} + - No tools available + {%- endif %} + + ### Resource Usage Requirements + {{ constraint }} + + ### Example Templates + {{ few_shots }} + +managed_agent: + task: |- + You are '{{name}}'. Your manager has submitted this task: + --- + {{task}} + --- + Provide comprehensive assistance. + report: |- + {{final_answer}} + +planning: + initial_plan: |- + + update_plan_pre_messages: |- + + update_plan_post_messages: |- + +final_answer: + pre_messages: |- + + post_messages: |- +``` + +## 2. Document Summary Agent Template + +```yaml +system_prompt: |- + You are a professional document summarization assistant. + + **Summary Requirements:** + 1. Extract main themes and key topics + 2. Generate representative summary + 3. Ensure accuracy and coherence + 4. Respect word limit + + **Guidelines:** + - Focus on main themes + - Highlight important concepts + - Use clear, concise language + - Avoid redundancy + - **Important: No separators, plain text only** + +user_prompt: | + Generate a summary for: + + Document name: {{ filename }} + + Content snippets: + {{ content }} + + Summary (max {{ max_words }} words): +``` + +## 3. Cluster Summary Agent Template + +```yaml +system_prompt: |- + You are a professional knowledge summarization assistant. + + **Summary Requirements:** + 1. Analyze multiple documents + 2. Extract common themes + 3. Generate collective summary + 4. Respect word limit + + **Guidelines:** + - Focus on shared themes + - Highlight key concepts + - Use concise language + - Avoid listing individual titles + +user_prompt: | + Summarize this document cluster: + + {{ cluster_content }} + + Summary ({{ max_words }} words): +``` + +## 4. Image Analysis Template + +```yaml +image_analysis: + system_prompt: |- + The user asks: {{ query }}. Describe this image concisely (200 words max). + + **Requirements:** + 1. Focus on question-relevant content + 2. Keep descriptions clear and concise + 3. Avoid irrelevant details + 4. Maintain objective description + + user_prompt: | + Observe and describe this image for the user's question. +``` + +## 5. Long Text Analysis Template + +```yaml +long_text_analysis: + system_prompt: |- + The user asks: {{ query }}. Summarize this text concisely (200 words max). + + **Requirements:** + 1. Extract question-relevant content + 2. Highlight core information + 3. Preserve key viewpoints + 4. Avoid redundancy + + user_prompt: | + Read and analyze this text: +``` + +## 6. Conditional Content Template + +```yaml +system_prompt: |- + ### Basic Information + You are {{APP_NAME}}. + + {%- if memory_list and memory_list|length > 0 %} + ### Contextual Memory + {%- set level_order = ['tenant', 'user_agent', 'user', 'agent'] %} + {%- for level in level_order %} + {%- if level in memory_by_level %} + **{{ level|title }} Level Memory:** + {%- for item in memory_by_level[level] %} + - {{ item.memory }} + {%- endfor %} + {%- endif %} + {%- endfor %} + {%- endif %} + + ### Core Task + {{ duty }} +``` + +## 7. Tool-Only Agent Template + +```yaml +system_prompt: |- + You have access to specific tools only. + + {%- if tools and tools.values() | list %} + ### Available Tools + {%- for tool in tools.values() %} + - {{ tool.name }}: {{ tool.description }} + Input: {{tool.inputs}} + Output: {{tool.output_type}} + {%- endfor %} + {%- else %} + - No tools available. + {%- endif %} + + ### Workflow + 1. Understand the user's request + 2. Select appropriate tools + 3. Execute tool calls + 4. Synthesize results + + ### Guidelines + - Call tools only when needed + - Use correct parameters + - Handle errors gracefully + +user_prompt: | + Task: {{ task }} + + {%- if context %} + Context: + {{ context }} + {%- endif %} + + Result: +``` + +## 8. Memory Integration Template + +```yaml +system_prompt: |- + ### Role + You are {{agent_name}}. + + {%- if memory_list and memory_list|length > 0 %} + ### Contextual Memory + {%- set level_order = ['tenant', 'user_agent', 'user', 'agent'] %} + {%- set memory_by_level = memory_list|groupby('memory_level') %} + {%- for level in level_order %} + {%- for group_level, memories in memory_by_level %} + {%- if group_level == level %} + + **{{ level|title }} Level Memory:** + {%- for item in memories %} + - {{ item.memory }} `({{ "%.2f"|format(item.score|float) }})` + {%- endfor %} + {%- endif %} + {%- endfor %} + {%- endfor %} + + **Memory Usage:** + - Conflicts: Earlier items take precedence + - Integration: Weave memories naturally + {%- endif %} + + ### Task + {{ task }} +``` + +## 9. Output Format Specification Template + +```yaml +system_prompt: |- + ### Role + You are {{role_name}}. + + ### Task + {{task_description}} + + ### Output Requirements + 1. **Markdown Format:** + - Standard Markdown syntax + - Single blank line between paragraphs + - Inline formulas: $formula$ + - Block formulas: $$formula$$ + + 2. **Reference Marks:** + - Format: `[[letter+number]]` (e.g., `[[a1]]`) + - Place after relevant sentences + - Multiple marks: `[[a1]][[b2]]` + + 3. **Code:** + - Use language tags: ```python + - Maintain original format + + 4. **Restrictions:** + - No HTML tags + - No separators + - No extra escape characters + +user_prompt: | + {{ user_input }} + + Response: +``` + +## 10. Minimal Template + +For simple, focused prompts: + +```yaml +system_prompt: |- + You are a {{role_type}} assistant. Your task is to {{primary_task}}. + + Requirements: + 1. {{requirement_1}} + 2. {{requirement_2}} + + Guidelines: + - {{guideline_1}} + - {{guideline_2}} + +user_prompt: | + {{ input_content }} + + {{ output_instruction }}: +``` + +## Template Variables Summary + +| Variable | Type | Description | Example | +|----------|------|-------------|---------| +| `{{APP_NAME}}` | String | Application name | "Nexent" | +| `{{APP_DESCRIPTION}}` | String | App description | "An AI assistant" | +| `{{time}}` | String/datetime | Current time | "2024-01-01" | +| `{{duty}}` | String | Core responsibilities | "Summarize documents" | +| `{{constraint}}` | String | Resource constraints | "Max 500 words" | +| `{{few_shots}}` | String | Example templates | "Q:... A:..." | +| `{{filename}}` | String | Document filename | "report.pdf" | +| `{{content}}` | String | Document content | "..." | +| `{{max_words}}` | Integer | Word limit | 200 | +| `{{task}}` | String | Task description | "Analyze..." | +| `{{query}}` | String | User query | "..." | +| `{{memory_list}}` | List | Context memories | [...] | diff --git a/.claude/skills/skill-creator/.openskills.json b/.claude/skills/skill-creator/.openskills.json new file mode 100644 index 000000000..a017de770 --- /dev/null +++ b/.claude/skills/skill-creator/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "anthropics/skills", + "sourceType": "git", + "repoUrl": "https://github.com/anthropics/skills", + "subpath": "skills\\skill-creator", + "installedAt": "2026-02-04T07:52:42.984Z" +} \ No newline at end of file diff --git a/.claude/skills/skill-creator/LICENSE.txt b/.claude/skills/skill-creator/LICENSE.txt new file mode 100644 index 000000000..7a4a3ea24 --- /dev/null +++ b/.claude/skills/skill-creator/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/.claude/skills/skill-creator/SKILL.md b/.claude/skills/skill-creator/SKILL.md new file mode 100644 index 000000000..b7f86598b --- /dev/null +++ b/.claude/skills/skill-creator/SKILL.md @@ -0,0 +1,356 @@ +--- +name: skill-creator +description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations. +license: Complete terms in LICENSE.txt +--- + +# Skill Creator + +This skill provides guidance for creating effective skills. + +## About Skills + +Skills are modular, self-contained packages that extend Claude's capabilities by providing +specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific +domains or tasks—they transform Claude from a general-purpose agent into a specialized agent +equipped with procedural knowledge that no model can fully possess. + +### What Skills Provide + +1. Specialized workflows - Multi-step procedures for specific domains +2. Tool integrations - Instructions for working with specific file formats or APIs +3. Domain expertise - Company-specific knowledge, schemas, business logic +4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks + +## Core Principles + +### Concise is Key + +The context window is a public good. Skills share the context window with everything else Claude needs: system prompt, conversation history, other Skills' metadata, and the actual user request. + +**Default assumption: Claude is already very smart.** Only add context Claude doesn't already have. Challenge each piece of information: "Does Claude really need this explanation?" and "Does this paragraph justify its token cost?" + +Prefer concise examples over verbose explanations. + +### Set Appropriate Degrees of Freedom + +Match the level of specificity to the task's fragility and variability: + +**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach. + +**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior. + +**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed. + +Think of Claude as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). + +### Anatomy of a Skill + +Every skill consists of a required SKILL.md file and optional bundled resources: + +``` +skill-name/ +├── SKILL.md (required) +│ ├── YAML frontmatter metadata (required) +│ │ ├── name: (required) +│ │ └── description: (required) +│ └── Markdown instructions (required) +└── Bundled Resources (optional) + ├── scripts/ - Executable code (Python/Bash/etc.) + ├── references/ - Documentation intended to be loaded into context as needed + └── assets/ - Files used in output (templates, icons, fonts, etc.) +``` + +#### SKILL.md (required) + +Every SKILL.md consists of: + +- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Claude reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. +- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all). + +#### Bundled Resources (optional) + +##### Scripts (`scripts/`) + +Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten. + +- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed +- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks +- **Benefits**: Token efficient, deterministic, may be executed without loading into context +- **Note**: Scripts may still need to be read by Claude for patching or environment-specific adjustments + +##### References (`references/`) + +Documentation and reference material intended to be loaded as needed into context to inform Claude's process and thinking. + +- **When to include**: For documentation that Claude should reference while working +- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications +- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides +- **Benefits**: Keeps SKILL.md lean, loaded only when Claude determines it's needed +- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md +- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. + +##### Assets (`assets/`) + +Files not intended to be loaded into context, but rather used within the output Claude produces. + +- **When to include**: When the skill needs files that will be used in the final output +- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography +- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified +- **Benefits**: Separates output resources from documentation, enables Claude to use files without loading them into context + +#### What to Not Include in a Skill + +A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including: + +- README.md +- INSTALLATION_GUIDE.md +- QUICK_REFERENCE.md +- CHANGELOG.md +- etc. + +The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxilary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion. + +### Progressive Disclosure Design Principle + +Skills use a three-level loading system to manage context efficiently: + +1. **Metadata (name + description)** - Always in context (~100 words) +2. **SKILL.md body** - When skill triggers (<5k words) +3. **Bundled resources** - As needed by Claude (Unlimited because scripts can be executed without reading into context window) + +#### Progressive Disclosure Patterns + +Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them. + +**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files. + +**Pattern 1: High-level guide with references** + +```markdown +# PDF Processing + +## Quick start + +Extract text with pdfplumber: +[code example] + +## Advanced features + +- **Form filling**: See [FORMS.md](FORMS.md) for complete guide +- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods +- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns +``` + +Claude loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. + +**Pattern 2: Domain-specific organization** + +For Skills with multiple domains, organize content by domain to avoid loading irrelevant context: + +``` +bigquery-skill/ +├── SKILL.md (overview and navigation) +└── reference/ + ├── finance.md (revenue, billing metrics) + ├── sales.md (opportunities, pipeline) + ├── product.md (API usage, features) + └── marketing.md (campaigns, attribution) +``` + +When a user asks about sales metrics, Claude only reads sales.md. + +Similarly, for skills supporting multiple frameworks or variants, organize by variant: + +``` +cloud-deploy/ +├── SKILL.md (workflow + provider selection) +└── references/ + ├── aws.md (AWS deployment patterns) + ├── gcp.md (GCP deployment patterns) + └── azure.md (Azure deployment patterns) +``` + +When the user chooses AWS, Claude only reads aws.md. + +**Pattern 3: Conditional details** + +Show basic content, link to advanced content: + +```markdown +# DOCX Processing + +## Creating documents + +Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md). + +## Editing documents + +For simple edits, modify the XML directly. + +**For tracked changes**: See [REDLINING.md](REDLINING.md) +**For OOXML details**: See [OOXML.md](OOXML.md) +``` + +Claude reads REDLINING.md or OOXML.md only when the user needs those features. + +**Important guidelines:** + +- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md. +- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Claude can see the full scope when previewing. + +## Skill Creation Process + +Skill creation involves these steps: + +1. Understand the skill with concrete examples +2. Plan reusable skill contents (scripts, references, assets) +3. Initialize the skill (run init_skill.py) +4. Edit the skill (implement resources and write SKILL.md) +5. Package the skill (run package_skill.py) +6. Iterate based on real usage + +Follow these steps in order, skipping only if there is a clear reason why they are not applicable. + +### Step 1: Understanding the Skill with Concrete Examples + +Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill. + +To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback. + +For example, when building an image-editor skill, relevant questions include: + +- "What functionality should the image-editor skill support? Editing, rotating, anything else?" +- "Can you give some examples of how this skill would be used?" +- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?" +- "What would a user say that should trigger this skill?" + +To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness. + +Conclude this step when there is a clear sense of the functionality the skill should support. + +### Step 2: Planning the Reusable Skill Contents + +To turn concrete examples into an effective skill, analyze each example by: + +1. Considering how to execute on the example from scratch +2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly + +Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows: + +1. Rotating a PDF requires re-writing the same code each time +2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill + +Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows: + +1. Writing a frontend webapp requires the same boilerplate HTML/React each time +2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill + +Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows: + +1. Querying BigQuery requires re-discovering the table schemas and relationships each time +2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill + +To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets. + +### Step 3: Initializing the Skill + +At this point, it is time to actually create the skill. + +Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step. + +When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. + +Usage: + +```bash +scripts/init_skill.py --path +``` + +The script: + +- Creates the skill directory at the specified path +- Generates a SKILL.md template with proper frontmatter and TODO placeholders +- Creates example resource directories: `scripts/`, `references/`, and `assets/` +- Adds example files in each directory that can be customized or deleted + +After initialization, customize or remove the generated SKILL.md and example files as needed. + +### Step 4: Edit the Skill + +When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Claude to use. Include information that would be beneficial and non-obvious to Claude. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Claude instance execute these tasks more effectively. + +#### Learn Proven Design Patterns + +Consult these helpful guides based on your skill's needs: + +- **Multi-step processes**: See references/workflows.md for sequential workflows and conditional logic +- **Specific output formats or quality standards**: See references/output-patterns.md for template and example patterns + +These files contain established best practices for effective skill design. + +#### Start with Reusable Skill Contents + +To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`. + +Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion. + +Any example files and directories not needed for the skill should be deleted. The initialization script creates example files in `scripts/`, `references/`, and `assets/` to demonstrate structure, but most skills won't need all of them. + +#### Update SKILL.md + +**Writing Guidelines:** Always use imperative/infinitive form. + +##### Frontmatter + +Write the YAML frontmatter with `name` and `description`: + +- `name`: The skill name +- `description`: This is the primary triggering mechanism for your skill, and helps Claude understand when to use the skill. + - Include both what the Skill does and specific triggers/contexts for when to use it. + - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Claude. + - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" + +Do not include any other fields in YAML frontmatter. + +##### Body + +Write instructions for using the skill and its bundled resources. + +### Step 5: Packaging a Skill + +Once development of the skill is complete, it must be packaged into a distributable .skill file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements: + +```bash +scripts/package_skill.py +``` + +Optional output directory specification: + +```bash +scripts/package_skill.py ./dist +``` + +The packaging script will: + +1. **Validate** the skill automatically, checking: + + - YAML frontmatter format and required fields + - Skill naming conventions and directory structure + - Description completeness and quality + - File organization and resource references + +2. **Package** the skill if validation passes, creating a .skill file named after the skill (e.g., `my-skill.skill`) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension. + +If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again. + +### Step 6: Iterate + +After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed. + +**Iteration workflow:** + +1. Use the skill on real tasks +2. Notice struggles or inefficiencies +3. Identify how SKILL.md or bundled resources should be updated +4. Implement changes and test again diff --git a/.claude/skills/skill-creator/references/output-patterns.md b/.claude/skills/skill-creator/references/output-patterns.md new file mode 100644 index 000000000..073ddda5f --- /dev/null +++ b/.claude/skills/skill-creator/references/output-patterns.md @@ -0,0 +1,82 @@ +# Output Patterns + +Use these patterns when skills need to produce consistent, high-quality output. + +## Template Pattern + +Provide templates for output format. Match the level of strictness to your needs. + +**For strict requirements (like API responses or data formats):** + +```markdown +## Report structure + +ALWAYS use this exact template structure: + +# [Analysis Title] + +## Executive summary +[One-paragraph overview of key findings] + +## Key findings +- Finding 1 with supporting data +- Finding 2 with supporting data +- Finding 3 with supporting data + +## Recommendations +1. Specific actionable recommendation +2. Specific actionable recommendation +``` + +**For flexible guidance (when adaptation is useful):** + +```markdown +## Report structure + +Here is a sensible default format, but use your best judgment: + +# [Analysis Title] + +## Executive summary +[Overview] + +## Key findings +[Adapt sections based on what you discover] + +## Recommendations +[Tailor to the specific context] + +Adjust sections as needed for the specific analysis type. +``` + +## Examples Pattern + +For skills where output quality depends on seeing examples, provide input/output pairs: + +```markdown +## Commit message format + +Generate commit messages following these examples: + +**Example 1:** +Input: Added user authentication with JWT tokens +Output: +``` +feat(auth): implement JWT-based authentication + +Add login endpoint and token validation middleware +``` + +**Example 2:** +Input: Fixed bug where dates displayed incorrectly in reports +Output: +``` +fix(reports): correct date formatting in timezone conversion + +Use UTC timestamps consistently across report generation +``` + +Follow this style: type(scope): brief description, then detailed explanation. +``` + +Examples help Claude understand the desired style and level of detail more clearly than descriptions alone. diff --git a/.claude/skills/skill-creator/references/workflows.md b/.claude/skills/skill-creator/references/workflows.md new file mode 100644 index 000000000..a350c3cc8 --- /dev/null +++ b/.claude/skills/skill-creator/references/workflows.md @@ -0,0 +1,28 @@ +# Workflow Patterns + +## Sequential Workflows + +For complex tasks, break operations into clear, sequential steps. It is often helpful to give Claude an overview of the process towards the beginning of SKILL.md: + +```markdown +Filling a PDF form involves these steps: + +1. Analyze the form (run analyze_form.py) +2. Create field mapping (edit fields.json) +3. Validate mapping (run validate_fields.py) +4. Fill the form (run fill_form.py) +5. Verify output (run verify_output.py) +``` + +## Conditional Workflows + +For tasks with branching logic, guide Claude through decision points: + +```markdown +1. Determine the modification type: + **Creating new content?** → Follow "Creation workflow" below + **Editing existing content?** → Follow "Editing workflow" below + +2. Creation workflow: [steps] +3. Editing workflow: [steps] +``` \ No newline at end of file diff --git a/.claude/skills/skill-creator/scripts/init_skill.py b/.claude/skills/skill-creator/scripts/init_skill.py new file mode 100644 index 000000000..329ad4e5a --- /dev/null +++ b/.claude/skills/skill-creator/scripts/init_skill.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +Skill Initializer - Creates a new skill from template + +Usage: + init_skill.py --path + +Examples: + init_skill.py my-new-skill --path skills/public + init_skill.py my-api-helper --path skills/private + init_skill.py custom-skill --path /custom/location +""" + +import sys +from pathlib import Path + + +SKILL_TEMPLATE = """--- +name: {skill_name} +description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.] +--- + +# {skill_title} + +## Overview + +[TODO: 1-2 sentences explaining what this skill enables] + +## Structuring This Skill + +[TODO: Choose the structure that best fits this skill's purpose. Common patterns: + +**1. Workflow-Based** (best for sequential processes) +- Works well when there are clear step-by-step procedures +- Example: DOCX skill with "Workflow Decision Tree" → "Reading" → "Creating" → "Editing" +- Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2... + +**2. Task-Based** (best for tool collections) +- Works well when the skill offers different operations/capabilities +- Example: PDF skill with "Quick Start" → "Merge PDFs" → "Split PDFs" → "Extract Text" +- Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2... + +**3. Reference/Guidelines** (best for standards or specifications) +- Works well for brand guidelines, coding standards, or requirements +- Example: Brand styling with "Brand Guidelines" → "Colors" → "Typography" → "Features" +- Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage... + +**4. Capabilities-Based** (best for integrated systems) +- Works well when the skill provides multiple interrelated features +- Example: Product Management with "Core Capabilities" → numbered capability list +- Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature... + +Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations). + +Delete this entire "Structuring This Skill" section when done - it's just guidance.] + +## [TODO: Replace with the first main section based on chosen structure] + +[TODO: Add content here. See examples in existing skills: +- Code samples for technical skills +- Decision trees for complex workflows +- Concrete examples with realistic user requests +- References to scripts/templates/references as needed] + +## Resources + +This skill includes example resource directories that demonstrate how to organize different types of bundled resources: + +### scripts/ +Executable code (Python/Bash/etc.) that can be run directly to perform specific operations. + +**Examples from other skills:** +- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation +- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing + +**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. + +**Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments. + +### references/ +Documentation and reference material intended to be loaded into context to inform Claude's process and thinking. + +**Examples from other skills:** +- Product management: `communication.md`, `context_building.md` - detailed workflow guides +- BigQuery: API reference documentation and query examples +- Finance: Schema documentation, company policies + +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working. + +### assets/ +Files not intended to be loaded into context, but rather used within the output Claude produces. + +**Examples from other skills:** +- Brand styling: PowerPoint template files (.pptx), logo files +- Frontend builder: HTML/React boilerplate project directories +- Typography: Font files (.ttf, .woff2) + +**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output. + +--- + +**Any unneeded directories can be deleted.** Not every skill requires all three types of resources. +""" + +EXAMPLE_SCRIPT = '''#!/usr/bin/env python3 +""" +Example helper script for {skill_name} + +This is a placeholder script that can be executed directly. +Replace with actual implementation or delete if not needed. + +Example real scripts from other skills: +- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields +- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images +""" + +def main(): + print("This is an example script for {skill_name}") + # TODO: Add actual script logic here + # This could be data processing, file conversion, API calls, etc. + +if __name__ == "__main__": + main() +''' + +EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title} + +This is a placeholder for detailed reference documentation. +Replace with actual reference content or delete if not needed. + +Example real reference docs from other skills: +- product-management/references/communication.md - Comprehensive guide for status updates +- product-management/references/context_building.md - Deep-dive on gathering context +- bigquery/references/ - API references and query examples + +## When Reference Docs Are Useful + +Reference docs are ideal for: +- Comprehensive API documentation +- Detailed workflow guides +- Complex multi-step processes +- Information too lengthy for main SKILL.md +- Content that's only needed for specific use cases + +## Structure Suggestions + +### API Reference Example +- Overview +- Authentication +- Endpoints with examples +- Error codes +- Rate limits + +### Workflow Guide Example +- Prerequisites +- Step-by-step instructions +- Common patterns +- Troubleshooting +- Best practices +""" + +EXAMPLE_ASSET = """# Example Asset File + +This placeholder represents where asset files would be stored. +Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. + +Asset files are NOT intended to be loaded into context, but rather used within +the output Claude produces. + +Example asset files from other skills: +- Brand guidelines: logo.png, slides_template.pptx +- Frontend builder: hello-world/ directory with HTML/React boilerplate +- Typography: custom-font.ttf, font-family.woff2 +- Data: sample_data.csv, test_dataset.json + +## Common Asset Types + +- Templates: .pptx, .docx, boilerplate directories +- Images: .png, .jpg, .svg, .gif +- Fonts: .ttf, .otf, .woff, .woff2 +- Boilerplate code: Project directories, starter files +- Icons: .ico, .svg +- Data files: .csv, .json, .xml, .yaml + +Note: This is a text placeholder. Actual assets can be any file type. +""" + + +def title_case_skill_name(skill_name): + """Convert hyphenated skill name to Title Case for display.""" + return ' '.join(word.capitalize() for word in skill_name.split('-')) + + +def init_skill(skill_name, path): + """ + Initialize a new skill directory with template SKILL.md. + + Args: + skill_name: Name of the skill + path: Path where the skill directory should be created + + Returns: + Path to created skill directory, or None if error + """ + # Determine skill directory path + skill_dir = Path(path).resolve() / skill_name + + # Check if directory already exists + if skill_dir.exists(): + print(f"❌ Error: Skill directory already exists: {skill_dir}") + return None + + # Create skill directory + try: + skill_dir.mkdir(parents=True, exist_ok=False) + print(f"✅ Created skill directory: {skill_dir}") + except Exception as e: + print(f"❌ Error creating directory: {e}") + return None + + # Create SKILL.md from template + skill_title = title_case_skill_name(skill_name) + skill_content = SKILL_TEMPLATE.format( + skill_name=skill_name, + skill_title=skill_title + ) + + skill_md_path = skill_dir / 'SKILL.md' + try: + skill_md_path.write_text(skill_content) + print("✅ Created SKILL.md") + except Exception as e: + print(f"❌ Error creating SKILL.md: {e}") + return None + + # Create resource directories with example files + try: + # Create scripts/ directory with example script + scripts_dir = skill_dir / 'scripts' + scripts_dir.mkdir(exist_ok=True) + example_script = scripts_dir / 'example.py' + example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) + example_script.chmod(0o755) + print("✅ Created scripts/example.py") + + # Create references/ directory with example reference doc + references_dir = skill_dir / 'references' + references_dir.mkdir(exist_ok=True) + example_reference = references_dir / 'api_reference.md' + example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) + print("✅ Created references/api_reference.md") + + # Create assets/ directory with example asset placeholder + assets_dir = skill_dir / 'assets' + assets_dir.mkdir(exist_ok=True) + example_asset = assets_dir / 'example_asset.txt' + example_asset.write_text(EXAMPLE_ASSET) + print("✅ Created assets/example_asset.txt") + except Exception as e: + print(f"❌ Error creating resource directories: {e}") + return None + + # Print next steps + print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}") + print("\nNext steps:") + print("1. Edit SKILL.md to complete the TODO items and update the description") + print("2. Customize or delete the example files in scripts/, references/, and assets/") + print("3. Run the validator when ready to check the skill structure") + + return skill_dir + + +def main(): + if len(sys.argv) < 4 or sys.argv[2] != '--path': + print("Usage: init_skill.py --path ") + print("\nSkill name requirements:") + print(" - Hyphen-case identifier (e.g., 'data-analyzer')") + print(" - Lowercase letters, digits, and hyphens only") + print(" - Max 40 characters") + print(" - Must match directory name exactly") + print("\nExamples:") + print(" init_skill.py my-new-skill --path skills/public") + print(" init_skill.py my-api-helper --path skills/private") + print(" init_skill.py custom-skill --path /custom/location") + sys.exit(1) + + skill_name = sys.argv[1] + path = sys.argv[3] + + print(f"🚀 Initializing skill: {skill_name}") + print(f" Location: {path}") + print() + + result = init_skill(skill_name, path) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/skill-creator/scripts/package_skill.py b/.claude/skills/skill-creator/scripts/package_skill.py new file mode 100644 index 000000000..5cd36cb16 --- /dev/null +++ b/.claude/skills/skill-creator/scripts/package_skill.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Skill Packager - Creates a distributable .skill file of a skill folder + +Usage: + python utils/package_skill.py [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import sys +import zipfile +from pathlib import Path +from quick_validate import validate_skill + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a .skill file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the .skill file (defaults to current directory) + + Returns: + Path to the created .skill file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"❌ Error: Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"❌ Error: Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"❌ Error: SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("🔍 Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"❌ Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"✅ {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + skill_filename = output_path / f"{skill_name}.skill" + + # Create the .skill file (zip format) + try: + with zipfile.ZipFile(skill_filename, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory + for file_path in skill_path.rglob('*'): + if file_path.is_file(): + # Calculate the relative path within the zip + arcname = file_path.relative_to(skill_path.parent) + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n✅ Successfully packaged skill to: {skill_filename}") + return skill_filename + + except Exception as e: + print(f"❌ Error creating .skill file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"📦 Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/skill-creator/scripts/quick_validate.py b/.claude/skills/skill-creator/scripts/quick_validate.py new file mode 100644 index 000000000..d9fbeb75e --- /dev/null +++ b/.claude/skills/skill-creator/scripts/quick_validate.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +Quick validation script for skills - minimal version +""" + +import sys +import os +import re +import yaml +from pathlib import Path + +def validate_skill(skill_path): + """Basic validation of a skill""" + skill_path = Path(skill_path) + + # Check SKILL.md exists + skill_md = skill_path / 'SKILL.md' + if not skill_md.exists(): + return False, "SKILL.md not found" + + # Read and validate frontmatter + content = skill_md.read_text() + if not content.startswith('---'): + return False, "No YAML frontmatter found" + + # Extract frontmatter + match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format" + + frontmatter_text = match.group(1) + + # Parse YAML frontmatter + try: + frontmatter = yaml.safe_load(frontmatter_text) + if not isinstance(frontmatter, dict): + return False, "Frontmatter must be a YAML dictionary" + except yaml.YAMLError as e: + return False, f"Invalid YAML in frontmatter: {e}" + + # Define allowed properties + ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata'} + + # Check for unexpected properties (excluding nested keys under metadata) + unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES + if unexpected_keys: + return False, ( + f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. " + f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}" + ) + + # Check required fields + if 'name' not in frontmatter: + return False, "Missing 'name' in frontmatter" + if 'description' not in frontmatter: + return False, "Missing 'description' in frontmatter" + + # Extract name for validation + name = frontmatter.get('name', '') + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}" + name = name.strip() + if name: + # Check naming convention (hyphen-case: lowercase with hyphens) + if not re.match(r'^[a-z0-9-]+$', name): + return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)" + if name.startswith('-') or name.endswith('-') or '--' in name: + return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens" + # Check name length (max 64 characters per spec) + if len(name) > 64: + return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters." + + # Extract and validate description + description = frontmatter.get('description', '') + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}" + description = description.strip() + if description: + # Check for angle brackets + if '<' in description or '>' in description: + return False, "Description cannot contain angle brackets (< or >)" + # Check description length (max 1024 characters per spec) + if len(description) > 1024: + return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters." + + return True, "Skill is valid!" + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) \ No newline at end of file diff --git a/.cursor/rules/frontend/page_layer_rules.mdc b/.cursor/rules/frontend/page_layer_rules.mdc index a9c499729..b660522da 100644 --- a/.cursor/rules/frontend/page_layer_rules.mdc +++ b/.cursor/rules/frontend/page_layer_rules.mdc @@ -21,6 +21,9 @@ description: Page layer rules for Next.js App Router pages and layouts - Client components: `const { t } = useTranslation('namespace')` - Server components: `getTranslations` from `next-intl` + + + - Organize translation keys by feature/namespace ### Data Fetching diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..7798227b1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# AGENTS + + + + + +## Available Skills + + + +When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. + +How to use skills: +- Invoke: `npx openskills read ` (run in your shell) + - For multiple: `npx openskills read skill-one,skill-two` +- The skill content will load with detailed instructions on how to complete the task +- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/) + +Usage notes: +- Only use skills listed in below +- Do not invoke a skill that is already loaded in your context +- Each skill invocation is stateless + + + + + +prompts-writing +Create, refine, and optimize high-quality YAML prompts for AI assistants. Use when working with prompt templates, system prompts, agent prompts, or any prompt engineering tasks. Provides structure guidelines, template patterns, and quality standards for YAML-based prompts. +project + + + +skill-creator +Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations. +project + + + + + + diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index d09029a97..4fd2411a7 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -15,7 +15,6 @@ get_vector_db_core, get_embedding_model, ) -from services.tenant_config_service import get_selected_knowledge_list, build_knowledge_name_mapping from services.remote_mcp_service import get_remote_mcp_server_list from services.memory_config_service import build_memory_context from services.image_service import get_vlm_model @@ -146,20 +145,16 @@ async def create_agent_config( try: for tool in tool_list: if "KnowledgeBaseSearchTool" == tool.class_name: - knowledge_info_list = get_selected_knowledge_list( - tenant_id=tenant_id, user_id=user_id) - if knowledge_info_list: - for knowledge_info in knowledge_info_list: - if knowledge_info.get('knowledge_sources') != 'elasticsearch': - continue - knowledge_name = knowledge_info.get("index_name") + index_names = tool.params.get("index_names") + if index_names: + for index_name in index_names: try: - message = ElasticSearchService().get_summary(index_name=knowledge_name) + message = ElasticSearchService().get_summary(index_name=index_name) summary = message.get("summary", "") - knowledge_base_summary += f"**{knowledge_name}**: {summary}\n\n" + knowledge_base_summary += f"**{index_name}**: {summary}\n\n" except Exception as e: logger.warning( - f"Failed to get summary for knowledge base {knowledge_name}: {e}") + f"Failed to get summary for knowledge base {index_name}: {e}") else: # TODO: Prompt should be refactored to yaml file knowledge_base_summary = "当前没有可用的知识库索引。\n" if language == 'zh' else "No knowledge base indexes are currently available.\n" @@ -238,24 +233,9 @@ async def create_tool_config_list(agent_id, tenant_id, user_id): # special logic for knowledge base search tool if tool_config.class_name == "KnowledgeBaseSearchTool": - knowledge_info_list = get_selected_knowledge_list( - tenant_id=tenant_id, user_id=user_id) - index_names = [knowledge_info.get( - "index_name") for knowledge_info in knowledge_info_list if knowledge_info.get('knowledge_sources') == 'elasticsearch'] - tool_config.metadata = { - "index_names": index_names, + tool_config.metadata = { "vdb_core": get_vector_db_core(), "embedding_model": get_embedding_model(tenant_id=tenant_id), - "name_resolver": build_knowledge_name_mapping(tenant_id=tenant_id, user_id=user_id), - } - elif tool_config.class_name == "DataMateSearchTool": - knowledge_info_list = get_selected_knowledge_list( - tenant_id=tenant_id, user_id=user_id) - index_names = [knowledge_info.get( - "index_name") for knowledge_info in knowledge_info_list if - knowledge_info.get('knowledge_sources') == 'datamate'] - tool_config.metadata = { - "index_names": index_names, } elif tool_config.class_name == "AnalyzeTextFileTool": tool_config.metadata = { diff --git a/backend/apps/agent_app.py b/backend/apps/agent_app.py index 72fa198ca..7a9ce7d6d 100644 --- a/backend/apps/agent_app.py +++ b/backend/apps/agent_app.py @@ -2,9 +2,11 @@ from http import HTTPStatus from typing import Optional -from fastapi import APIRouter, Body, Header, HTTPException, Request +from fastapi import APIRouter, Body, Header, HTTPException, Request, Query +from fastapi.encoders import jsonable_encoder +from starlette.responses import JSONResponse -from consts.model import AgentRequest, AgentInfoRequest, AgentIDRequest, ConversationResponse, AgentImportRequest, AgentNameBatchCheckRequest, AgentNameBatchRegenerateRequest +from consts.model import AgentRequest, AgentInfoRequest, AgentIDRequest, ConversationResponse, AgentImportRequest, AgentNameBatchCheckRequest, AgentNameBatchRegenerateRequest, VersionPublishRequest, VersionListResponse, VersionDetailResponse, VersionRollbackRequest, VersionStatusRequest, CurrentVersionResponse, VersionCompareRequest from services.agent_service import ( get_agent_info_impl, get_creating_sub_agent_info_impl, @@ -20,6 +22,18 @@ get_agent_call_relationship_impl, clear_agent_new_mark_impl ) +from services.agent_version_service import ( + publish_version_impl, + get_version_list_impl, + get_version_impl, + get_version_detail_impl, + rollback_version_impl, + update_version_status_impl, + delete_version_impl, + get_current_version_impl, + compare_versions_impl, + list_published_agents_impl, +) from utils.auth_utils import get_current_user_info, get_current_user_id # Import monitoring utilities @@ -63,13 +77,22 @@ async def agent_stop_api(conversation_id: int, authorization: Optional[str] = He @agent_config_router.post("/search_info") -async def search_agent_info_api(agent_id: int = Body(...), authorization: Optional[str] = Header(None)): +async def search_agent_info_api( + agent_id: int = Body(...), + version_no: int = Body(0), + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None) +): """ - Search agent info by agent_id + Search agent info by agent_id and version_no + version_no defaults to 0 (current/draft version) """ try: - _, tenant_id = get_current_user_id(authorization) - return await get_agent_info_impl(agent_id, tenant_id) + _, auth_tenant_id = get_current_user_id(authorization) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id + return await get_agent_info_impl(agent_id, effective_tenant_id, version_no) except Exception as e: logger.error(f"Agent search info error: {str(e)}") raise HTTPException( @@ -104,12 +127,21 @@ async def update_agent_info_api(request: AgentInfoRequest, authorization: Option @agent_config_router.delete("") -async def delete_agent_api(request: AgentIDRequest, authorization: Optional[str] = Header(None)): +async def delete_agent_api( + request: AgentIDRequest, + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + http_request: Request = None +): """ Delete an agent """ try: - await delete_agent_impl(request.agent_id, authorization) + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id + await delete_agent_impl(request.agent_id, effective_tenant_id, user_id) return {} except Exception as e: logger.error(f"Agent delete error: {str(e)}") @@ -195,13 +227,20 @@ async def regenerate_agent_name_batch_api(request: AgentNameBatchRegenerateReque @agent_config_router.get("/list") -async def list_all_agent_info_api(authorization: Optional[str] = Header(None), request: Request = None): +async def list_all_agent_info_api( + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + request: Request = None +): """ list all agent info """ try: - _, tenant_id, _ = get_current_user_info(authorization, request) - return await list_all_agent_info_impl(tenant_id=tenant_id) + user_id, auth_tenant_id, _ = get_current_user_info(authorization, request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id + return await list_all_agent_info_impl(tenant_id=effective_tenant_id, user_id=user_id) except Exception as e: logger.error(f"Agent list error: {str(e)}") raise HTTPException( @@ -220,3 +259,251 @@ async def get_agent_call_relationship_api(agent_id: int, authorization: Optional logger.error(f"Agent call relationship error: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to get agent call relationship.") + + +# Agent Version Management APIs +# --------------------------------------------------------------------------- + + +@agent_config_router.post("/{agent_id}/publish") +async def publish_version_api( + agent_id: int, + request: VersionPublishRequest, + authorization: str = Header(None), +): + """ + Publish a new version + """ + try: + user_id, tenant_id = get_current_user_id(authorization) + result = publish_version_impl( + agent_id=agent_id, + tenant_id=tenant_id, + user_id=user_id, + version_name=request.version_name, + release_note=request.release_note, + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.error(f"Publish version error: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Publish version error.") + + +@agent_config_router.post("/{agent_id}/versions/compare") +async def compare_versions_api( + agent_id: int, + request: VersionCompareRequest, + authorization: str = Header(None), +): + """ + Compare two versions and return their differences + """ + try: + _, tenant_id = get_current_user_id(authorization) + result = compare_versions_impl( + agent_id=agent_id, + tenant_id=tenant_id, + version_no_a=request.version_no_a, + version_no_b=request.version_no_b, + ) + return JSONResponse(status_code=HTTPStatus.OK, content=jsonable_encoder(result)) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.error(f"Compare versions error: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Compare versions error.") + + +@agent_config_router.get("/{agent_id}/versions", response_model=VersionListResponse) +async def get_version_list_api( + agent_id: int, + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + request: Request = None +): + """ + Get version list for an agent + """ + try: + user_id, auth_tenant_id, _ = get_current_user_info(authorization, request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id + logger.info(f"Get version list for agent_id: {agent_id}, tenant_id: {effective_tenant_id}") + result = get_version_list_impl( + agent_id=agent_id, + tenant_id=effective_tenant_id, + ) + logger.info(f"Version list: {result}") + return JSONResponse(status_code=HTTPStatus.OK, content=jsonable_encoder(result)) + except Exception as e: + logger.error(f"Get version list error: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Get version list error.") + + +@agent_config_router.get("/{agent_id}/versions/{version_no}", response_model=VersionDetailResponse) +async def get_version_api( + agent_id: int, + version_no: int, + authorization: str = Header(None), +): + """ + Get version + """ + try: + _, tenant_id = get_current_user_id(authorization) + result = get_version_impl( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + ) + return JSONResponse(status_code=HTTPStatus.OK, content=jsonable_encoder(result)) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except Exception as e: + logger.error(f"Get version detail error: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Get version detail error.") + +@agent_config_router.get("/{agent_id}/versions/{version_no}/detail", response_model=VersionDetailResponse) +async def get_version_detail_api( + agent_id: int, + version_no: int, + authorization: str = Header(None), +): + """ + Get version detail including snapshot data + """ + try: + _, tenant_id = get_current_user_id(authorization) + result = get_version_detail_impl( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + ) + return JSONResponse(status_code=HTTPStatus.OK, content=jsonable_encoder(result)) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except Exception as e: + logger.error(f"Get version detail error: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Get version detail error.") + + +@agent_config_router.post("/{agent_id}/versions/{version_no}/rollback") +async def rollback_version_api( + agent_id: int, + version_no: int, + authorization: str = Header(None), +): + """ + Rollback to a specific version by updating current_version_no only. + This does NOT create a new version - the draft will point to the target version. + Use the publish endpoint to create an actual new version after rollback. + """ + try: + _, tenant_id = get_current_user_id(authorization) + result = rollback_version_impl( + agent_id=agent_id, + tenant_id=tenant_id, + target_version_no=version_no, + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.error(f"Rollback version error: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Rollback version error.") + + +@agent_config_router.patch("/{agent_id}/versions/{version_no}/status") +async def update_version_status_api( + agent_id: int, + version_no: int, + request: VersionStatusRequest, + authorization: str = Header(None), +): + """ + Update version status (DISABLED / ARCHIVED) + """ + try: + user_id, tenant_id = get_current_user_id(authorization) + result = update_version_status_impl( + agent_id=agent_id, + tenant_id=tenant_id, + user_id=user_id, + version_no=version_no, + status=request.status, + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.error(f"Update version status error: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Update version status error.") + + +@agent_config_router.delete("/{agent_id}/versions/{version_no}") +async def delete_version_api( + agent_id: int, + version_no: int, + authorization: str = Header(None), +): + """ + Delete a version (soft delete by setting delete_flag='Y') + """ + try: + user_id, tenant_id = get_current_user_id(authorization) + result = delete_version_impl( + agent_id=agent_id, + tenant_id=tenant_id, + user_id=user_id, + version_no=version_no, + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.error(f"Delete version error: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Delete version error.") + + +@agent_config_router.get("/{agent_id}/current_version", response_model=CurrentVersionResponse) +async def get_current_version_api( + agent_id: int, + authorization: str = Header(None), +): + """ + Get current published version + """ + try: + _, tenant_id = get_current_user_id(authorization) + result = get_current_version_impl( + agent_id=agent_id, + tenant_id=tenant_id, + ) + return JSONResponse(status_code=HTTPStatus.OK, content=jsonable_encoder(result)) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except Exception as e: + logger.error(f"Get current version error: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Get current version error.") + + +@agent_config_router.get("/published_list") +async def list_published_agents_api( + authorization: Optional[str] = Header(None), + request: Request = None, +): + """ + List all published agents with their current published version information. + """ + try: + user_id, tenant_id, _ = get_current_user_info(authorization, request) + return await list_published_agents_impl(tenant_id=tenant_id, user_id=user_id) + except Exception as e: + logger.error(f"Published agents list error: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Published agents list error." + ) + diff --git a/backend/apps/config_app.py b/backend/apps/config_app.py index db979ac98..8691b15e0 100644 --- a/backend/apps/config_app.py +++ b/backend/apps/config_app.py @@ -8,6 +8,7 @@ from apps.config_sync_app import router as config_sync_router from apps.datamate_app import router as datamate_router from apps.vectordatabase_app import router as vectordatabase_router +from apps.dify_app import router as dify_router from apps.file_management_app import file_management_config_router as file_manager_router from apps.image_app import router as proxy_router from apps.knowledge_summary_app import router as summary_router @@ -50,6 +51,7 @@ app.include_router(file_manager_router) app.include_router(proxy_router) app.include_router(tool_config_router) +app.include_router(dify_router) # Choose user management router based on IS_SPEED_MODE if IS_SPEED_MODE: diff --git a/backend/apps/datamate_app.py b/backend/apps/datamate_app.py index 8139b2982..ca88648a4 100644 --- a/backend/apps/datamate_app.py +++ b/backend/apps/datamate_app.py @@ -3,21 +3,31 @@ from fastapi import APIRouter, Header, HTTPException, Path from fastapi.responses import JSONResponse +from fastapi import Body +from pydantic import BaseModel from http import HTTPStatus from services.datamate_service import ( sync_datamate_knowledge_bases_and_create_records, - fetch_datamate_knowledge_base_file_list + fetch_datamate_knowledge_base_file_list, + check_datamate_connection ) from utils.auth_utils import get_current_user_id +from consts.exceptions import DataMateConnectionError router = APIRouter(prefix="/datamate") logger = logging.getLogger("datamate_app") +class SyncDatamateRequest(BaseModel): + """Request body for syncing DataMate knowledge bases.""" + datamate_url: Optional[str] = None + + @router.post("/sync_datamate_knowledges") async def sync_datamate_knowledges( - authorization: Optional[str] = Header(None) + authorization: Optional[str] = Header(None), + request: SyncDatamateRequest = Body(None) ): """Sync DataMate knowledge bases and create knowledge records in local database.""" try: @@ -25,8 +35,12 @@ async def sync_datamate_knowledges( return await sync_datamate_knowledge_bases_and_create_records( tenant_id=tenant_id, - user_id=user_id + user_id=user_id, + datamate_url=request.datamate_url if request else None ) + except DataMateConnectionError as e: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error syncing DataMate knowledge bases and creating records: {str(e)}") @@ -38,7 +52,7 @@ async def get_datamate_knowledge_base_files_endpoint( description="ID of the DataMate knowledge base"), authorization: Optional[str] = Header(None) ): - """Get all files from a DataMate knowledge base.""" + """Get all files from a specific DataMate knowledge base.""" try: user_id, tenant_id = get_current_user_id(authorization) result = await fetch_datamate_knowledge_base_file_list(knowledge_base_id, tenant_id) @@ -46,3 +60,40 @@ async def get_datamate_knowledge_base_files_endpoint( except Exception as e: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error fetching DataMate knowledge base files: {str(e)}") + + +@router.post("/test_connection") +async def test_datamate_connection_endpoint( + authorization: Optional[str] = Header(None), + request: SyncDatamateRequest = Body(None) +): + """ + Test connection to DataMate server. + + Returns: + JSON with success status and message + """ + try: + user_id, tenant_id = get_current_user_id(authorization) + datamate_url = request.datamate_url if request else None + + # Test the connection + is_connected, error_message = await check_datamate_connection(tenant_id, datamate_url) + + if is_connected: + return JSONResponse( + status_code=HTTPStatus.OK, + content={"success": True, "message": "Connection successful"} + ) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Cannot connect to DataMate server: {error_message}" + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Error testing DataMate connection: {str(e)}" + ) diff --git a/backend/apps/dify_app.py b/backend/apps/dify_app.py new file mode 100644 index 000000000..c7d9321af --- /dev/null +++ b/backend/apps/dify_app.py @@ -0,0 +1,71 @@ +""" +Dify App Layer +FastAPI endpoints for Dify knowledge base operations. + +This module provides API endpoints to interact with Dify's datasets API, +including fetching knowledge bases and transforming responses to a format +compatible with the frontend. +""" +import logging +from http import HTTPStatus +from typing import Optional + +from fastapi import APIRouter, Header, HTTPException, Query +from fastapi.responses import JSONResponse + +from services.dify_service import fetch_dify_datasets_impl +from utils.auth_utils import get_current_user_id + +router = APIRouter(prefix="/dify") +logger = logging.getLogger("dify_app") + + +@router.get("/datasets") +async def fetch_dify_datasets_api( + dify_api_base: str = Query(..., description="Dify API base URL"), + api_key: str = Query(..., description="Dify API key"), + authorization: Optional[str] = Header(None) +): + """ + Fetch datasets (knowledge bases) from Dify API. + + Returns knowledge bases in a format consistent with DataMate for frontend compatibility. + """ + try: + # Normalize URL by removing trailing slash + dify_api_base = dify_api_base.rstrip('/') + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch Dify datasets: {str(e)}" + ) + + try: + _, tenant_id = get_current_user_id(authorization) + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch Dify datasets: {str(e)}" + ) + + try: + result = fetch_dify_datasets_impl( + dify_api_base=dify_api_base, + api_key=api_key, + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content=result + ) + except ValueError as e: + logger.warning(f"Invalid Dify configuration: {e}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + logger.error(f"Failed to fetch Dify datasets: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch Dify datasets: {str(e)}" + ) diff --git a/backend/apps/invitation_app.py b/backend/apps/invitation_app.py index 44d1e5934..2aa3edc9e 100644 --- a/backend/apps/invitation_app.py +++ b/backend/apps/invitation_app.py @@ -11,7 +11,7 @@ from consts.model import ( InvitationCreateRequest, InvitationUpdateRequest, InvitationListRequest ) -from consts.exceptions import NotFoundException, ValidationError, UnauthorizedError +from consts.exceptions import NotFoundException, ValidationError, UnauthorizedError, DuplicateError from services.invitation_service import ( create_invitation_code, update_invitation_code, get_invitation_by_code, check_invitation_available, use_invitation_code, update_invitation_code_status, @@ -131,6 +131,12 @@ async def create_invitation_endpoint( status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) ) + except DuplicateError as exc: + logger.warning(f"Duplicate invitation code: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail=str(exc) + ) except NotFoundException as exc: logger.warning(f"User not found during invitation creation: {str(exc)}") raise HTTPException( @@ -279,6 +285,40 @@ async def get_invitation_endpoint(invitation_code: str) -> JSONResponse: ) +@router.get("/{invitation_code}/check") +async def check_invitation_code_endpoint(invitation_code: str) -> JSONResponse: + """ + Check if invitation code already exists + + Args: + invitation_code: Invitation code to check + + Returns: + JSONResponse: Check result with exists flag + """ + try: + invitation_info = get_invitation_by_code(invitation_code) + exists = invitation_info is not None + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Invitation code check completed", + "data": { + "invitation_code": invitation_code, + "exists": exists + } + } + ) + + except Exception as exc: + logger.error(f"Unexpected error checking invitation code {invitation_code}: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to check invitation code" + ) + + @router.delete("/{invitation_code}") async def delete_invitation_endpoint( invitation_code: str, diff --git a/backend/apps/mock_user_management_app.py b/backend/apps/mock_user_management_app.py index 885d7e23c..8e9f6adc3 100644 --- a/backend/apps/mock_user_management_app.py +++ b/backend/apps/mock_user_management_app.py @@ -6,7 +6,9 @@ from http import HTTPStatus from consts.const import MOCK_USER, MOCK_SESSION +from consts.exceptions import UnauthorizedError from consts.model import UserSignInRequest, UserSignUpRequest +from services.user_management_service import get_user_info logger = logging.getLogger("mock_user_management_app") router = APIRouter(prefix="/user", tags=["user"]) @@ -21,7 +23,7 @@ async def service_health(): return JSONResponse(status_code=HTTPStatus.OK, content={"message": "Auth service is available"}) except Exception as e: logger.error(f"Service health check failed: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Service health check failed") @@ -39,7 +41,7 @@ async def signup(request: UserSignUpRequest): success_message = "🎉 Admin account registered successfully! You now have system management permissions." else: success_message = "🎉 User account registered successfully! Please start experiencing the AI assistant service." - + user_data = { "user": { "id": MOCK_USER["id"], @@ -54,12 +56,12 @@ async def signup(request: UserSignUpRequest): }, "registration_type": "admin" if request.is_admin else "user" } - + return JSONResponse(status_code=HTTPStatus.OK, content={"message": success_message, "data": user_data}) except Exception as e: logger.error(f"User signup failed: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="User registration failed") @@ -88,11 +90,11 @@ async def signin(request: UserSignInRequest): } } } - + return JSONResponse(status_code=HTTPStatus.OK, content=signin_content) except Exception as e: logger.error(f"User signin failed: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="User login failed") @@ -106,7 +108,7 @@ async def user_refresh_token(request: Request): # In speed/mock mode, extend for a very long time (10 years) new_expires_at = int((datetime.now() + timedelta(days=3650)).timestamp()) - + session_info = { "access_token": f"mock_access_token_{new_expires_at}", "refresh_token": f"mock_refresh_token_{new_expires_at}", @@ -118,7 +120,7 @@ async def user_refresh_token(request: Request): content={"message": "Token refresh successful", "data": {"session": session_info}}) except Exception as e: logger.error(f"Token refresh failed: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Token refresh failed") @@ -134,7 +136,7 @@ async def logout(request: Request): content={"message": "Logout successful"}) except Exception as e: logger.error(f"User logout failed: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="User logout failed") @@ -152,13 +154,13 @@ async def get_session(request: Request): "role": MOCK_USER["role"] } } - + return JSONResponse(status_code=HTTPStatus.OK, content={"message": "Session is valid", "data": data}) except Exception as e: logger.error(f"Session validation failed: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Session validation failed") @@ -174,5 +176,29 @@ async def get_user_id(request: Request): "data": {"user_id": MOCK_USER["id"]}}) except Exception as e: logger.error(f"Get user ID failed: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to get user ID") + + +@router.get("/current_user_info") +async def get_user_information(request: Request): + """Get current user information including user ID, group IDs, tenant ID, and role""" + try: + # In mock mode, always get user ID by MOCK_USER + user_id = MOCK_USER["id"] + # Get user information + user_info = await get_user_info(user_id) + if not user_info: + raise UnauthorizedError("User information not found") + + return JSONResponse(status_code=HTTPStatus.OK, + content={"message": "Success", + "data": user_info}) + except UnauthorizedError as e: + logging.error(f"Get user information unauthorized: {str(e)}") + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, + detail="User not logged in or session invalid") + except Exception as e: + logging.error(f"Get user information failed: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Get user information failed") diff --git a/backend/apps/model_managment_app.py b/backend/apps/model_managment_app.py index 0c3c7a8cf..0a5a04139 100644 --- a/backend/apps/model_managment_app.py +++ b/backend/apps/model_managment_app.py @@ -18,6 +18,15 @@ BatchCreateModelsRequest, ModelRequest, ProviderModelRequest, + ManageTenantModelListRequest, + ManageTenantModelListResponse, + ManageTenantModelCreateRequest, + ManageTenantModelUpdateRequest, + ManageTenantModelDeleteRequest, + ManageTenantModelHealthcheckRequest, + ManageBatchCreateModelsRequest, + ManageProviderModelListRequest, + ManageProviderModelCreateRequest, ) from fastapi import APIRouter, Header, Query, HTTPException @@ -39,6 +48,7 @@ delete_model_for_tenant, list_models_for_tenant, list_llm_models_for_tenant, + list_models_for_admin, ) from utils.auth_utils import get_current_user_id @@ -335,3 +345,319 @@ async def check_temporary_model_health(request: ModelRequest): logging.error(f"Failed to verify model connectivity: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + +# Manage Tenant Model CRUD Endpoints +# --------------------------------------------------------------------------- + +@router.post("/manage/healthcheck") +async def manage_check_model_health( + request: ManageTenantModelHealthcheckRequest, + authorization: Optional[str] = Header(None) +): + """Check and update model connectivity for a specified tenant (admin/manage operation). + + This endpoint allows checking connectivity for any tenant's model, typically used by super admins. + + Args: + request: Query request with target tenant_id and model display_name. + authorization: Bearer token header used to derive `user_id`. + + Returns: + Connectivity check result with updated status. + """ + try: + user_id, _ = get_current_user_id(authorization) + logger.debug( + f"Start to check model connectivity for tenant, user_id: {user_id}, " + f"target_tenant_id: {request.tenant_id}, display_name: {request.display_name}") + + result = await check_model_connectivity(request.display_name, request.tenant_id) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully checked model connectivity", + "data": result + }) + except LookupError as e: + logging.error(f"Failed to check model connectivity for tenant: {str(e)}") + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except ValueError as e: + logging.error(f"Invalid model configuration: {str(e)}") + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except Exception as e: + logging.error(f"Failed to check model connectivity for tenant: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.post("/manage/create") +async def manage_create_model( + request: ManageTenantModelCreateRequest, + authorization: Optional[str] = Header(None) +): + """Create a model in a specified tenant (admin/manage operation). + + This endpoint allows creating models for any tenant, typically used by super admins. + + Args: + request: Model configuration with target tenant_id. + authorization: Bearer token header used to derive `user_id`. + + Returns: + Success message on successful creation. + """ + try: + user_id, _ = get_current_user_id(authorization) + logger.debug( + f"Start to create model for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}") + + model_data = request.model_dump(exclude={'tenant_id'}) + await create_model_for_tenant(user_id, request.tenant_id, model_data) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Model created successfully", + "data": {"tenant_id": request.tenant_id} + }) + except ValueError as e: + logging.error(f"Failed to create model for tenant: {str(e)}") + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) + except Exception as e: + logging.error(f"Failed to create model for tenant: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.post("/manage/update") +async def manage_update_model( + request: ManageTenantModelUpdateRequest, + authorization: Optional[str] = Header(None) +): + """Update a model in a specified tenant (admin/manage operation). + + This endpoint allows updating models for any tenant, typically used by super admins. + + Args: + request: Update payload with target tenant_id and current display_name. + authorization: Bearer token header used to derive `user_id`. + + Returns: + Success message on successful update. + """ + try: + user_id, _ = get_current_user_id(authorization) + logger.debug( + f"Start to update model for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " + f"current_display_name: {request.current_display_name}") + + model_data = request.model_dump(exclude={'tenant_id', 'current_display_name'}, exclude_unset=True) + await update_single_model_for_tenant( + user_id, request.tenant_id, request.current_display_name, model_data + ) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Model updated successfully", + "data": {"tenant_id": request.tenant_id} + }) + except LookupError as e: + logging.error(f"Failed to update model for tenant: {str(e)}") + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except ValueError as e: + logging.error(f"Failed to update model for tenant: {str(e)}") + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) + except Exception as e: + logging.error(f"Failed to update model for tenant: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.post("/manage/delete") +async def manage_delete_model( + request: ManageTenantModelDeleteRequest, + authorization: Optional[str] = Header(None) +): + """Delete a model from a specified tenant (admin/manage operation). + + This endpoint allows deleting models from any tenant, typically used by super admins. + + Args: + request: Delete request with target tenant_id and display_name. + authorization: Bearer token header used to derive `user_id`. + + Returns: + Success message with deleted model name. + """ + try: + user_id, _ = get_current_user_id(authorization) + logger.debug( + f"Start to delete model for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " + f"display_name: {request.display_name}") + + model_name = await delete_model_for_tenant( + user_id, request.tenant_id, request.display_name + ) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Model deleted successfully", + "data": { + "tenant_id": request.tenant_id, + "display_name": model_name + } + }) + except LookupError as e: + logging.error(f"Failed to delete model for tenant: {str(e)}") + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except Exception as e: + logging.error(f"Failed to delete model for tenant: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.post("/manage/batch_create") +async def manage_batch_create_models( + request: ManageBatchCreateModelsRequest, + authorization: Optional[str] = Header(None) +): + """Batch create/update models in a specified tenant (admin/manage operation). + + This endpoint synchronizes provider models for any tenant by creating/updating/deleting records. + Typically used by super admins to bulk import models. + + Args: + request: Batch payload with target tenant_id, provider, type, api_key, and models list. + authorization: Bearer token header used to derive `user_id`. + + Returns: + Success message on completion. + """ + try: + user_id, _ = get_current_user_id(authorization) + logger.debug( + f"Start to batch create models for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " + f"provider: {request.provider}, type: {request.type}, models count: {len(request.models)}") + + batch_model_config = request.model_dump() + await batch_create_models_for_tenant(user_id, request.tenant_id, batch_model_config) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Batch create models successfully", + "data": { + "tenant_id": request.tenant_id, + "provider": request.provider, + "type": request.type, + "models_count": len(request.models) + } + }) + except Exception as e: + logging.error(f"Failed to batch create models for tenant: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.post("/manage/list", response_model=ManageTenantModelListResponse) +async def manage_list_models( + request: ManageTenantModelListRequest, + authorization: Optional[str] = Header(None) +): + """List models for a specified tenant (admin/manage operation). + + This endpoint allows querying models for any tenant, typically used by super admins. + + Args: + request: Query request with target tenant_id and pagination params. + authorization: Bearer token header used to derive `user_id`. + + Returns: + Paginated model list for the specified tenant. + """ + try: + user_id, _ = get_current_user_id(authorization) + logger.debug( + f"Start to list models for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " + f"page: {request.page}, page_size: {request.page_size}") + + result = await list_models_for_admin( + request.tenant_id, + request.model_type, + request.page, + request.page_size + ) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully retrieved model list", + "data": jsonable_encoder(result) + }) + except Exception as e: + logging.error(f"Failed to list models for tenant: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=str(e)) + + +@router.post("/manage/provider/list") +async def manage_list_provider_models( + request: ManageProviderModelListRequest, + authorization: Optional[str] = Header(None) +): + """List provider models for a specified tenant (admin/manage operation). + + This endpoint fetches persisted models from a provider for any tenant, + typically used by super admins when bulk importing models. + + Args: + request: Query request with target tenant_id, provider, model_type. + authorization: Bearer token header used to derive `user_id`. + + Returns: + List of available provider models for the specified tenant. + """ + try: + user_id, _ = get_current_user_id(authorization) + logger.debug( + f"Start to list provider models for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " + f"provider: {request.provider}, model_type: {request.model_type}") + + model_list = await list_provider_models_for_tenant( + request.tenant_id, request.provider, request.model_type + ) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully retrieved provider model list", + "data": jsonable_encoder(model_list) + }) + except Exception as e: + logging.error(f"Failed to list provider models for tenant: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=str(e)) + + +@router.post("/manage/provider/create") +async def manage_create_provider_models( + request: ManageProviderModelCreateRequest, + authorization: Optional[str] = Header(None) +): + """Create/fetch provider models for a specified tenant (admin/manage operation). + + This endpoint fetches available models from a provider and prepares them for + bulk importing into a specific tenant, typically used by super admins. + + Args: + request: Query request with target tenant_id, provider, model_type, and optional api_key/base_url. + authorization: Bearer token header used to derive `user_id`. + + Returns: + List of available provider models for the specified tenant. + """ + try: + user_id, _ = get_current_user_id(authorization) + logger.debug( + f"Start to create provider models for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " + f"provider: {request.provider}, model_type: {request.model_type}") + + # Build provider request dict for the service function + provider_request = { + "provider": request.provider, + "model_type": request.model_type, + "api_key": request.api_key, + "base_url": request.base_url, + } + model_list = await create_provider_models_for_tenant( + request.tenant_id, provider_request + ) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully created provider models", + "data": jsonable_encoder(model_list) + }) + except Exception as e: + logging.error(f"Failed to create provider models for tenant: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=str(e)) diff --git a/backend/apps/remote_mcp_app.py b/backend/apps/remote_mcp_app.py index 7fdb7159d..fe16fd9be 100644 --- a/backend/apps/remote_mcp_app.py +++ b/backend/apps/remote_mcp_app.py @@ -1,7 +1,7 @@ import logging from typing import Optional -from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form +from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form, Query, Request from fastapi.responses import JSONResponse from http import HTTPStatus @@ -16,11 +16,12 @@ delete_mcp_by_container_id, upload_and_start_mcp_image, update_remote_mcp_server_list, + attach_mcp_container_permissions, ) from database.remote_mcp_db import check_mcp_name_exists from services.tool_configuration_service import get_tool_from_remote_mcp_server from services.mcp_container_service import MCPContainerManager -from utils.auth_utils import get_current_user_id +from utils.auth_utils import get_current_user_info router = APIRouter(prefix="/mcp") logger = logging.getLogger("remote_mcp_app") @@ -29,8 +30,7 @@ @router.post("/tools") async def get_tools_from_remote_mcp( service_name: str, - mcp_url: str, - authorization: Optional[str] = Header(None) + mcp_url: str ): """ Used to list tool information from the remote MCP server """ try: @@ -54,12 +54,17 @@ async def get_tools_from_remote_mcp( async def add_remote_proxies( mcp_url: str, service_name: str, - authorization: Optional[str] = Header(None) + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + http_request: Request = None ): """ Used to add a remote MCP server """ try: - user_id, tenant_id = get_current_user_id(authorization) - await add_remote_mcp_server_list(tenant_id=tenant_id, + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id + await add_remote_mcp_server_list(tenant_id=effective_tenant_id, user_id=user_id, remote_mcp_server=mcp_url, remote_mcp_server_name=service_name, @@ -88,12 +93,17 @@ async def add_remote_proxies( async def delete_remote_proxies( service_name: str, mcp_url: str, - authorization: Optional[str] = Header(None) + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + http_request: Request = None ): """ Used to delete a remote MCP server """ try: - user_id, tenant_id = get_current_user_id(authorization) - await delete_remote_mcp_server_list(tenant_id=tenant_id, + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id + await delete_remote_mcp_server_list(tenant_id=effective_tenant_id, user_id=user_id, remote_mcp_server=mcp_url, remote_mcp_server_name=service_name) @@ -111,14 +121,19 @@ async def delete_remote_proxies( @router.put("/update") async def update_remote_proxy( update_data: MCPUpdateRequest, - authorization: Optional[str] = Header(None) + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + http_request: Request = None ): """ Used to update an existing remote MCP server """ try: - user_id, tenant_id = get_current_user_id(authorization) + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id await update_remote_mcp_server_list( update_data=update_data, - tenant_id=tenant_id, + tenant_id=effective_tenant_id, user_id=user_id ) return JSONResponse( @@ -142,12 +157,20 @@ async def update_remote_proxy( @router.get("/list") async def get_remote_proxies( - authorization: Optional[str] = Header(None) + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + http_request: Request = None ): """ Used to get the list of remote MCP servers """ try: - _, tenant_id = get_current_user_id(authorization) - remote_mcp_server_list = await get_remote_mcp_server_list(tenant_id=tenant_id) + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id + remote_mcp_server_list = await get_remote_mcp_server_list( + tenant_id=effective_tenant_id, + user_id=user_id, + ) return JSONResponse( status_code=HTTPStatus.OK, content={"remote_mcp_server_list": remote_mcp_server_list, @@ -161,12 +184,21 @@ async def get_remote_proxies( @router.get("/healthcheck") -async def check_mcp_health(mcp_url: str, service_name: str, authorization: Optional[str] = Header(None)): +async def check_mcp_health( + mcp_url: str, + service_name: str, + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + http_request: Request = None +): """ Used to check the health of the MCP server, the front end can call it, and automatically update the database status """ try: - user_id, tenant_id = get_current_user_id(authorization) - await check_mcp_health_and_update_db(mcp_url, service_name, tenant_id, user_id) + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id + await check_mcp_health_and_update_db(mcp_url, service_name, effective_tenant_id, user_id) return JSONResponse( status_code=HTTPStatus.OK, content={"status": "success"} @@ -184,7 +216,10 @@ async def check_mcp_health(mcp_url: str, service_name: str, authorization: Optio @router.post("/add-from-config") async def add_mcp_from_config( mcp_config: MCPConfigRequest, - authorization: Optional[str] = Header(None) + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + http_request: Request = None ): """ Add MCP server by starting a container with command+args config. @@ -202,7 +237,9 @@ async def add_mcp_from_config( } """ try: - user_id, tenant_id = get_current_user_id(authorization) + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id # Initialize container manager try: @@ -233,7 +270,7 @@ async def add_mcp_from_config( continue # Check if MCP service name already exists before starting container - if check_mcp_name_exists(mcp_name=service_name, tenant_id=tenant_id): + if check_mcp_name_exists(mcp_name=service_name, tenant_id=effective_tenant_id): errors.append(f"{service_name}: MCP name already exists") continue @@ -256,7 +293,7 @@ async def add_mcp_from_config( # Start container container_info = await container_manager.start_mcp_container( service_name=service_name, - tenant_id=tenant_id, + tenant_id=effective_tenant_id, user_id=user_id, env_vars=env_vars, host_port=port, @@ -266,7 +303,7 @@ async def add_mcp_from_config( # Register to remote MCP server list await add_remote_mcp_server_list( - tenant_id=tenant_id, + tenant_id=effective_tenant_id, user_id=user_id, remote_mcp_server=container_info["mcp_url"], remote_mcp_server_name=service_name, @@ -320,11 +357,16 @@ async def add_mcp_from_config( @router.delete("/container/{container_id}") async def stop_mcp_container( container_id: str, - authorization: Optional[str] = Header(None) + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + http_request: Request = None ): """ Stop and remove MCP container """ try: - user_id, tenant_id = get_current_user_id(authorization) + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id try: container_manager = MCPContainerManager() @@ -340,7 +382,7 @@ async def stop_mcp_container( if success: # Soft delete the corresponding MCP record (if any) by container ID await delete_mcp_by_container_id( - tenant_id=tenant_id, + tenant_id=effective_tenant_id, user_id=user_id, container_id=container_id, ) @@ -368,11 +410,16 @@ async def stop_mcp_container( @router.get("/containers") async def list_mcp_containers( - authorization: Optional[str] = Header(None) + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + http_request: Request = None ): """ List all MCP containers for the current tenant """ try: - user_id, tenant_id = get_current_user_id(authorization) + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id try: container_manager = MCPContainerManager() @@ -383,7 +430,12 @@ async def list_mcp_containers( detail="Docker service unavailable" ) - containers = container_manager.list_mcp_containers(tenant_id=tenant_id) + containers = container_manager.list_mcp_containers(tenant_id=effective_tenant_id) + containers = attach_mcp_container_permissions( + containers=containers, + tenant_id=effective_tenant_id, + user_id=user_id, + ) return JSONResponse( status_code=HTTPStatus.OK, @@ -406,11 +458,16 @@ async def list_mcp_containers( async def get_container_logs( container_id: str, tail: int = 100, - authorization: Optional[str] = Header(None) + tenant_id: Optional[str] = Query( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + http_request: Request = None ): """ Get logs from MCP container """ try: - user_id, tenant_id = get_current_user_id(authorization) + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id try: container_manager = MCPContainerManager() @@ -451,7 +508,10 @@ async def upload_mcp_image( None, description="Name for the MCP service (auto-generated if not provided)"), env_vars: Optional[str] = Form( None, description="Environment variables as JSON string"), - authorization: Optional[str] = Header(None) + tenant_id: Optional[str] = Form( + None, description="Tenant ID for filtering (uses auth if not provided)"), + authorization: Optional[str] = Header(None), + http_request: Request = None ): """ Upload Docker image tar file and start MCP container. @@ -459,14 +519,16 @@ async def upload_mcp_image( Container naming: {filename-without-extension}-{tenant-id[:8]}-{user-id[:8]} """ try: - user_id, tenant_id = get_current_user_id(authorization) + user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) + # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id + effective_tenant_id = tenant_id or auth_tenant_id # Read file content content = await file.read() # Call service layer to handle the business logic result = await upload_and_start_mcp_image( - tenant_id=tenant_id, + tenant_id=effective_tenant_id, user_id=user_id, file_content=content, filename=file.filename, diff --git a/backend/apps/tenant_config_app.py b/backend/apps/tenant_config_app.py index 371e3f864..cd67f0c8f 100644 --- a/backend/apps/tenant_config_app.py +++ b/backend/apps/tenant_config_app.py @@ -1,14 +1,10 @@ import logging from http import HTTPStatus -from typing import List, Optional -from fastapi import APIRouter, Body, Header, HTTPException +from fastapi import APIRouter, HTTPException from fastapi.responses import JSONResponse from consts.const import DEPLOYMENT_VERSION, APP_VERSION -from consts.model import UpdateKnowledgeListRequest -from services.tenant_config_service import get_selected_knowledge_list, update_selected_knowledge -from utils.auth_utils import get_current_user_id logger = logging.getLogger("tenant_config_app") router = APIRouter(prefix="/tenant_config") @@ -34,74 +30,4 @@ def get_deployment_version(): ) -@router.get("/load_knowledge_list") -def load_knowledge_list( - authorization: Optional[str] = Header(None) -): - try: - user_id, tenant_id = get_current_user_id(authorization) - selected_knowledge_info = get_selected_knowledge_list( - tenant_id=tenant_id, user_id=user_id) - - content = {"selectedKbNames": [item["index_name"] for item in selected_knowledge_info], - "selectedKbModels": [item["embedding_model_name"] for item in selected_knowledge_info], - "selectedKbSources": [item["knowledge_sources"] for item in selected_knowledge_info]} - - return JSONResponse( - status_code=HTTPStatus.OK, - content={"content": content, "status": "success"} - ) - except Exception as e: - logger.error(f"load knowledge list failed, error: {e}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to load configuration" - ) - -@router.post("/update_knowledge_list") -def update_knowledge_list( - authorization: Optional[str] = Header(None), - request: UpdateKnowledgeListRequest = Body(...) -): - try: - user_id, tenant_id = get_current_user_id(authorization) - - # Convert grouped request to flat lists - knowledge_list = [] - knowledge_sources = [] - - if request.nexent: - knowledge_list.extend(request.nexent) - knowledge_sources.extend(["nexent"] * len(request.nexent)) - - if request.datamate: - knowledge_list.extend(request.datamate) - knowledge_sources.extend(["datamate"] * len(request.datamate)) - - result = update_selected_knowledge( - tenant_id=tenant_id, user_id=user_id, index_name_list=knowledge_list, knowledge_sources=knowledge_sources) - if result: - # Get updated knowledge base information - selected_knowledge_info = get_selected_knowledge_list( - tenant_id=tenant_id, user_id=user_id) - - content = {"selectedKbNames": [item["index_name"] for item in selected_knowledge_info], - "selectedKbModels": [item["embedding_model_name"] for item in selected_knowledge_info], - "selectedKbSources": [item["knowledge_sources"] for item in selected_knowledge_info]} - - return JSONResponse( - status_code=HTTPStatus.OK, - content={"content": content, "message": "update success", "status": "success"} - ) - else: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to update configuration" - ) - except Exception as e: - logger.error(f"update knowledge list failed, error: {e}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to update configuration" - ) diff --git a/backend/apps/user_app.py b/backend/apps/user_app.py index 57cf3b19f..32591b01f 100644 --- a/backend/apps/user_app.py +++ b/backend/apps/user_app.py @@ -11,10 +11,10 @@ from consts.model import ( UserListRequest, UserUpdateRequest ) -from consts.exceptions import NotFoundException, ValidationError, UnauthorizedError from services.user_service import ( - get_users, update_user, delete_user + get_users, update_user, delete_user_and_cleanup ) +from database.user_tenant_db import get_user_tenant_by_user_id from utils.auth_utils import get_current_user_id logger = logging.getLogger("user_app") @@ -116,7 +116,13 @@ async def delete_user_endpoint( authorization: Optional[str] = Header(None) ) -> JSONResponse: """ - Soft delete user and remove from all groups + Permanently delete user and all related data. + + This performs complete cleanup including: + - Soft-delete user-tenant relationship and groups + - Soft-delete memory configs and conversations + - Clear user-level memories + - Permanently delete user from Supabase Args: user_id: User identifier @@ -129,13 +135,17 @@ async def delete_user_endpoint( # Get current user ID from token for access control current_user_id, _ = get_current_user_id(authorization) - # Delete user (soft delete) - success = await delete_user(user_id, current_user_id) + # Get user tenant ID for cleanup operations + user_tenant = get_user_tenant_by_user_id(user_id) + if not user_tenant: + raise ValueError(f"User {user_id} not found") + + tenant_id = user_tenant["tenant_id"] - if not success: - raise ValueError(f"Failed to delete user {user_id}") + # Perform complete user cleanup + await delete_user_and_cleanup(user_id, tenant_id) - logger.info(f"Soft deleted user {user_id} by user {current_user_id}") + logger.info(f"Permanently deleted user {user_id} by admin {current_user_id}") return JSONResponse( status_code=HTTPStatus.OK, diff --git a/backend/apps/user_management_app.py b/backend/apps/user_management_app.py index 5b5e0d3d7..1ebf0bace 100644 --- a/backend/apps/user_management_app.py +++ b/backend/apps/user_management_app.py @@ -11,7 +11,8 @@ from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException from services.user_management_service import get_authorized_client, validate_token, \ check_auth_service_health, signup_user, signup_user_with_invitation, signin_user, refresh_user_token, \ - get_session_by_authorization, revoke_regular_user, get_user_info + get_session_by_authorization, get_user_info +from services.user_service import delete_user_and_cleanup from consts.exceptions import UnauthorizedError from utils.auth_utils import get_current_user_id @@ -276,7 +277,7 @@ async def revoke_user_account(request: Request): detail="Admin account cannot be deleted via this endpoint") # Orchestrate revoke for regular user - await revoke_regular_user(user_id=user_id, tenant_id=tenant_id) + await delete_user_and_cleanup(user_id=user_id, tenant_id=tenant_id) return JSONResponse(status_code=HTTPStatus.OK, content={"message": "User account revoked"}) except UnauthorizedError as e: diff --git a/backend/apps/vectordatabase_app.py b/backend/apps/vectordatabase_app.py index afcfb76a6..04ea9820f 100644 --- a/backend/apps/vectordatabase_app.py +++ b/backend/apps/vectordatabase_app.py @@ -53,14 +53,32 @@ def create_new_index( index_name: str = Path(..., description="Name of the index to create"), embedding_dim: Optional[int] = Query( None, description="Dimension of the embedding vectors"), + request: Dict[str, Any] = Body( + None, description="Request body with optional fields (ingroup_permission, group_ids)"), vdb_core: VectorDatabaseCore = Depends(get_vector_db_core), authorization: Optional[str] = Header(None) ): """Create a new vector index and store it in the knowledge table""" try: user_id, tenant_id = get_current_user_id(authorization) + + # Extract optional fields from request body + ingroup_permission = None + group_ids = None + if request: + ingroup_permission = request.get("ingroup_permission") + group_ids = request.get("group_ids") + # Treat path parameter as user-facing knowledge base name for new creations - return ElasticSearchService.create_knowledge_base(index_name, embedding_dim, vdb_core, user_id, tenant_id) + return ElasticSearchService.create_knowledge_base( + knowledge_name=index_name, + embedding_dim=embedding_dim, + vdb_core=vdb_core, + user_id=user_id, + tenant_id=tenant_id, + ingroup_permission=ingroup_permission, + group_ids=group_ids, + ) except Exception as e: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error creating index: {str(e)}") @@ -395,6 +413,7 @@ def create_chunk( chunk_request=payload, vdb_core=vdb_core, user_id=user_id, + tenant_id=tenant_id, ) return JSONResponse(status_code=HTTPStatus.OK, content=result) except ValueError as e: diff --git a/backend/consts/const.py b/backend/consts/const.py index f09ae9cf4..96c937ef0 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -64,6 +64,14 @@ class VectorDatabaseType(str, Enum): DEFAULT_USER_ID = "user_id" DEFAULT_TENANT_ID = "tenant_id" +# Roles that can edit all resources within a tenant (permission = EDIT). +# Keep this centralized to avoid drifting role logic across modules. +CAN_EDIT_ALL_USER_ROLES = {"SU", "ADMIN", "SPEED"} + +# Permission constants used by list endpoints (e.g., /agent/list, /mcp/list). +PERMISSION_READ = "READ_ONLY" +PERMISSION_EDIT = "EDIT" + # Deployment Version Configuration DEPLOYMENT_VERSION = os.getenv("DEPLOYMENT_VERSION", "speed") @@ -184,7 +192,8 @@ class VectorDatabaseType(str, Enum): DEBUG_JWT_EXPIRE_SECONDS = int(os.getenv('DEBUG_JWT_EXPIRE_SECONDS', '0') or 0) # User info query source control: "supabase" or "pg" (default: "supabase" for backward compatibility) -USER_INFO_QUERY_SOURCE = os.getenv('USER_INFO_QUERY_SOURCE', 'supabase').lower() +USER_INFO_QUERY_SOURCE = os.getenv( + 'USER_INFO_QUERY_SOURCE', 'supabase').lower() # Memory Search Status Messages (for i18n placeholders) MEMORY_SEARCH_START_MSG = "" @@ -294,4 +303,4 @@ class VectorDatabaseType(str, Enum): MODEL_ENGINE_ENABLED = os.getenv("MODEL_ENGINE_ENABLED") # APP Version -APP_VERSION = "v1.7.10" +APP_VERSION = "v1.8.0" diff --git a/backend/consts/exceptions.py b/backend/consts/exceptions.py index 815ed0eef..94b1f770d 100644 --- a/backend/consts/exceptions.py +++ b/backend/consts/exceptions.py @@ -27,7 +27,7 @@ class MemoryPreparationException(Exception): """Raised when memory preprocessing or retrieval fails prior to agent run.""" pass - + class MCPConnectionError(Exception): """Raised when MCP connection fails.""" pass @@ -101,4 +101,14 @@ class ToolExecutionException(Exception): class MCPContainerError(Exception): """Raised when MCP container operation fails.""" - pass \ No newline at end of file + pass + + +class DuplicateError(Exception): + """Raised when a duplicate resource already exists.""" + pass + + +class DataMateConnectionError(Exception): + """Raised when DataMate connection fails or URL is not configured.""" + pass diff --git a/backend/consts/model.py b/backend/consts/model.py index 089c09b27..a4862cd59 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -279,6 +279,7 @@ class AgentInfoRequest(BaseModel): enabled_tool_ids: Optional[List[int]] = None related_agent_ids: Optional[List[int]] = None group_ids: Optional[List[int]] = None + version_no: int = 0 class AgentIDRequest(BaseModel): @@ -290,6 +291,7 @@ class ToolInstanceInfoRequest(BaseModel): agent_id: int params: Dict[str, Any] enabled: bool + version_no: int = 0 class ToolInstanceSearchRequest(BaseModel): @@ -633,3 +635,166 @@ class InvitationUseResponse(BaseModel): code_type: str = Field(..., description="Code type") group_ids: Optional[List[int]] = Field( None, description="Associated group IDs") + + +# Manage Tenant Model Data Models +# --------------------------------------------------------------------------- +class ManageTenantModelListRequest(BaseModel): + """Request model for listing models in a specific tenant (manage operation)""" + tenant_id: str = Field(..., min_length=1, description="Target tenant ID to query models for") + model_type: Optional[str] = Field( + None, description="Filter by model type (e.g., 'llm', 'embedding')") + page: int = Field(1, ge=1, description="Page number for pagination") + page_size: int = Field(20, ge=1, le=100, description="Items per page") + + +class ManageTenantModelListResponse(BaseModel): + """Response model for tenant model list query""" + tenant_id: str = Field(..., description="Tenant identifier") + tenant_name: str = Field(..., description="Tenant display name") + models: List[Dict[str, Any]] = Field( + default_factory=list, description="List of models for this tenant") + total: int = Field(0, description="Total number of models") + page: int = Field(1, description="Current page number") + page_size: int = Field(20, description="Items per page") + total_pages: int = Field(0, description="Total number of pages") + + +class ManageTenantModelCreateRequest(BaseModel): + """Request model for creating a model in a specific tenant (admin/manage operation)""" + tenant_id: str = Field(..., min_length=1, description="Target tenant ID to create model for") + model_repo: Optional[str] = Field('', description="Model repository path") + model_name: str = Field(..., description="Model name") + model_type: str = Field(..., description="Model type (e.g., 'llm', 'embedding', 'vlm', 'tts', 'stt')") + api_key: Optional[str] = Field('', description="API key for the model") + base_url: Optional[str] = Field('', description="Base URL for the model API") + max_tokens: Optional[int] = Field(0, description="Maximum tokens for the model") + display_name: Optional[str] = Field('', description="Display name for the model") + expected_chunk_size: Optional[int] = Field(None, description="Expected chunk size for embedding models") + maximum_chunk_size: Optional[int] = Field(None, description="Maximum chunk size for embedding models") + chunk_batch: Optional[int] = Field(None, description="Batch size for chunking") + + +class ManageTenantModelUpdateRequest(BaseModel): + """Request model for updating a model in a specific tenant (admin/manage operation)""" + tenant_id: str = Field(..., min_length=1, description="Target tenant ID to update model for") + current_display_name: str = Field(..., description="Current display name of the model to update") + model_repo: Optional[str] = Field(None, description="Model repository path") + model_name: Optional[str] = Field(None, description="Model name") + model_type: Optional[str] = Field(None, description="Model type") + api_key: Optional[str] = Field(None, description="API key for the model") + base_url: Optional[str] = Field(None, description="Base URL for the model API") + max_tokens: Optional[int] = Field(None, description="Maximum tokens for the model") + display_name: Optional[str] = Field(None, description="New display name for the model") + expected_chunk_size: Optional[int] = Field(None, description="Expected chunk size for embedding models") + maximum_chunk_size: Optional[int] = Field(None, description="Maximum chunk size for embedding models") + chunk_batch: Optional[int] = Field(None, description="Batch size for chunking") + + +class ManageTenantModelDeleteRequest(BaseModel): + """Request model for deleting a model from a specific tenant (admin/manage operation)""" + tenant_id: str = Field(..., min_length=1, description="Target tenant ID to delete model from") + display_name: str = Field(..., description="Display name of the model to delete") + + +class ManageTenantModelHealthcheckRequest(BaseModel): + """Request model for checking model connectivity in a specific tenant (admin/manage operation)""" + tenant_id: str = Field(..., min_length=1, description="Target tenant ID to check model connectivity") + display_name: str = Field(..., description="Display name of the model to check") + + +class ManageBatchCreateModelsRequest(BaseModel): + """Request model for batch creating/updating models in a specific tenant (admin/manage operation)""" + tenant_id: str = Field(..., min_length=1, description="Target tenant ID to batch create models for") + provider: str = Field(..., description="Model provider (e.g., 'silicon', 'modelengine')") + type: str = Field(..., description="Model type (e.g., 'llm', 'embedding')") + api_key: str = Field('', description="API key for the models") + models: List[Dict[str, Any]] = Field(default_factory=list, description="List of models to create/update") + + +class ManageProviderModelListRequest(BaseModel): + """Request model for listing provider models in a specific tenant (admin/manage operation)""" + tenant_id: str = Field(..., min_length=1, description="Target tenant ID to query provider models for") + provider: str = Field(..., description="Model provider (e.g., 'silicon', 'modelengine')") + model_type: str = Field(..., description="Model type (e.g., 'llm', 'embedding')") + + +class ManageProviderModelCreateRequest(BaseModel): + """Request model for creating provider models in a specific tenant (admin/manage operation)""" + tenant_id: str = Field(..., min_length=1, description="Target tenant ID to create provider models for") + provider: str = Field(..., description="Model provider (e.g., 'silicon', 'modelengine')") + model_type: str = Field(..., description="Model type (e.g., 'llm', 'embedding')") + api_key: Optional[str] = Field('', description="API key for the provider") + base_url: Optional[str] = Field('', description="Base URL for the provider API") + + +# Agent Version Management Data Models +# --------------------------------------------------------------------------- +class VersionPublishRequest(BaseModel): + """Request model for publishing a new version""" + version_name: Optional[str] = Field(None, description="User-defined version name for display") + release_note: Optional[str] = Field(None, description="Release notes / publish remarks") + + +class VersionListItemResponse(BaseModel): + """Response model for version list item""" + id: int = Field(..., description="Version record ID") + version_no: int = Field(..., description="Version number") + version_name: Optional[str] = Field(None, description="User-defined version name") + release_note: Optional[str] = Field(None, description="Release notes") + source_version_no: Optional[int] = Field(None, description="Source version number if rollback") + source_type: Optional[str] = Field(None, description="Source type: NORMAL / ROLLBACK") + status: str = Field(..., description="Version status: RELEASED / DISABLED / ARCHIVED") + created_by: str = Field(..., description="User who published this version") + create_time: Optional[str] = Field(None, description="Publish timestamp") + + +class VersionListResponse(BaseModel): + """Response model for version list""" + items: List[VersionListItemResponse] = Field(..., description="Version list items") + total: int = Field(..., description="Total count") + + +class VersionDetailResponse(BaseModel): + """Response model for version detail including snapshot data""" + id: int = Field(..., description="Version record ID") + version_no: int = Field(..., description="Version number") + version_name: Optional[str] = Field(None, description="User-defined version name") + release_note: Optional[str] = Field(None, description="Release notes") + source_version_no: Optional[int] = Field(None, description="Source version number") + source_type: Optional[str] = Field(None, description="Source type") + status: str = Field(..., description="Version status") + created_by: str = Field(..., description="User who published this version") + create_time: Optional[str] = Field(None, description="Publish timestamp") + agent_info: Optional[dict] = Field(None, description="Agent info snapshot") + tool_instances: List[dict] = Field(default_factory=list, description="Tool instance snapshots") + relations: List[dict] = Field(default_factory=list, description="Relation snapshots") + + +class VersionRollbackRequest(BaseModel): + """Request model for rollback to a specific version""" + version_name: Optional[str] = Field(None, description="New version name for the rollback version") + release_note: Optional[str] = Field(None, description="Release notes for the rollback version") + + +class VersionStatusRequest(BaseModel): + """Request model for updating version status""" + status: str = Field(..., description="New status: DISABLED / ARCHIVED") + + +class VersionCompareRequest(BaseModel): + """Request model for comparing two versions""" + version_no_a: int = Field(..., description="First version number for comparison") + version_no_b: int = Field(..., description="Second version number for comparison") + + +class CurrentVersionResponse(BaseModel): + """Response model for current published version""" + version_no: int = Field(..., description="Current published version number") + version_name: Optional[str] = Field(None, description="Version name") + status: str = Field(..., description="Version status") + source_type: Optional[str] = Field(None, description="Source type") + source_version_no: Optional[int] = Field(None, description="Source version number") + release_note: Optional[str] = Field(None, description="Release notes") + created_by: str = Field(..., description="User who published this version") + create_time: Optional[str] = Field(None, description="Publish timestamp") diff --git a/backend/database/agent_db.py b/backend/database/agent_db.py index 6ed3a1f6e..3ced7625b 100644 --- a/backend/database/agent_db.py +++ b/backend/database/agent_db.py @@ -9,14 +9,21 @@ logger = logging.getLogger("agent_db") -def search_agent_info_by_agent_id(agent_id: int, tenant_id: str): +def search_agent_info_by_agent_id(agent_id: int, tenant_id: str, version_no: int = 0): """ - Search agent info by agent_id + Search agent info by agent_id. + Default version_no=0 queries the draft version. + + Args: + agent_id: Agent ID + tenant_id: Tenant ID + version_no: Version number to filter. Default 0 = draft/editing state """ with get_db_session() as session: agent = session.query(AgentInfo).filter( AgentInfo.agent_id == agent_id, AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == version_no, AgentInfo.delete_flag != 'Y' ).first() @@ -28,27 +35,40 @@ def search_agent_info_by_agent_id(agent_id: int, tenant_id: str): return agent_dict -def search_agent_id_by_agent_name(agent_name: str, tenant_id: str): +def search_agent_id_by_agent_name(agent_name: str, tenant_id: str, version_no: int = 0): """ - Search agent id by agent name + Search agent id by agent name. + Default version_no=0 queries the draft version. + + Args: + agent_name: Agent name + tenant_id: Tenant ID + version_no: Version number to filter. Default 0 = draft/editing state """ with get_db_session() as session: agent = session.query(AgentInfo).filter( AgentInfo.name == agent_name, AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == version_no, AgentInfo.delete_flag != 'Y').first() if not agent: raise ValueError("agent not found") return agent.agent_id -def search_blank_sub_agent_by_main_agent_id(tenant_id: str): +def search_blank_sub_agent_by_main_agent_id(tenant_id: str, version_no: int = 0): """ - Search blank sub agent by main agent id + Search blank sub agent by main agent id. + Default version_no=0 queries the draft version. + + Args: + tenant_id: Tenant ID + version_no: Version number to filter. Default 0 = draft/editing state """ with get_db_session() as session: sub_agent = session.query(AgentInfo).filter( AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == version_no, AgentInfo.delete_flag != 'Y', AgentInfo.enabled == False ).first() @@ -58,28 +78,39 @@ def search_blank_sub_agent_by_main_agent_id(tenant_id: str): return None -def query_sub_agents_id_list(main_agent_id: int, tenant_id: str): +def query_sub_agents_id_list(main_agent_id: int, tenant_id: str, version_no: int = 0): """ - Query the sub agent id list by main agent id + Query the sub agent id list by main agent id. + Default version_no=0 queries the draft version. + + Args: + main_agent_id: Parent agent ID + tenant_id: Tenant ID + version_no: Version number to filter. Default 0 = draft/editing state """ with get_db_session() as session: - query = session.query(AgentRelation).filter(AgentRelation.parent_agent_id == main_agent_id, - AgentRelation.tenant_id == tenant_id, - AgentRelation.delete_flag != 'Y') + query = session.query(AgentRelation).filter( + AgentRelation.parent_agent_id == main_agent_id, + AgentRelation.tenant_id == tenant_id, + AgentRelation.version_no == version_no, + AgentRelation.delete_flag != 'Y') relations = query.all() return [relation.selected_agent_id for relation in relations] -def clear_agent_new_mark(agent_id: int, tenant_id: str, user_id: str): +def clear_agent_new_mark(agent_id: int, tenant_id: str, user_id: str, version_no: int = 0): """ - Clear the NEW mark for an agent + Clear the NEW mark for an agent. + This clears the NEW mark for ALL versions of the agent, regardless of version_no parameter. Args: agent_id (int): Agent ID tenant_id (str): Tenant ID user_id (str): User ID (for audit purposes) + version_no: Version number (kept for API compatibility, but always clears all versions) """ with get_db_session() as session: + # Clear NEW mark for ALL versions of this agent result = session.execute( update(AgentInfo) .where( @@ -93,9 +124,16 @@ def clear_agent_new_mark(agent_id: int, tenant_id: str, user_id: str): return result.rowcount -def mark_agents_as_new(agent_ids: list[int], tenant_id: str, user_id: str): +def mark_agents_as_new(agent_ids: list[int], tenant_id: str, user_id: str, version_no: int = 0): """ - Mark a list of agents as new (is_new = True) + Mark a list of agents as new. + This marks ALL versions of the specified agents as new, regardless of version_no parameter. + + Args: + agent_ids: List of Agent IDs + tenant_id: Tenant ID + user_id: User ID + version_no: Version number (kept for API compatibility, but always marks all versions) """ if not agent_ids: return @@ -113,7 +151,7 @@ def mark_agents_as_new(agent_ids: list[int], tenant_id: str, user_id: str): def create_agent(agent_info, tenant_id: str, user_id: str): """ - Create a new agent in the database. + Create a new agent in the database (draft version, version_no=0). :param agent_info: Dictionary containing agent information :param tenant_id: :param user_id: @@ -123,6 +161,7 @@ def create_agent(agent_info, tenant_id: str, user_id: str): info_with_metadata.setdefault("max_steps", 5) info_with_metadata.update({ "tenant_id": tenant_id, + "version_no": 0, # Default to draft version "created_by": user_id, "updated_by": user_id, "is_new": True, # Mark new agents as new @@ -133,24 +172,58 @@ def create_agent(agent_info, tenant_id: str, user_id: str): session.add(new_agent) session.flush() - return as_dict(new_agent) + # Directly extract agent_id and return as dict + result = { + "agent_id": new_agent.agent_id, + "tenant_id": new_agent.tenant_id, + "name": new_agent.name, + "display_name": new_agent.display_name, + "description": new_agent.description, + "author": new_agent.author, + "model_id": new_agent.model_id, + "model_name": new_agent.model_name, + "max_steps": new_agent.max_steps, + "duty_prompt": new_agent.duty_prompt, + "constraint_prompt": new_agent.constraint_prompt, + "few_shots_prompt": new_agent.few_shots_prompt, + "parent_agent_id": new_agent.parent_agent_id, + "enabled": new_agent.enabled, + "provide_run_summary": new_agent.provide_run_summary, + "business_description": new_agent.business_description, + "business_logic_model_id": new_agent.business_logic_model_id, + "business_logic_model_name": new_agent.business_logic_model_name, + "group_ids": new_agent.group_ids, + "is_new": new_agent.is_new, + "current_version_no": new_agent.current_version_no, + "version_no": new_agent.version_no, + "created_by": new_agent.created_by, + "updated_by": new_agent.updated_by, + "delete_flag": new_agent.delete_flag, + } + return result -def update_agent(agent_id, agent_info, tenant_id, user_id): +def update_agent(agent_id, agent_info, user_id, version_no: int = 0): """ Update an existing agent in the database. - :param agent_id: ID of the agent to update - :param agent_info: Dictionary containing updated agent information - :param tenant_id: tenant ID - :param user_id: Optional user ID - :return: Updated agent object + Default version_no=0 updates the draft version. + + Args: + agent_id: ID of the agent to update + agent_info: Dictionary containing updated agent information + tenant_id: Tenant ID + user_id: Optional user ID + version_no: Version number to filter. Default 0 = draft/editing state + Returns: + Updated agent object """ with (get_db_session() as session): # update ag_tenant_agent_t - agent = session.query(AgentInfo).filter(AgentInfo.agent_id == agent_id, - AgentInfo.tenant_id == tenant_id, - AgentInfo.delete_flag != 'Y' - ).first() + agent = session.query(AgentInfo).filter( + AgentInfo.agent_id == agent_id, + AgentInfo.version_no == version_no, + AgentInfo.delete_flag != 'Y' + ).first() if not agent: raise ValueError("ag_tenant_agent_t Agent not found") @@ -165,40 +238,73 @@ def update_agent(agent_id, agent_info, tenant_id, user_id): def delete_agent_by_id(agent_id, tenant_id: str, user_id: str): """ - Delete an agent in the database. + Delete an agent in the database (all versions). :param agent_id: ID of the agent to delete :param tenant_id: Tenant ID for filtering, mandatory :param user_id: Optional user ID for filtering :return: None """ + from sqlalchemy import update as sqlalchemy_update + with get_db_session() as session: - session.query(AgentInfo).filter(AgentInfo.agent_id == agent_id, - AgentInfo.tenant_id == tenant_id).update( - {AgentInfo.delete_flag: 'Y', 'updated_by': user_id}) - session.query(ToolInstance).filter(ToolInstance.agent_id == agent_id, - ToolInstance.tenant_id == tenant_id).update( - {ToolInstance.delete_flag: 'Y', 'updated_by': user_id}) - session.commit() + # Soft delete all agent versions (version_no >= 0) + session.execute( + sqlalchemy_update(AgentInfo) + .where( + AgentInfo.agent_id == agent_id, + AgentInfo.tenant_id == tenant_id + ) + .values(delete_flag='Y', updated_by=user_id) + ) + # Soft delete all tool instances (all versions) + session.execute( + sqlalchemy_update(ToolInstance) + .where( + ToolInstance.agent_id == agent_id, + ToolInstance.tenant_id == tenant_id + ) + .values(delete_flag='Y', updated_by=user_id) + ) -def query_all_agent_info_by_tenant_id(tenant_id: str): +def query_all_agent_info_by_tenant_id(tenant_id: str, version_no: int = 0): """ - Query all agent info by tenant id + Query all agent info by tenant id. + Default version_no=0 queries all draft versions. + + Args: + tenant_id: Tenant ID + version_no: Version number to filter. Default 0 = draft/editing state """ with get_db_session() as session: - agents = session.query(AgentInfo).filter(AgentInfo.tenant_id == tenant_id, - AgentInfo.delete_flag != 'Y').order_by(AgentInfo.create_time.desc()).all() + agents = session.query(AgentInfo).filter( + AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == version_no, + AgentInfo.delete_flag != 'Y' + ).order_by(AgentInfo.create_time.desc()).all() return [as_dict(agent) for agent in agents] -def insert_related_agent(parent_agent_id: int, child_agent_id: int, tenant_id: str) -> bool: +def insert_related_agent(parent_agent_id: int, child_agent_id: int, tenant_id: str, user_id: str, version_no: int = 0) -> bool: + """ + Insert a related agent. + Default version_no=0 creates the draft version. + + Args: + parent_agent_id: Parent agent ID + child_agent_id: Child agent ID + tenant_id: Tenant ID + user_id: User ID + version_no: Version number. Default 0 = draft/editing state + """ try: relation_info = { "parent_agent_id": parent_agent_id, "selected_agent_id": child_agent_id, "tenant_id": tenant_id, - "created_by": tenant_id, - "updated_by": tenant_id + "version_no": version_no, + "created_by": user_id, + "updated_by": user_id } with get_db_session() as session: new_relation = AgentRelation( @@ -211,34 +317,53 @@ def insert_related_agent(parent_agent_id: int, child_agent_id: int, tenant_id: s return False -def delete_related_agent(parent_agent_id: int, child_agent_id: int, tenant_id: str) -> bool: +def delete_related_agent(parent_agent_id: int, child_agent_id: int, tenant_id: str, user_id: str, version_no: int = 0) -> bool: + """ + Delete a related agent. + Default version_no=0 deletes the draft version. + + Args: + parent_agent_id: Parent agent ID + child_agent_id: Child agent ID + tenant_id: Tenant ID + user_id: User ID + version_no: Version number to filter. Default 0 = draft/editing state + """ try: with get_db_session() as session: - session.query(AgentRelation).filter(AgentRelation.parent_agent_id == parent_agent_id, - AgentRelation.selected_agent_id == child_agent_id, - AgentRelation.tenant_id == tenant_id).update( - {AgentRelation.delete_flag: 'Y', 'updated_by': tenant_id}) + session.query(AgentRelation).filter( + AgentRelation.parent_agent_id == parent_agent_id, + AgentRelation.selected_agent_id == child_agent_id, + AgentRelation.tenant_id == tenant_id, + AgentRelation.version_no == version_no + ).update( + {AgentRelation.delete_flag: 'Y', 'updated_by': user_id}) return True except Exception as e: logger.error(f"Failed to delete related agent: {str(e)}") return False -def update_related_agents(parent_agent_id: int, related_agent_ids: List[int], tenant_id: str, user_id: str): +def update_related_agents(parent_agent_id: int, related_agent_ids: List[int], tenant_id: str, user_id: str, version_no: int = 0): """ Update related agents for a parent agent by replacing all existing relations. + Default version_no=0 updates the draft version. + This function handles both creation and deletion of relations in a single transaction. - :param parent_agent_id: ID of the parent agent - :param related_agent_ids: List of child agent IDs to be related - :param tenant_id: Tenant ID - :param user_id: User ID for audit trail - :return: None + + Args: + parent_agent_id: ID of the parent agent + related_agent_ids: List of child agent IDs to be related + tenant_id: Tenant ID + user_id: User ID for audit trail + version_no: Version number to filter. Default 0 = draft/editing state """ with get_db_session() as session: # Get current relations current_relations = session.query(AgentRelation).filter( AgentRelation.parent_agent_id == parent_agent_id, AgentRelation.tenant_id == tenant_id, + AgentRelation.version_no == version_no, AgentRelation.delete_flag != 'Y' ).all() @@ -257,7 +382,8 @@ def update_related_agents(parent_agent_id: int, related_agent_ids: List[int], te session.query(AgentRelation).filter( AgentRelation.parent_agent_id == parent_agent_id, AgentRelation.selected_agent_id.in_(ids_to_delete), - AgentRelation.tenant_id == tenant_id + AgentRelation.tenant_id == tenant_id, + AgentRelation.version_no == version_no ).update( {AgentRelation.delete_flag: 'Y', 'updated_by': user_id}, synchronize_session=False @@ -269,6 +395,7 @@ def update_related_agents(parent_agent_id: int, related_agent_ids: List[int], te "parent_agent_id": parent_agent_id, "selected_agent_id": child_agent_id, "tenant_id": tenant_id, + "version_no": version_no, "created_by": user_id, "updated_by": user_id } @@ -276,15 +403,28 @@ def update_related_agents(parent_agent_id: int, related_agent_ids: List[int], te **filter_property(relation_info, AgentRelation)) session.add(new_relation) - session.commit() +def delete_agent_relationship(agent_id: int, tenant_id: str, user_id: str, version_no: int = 0): + """ + Delete all relationships for an agent. + Default version_no=0 deletes the draft version. -def delete_agent_relationship(agent_id: int, tenant_id: str, user_id: str): + Args: + agent_id: Agent ID + tenant_id: Tenant ID + user_id: User ID + version_no: Version number to filter. Default 0 = draft/editing state + """ with get_db_session() as session: - session.query(AgentRelation).filter(AgentRelation.parent_agent_id == agent_id, - AgentRelation.tenant_id == tenant_id).update( + session.query(AgentRelation).filter( + AgentRelation.parent_agent_id == agent_id, + AgentRelation.tenant_id == tenant_id, + AgentRelation.version_no == version_no + ).update( {AgentRelation.delete_flag: 'Y', 'updated_by': user_id}) - session.query(AgentRelation).filter(AgentRelation.selected_agent_id == agent_id, - AgentRelation.tenant_id == tenant_id).update( + session.query(AgentRelation).filter( + AgentRelation.selected_agent_id == agent_id, + AgentRelation.tenant_id == tenant_id, + AgentRelation.version_no == version_no + ).update( {AgentRelation.delete_flag: 'Y', 'updated_by': user_id}) - session.commit() diff --git a/backend/database/agent_version_db.py b/backend/database/agent_version_db.py new file mode 100644 index 000000000..1f36383f8 --- /dev/null +++ b/backend/database/agent_version_db.py @@ -0,0 +1,373 @@ +import logging +from typing import List, Optional, Tuple +from sqlalchemy import select, insert, update, func + +from database.client import get_db_session, as_dict +from database.db_models import AgentInfo, ToolInstance, AgentRelation, AgentVersion + +logger = logging.getLogger("agent_version_db") + +# Version source types +SOURCE_TYPE_NORMAL = "NORMAL" +SOURCE_TYPE_ROLLBACK = "ROLLBACK" + +# Version statuses +STATUS_RELEASED = "RELEASED" +STATUS_DISABLED = "DISABLED" +STATUS_ARCHIVED = "ARCHIVED" + + +def search_version_by_version_no( + agent_id: int, + tenant_id: str, + version_no: int, +) -> Optional[dict]: + """ + Search version metadata by version_no + """ + with get_db_session() as session: + version = session.query(AgentVersion).filter( + AgentVersion.agent_id == agent_id, + AgentVersion.tenant_id == tenant_id, + AgentVersion.version_no == version_no, + AgentVersion.delete_flag == 'N', + ).first() + return as_dict(version) if version else None + + +def search_version_by_id( + version_id: int, + tenant_id: str, +) -> Optional[dict]: + """ + Search version metadata by id + """ + with get_db_session() as session: + version = session.query(AgentVersion).filter( + AgentVersion.id == version_id, + AgentVersion.tenant_id == tenant_id, + AgentVersion.delete_flag == 'N', + ).first() + return as_dict(version) if version else None + +def query_version_list( + agent_id: int, + tenant_id: str, +) -> List[dict]: + """ + Query version list for an agent + """ + with get_db_session() as session: + versions = session.query(AgentVersion).filter( + AgentVersion.agent_id == agent_id, + AgentVersion.tenant_id == tenant_id, + AgentVersion.delete_flag == 'N', + ).order_by(AgentVersion.version_no.desc()).all() + + return [as_dict(v) for v in versions] + + +def query_current_version_no( + agent_id: int, + tenant_id: str, +) -> Optional[int]: + """ + Query current published version_no from agent draft (version_no=0) + """ + with get_db_session() as session: + agent = session.query(AgentInfo).filter( + AgentInfo.agent_id == agent_id, + AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == 0, + AgentInfo.delete_flag == 'N', + ).first() + return agent.current_version_no if agent else None + + +def query_agent_snapshot( + agent_id: int, + tenant_id: str, + version_no: int, +) -> Tuple[Optional[dict], List[dict], List[dict]]: + """ + Query agent snapshot data (agent_info, tools, relations) for a specific version + """ + with get_db_session() as session: + # Query agent info snapshot + agent = session.query(AgentInfo).filter( + AgentInfo.agent_id == agent_id, + AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == version_no, + AgentInfo.delete_flag == 'N', + ).first() + + # Query tool instances snapshot + tools = session.query(ToolInstance).filter( + ToolInstance.agent_id == agent_id, + ToolInstance.tenant_id == tenant_id, + ToolInstance.version_no == version_no, + ToolInstance.delete_flag == 'N', + ).all() + + # Query relations snapshot + relations = session.query(AgentRelation).filter( + AgentRelation.parent_agent_id == agent_id, + AgentRelation.tenant_id == tenant_id, + AgentRelation.version_no == version_no, + AgentRelation.delete_flag == 'N', + ).all() + + agent_dict = as_dict(agent) if agent else None + tools_list = [as_dict(t) for t in tools] + relations_list = [as_dict(r) for r in relations] + + return agent_dict, tools_list, relations_list + + +def query_agent_draft( + agent_id: int, + tenant_id: str, +) -> Tuple[Optional[dict], List[dict], List[dict]]: + """ + Query agent draft data (version_no=0) + """ + return query_agent_snapshot(agent_id, tenant_id, version_no=0) + + +def insert_version( + version_data: dict, +) -> int: + """ + Insert a new version metadata record + Returns: version id + """ + with get_db_session() as session: + result = session.execute( + insert(AgentVersion).values(**version_data).returning(AgentVersion.id) + ) + return result.scalar_one() + + +def update_version_status( + agent_id: int, + tenant_id: str, + version_no: int, + status: str, + updated_by: str, +) -> int: + """ + Update version status + Returns: number of rows affected + """ + with get_db_session() as session: + result = session.execute( + update(AgentVersion) + .where( + AgentVersion.agent_id == agent_id, + AgentVersion.tenant_id == tenant_id, + AgentVersion.version_no == version_no, + AgentVersion.delete_flag == 'N', + ) + .values(status=status, updated_by=updated_by, update_time=func.now()) + ) + return result.rowcount + + +def update_agent_current_version( + agent_id: int, + tenant_id: str, + current_version_no: int, +) -> int: + """ + Update agent draft's current_version_no + Returns: number of rows affected + """ + with get_db_session() as session: + result = session.execute( + update(AgentInfo) + .where( + AgentInfo.agent_id == agent_id, + AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == 0, + AgentInfo.delete_flag == 'N', + ) + .values(current_version_no=current_version_no) + ) + return result.rowcount + + +def insert_agent_snapshot( + agent_data: dict, +) -> None: + """ + Insert agent snapshot (copy from draft to new version) + """ + with get_db_session() as session: + session.execute(insert(AgentInfo).values(**agent_data)) + + +def insert_tool_snapshot( + tool_data: dict, +) -> None: + """ + Insert tool instance snapshot + """ + with get_db_session() as session: + session.execute(insert(ToolInstance).values(**tool_data)) + + +def insert_relation_snapshot( + relation_data: dict, +) -> None: + """ + Insert relation snapshot + """ + with get_db_session() as session: + session.execute(insert(AgentRelation).values(**relation_data)) + + +def update_agent_snapshot( + agent_id: int, + tenant_id: str, + version_no: int, + agent_data: dict, +) -> int: + """ + Update agent snapshot data (used for rollback restore) + Returns: number of rows affected + """ + with get_db_session() as session: + result = session.execute( + update(AgentInfo) + .where( + AgentInfo.agent_id == agent_id, + AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == version_no, + AgentInfo.delete_flag == 'N', + ) + .values(**agent_data) + ) + return result.rowcount + + +def delete_agent_snapshot( + agent_id: int, + tenant_id: str, + version_no: int, + deleted_by: str, +) -> int: + """ + Soft delete agent snapshot for a version + Returns: number of rows affected + """ + with get_db_session() as session: + result = session.execute( + update(AgentInfo) + .where( + AgentInfo.agent_id == agent_id, + AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == version_no, + AgentInfo.delete_flag == 'N', + ) + .values(delete_flag='Y', updated_by=deleted_by, update_time=func.now()) + ) + return result.rowcount + + +def delete_tool_snapshot( + agent_id: int, + tenant_id: str, + version_no: int, + deleted_by: str = None, +) -> int: + """ + Delete all tool snapshots for a version (used before restoring from rollback) + Returns: number of rows affected + """ + with get_db_session() as session: + values = {'delete_flag': 'Y'} + if deleted_by: + values['updated_by'] = deleted_by + values['update_time'] = func.now() + result = session.execute( + update(ToolInstance) + .where( + ToolInstance.agent_id == agent_id, + ToolInstance.tenant_id == tenant_id, + ToolInstance.version_no == version_no, + ToolInstance.delete_flag == 'N', + ) + .values(**values) + ) + return result.rowcount + + +def delete_relation_snapshot( + agent_id: int, + tenant_id: str, + version_no: int, + deleted_by: str = None, +) -> int: + """ + Delete all relation snapshots for a version (used before restoring from rollback) + Returns: number of rows affected + """ + with get_db_session() as session: + values = {'delete_flag': 'Y'} + if deleted_by: + values['updated_by'] = deleted_by + values['update_time'] = func.now() + result = session.execute( + update(AgentRelation) + .where( + AgentRelation.parent_agent_id == agent_id, + AgentRelation.tenant_id == tenant_id, + AgentRelation.version_no == version_no, + AgentRelation.delete_flag == 'N', + ) + .values(**values) + ) + return result.rowcount + + +def get_next_version_no( + agent_id: int, + tenant_id: str, +) -> int: + """ + Calculate the next version number for an agent + """ + with get_db_session() as session: + max_version = session.query(func.max(AgentInfo.version_no)).filter( + AgentInfo.agent_id == agent_id, + AgentInfo.tenant_id == tenant_id, + AgentInfo.delete_flag == 'N', + ).scalar() + return (max_version or 0) + 1 + + +def delete_version( + agent_id: int, + tenant_id: str, + version_no: int, + deleted_by: str, +) -> int: + """ + Soft delete a version by setting delete_flag='Y' + Returns: number of rows affected + """ + with get_db_session() as session: + logger.info(f"Attempting to delete version: agent_id={agent_id}, tenant_id={tenant_id}, version_no={version_no}, deleted_by={deleted_by}") + result = session.execute( + update(AgentVersion) + .where( + AgentVersion.agent_id == agent_id, + AgentVersion.tenant_id == tenant_id, + AgentVersion.version_no == version_no, + AgentVersion.delete_flag == 'N', + ) + .values(delete_flag='Y', updated_by=deleted_by, update_time=func.now()) + ) + rows_affected = result.rowcount + logger.info(f"Delete version result: rows_affected={rows_affected} for agent_id={agent_id}, tenant_id={tenant_id}, version_no={version_no}") + return rows_affected \ No newline at end of file diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 7e6be7a3a..8649b6ae8 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -1,4 +1,4 @@ -from sqlalchemy import BigInteger, Boolean, Column, Integer, JSON, Numeric, Sequence, String, Text, TIMESTAMP +from sqlalchemy import BigInteger, Boolean, Column, Integer, JSON, Numeric, PrimaryKeyConstraint, Sequence, String, Text, TIMESTAMP from sqlalchemy.orm import DeclarativeBase from sqlalchemy.sql import func @@ -201,7 +201,9 @@ class AgentInfo(TableBase): __tablename__ = "ag_tenant_agent_t" __table_args__ = {"schema": SCHEMA} - agent_id = Column(Integer, primary_key=True, nullable=False, doc="ID") + agent_id = Column(Integer, Sequence( + "ag_tenant_agent_t_agent_id_seq", schema=SCHEMA), nullable=False, primary_key=True, autoincrement=True, doc="ID") + version_no = Column(Integer, default=0, nullable=False, primary_key=True, doc="Version number. 0 = draft/editing state, >=1 = published snapshot") name = Column(String(100), doc="Agent name") display_name = Column(String(100), doc="Agent display name") description = Column(Text, doc="Description") @@ -223,6 +225,7 @@ class AgentInfo(TableBase): business_logic_model_id = Column(Integer, doc="Model ID used for business logic prompt generation, foreign key reference to model_record_t.model_id") group_ids = Column(String, doc="Agent group IDs list") is_new = Column(Boolean, default=False, doc="Whether this agent is marked as new for the user") + current_version_no = Column(Integer, nullable=True, doc="Current published version number. NULL means no version published yet") class ToolInstance(TableBase): @@ -240,6 +243,7 @@ class ToolInstance(TableBase): user_id = Column(String(100), doc="User ID") tenant_id = Column(String(100), doc="Tenant ID") enabled = Column(Boolean, doc="Enabled") + version_no = Column(Integer, default=0, nullable=False, doc="Version number. 0 = draft/editing state, >=1 = published snapshot") class KnowledgeRecord(TableBase): @@ -342,11 +346,11 @@ class AgentRelation(TableBase): __tablename__ = "ag_agent_relation_t" __table_args__ = {"schema": SCHEMA} - relation_id = Column(Integer, primary_key=True, - nullable=False, doc="Relationship ID, primary key") - selected_agent_id = Column(Integer, doc="Selected agent ID") + relation_id = Column(Integer, primary_key=True, autoincrement=True, nullable=False, doc="Relationship ID, primary key") + selected_agent_id = Column(Integer, primary_key=True, doc="Selected agent ID") parent_agent_id = Column(Integer, doc="Parent agent ID") tenant_id = Column(String(100), doc="Tenant ID") + version_no = Column(Integer, default=0, nullable=False, doc="Version number. 0 = draft/editing state, >=1 = published snapshot") class PartnerMappingId(TableBase): @@ -449,3 +453,22 @@ class RolePermission(SimpleTableBase): permission_category = Column(String(30), doc="Permission category") permission_type = Column(String(30), doc="Permission type") permission_subtype = Column(String(30), doc="Permission subtype") + + +class AgentVersion(TableBase): + """ + Agent version metadata table. Stores version info, release notes, and version lineage. + """ + __tablename__ = "ag_tenant_agent_version_t" + __table_args__ = {"schema": SCHEMA} + + id = Column(BigInteger, Sequence("ag_tenant_agent_version_t_id_seq", schema=SCHEMA), + primary_key=True, nullable=False, doc="Primary key, auto-increment") + tenant_id = Column(String(100), nullable=False, doc="Tenant ID") + agent_id = Column(Integer, nullable=False, doc="Agent ID") + version_no = Column(Integer, nullable=False, doc="Version number, starts from 1. Does not include 0 (draft)") + version_name = Column(String(100), doc="User-defined version name for display") + release_note = Column(Text, doc="Release notes / publish remarks") + source_version_no = Column(Integer, doc="Source version number. If this version is a rollback, record the source version") + source_type = Column(String(30), doc="Source type: NORMAL (normal publish) / ROLLBACK (rollback and republish)") + status = Column(String(30), default="RELEASED", doc="Version status: RELEASED / DISABLED / ARCHIVED") diff --git a/backend/database/group_db.py b/backend/database/group_db.py index c6a7c1e3a..c222bd489 100644 --- a/backend/database/group_db.py +++ b/backend/database/group_db.py @@ -149,7 +149,7 @@ def modify_group(group_id: int, updates: Dict[str, Any], updated_by: Optional[st def remove_group(group_id: int, updated_by: Optional[str] = None) -> bool: """ - Remove group (soft delete) + Remove group (soft delete) and all its user relationships Args: group_id (int): Group ID @@ -163,11 +163,18 @@ def remove_group(group_id: int, updated_by: Optional[str] = None) -> bool: if updated_by: update_data["updated_by"] = updated_by + # Soft delete the group result = session.query(TenantGroupInfo).filter( TenantGroupInfo.group_id == group_id, TenantGroupInfo.delete_flag == "N" ).update(update_data, synchronize_session=False) + # Soft delete all user-group relationships for this group + session.query(TenantGroupUser).filter( + TenantGroupUser.group_id == group_id, + TenantGroupUser.delete_flag == "N" + ).update(update_data, synchronize_session=False) + return result > 0 @@ -322,6 +329,30 @@ def count_group_users(group_id: int) -> int: return result +def remove_group_users(group_id: int, removed_by: Optional[str] = None) -> int: + """ + Remove all users from a group (soft delete all group-user relationships) + + Args: + group_id (int): Group ID + removed_by (Optional[str]): User who performed the removal + + Returns: + int: Number of group memberships removed + """ + with get_db_session() as session: + update_data: Dict[str, Any] = {"delete_flag": "Y"} + if removed_by: + update_data["updated_by"] = removed_by + + result = session.query(TenantGroupUser).filter( + TenantGroupUser.group_id == group_id, + TenantGroupUser.delete_flag == "N" + ).update(update_data, synchronize_session=False) + + return result + + def remove_user_from_all_groups(user_id: str, removed_by: str) -> int: """ Remove user from all groups (soft delete) @@ -344,3 +375,30 @@ def remove_user_from_all_groups(user_id: str, removed_by: str) -> int: }) return result + + +def check_group_name_exists(tenant_id: str, group_name: str, exclude_group_id: Optional[int] = None) -> bool: + """ + Check if a group with the given name already exists in the tenant + + Args: + tenant_id (str): Tenant ID + group_name (str): Group name to check + exclude_group_id (Optional[int]): Group ID to exclude (for update operations) + + Returns: + bool: True if group name exists, False otherwise + """ + with get_db_session() as session: + query = session.query(TenantGroupInfo).filter( + TenantGroupInfo.tenant_id == tenant_id, + TenantGroupInfo.group_name == group_name, + TenantGroupInfo.delete_flag == "N" + ) + + # Exclude specific group ID for update operations + if exclude_group_id is not None: + query = query.filter(TenantGroupInfo.group_id != exclude_group_id) + + result = query.first() + return result is not None \ No newline at end of file diff --git a/backend/database/model_management_db.py b/backend/database/model_management_db.py index 257320499..cb1c6c69f 100644 --- a/backend/database/model_management_db.py +++ b/backend/database/model_management_db.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List, Optional -from sqlalchemy import and_, func, insert, select, update +from sqlalchemy import and_, desc, func, insert, select, update from consts.const import DEFAULT_EXPECTED_CHUNK_SIZE, DEFAULT_MAXIMUM_CHUNK_SIZE from .client import as_dict, db_client, get_db_session @@ -147,6 +147,9 @@ def get_model_records(filters: Optional[Dict[str, Any]], tenant_id: str) -> List conditions.append(getattr(ModelRecord, key) == value) stmt = stmt.where(and_(*conditions)) + # Order by creation time descending (newest first) + stmt = stmt.order_by(desc(ModelRecord.create_time)) + # Execute the query records = session.scalars(stmt).all() diff --git a/backend/database/tenant_config_db.py b/backend/database/tenant_config_db.py index 0de398af6..d21572b2e 100644 --- a/backend/database/tenant_config_db.py +++ b/backend/database/tenant_config_db.py @@ -1,6 +1,7 @@ import logging from typing import Any, Dict +from sqlalchemy import func from sqlalchemy.exc import SQLAlchemyError from database.client import get_db_session @@ -141,14 +142,22 @@ def update_config_by_tenant_config_id_and_data(tenant_config_id: int, insert_dat def get_all_tenant_ids(): """ - Get all tenant IDs that have tenant configurations + Get all tenant IDs that have tenant configurations, sorted by creation time descending (newest first). Returns: - List[str]: List of tenant IDs + List[str]: List of tenant IDs sorted by creation time (newest first) """ with get_db_session() as session: - result = session.query(TenantConfig.tenant_id).filter( + # Query tenant_ids grouped by tenant_id, ordered by maximum create_time (newest config creation) + result = session.query( + TenantConfig.tenant_id, + func.max(TenantConfig.create_time).label("max_create_time") + ).filter( TenantConfig.delete_flag == "N" - ).distinct().all() + ).group_by( + TenantConfig.tenant_id + ).order_by( + func.max(TenantConfig.create_time).desc() + ).all() return [row[0] for row in result] diff --git a/backend/database/tool_db.py b/backend/database/tool_db.py index ff9c1488c..0001315a7 100644 --- a/backend/database/tool_db.py +++ b/backend/database/tool_db.py @@ -6,31 +6,44 @@ from database.db_models import ToolInstance, ToolInfo -def create_tool(tool_info): +def create_tool(tool_info, version_no: int = 0): """ - Create ToolInstance in the database based on tenant_id and agent_id, optional user_id. - :param tool_info: Dictionary containing tool information + Create ToolInstance in the database. + Default version_no=0 creates the draft version. - :return: Created or updated ToolInstance object + Args: + tool_info: Dictionary containing tool information + version_no: Version number. Default 0 = draft/editing state + + Returns: + Created ToolInstance object """ + tool_info_dict = tool_info.copy() + tool_info_dict.setdefault("version_no", version_no) + with get_db_session() as session: # Create a new ToolInstance new_tool_instance = ToolInstance( - **filter_property(tool_info, ToolInstance)) + **filter_property(tool_info_dict, ToolInstance)) session.add(new_tool_instance) -def create_or_update_tool_by_tool_info(tool_info, tenant_id: str, user_id: str): +def create_or_update_tool_by_tool_info(tool_info, tenant_id: str, user_id: str, version_no: int = 0): """ - Create or update a ToolInstance in the database based on tenant_id and agent_id, optional user_id. + Create or update a ToolInstance in the database. + Default version_no=0 operates on the draft version. + + Args: + tool_info: Dictionary containing tool information + tenant_id: Tenant ID for filtering, mandatory + user_id: Optional user ID for filtering + version_no: Version number to filter. Default 0 = draft/editing state - :param tool_info: Dictionary containing tool information - :param tenant_id: Tenant ID for filtering, mandatory - :param user_id: Optional user ID for filtering - :return: Created or updated ToolInstance object + Returns: + Created or updated ToolInstance object """ tool_info_dict = tool_info.__dict__ | { - "tenant_id": tenant_id, "user_id": user_id} + "tenant_id": tenant_id, "user_id": user_id, "version_no": version_no} with get_db_session() as session: # Query if there is an existing ToolInstance @@ -39,7 +52,8 @@ def create_or_update_tool_by_tool_info(tool_info, tenant_id: str, user_id: str): ToolInstance.user_id == user_id, ToolInstance.agent_id == tool_info_dict['agent_id'], ToolInstance.delete_flag != 'Y', - ToolInstance.tool_id == tool_info_dict['tool_id'] + ToolInstance.tool_id == tool_info_dict['tool_id'], + ToolInstance.version_no == version_no ) tool_instance = query.first() if tool_instance: @@ -48,7 +62,7 @@ def create_or_update_tool_by_tool_info(tool_info, tenant_id: str, user_id: str): if hasattr(tool_instance, key): setattr(tool_instance, key, value) else: - create_tool(tool_info_dict) + create_tool(tool_info_dict, version_no) return tool_instance @@ -67,19 +81,26 @@ def query_all_tools(tenant_id: str): return [as_dict(tool) for tool in tools] -def query_tool_instances_by_id(agent_id: int, tool_id: int, tenant_id: str): +def query_tool_instances_by_id(agent_id: int, tool_id: int, tenant_id: str, version_no: int = 0): """ - Query ToolInstance in the database based on tenant_id and agent_id, optional user_id. - :param agent_id: Agent ID for filtering, mandatory - :param tool_id: Tool ID for filtering, mandatory - :param tenant_id: Tenant ID for filtering, mandatory - :return: List of ToolInstance objects + Query ToolInstance in the database. + Default version_no=0 queries the draft version. + + Args: + agent_id: Agent ID for filtering, mandatory + tool_id: Tool ID for filtering, mandatory + tenant_id: Tenant ID for filtering, mandatory + version_no: Version number to filter. Default 0 = draft/editing state + + Returns: + ToolInstance object or None """ with get_db_session() as session: query = session.query(ToolInstance).filter( ToolInstance.tenant_id == tenant_id, ToolInstance.agent_id == agent_id, ToolInstance.tool_id == tool_id, + ToolInstance.version_no == version_no, ToolInstance.delete_flag != 'Y') tool_instance = query.first() if tool_instance: @@ -100,16 +121,23 @@ def query_tools_by_ids(tool_id_list: List[int]): return [as_dict(tool) for tool in tools] -def query_all_enabled_tool_instances(agent_id: int, tenant_id: str): +def query_all_enabled_tool_instances(agent_id: int, tenant_id: str, version_no: int = 0): """ - Query ToolInstance in the database based on tenant_id and agent_id, optional user_id. - :param tenant_id: Tenant ID for filtering, mandatory - :param agent_id: Optional agent ID for filtering - :return: List of ToolInstance objects + Query enabled ToolInstance in the database. + Default version_no=0 queries the draft version. + + Args: + agent_id: Agent ID for filtering, mandatory + tenant_id: Tenant ID for filtering, mandatory + version_no: Version number to filter. Default 0 = draft/editing state + + Returns: + List of ToolInstance objects """ with get_db_session() as session: query = session.query(ToolInstance).filter( ToolInstance.tenant_id == tenant_id, + ToolInstance.version_no == version_no, ToolInstance.delete_flag != 'Y', ToolInstance.enabled, ToolInstance.agent_id == agent_id) @@ -117,6 +145,48 @@ def query_all_enabled_tool_instances(agent_id: int, tenant_id: str): return [as_dict(tool) for tool in tools] +def query_tool_instances_by_agent_id(agent_id: int, tenant_id: str, version_no: int = 0): + """ + Query all ToolInstance for an agent (regardless of enabled status). + Default version_no=0 queries the draft version. + + Args: + agent_id: Agent ID for filtering, mandatory + tenant_id: Tenant ID for filtering, mandatory + version_no: Version number to filter. Default 0 = draft/editing state + + Returns: + List of ToolInstance objects + """ + with get_db_session() as session: + query = session.query(ToolInstance).filter( + ToolInstance.tenant_id == tenant_id, + ToolInstance.agent_id == agent_id, + ToolInstance.version_no == version_no, + ToolInstance.delete_flag != 'Y') + tools = query.all() + return [as_dict(tool) for tool in tools] + + +def check_tool_list_initialized(tenant_id: str) -> bool: + """ + Check if tool list has been initialized for the tenant. + + Args: + tenant_id: Tenant ID to check + + Returns: + True if tools have been initialized, False otherwise + """ + with get_db_session() as session: + # Check if any tools exist for this tenant + count = session.query(ToolInfo).filter( + ToolInfo.delete_flag != 'Y', + ToolInfo.author == tenant_id + ).count() + return count > 0 + + def update_tool_table_from_scan_tool_list(tenant_id: str, user_id: str, tool_list: List[ToolInfo]): """ scan all tools and update the tool table in PG database, remove the duplicate tools @@ -175,12 +245,25 @@ def add_tool_field(tool_info): return tool_info -def search_tools_for_sub_agent(agent_id, tenant_id): +def search_tools_for_sub_agent(agent_id, tenant_id, version_no: int = 0): + """ + Query enabled tools for a sub-agent. + Default version_no=0 queries the draft version. + + Args: + agent_id: Agent ID + tenant_id: Tenant ID + version_no: Version number to filter. Default 0 = draft/editing state + + Returns: + List of tool instance dictionaries + """ with get_db_session() as session: # query if there is an existing ToolInstance query = session.query(ToolInstance).filter( ToolInstance.agent_id == agent_id, ToolInstance.tenant_id == tenant_id, + ToolInstance.version_no == version_no, ToolInstance.delete_flag != 'Y', ToolInstance.enabled ) @@ -205,22 +288,47 @@ def check_tool_is_available(tool_id_list: List[int]): return [tool.is_available for tool in tools] -def delete_tools_by_agent_id(agent_id, tenant_id, user_id): +def delete_tools_by_agent_id(agent_id, tenant_id, user_id, version_no: int = 0): + """ + Delete all tool instances for an agent. + Default version_no=0 deletes the draft version. + + Args: + agent_id: Agent ID + tenant_id: Tenant ID + user_id: User ID + version_no: Version number to filter. Default 0 = draft/editing state + """ with get_db_session() as session: session.query(ToolInstance).filter( ToolInstance.agent_id == agent_id, - ToolInstance.tenant_id == tenant_id + ToolInstance.tenant_id == tenant_id, + ToolInstance.version_no == version_no ).update({ ToolInstance.delete_flag: 'Y', 'updated_by': user_id }) -def search_last_tool_instance_by_tool_id(tool_id: int, tenant_id: str, user_id: str): +def search_last_tool_instance_by_tool_id(tool_id: int, tenant_id: str, user_id: str, version_no: int = 0): + """ + Query the latest ToolInstance by tool_id. + Default version_no=0 queries the draft version. + + Args: + tool_id: Tool ID + tenant_id: Tenant ID + user_id: User ID + version_no: Version number to filter. Default 0 = draft/editing state + + Returns: + ToolInstance object or None + """ with get_db_session() as session: query = session.query(ToolInstance).filter( ToolInstance.tool_id == tool_id, ToolInstance.tenant_id == tenant_id, ToolInstance.user_id == user_id, + ToolInstance.version_no == version_no, ToolInstance.delete_flag != 'Y' - ).order_by(ToolInstance.update_time.desc()) + ).order_by(ToolInstance.update_time.desc()) tool_instance = query.first() return as_dict(tool_instance) if tool_instance else None \ No newline at end of file diff --git a/backend/prompts/analyze_file.yaml b/backend/prompts/analyze_file_zh.yaml similarity index 100% rename from backend/prompts/analyze_file.yaml rename to backend/prompts/analyze_file_zh.yaml diff --git a/backend/prompts/cluster_summary_agent.yaml b/backend/prompts/cluster_summary_agent_en.yaml similarity index 100% rename from backend/prompts/cluster_summary_agent.yaml rename to backend/prompts/cluster_summary_agent_en.yaml diff --git a/backend/prompts/cluster_summary_agent_zh.yaml b/backend/prompts/cluster_summary_agent_zh.yaml new file mode 100644 index 000000000..6c643a789 --- /dev/null +++ b/backend/prompts/cluster_summary_agent_zh.yaml @@ -0,0 +1,24 @@ +system_prompt: |- + 你是一个专业的知识总结助手。你的任务是根据多个文档生成一个简洁的总结。 + + **总结要求:** + 1. 输入包含多个文档(每个文档有标题和内容片段) + 2. 你需要提取这些文档的共同主题和关键话题 + 3. 生成一个总结,代表这组文档的集体内容 + 4. 总结应准确、连贯且自然语言 + 5. 保持在指定的字数限制内 + + **指导原则:** + - 专注于识别共同主题和话题 + - 突出关键概念、领域或主题 + - 使用清晰简洁的语言 + - 除非必要,避免列出单个文档标题 + - 总结应帮助用户理解这组文档涵盖的内容 + +user_prompt: | + 请生成以下文档簇的简洁总结: + + {{ cluster_content }} + + 总结({{ max_words }}字): + diff --git a/backend/prompts/cluster_summary_reduce.yaml b/backend/prompts/cluster_summary_reduce_en.yaml similarity index 61% rename from backend/prompts/cluster_summary_reduce.yaml rename to backend/prompts/cluster_summary_reduce_en.yaml index ece360813..f860720d5 100644 --- a/backend/prompts/cluster_summary_reduce.yaml +++ b/backend/prompts/cluster_summary_reduce_en.yaml @@ -1,31 +1,30 @@ system_prompt: |- - You are a professional cluster summarization assistant. Your task is to merge multiple document summaries into a cohesive cluster summary. - + You are a professional summary assistant. Your task is to merge multiple document summaries into a coherent summary. + **Summary Requirements:** - 1. The input contains summaries of multiple documents that belong to the same cluster - 2. These documents share similar themes or topics (grouped by clustering) + 1. The input contains summaries of multiple documents that are related + 2. These documents share similar themes or topics 3. You need to synthesize a unified summary that captures the collective content 4. The summary should highlight common themes and key information across documents 5. Keep the summary within the specified word limit - + **Guidelines:** - Identify shared themes and topics across documents - Highlight common concepts and subject matter - Use clear and concise language - Avoid listing individual document titles unless necessary - Focus on what this group of documents collectively covers - - The summary should be coherent and represent the cluster's unified content + - The summary should be coherent and represent the unified content - **Important: Do not use any separators (like ---, ***, etc.), generate plain text summary only** user_prompt: | - Please generate a unified summary of the following document cluster based on individual document summaries: - + Please generate a unified summary of the following documents based on individual document summaries: + {{ document_summaries }} - + **Important Reminders:** - Do not use any separators (like ---, ***, ===, etc.) - Do not include document titles or filenames - Generate plain text summary content only - - Cluster Summary ({{ max_words }} words): + Summary (no more than {{ max_words }} words): \ No newline at end of file diff --git a/backend/prompts/cluster_summary_reduce_zh.yaml b/backend/prompts/cluster_summary_reduce_zh.yaml index f6ef4a641..59a50b025 100644 --- a/backend/prompts/cluster_summary_reduce_zh.yaml +++ b/backend/prompts/cluster_summary_reduce_zh.yaml @@ -1,9 +1,9 @@ system_prompt: |- - 你是一个专业的簇总结助手。你的任务是将多个文档总结合并为一个连贯的簇总结。 + 你是一个专业的总结助手。你的任务是将多个文档总结合并为一个连贯的总结。 **总结要求:** - 1. 输入包含属于同一簇的多个文档的总结 - 2. 这些文档共享相似的主题或话题(通过聚类分组) + 1. 输入包含属于同一主题的多个文档的总结 + 2. 这些文档共享相似的主题或话题 3. 你需要综合成一个统一的总结,捕捉集合内容 4. 总结应突出文档间的共同主题和关键信息 5. 保持在指定的字数限制内 @@ -12,14 +12,14 @@ system_prompt: |- - 识别文档间的共同主题和话题 - 突出共同概念和主题内容 - 使用清晰简洁的语言 - - 除非必要,避免列出单个文档标题 + - 避免列出单个文档标题 - 专注于这组文档共同涵盖的内容 - - 总结应连贯且代表簇的统一内容 + - 总结应连贯且代表所有文档的统一内容 - 确保准确、全面,明确关键实体,不要遗漏重要信息 - **重要:不要使用任何分隔符(如---、***等),直接生成纯文本总结** user_prompt: | - 请根据以下文档总结生成统一的学生簇总结: + 请根据以下文档总结生成统一的整体总结: {{ document_summaries }} @@ -28,5 +28,5 @@ user_prompt: | - 不要包含文档标题或文件名 - 直接生成纯文本总结内容 - 簇总结({{ max_words }}字): + 总结(不超过{{ max_words }}字): diff --git a/backend/prompts/document_summary_agent.yaml b/backend/prompts/document_summary_agent_en.yaml similarity index 95% rename from backend/prompts/document_summary_agent.yaml rename to backend/prompts/document_summary_agent_en.yaml index 88b4d9a93..e27d3037e 100644 --- a/backend/prompts/document_summary_agent.yaml +++ b/backend/prompts/document_summary_agent_en.yaml @@ -24,5 +24,5 @@ user_prompt: | Content snippets: {{ content }} - Summary ({{ max_words }} words): + Summary (no more than {{ max_words }} words): diff --git a/backend/prompts/document_summary_agent_zh.yaml b/backend/prompts/document_summary_agent_zh.yaml index 4f443ca38..5819459d3 100644 --- a/backend/prompts/document_summary_agent_zh.yaml +++ b/backend/prompts/document_summary_agent_zh.yaml @@ -25,5 +25,5 @@ user_prompt: | 内容片段: {{ content }} - 总结({{ max_words }}字): + 总结(不超过{{ max_words }}字): diff --git a/backend/prompts/knowledge_summary_agent.yaml b/backend/prompts/knowledge_summary_agent_zh.yaml similarity index 100% rename from backend/prompts/knowledge_summary_agent.yaml rename to backend/prompts/knowledge_summary_agent_zh.yaml diff --git a/backend/prompts/managed_system_prompt_template.yaml b/backend/prompts/managed_system_prompt_template_zh.yaml similarity index 100% rename from backend/prompts/managed_system_prompt_template.yaml rename to backend/prompts/managed_system_prompt_template_zh.yaml diff --git a/backend/prompts/manager_system_prompt_template.yaml b/backend/prompts/manager_system_prompt_template_zh.yaml similarity index 100% rename from backend/prompts/manager_system_prompt_template.yaml rename to backend/prompts/manager_system_prompt_template_zh.yaml diff --git a/backend/prompts/utils/file_processing_messages.yaml b/backend/prompts/utils/file_processing_messages.yaml deleted file mode 100644 index 04c0f188c..000000000 --- a/backend/prompts/utils/file_processing_messages.yaml +++ /dev/null @@ -1,5 +0,0 @@ -FILE_CONTENT_SUCCESS: "文件 {filename} 内容: {content}" -FILE_CONTENT_ERROR: "文件 {filename} 内容: 处理文本文件 {filename} 时出错: {error}" -FILE_PROCESSING_ERROR: "文件处理失败 (状态码: {status_code}): {error_detail}" -IMAGE_CONTENT_SUCCESS: "图片文件 {filename} 内容: {content}" -IMAGE_CONTENT_ERROR: "图片文件 {filename} 内容: 处理图片文件 {filename} 时出错: {error}" diff --git a/backend/prompts/utils/file_processing_messages_en.yaml b/backend/prompts/utils/file_processing_messages_en.yaml deleted file mode 100644 index 31e3af5d7..000000000 --- a/backend/prompts/utils/file_processing_messages_en.yaml +++ /dev/null @@ -1,5 +0,0 @@ -FILE_CONTENT_SUCCESS: "File {filename} content: {content}" -FILE_CONTENT_ERROR: "File {filename} content: Error processing text file {filename}: {error}" -FILE_PROCESSING_ERROR: "File processing failed (status code: {status_code}): {error_detail}" -IMAGE_CONTENT_SUCCESS: "Image file {filename} content: {content}" -IMAGE_CONTENT_ERROR: "Image file {filename} content: Error processing image file {filename}: {error}" diff --git a/backend/prompts/utils/generate_title.yaml b/backend/prompts/utils/generate_title_zh.yaml similarity index 100% rename from backend/prompts/utils/generate_title.yaml rename to backend/prompts/utils/generate_title_zh.yaml diff --git a/backend/prompts/utils/prompt_generate.yaml b/backend/prompts/utils/prompt_generate_zh.yaml similarity index 100% rename from backend/prompts/utils/prompt_generate.yaml rename to backend/prompts/utils/prompt_generate_zh.yaml diff --git a/backend/pyproject.toml b/backend/pyproject.toml index af0d10ef4..65e27107a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -25,7 +25,8 @@ data-process = [ "celery>=5.3.6", "flower>=2.0.1", "nest_asyncio>=1.5.6", - "unstructured[csv,docx,pdf,pptx,xlsx,md]==0.18.14" + "unstructured[csv,docx,pdf,pptx,xlsx,md]==0.18.14", + "huggingface_hub>=0.19.0,<0.21.0" ] test = [ "pytest", diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py index 868f5076b..8acdd8b28 100644 --- a/backend/services/agent_service.py +++ b/backend/services/agent_service.py @@ -15,7 +15,9 @@ from agents.agent_run_manager import agent_run_manager from agents.create_agent_info import create_agent_run_info, create_tool_config_list from agents.preprocess_manager import preprocess_manager -from consts.const import MEMORY_SEARCH_START_MSG, MEMORY_SEARCH_DONE_MSG, MEMORY_SEARCH_FAIL_MSG, TOOL_TYPE_MAPPING, LANGUAGE, MESSAGE_ROLE, MODEL_CONFIG_MAPPING +from services.agent_version_service import publish_version_impl +from consts.const import MEMORY_SEARCH_START_MSG, MEMORY_SEARCH_DONE_MSG, MEMORY_SEARCH_FAIL_MSG, TOOL_TYPE_MAPPING, \ + LANGUAGE, MESSAGE_ROLE, MODEL_CONFIG_MAPPING, CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ from consts.exceptions import MemoryPreparationException from consts.model import ( AgentInfoRequest, @@ -52,9 +54,11 @@ query_all_enabled_tool_instances, query_all_tools, query_tool_instances_by_id, + query_tool_instances_by_agent_id, search_tools_for_sub_agent ) from database.group_db import query_group_ids_by_user +from database.user_tenant_db import get_user_tenant_by_user_id from utils.str_utils import convert_list_to_string, convert_string_to_list from services.conversation_management_service import save_conversation_assistant, save_conversation_user from services.memory_config_service import build_memory_context @@ -704,9 +708,9 @@ async def get_creating_sub_agent_id_service(tenant_id: str, user_id: str = None) return create_agent(agent_info={"enabled": False}, tenant_id=tenant_id, user_id=user_id)["agent_id"] -async def get_agent_info_impl(agent_id: int, tenant_id: str): +async def get_agent_info_impl(agent_id: int, tenant_id: str, version_no: int = 0): try: - agent_info = search_agent_info_by_agent_id(agent_id, tenant_id) + agent_info = search_agent_info_by_agent_id(agent_id, tenant_id, version_no) except Exception as e: logger.error(f"Failed to get agent info: {str(e)}") raise ValueError(f"Failed to get agent info: {str(e)}") @@ -824,7 +828,7 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = agent_id = created["agent_id"] else: # Update agent - update_agent(agent_id, request, tenant_id, user_id) + update_agent(agent_id, request, user_id) except Exception as e: logger.error(f"Failed to update agent info: {str(e)}") raise ValueError(f"Failed to update agent info: {str(e)}") @@ -833,23 +837,40 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = try: if request.enabled_tool_ids is not None and agent_id is not None: enabled_set = set(request.enabled_tool_ids) - # Get all tools for current tenant - all_tools = query_all_tools(tenant_id=tenant_id) - for tool in all_tools: - tool_id = tool.get("tool_id") - if tool_id is None: - continue + # Query existing tool instances for this agent + existing_instances = query_tool_instances_by_agent_id( + agent_id, tenant_id) + + # Handle unselected tool(already exist instance)→ enabled=False + for instance in existing_instances: + inst_tool_id = instance.get("tool_id") + if inst_tool_id is not None and inst_tool_id not in enabled_set: + create_or_update_tool_by_tool_info( + tool_info=ToolInstanceInfoRequest( + tool_id=inst_tool_id, + agent_id=agent_id, + params=instance.get("params", {}), + enabled=False + ), + tenant_id=tenant_id, + user_id=user_id + ) + + # Handle selected tool → enabled=True(create or update) + for tool_id in enabled_set: # Keep existing params if any - existing_instance = query_tool_instances_by_id( - agent_id, tool_id, tenant_id) - params = (existing_instance or {}).get( - "params", {}) if existing_instance else {} + existing_instance = next( + (inst for inst in existing_instances + if inst.get("tool_id") == tool_id), + None + ) + params = (existing_instance or {}).get("params", {}) create_or_update_tool_by_tool_info( tool_info=ToolInstanceInfoRequest( tool_id=tool_id, agent_id=agent_id, params=params, - enabled=(tool_id in enabled_set) + enabled=True, ), tenant_id=tenant_id, user_id=user_id @@ -895,9 +916,15 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = return {"agent_id": agent_id} -async def delete_agent_impl(agent_id: int, authorization: str = Header(None)): - user_id, tenant_id, _ = get_current_user_info(authorization) +async def delete_agent_impl(agent_id: int, tenant_id: str, user_id: str): + """ + Delete an agent and all related data. + Args: + agent_id: Agent ID to delete + tenant_id: Tenant ID + user_id: User ID performing the deletion + """ try: delete_agent_by_id(agent_id, tenant_id, user_id) delete_agent_relationship(agent_id, tenant_id, user_id) @@ -1210,6 +1237,17 @@ async def import_agent_by_agent_id( tool.agent_id = new_agent_id create_or_update_tool_by_tool_info( tool_info=tool, tenant_id=tenant_id, user_id=user_id) + # Auto-publish initial version v1 for market-imported agents + try: + publish_version_impl( + agent_id=new_agent_id, + tenant_id=tenant_id, + user_id=user_id, + version_name="v1", + release_note="Initial version from Agent Market" + ) + except Exception as e: + logger.warning(f"Failed to auto-publish version v1 for agent {new_agent_id}: {str(e)}") return new_agent_id @@ -1244,12 +1282,13 @@ async def clear_agent_new_mark_impl(agent_id: int, tenant_id: str, user_id: str) -async def list_all_agent_info_impl(tenant_id: str) -> list[dict]: +async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: """ list all agent info Args: tenant_id (str): tenant id + user_id (str): user id (used for permission calculation and filtering) Raises: ValueError: failed to query all agent info @@ -1258,6 +1297,22 @@ async def list_all_agent_info_impl(tenant_id: str) -> list[dict]: list: list of agent info """ try: + user_tenant_record = get_user_tenant_by_user_id(user_id) or {} + user_role = str(user_tenant_record.get("user_role") or "").upper() + + can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES + + # For DEV/USER, restrict visible agents to those whose group_ids overlap user's groups. + user_group_ids: set[int] = set() + if not can_edit_all: + try: + user_group_ids = set(query_group_ids_by_user(user_id) or []) + except Exception as e: + logger.warning( + f"Failed to query user group ids for filtering: user_id={user_id}, err={str(e)}" + ) + user_group_ids = set() + agent_list = query_all_agent_info_by_tenant_id(tenant_id=tenant_id) model_cache: Dict[int, Optional[dict]] = {} @@ -1267,6 +1322,12 @@ async def list_all_agent_info_impl(tenant_id: str) -> list[dict]: if not agent["enabled"]: continue + # Apply visibility filter for DEV/USER based on group overlap + if not can_edit_all: + agent_group_ids = set(convert_string_to_list(agent.get("group_ids"))) + if len(user_group_ids.intersection(agent_group_ids)) == 0: + continue + # Use shared availability check function _, unavailable_reasons = check_agent_availability( agent_id=agent["agent_id"], @@ -1297,6 +1358,8 @@ async def list_all_agent_info_impl(tenant_id: str) -> list[dict]: model_cache[model_id] = get_model_by_model_id(model_id, tenant_id) model_info = model_cache.get(model_id) + permission = PERMISSION_EDIT if can_edit_all or str(agent.get("created_by")) == str(user_id) else PERMISSION_READ + simple_agent_list.append({ "agent_id": agent["agent_id"], "name": agent["name"] if agent["name"] else agent["display_name"], @@ -1309,7 +1372,9 @@ async def list_all_agent_info_impl(tenant_id: str) -> list[dict]: "is_available": len(unavailable_reasons) == 0, "unavailable_reasons": unavailable_reasons, "is_new": agent.get("is_new", False), - "group_ids": convert_string_to_list(agent.get("group_ids")) + "group_ids": convert_string_to_list(agent.get("group_ids")), + "permission": permission, + "is_published": agent.get("current_version_no") is not None, }) return simple_agent_list diff --git a/backend/services/agent_version_service.py b/backend/services/agent_version_service.py new file mode 100644 index 000000000..6e013307c --- /dev/null +++ b/backend/services/agent_version_service.py @@ -0,0 +1,744 @@ +import logging +from typing import Optional, Tuple, List, Dict, Any +from sqlalchemy import update + +from database.client import get_db_session, as_dict +from database.db_models import AgentInfo, ToolInstance, AgentRelation +from database.agent_version_db import ( + search_version_by_version_no, + query_version_list, + query_current_version_no, + query_agent_snapshot, + query_agent_draft, + insert_version, + update_version_status, + update_agent_current_version, + insert_agent_snapshot, + insert_tool_snapshot, + insert_relation_snapshot, + delete_agent_snapshot, + delete_tool_snapshot, + delete_relation_snapshot, + get_next_version_no, + delete_version, + SOURCE_TYPE_NORMAL, + SOURCE_TYPE_ROLLBACK, + STATUS_RELEASED, + STATUS_DISABLED, + STATUS_ARCHIVED, +) +from database.model_management_db import get_model_by_model_id +from utils.str_utils import convert_string_to_list + +logger = logging.getLogger("agent_version_service") + + +def _remove_audit_fields_for_insert(data: dict) -> None: + """ + Remove audit fields that should not be copied during snapshot + """ + data.pop('create_time', None) + data.pop('update_time', None) + data.pop('created_by', None) + data.pop('updated_by', None) + data.pop('delete_flag', None) + + +def publish_version_impl( + agent_id: int, + tenant_id: str, + user_id: str, + version_name: Optional[str] = None, + release_note: Optional[str] = None, + source_type: str = SOURCE_TYPE_NORMAL, + source_version_no: Optional[int] = None, +) -> dict: + """ + Publish a new version + 1. Copy draft data (version_no=0) to new version + 2. Create version metadata record + 3. Update current_version_no + """ + # Get draft data + agent_draft, tools_draft, relations_draft = query_agent_draft(agent_id, tenant_id) + if not agent_draft: + raise ValueError("Agent draft not found") + + # Calculate new version number + new_version_no = get_next_version_no(agent_id, tenant_id) + + # Prepare agent snapshot data + agent_snapshot = agent_draft.copy() + agent_snapshot.pop('version_no', None) + agent_snapshot.pop('current_version_no', None) + agent_snapshot['version_no'] = new_version_no + _remove_audit_fields_for_insert(agent_snapshot) + + # Insert agent snapshot + insert_agent_snapshot(agent_snapshot) + + # Insert tool snapshots + for tool in tools_draft: + tool_snapshot = tool.copy() + tool_snapshot.pop('version_no', None) + tool_snapshot['version_no'] = new_version_no + _remove_audit_fields_for_insert(tool_snapshot) + insert_tool_snapshot(tool_snapshot) + + # Insert relation snapshots + for rel in relations_draft: + rel_snapshot = rel.copy() + rel_snapshot.pop('version_no', None) + rel_snapshot['version_no'] = new_version_no + _remove_audit_fields_for_insert(rel_snapshot) + insert_relation_snapshot(rel_snapshot) + + # Create version metadata + version_data = { + 'tenant_id': tenant_id, + 'agent_id': agent_id, + 'version_no': new_version_no, + 'version_name': version_name, + 'release_note': release_note, + 'source_type': source_type, + 'source_version_no': source_version_no, + 'status': STATUS_RELEASED, + 'created_by': user_id, + } + version_id = insert_version(version_data) + + # Update current_version_no in draft + update_agent_current_version(agent_id, tenant_id, new_version_no) + + return { + "id": version_id, + "version_no": new_version_no, + "message": "Version published successfully", + } + + +def get_version_list_impl( + agent_id: int, + tenant_id: str, +) -> dict: + """ + Get version list for an agent + """ + items = query_version_list( + agent_id=agent_id, + tenant_id=tenant_id, + ) + total = len(items) + return { + "items": items, + "total": total, + } + + +def get_version_impl( + agent_id: int, + tenant_id: str, + version_no: int, +) -> dict: + """ + Get version + """ + return search_version_by_version_no(agent_id, tenant_id, version_no) + + +def get_version_detail_impl( + agent_id: int, + tenant_id: str, + version_no: int, +) -> dict: + """ + Get version detail including snapshot data, structured like agent info. + Returns agent info with tools, sub_agents, availability, etc. + """ + result: Dict[str, Any] = {} + + # Get version metadata first + version = search_version_by_version_no(agent_id, tenant_id, version_no) + if not version: + raise ValueError(f"Version {version_no} not found") + + # Add version metadata as a nested object + result['version'] = { + 'version_name': version.get('version_name'), + 'version_status': version.get('status'), + 'release_note': version.get('release_note'), + 'source_type': version.get('source_type'), + 'source_version_no': version.get('source_version_no'), + } + + # Get snapshot data + agent_snapshot, tools_snapshot, relations_snapshot = query_agent_snapshot( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + ) + + if not agent_snapshot: + raise ValueError(f"Agent snapshot for version {version_no} not found") + + # Copy all fields from agent_snapshot (excluding current_version_no as it has no meaning for version snapshot) + for key, value in agent_snapshot.items(): + if key != 'current_version_no': + result[key] = value + + # Add tools + result['tools'] = tools_snapshot + + # Extract sub_agent_id_list from relations + result['sub_agent_id_list'] = [r['selected_agent_id'] for r in relations_snapshot] + + # Get model name from model_id + if result.get('model_id') is not None and result['model_id'] != 0: + model_info = get_model_by_model_id(result['model_id']) + result['model_name'] = model_info.get('display_name', None) if model_info else None + else: + result['model_name'] = None + + # Get business logic model name + if result.get('business_logic_model_id') is not None and result['business_logic_model_id'] != 0: + business_logic_model_info = get_model_by_model_id(result['business_logic_model_id']) + result['business_logic_model_name'] = business_logic_model_info.get('display_name', None) if business_logic_model_info else None + else: + result['business_logic_model_name'] = None + + # Convert group_ids string to list + if result.get('group_ids') is not None: + result['group_ids'] = convert_string_to_list(result.get('group_ids', '')) + else: + result['group_ids'] = [] + + # Build tool instances list for availability check + tool_instances_for_check = [] + for tool in tools_snapshot: + tool_instance = { + 'id': tool.get('tool_id'), + 'enabled': tool.get('enabled', True), + 'tool_id': tool.get('tool_id'), + } + tool_instances_for_check.append(tool_instance) + + # Check agent availability + is_available, unavailable_reasons = _check_version_snapshot_availability( + agent_id=agent_id, + tenant_id=tenant_id, + agent_info=result, + tool_instances=tool_instances_for_check, + ) + result['is_available'] = is_available + result['unavailable_reasons'] = unavailable_reasons + + return result + + +def _check_version_snapshot_availability( + agent_id: int, + tenant_id: str, + agent_info: dict, + tool_instances: List[dict], +) -> Tuple[bool, List[str]]: + """ + Check if a version snapshot agent is available. + Simplified version of check_agent_availability for snapshots. + """ + unavailable_reasons: List[str] = [] + + # Check if agent info exists + if not agent_info: + return False, ["agent_not_found"] + + # Check model availability + model_id = agent_info.get('model_id') + if model_id is None or model_id == 0: + unavailable_reasons.append("model_not_configured") + + # Check tools availability + if not tool_instances: + unavailable_reasons.append("no_tools") + else: + # Check if at least one tool is enabled + has_enabled_tool = any(t.get('enabled', True) for t in tool_instances) + if not has_enabled_tool: + unavailable_reasons.append("all_tools_disabled") + + return len(unavailable_reasons) == 0, unavailable_reasons + + +def rollback_version_impl( + agent_id: int, + tenant_id: str, + target_version_no: int, +) -> dict: + """ + Rollback to a specific version by updating current_version_no only. + This does NOT create a new version - it simply points the draft to an existing version. + The actual version creation happens when user clicks "publish". + + Args: + agent_id: Agent ID + tenant_id: Tenant ID + target_version_no: The version number to rollback to + + Returns: + Success message with target version info + """ + # Verify the target version exists + version = search_version_by_version_no(agent_id, tenant_id, target_version_no) + if not version: + raise ValueError(f"Version {target_version_no} not found") + + # Update current_version_no in draft to point to target version + rows_affected = update_agent_current_version( + agent_id=agent_id, + tenant_id=tenant_id, + current_version_no=target_version_no, + ) + + if rows_affected == 0: + raise ValueError("Agent draft not found") + + return { + "message": f"Successfully rolled back to version {target_version_no}", + "version_no": target_version_no, + "version_name": version.get("version_name"), + } + + +def update_version_status_impl( + agent_id: int, + tenant_id: str, + user_id: str, + version_no: int, + status: str, +) -> dict: + """ + Update version status (DISABLED / ARCHIVED) + """ + valid_statuses = [STATUS_DISABLED, STATUS_ARCHIVED] + if status not in valid_statuses: + raise ValueError(f"Invalid status. Must be one of: {valid_statuses}") + + rows_affected = update_version_status( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + status=status, + updated_by=user_id, + ) + + if rows_affected == 0: + raise ValueError(f"Version {version_no} not found") + + return {"message": "Status updated successfully"} + + +def delete_version_impl( + agent_id: int, + tenant_id: str, + user_id: str, + version_no: int, +) -> dict: + """ + Soft delete a version by setting delete_flag='Y' + Also soft deletes all related snapshot data (agent, tools, relations) for this version + """ + # Check if version exists + version = search_version_by_version_no(agent_id, tenant_id, version_no) + if not version: + raise ValueError(f"Version {version_no} not found") + + # Prevent deleting the current published version + current_version_no = query_current_version_no(agent_id, tenant_id) + if current_version_no == version_no: + raise ValueError("Cannot delete the current published version") + + # Prevent deleting draft version (version_no=0) + if version_no == 0: + raise ValueError("Cannot delete draft version") + + # Soft delete version metadata + rows_affected = delete_version( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + deleted_by=user_id, + ) + + if rows_affected == 0: + raise ValueError(f"Version {version_no} not found") + + # Soft delete all related snapshot data for this version + # 1. Delete agent snapshot + delete_agent_snapshot( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + deleted_by=user_id, + ) + + # 2. Delete tool snapshots + delete_tool_snapshot( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + deleted_by=user_id, + ) + + # 3. Delete relation snapshots + delete_relation_snapshot( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + deleted_by=user_id, + ) + + logger.info(f"Successfully deleted version {version_no} and all related snapshots for agent_id={agent_id}, tenant_id={tenant_id}") + + return {"message": f"Version {version_no} deleted successfully"} + + +def get_current_version_impl( + agent_id: int, + tenant_id: str, +) -> dict: + """ + Get current published version + """ + current_version_no = query_current_version_no(agent_id, tenant_id) + if current_version_no is None: + raise ValueError("No published version") + + version = search_version_by_version_no(agent_id, tenant_id, current_version_no) + if not version: + raise ValueError(f"Version {current_version_no} not found") + + return { + "version_no": current_version_no, + "version_name": version.get('version_name'), + "status": version.get('status'), + "source_type": version.get('source_type'), + "source_version_no": version.get('source_version_no'), + "release_note": version.get('release_note'), + "created_by": version.get('created_by'), + "create_time": version.get('create_time'), + } + + +def compare_versions_impl( + agent_id: int, + tenant_id: str, + version_no_a: int, + version_no_b: int, +) -> dict: + """ + Compare two versions and return their differences. + Returns detailed comparison data for both versions. + Handles version 0 as draft data. + """ + # Get version A detail (handles version 0 as draft) + version_a = _get_version_detail_or_draft(agent_id, tenant_id, version_no_a) + # Get version B detail (handles version 0 as draft) + version_b = _get_version_detail_or_draft(agent_id, tenant_id, version_no_b) + + # Calculate differences + differences = [] + + # Compare name + if version_a.get('name') != version_b.get('name'): + differences.append({ + 'field': 'name', + 'label': 'Name', + 'value_a': version_a.get('name'), + 'value_b': version_b.get('name'), + }) + + # Compare model_name + if version_a.get('model_name') != version_b.get('model_name'): + differences.append({ + 'field': 'model_name', + 'label': 'Model', + 'value_a': version_a.get('model_name'), + 'value_b': version_b.get('model_name'), + }) + + # Compare max_steps + if version_a.get('max_steps') != version_b.get('max_steps'): + differences.append({ + 'field': 'max_steps', + 'label': 'Max Steps', + 'value_a': version_a.get('max_steps'), + 'value_b': version_b.get('max_steps'), + }) + + # Compare description + if version_a.get('description') != version_b.get('description'): + differences.append({ + 'field': 'description', + 'label': 'Description', + 'value_a': version_a.get('description'), + 'value_b': version_b.get('description'), + }) + + # Compare duty_prompt + if version_a.get('duty_prompt') != version_b.get('duty_prompt'): + differences.append({ + 'field': 'duty_prompt', + 'label': 'Duty Prompt', + 'value_a': version_a.get('duty_prompt'), + 'value_b': version_b.get('duty_prompt'), + }) + + # Compare tools count + tools_a_count = len(version_a.get('tools', [])) + tools_b_count = len(version_b.get('tools', [])) + if tools_a_count != tools_b_count: + differences.append({ + 'field': 'tools_count', + 'label': 'Tools Count', + 'value_a': tools_a_count, + 'value_b': tools_b_count, + }) + + # Compare sub_agents count + sub_agents_a_count = len(version_a.get('sub_agent_id_list', [])) + sub_agents_b_count = len(version_b.get('sub_agent_id_list', [])) + if sub_agents_a_count != sub_agents_b_count: + differences.append({ + 'field': 'sub_agents_count', + 'label': 'Sub Agents Count', + 'value_a': sub_agents_a_count, + 'value_b': sub_agents_b_count, + }) + + return { + 'version_a': version_a, + 'version_b': version_b, + 'differences': differences, + } + + +def _get_version_detail_or_draft( + agent_id: int, + tenant_id: str, + version_no: int, +) -> dict: + """ + Get version detail for published versions, or draft data for version 0. + Returns structured agent info similar to get_version_detail_impl. + """ + result: Dict[str, Any] = {} + + if version_no == 0: + # Get draft data for version 0 + agent_draft, tools_draft, relations_draft = query_agent_draft(agent_id, tenant_id) + if not agent_draft: + raise ValueError(f"Draft version not found") + + # Copy draft data + for key, value in agent_draft.items(): + if key != 'current_version_no': + result[key] = value + + result['tools'] = tools_draft + result['sub_agent_id_list'] = [r['selected_agent_id'] for r in relations_draft] + result['version'] = { + 'version_name': 'Draft', + 'version_status': 'DRAFT', + 'release_note': '', + 'source_type': 'DRAFT', + 'source_version_no': 0, + } + else: + # Get published version detail + result = get_version_detail_impl(agent_id, tenant_id, version_no) + + # Get model name from model_id + if result.get('model_id') is not None and result['model_id'] != 0: + model_info = get_model_by_model_id(result['model_id']) + result['model_name'] = model_info.get('display_name', None) if model_info else None + else: + result['model_name'] = None + + # Get business logic model name + if result.get('business_logic_model_id') is not None and result['business_logic_model_id'] != 0: + business_logic_model_info = get_model_by_model_id(result['business_logic_model_id']) + result['business_logic_model_name'] = business_logic_model_info.get('display_name', None) if business_logic_model_info else None + else: + result['business_logic_model_name'] = None + + # Convert group_ids string to list (only if it's not already a list) + group_ids = result.get('group_ids') + if group_ids is not None: + # If already a list, keep it as is; otherwise convert from string + if isinstance(group_ids, list): + result['group_ids'] = group_ids + else: + result['group_ids'] = convert_string_to_list(str(group_ids)) + else: + result['group_ids'] = [] + + return result + + +async def list_published_agents_impl( + tenant_id: str, + user_id: str, +) -> list[dict]: + """ + List all published agents with their current published version information. + 1. Query all agents with version_no=0 (draft versions) + 2. For each agent with current_version_no > 0, get the published version snapshot + 3. Return the list of published agents + + Args: + tenant_id (str): Tenant ID + user_id (str): User ID (for permission calculation and filtering) + + Returns: + list[dict]: List of published agent info + """ + try: + from database.agent_db import ( + query_all_agent_info_by_tenant_id, + ) + from services.agent_service import ( + CAN_EDIT_ALL_USER_ROLES, + get_user_tenant_by_user_id, + query_group_ids_by_user, + PERMISSION_EDIT, + PERMISSION_READ, + get_model_by_model_id, + check_agent_availability, + _apply_duplicate_name_availability_rules, + ) + from database.agent_version_db import query_agent_snapshot + + # Get user role for permission check + user_tenant_record = get_user_tenant_by_user_id(user_id) or {} + user_role = str(user_tenant_record.get("user_role") or "").upper() + can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES + + # Get user's group IDs for filtering + user_group_ids: set[int] = set() + if not can_edit_all: + try: + user_group_ids = set(query_group_ids_by_user(user_id) or []) + except Exception as e: + logger.warning( + f"Failed to query user group ids for filtering: user_id={user_id}, err={str(e)}" + ) + user_group_ids = set() + + # Get all draft agents (version_no=0) + agent_list = query_all_agent_info_by_tenant_id(tenant_id=tenant_id) + + model_cache: Dict[int, Optional[dict]] = {} + enriched_agents: list[dict] = [] + + for agent in agent_list: + # Filter out disabled agents + if not agent.get("enabled"): + continue + + # Apply visibility filter for DEV/USER based on group overlap + if not can_edit_all: + agent_group_ids = set(convert_string_to_list(agent.get("group_ids"))) + if len(user_group_ids.intersection(agent_group_ids)) == 0: + continue + + agent_id = agent.get("agent_id") + current_version_no = agent.get("current_version_no") + + # Only include agents that have a published version (current_version_no > 0) + if not current_version_no or current_version_no <= 0: + continue + + # Get the published version snapshot + agent_snapshot, tools_snapshot, relations_snapshot = query_agent_snapshot( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=current_version_no, + ) + + if not agent_snapshot: + logger.warning( + f"Published version snapshot not found for agent_id={agent_id}, version_no={current_version_no}" + ) + continue + + # Build the agent info from snapshot + agent_info: Dict[str, Any] = {} + + # Copy all fields from snapshot (excluding current_version_no as it's not meaningful for version) + for key, value in agent_snapshot.items(): + if key != 'current_version_no': + agent_info[key] = value + + # Add tools + agent_info['tools'] = tools_snapshot + + # Extract sub_agent_id_list from relations + agent_info['sub_agent_id_list'] = [r['selected_agent_id'] for r in relations_snapshot] + + # Add published version info + agent_info['published_version_no'] = current_version_no + + # Check agent availability using the shared function + _, unavailable_reasons = check_agent_availability( + agent_id=agent_id, + tenant_id=tenant_id, + agent_info=agent_info, + model_cache=model_cache + ) + + # Preserve the raw data so we can adjust availability for duplicates + enriched_agents.append({ + "raw_agent": agent_info, + "unavailable_reasons": unavailable_reasons, + }) + + # Handle duplicate name/display_name: keep the earliest created agent available, + # mark later ones as unavailable due to duplication. + _apply_duplicate_name_availability_rules(enriched_agents) + + # Build the final simple agent list + simple_agent_list: list[dict] = [] + for entry in enriched_agents: + agent = entry["raw_agent"] + unavailable_reasons = list(dict.fromkeys(entry["unavailable_reasons"])) + + model_id = agent.get("model_id") + model_info = None + if model_id is not None: + if model_id not in model_cache: + model_cache[model_id] = get_model_by_model_id(model_id, tenant_id) + model_info = model_cache.get(model_id) + + permission = PERMISSION_EDIT if can_edit_all or str(agent.get("created_by")) == str(user_id) else PERMISSION_READ + + simple_agent_list.append({ + "agent_id": agent.get("agent_id"), + "name": agent.get("name") if agent.get("name") else agent.get("display_name"), + "display_name": agent.get("display_name") if agent.get("display_name") else agent.get("name"), + "description": agent.get("description"), + "author": agent.get("author"), + "model_id": model_id, + "model_name": model_info.get("model_name") if model_info is not None else agent.get("model_name"), + "model_display_name": model_info.get("display_name") if model_info is not None else None, + "is_available": len(unavailable_reasons) == 0, + "unavailable_reasons": unavailable_reasons, + "is_new": agent.get("is_new", False), + "group_ids": agent.get("group_ids", []), + "permission": permission, + "published_version_no": agent.get("published_version_no"), + }) + + return simple_agent_list + + except Exception as e: + logger.error(f"Failed to list published agents: {str(e)}") + raise ValueError(f"Failed to list published agents: {str(e)}") diff --git a/backend/services/datamate_service.py b/backend/services/datamate_service.py index 314c410f2..776e0eb1d 100644 --- a/backend/services/datamate_service.py +++ b/backend/services/datamate_service.py @@ -5,7 +5,7 @@ This service layer uses the DataMate SDK client to interact with DataMate APIs. """ import logging -from typing import Dict, List, Any +from typing import Dict, List, Any, Optional import asyncio from consts.const import DATAMATE_URL @@ -75,17 +75,26 @@ async def _create_datamate_knowledge_records(knowledge_base_ids: List[str], return created_records -def _get_datamate_core(tenant_id: str) -> DataMateCore: - """Get DataMate core instance.""" - datamate_url = tenant_config_manager.get_app_config( +def _get_datamate_core(tenant_id: str, datamate_url: Optional[str] = None) -> DataMateCore: + """ + Get DataMate core instance. + + Args: + tenant_id: Tenant ID for configuration lookup + datamate_url: Optional DataMate server URL (for dynamic configuration) + + Returns: + DataMateCore instance + """ + datamate_server_url = datamate_url if datamate_url else tenant_config_manager.get_app_config( DATAMATE_URL, tenant_id=tenant_id) - if not datamate_url: + if not datamate_server_url: raise ValueError(f"DataMate URL not configured for tenant {tenant_id}") # For HTTPS URLs with self-signed certificates, disable SSL verification - verify_ssl = not datamate_url.startswith("https://") + verify_ssl = not datamate_server_url.startswith("https://") - return DataMateCore(base_url=datamate_url, verify_ssl=verify_ssl) + return DataMateCore(base_url=datamate_server_url, verify_ssl=verify_ssl) async def fetch_datamate_knowledge_base_file_list(knowledge_base_id: str, tenant_id: str) -> Dict[str, Any]: @@ -121,32 +130,27 @@ async def fetch_datamate_knowledge_base_file_list(knowledge_base_id: str, tenant f"Failed to fetch file list for knowledge base {knowledge_base_id}: {str(e)}") -async def sync_datamate_knowledge_bases_and_create_records(tenant_id: str, user_id: str) -> Dict[str, Any]: +async def sync_datamate_knowledge_bases_and_create_records( + tenant_id: str, + user_id: str, + datamate_url: Optional[str] = None +) -> Dict[str, Any]: """ Sync all DataMate knowledge bases and create knowledge records in local database. Args: tenant_id: Tenant ID for creating knowledge records user_id: User ID for creating knowledge records + datamate_url: Optional DataMate server URL from request (for dynamic configuration) Returns: Dictionary containing knowledge bases list and created records. """ - # Check if ModelEngine is enabled - if str(MODEL_ENGINE_ENABLED).lower() != "true": - logger.info( - f"ModelEngine is disabled (MODEL_ENGINE_ENABLED={MODEL_ENGINE_ENABLED}), skipping DataMate sync") - return { - "indices": [], - "count": 0, - "indices_info": [], - "created_records": [] - } - - # Verify DataMate URL is configured before proceeding - datamate_url = tenant_config_manager.get_app_config( + # Use provided datamate_url from request, fallback to tenant config + effective_datamate_url = datamate_url if datamate_url else tenant_config_manager.get_app_config( DATAMATE_URL, tenant_id=tenant_id) - if not datamate_url: + + if not effective_datamate_url: logger.warning( f"DataMate URL not configured for tenant {tenant_id}, skipping sync") return { @@ -157,13 +161,20 @@ async def sync_datamate_knowledge_bases_and_create_records(tenant_id: str, user_ } logger.info( - f"Starting DataMate sync for tenant {tenant_id} using URL: {datamate_url}") + f"Starting DataMate sync for tenant {tenant_id} using URL: {effective_datamate_url}") try: - core = _get_datamate_core(tenant_id) + core = _get_datamate_core(tenant_id, effective_datamate_url) + + # Run synchronous SDK calls in executor to avoid blocking event loop + loop = asyncio.get_event_loop() + + # Step 1: Get knowledge base ids + knowledge_base_ids = await loop.run_in_executor( + None, + core.get_user_indices + ) - # Step 1: Get knowledge base id - knowledge_base_ids = core.get_user_indices() if not knowledge_base_ids: return { "indices": [], @@ -171,8 +182,10 @@ async def sync_datamate_knowledge_bases_and_create_records(tenant_id: str, user_ } # Step 2: Get detailed information for all knowledge bases - details, knowledge_base_names = core.get_indices_detail( - knowledge_base_ids) + details, knowledge_base_names = await loop.run_in_executor( + None, + lambda: core.get_indices_detail(knowledge_base_ids) + ) response = { "indices": knowledge_base_names, @@ -246,3 +259,67 @@ async def sync_datamate_knowledge_bases_and_create_records(tenant_id: str, user_ "indices": [], "count": 0, } + + +async def check_datamate_connection( + tenant_id: str, + datamate_url: Optional[str] = None +) -> tuple: + """ + Test connection to DataMate server. + + Args: + tenant_id: Tenant ID for configuration lookup. + datamate_url: Optional DataMate server URL from request (for dynamic configuration). + + Returns: + Tuple of (is_connected: bool, error_message: str). + is_connected is True if connection successful, False otherwise. + error_message contains error details if connection failed, empty string if successful. + """ + # Check if ModelEngine is enabled + if str(MODEL_ENGINE_ENABLED).lower() != "true": + logger.info( + f"ModelEngine is disabled (MODEL_ENGINE_ENABLED={MODEL_ENGINE_ENABLED}), skipping DataMate connection test") + return (False, "ModelEngine is disabled") + + # Use provided datamate_url from request, fallback to tenant config + effective_datamate_url = datamate_url if datamate_url else tenant_config_manager.get_app_config( + DATAMATE_URL, tenant_id=tenant_id) + + if not effective_datamate_url: + logger.warning( + f"DataMate URL not configured for tenant {tenant_id}") + return (False, "DataMate URL not configured") + + logger.info( + f"Testing DataMate connection for tenant {tenant_id} using URL: {effective_datamate_url}") + + try: + core = _get_datamate_core(tenant_id, effective_datamate_url) + + # Run synchronous SDK call in executor to avoid blocking event loop + loop = asyncio.get_event_loop() + + # Test connection by fetching user indices + await loop.run_in_executor( + None, + core.get_user_indices + ) + + logger.info( + f"DataMate connection test successful for tenant {tenant_id}") + return (True, "") + + except ValueError as e: + # Configuration error (e.g., missing DataMate URL) + error_msg = str(e) + logger.error( + f"DataMate connection test failed (configuration error) for tenant {tenant_id}: {error_msg}") + return (False, error_msg) + + except Exception as e: + error_msg = str(e) + logger.error( + f"DataMate connection test failed for tenant {tenant_id}: {error_msg}") + return (False, error_msg) diff --git a/backend/services/dify_service.py b/backend/services/dify_service.py new file mode 100644 index 000000000..14e3a4d6f --- /dev/null +++ b/backend/services/dify_service.py @@ -0,0 +1,171 @@ +""" +Dify Service Layer +Handles API calls to Dify for knowledge base operations. + +This service layer provides functionality to interact with Dify's API, +including fetching datasets (knowledge bases) and transforming responses +to DataMate-compatible format for frontend compatibility. +""" +import json +import logging +from typing import Any, Dict + +import httpx + +from nexent.utils.http_client_manager import http_client_manager + +logger = logging.getLogger("dify_service") + + +def fetch_dify_datasets_impl( + dify_api_base: str, + api_key: str, +) -> Dict[str, Any]: + """ + Fetch datasets (knowledge bases) from Dify API and transform to DataMate-compatible format. + + Args: + dify_api_base: Dify API base URL + api_key: Dify API key with Bearer token + + Returns: + Dictionary containing knowledge bases in DataMate-compatible format: + { + "indices": ["dataset_id_1", "dataset_id_2", ...], + "count": 2, + "indices_info": [ + { + "name": "dataset_id_1", + "display_name": "知识库名称", + "stats": { + "base_info": { + "doc_count": 10, + "chunk_count": 100, + "store_size": "", + "process_source": "Dify", + "embedding_model": "", + "embedding_dim": 0, + "creation_date": timestamp, + "update_date": timestamp + }, + "search_performance": { + "total_search_count": 0, + "hit_count": 0 + } + } + }, + ... + ], + "pagination": { + "embedding_available": False + } + } + + Raises: + ValueError: If invalid parameters provided + Exception: If API request fails + """ + # Validate inputs + if not dify_api_base or not isinstance(dify_api_base, str): + raise ValueError( + "dify_api_base is required and must be a non-empty string") + + if not api_key or not isinstance(api_key, str): + raise ValueError("api_key is required and must be a non-empty string") + + # Normalize API base URL + api_base = dify_api_base.rstrip("/") + + # Remove /v1 suffix if present to avoid URL duplication + # E.g., "https://api.dify.ai/v1" -> "https://api.dify.ai" + if api_base.endswith("/v1"): + api_base = api_base[:-3] + + # Build request URL with pagination + url = f"{api_base}/v1/datasets" + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + logger.info(f"Fetching Dify datasets from: {url}") + + try: + # Use shared HttpClientManager for connection pooling + client = http_client_manager.get_sync_client( + base_url=api_base, + timeout=30.0, + verify_ssl=False + ) + response = client.get(url, headers=headers) + response.raise_for_status() + + result = response.json() + + # Parse Dify API response + datasets_data = result.get("data", []) + + # Transform to DataMate-compatible format + indices = [] + indices_info = [] + embedding_available = False # Default value if no datasets or all skipped + + for dataset in datasets_data: + dataset_id = dataset.get("id", "") + dataset_name = dataset.get("name", "") + document_count = dataset.get("document_count", 0) + created_at = dataset.get("created_at", 0) + updated_at = dataset.get("updated_at", 0) + embedding_available = dataset.get("embedding_available", False) + + if not dataset_id: + continue + + indices.append(dataset_id) + + # Create indices_info entry (compatible with DataMate format) + indices_info.append({ + "name": dataset_id, + "display_name": dataset_name, + "stats": { + "base_info": { + "doc_count": document_count, + "chunk_count": 0, # Dify doesn't provide chunk count directly + "store_size": "", + "process_source": "Dify", + "embedding_model": dataset.get("embedding_model", ""), + "embedding_dim": 0, + "creation_date": created_at * 1000 if created_at else 0, # Convert to milliseconds + "update_date": updated_at * 1000 if updated_at else 0 + }, + "search_performance": { + "total_search_count": 0, + "hit_count": 0 + } + } + }) + + return { + "indices": indices, + "count": len(indices), + "indices_info": indices_info, + "pagination": { + "embedding_available": embedding_available + } + } + + except httpx.RequestError as e: + logger.error(f"Dify API request failed: {str(e)}") + raise Exception(f"Dify API request failed: {str(e)}") + except httpx.HTTPStatusError as e: + logger.error(f"Dify API HTTP error: {str(e)}") + raise Exception(f"Dify API HTTP error: {str(e)}") + except json.JSONDecodeError as e: + logger.error(f"Failed to parse Dify API response: {str(e)}") + raise Exception(f"Failed to parse Dify API response: {str(e)}") + except KeyError as e: + logger.error( + f"Unexpected Dify API response format: missing key {str(e)}") + raise Exception( + f"Unexpected Dify API response format: missing key {str(e)}") diff --git a/backend/services/group_service.py b/backend/services/group_service.py index 7a3951618..a8c0c585d 100644 --- a/backend/services/group_service.py +++ b/backend/services/group_service.py @@ -16,7 +16,8 @@ query_groups_by_user, query_group_ids_by_user, check_user_in_group, - count_group_users + count_group_users, + check_group_name_exists ) from database.user_tenant_db import get_user_tenant_by_user_id from database.tenant_config_db import get_single_config_info, insert_config, update_config_by_tenant_config_id @@ -207,6 +208,7 @@ def create_group(tenant_id: str, group_name: str, group_description: Optional[st Raises: NotFoundException: When user not found UnauthorizedError: When user doesn't have permission + ValidationError: When group name already exists in the tenant """ # Check user permission if user_id: @@ -218,6 +220,10 @@ def create_group(tenant_id: str, group_name: str, group_description: Optional[st if user_role not in ["SU", "ADMIN"]: raise UnauthorizedError(f"User role {user_role} not authorized to create groups") + # Check if group name already exists in the tenant + if check_group_name_exists(tenant_id, group_name): + raise ValidationError(f"Group name '{group_name}' already exists in this tenant") + # Create group group_id = add_group( tenant_id=tenant_id, @@ -250,6 +256,7 @@ def update_group(group_id: int, updates: Dict[str, Any], user_id: str) -> bool: Raises: NotFoundException: When user or group not found UnauthorizedError: When user doesn't have permission + ValidationError: When group name already exists in the tenant """ # Check user permission user_info = get_user_tenant_by_user_id(user_id) @@ -265,6 +272,13 @@ def update_group(group_id: int, updates: Dict[str, Any], user_id: str) -> bool: if not group: raise NotFoundException(f"Group {group_id} not found") + tenant_id = group.get("tenant_id") + + # Check if new group name already exists in the tenant (when updating group_name) + if "group_name" in updates and updates["group_name"]: + if check_group_name_exists(tenant_id, updates["group_name"], exclude_group_id=group_id): + raise ValidationError(f"Group name '{updates['group_name']}' already exists in this tenant") + # Update group success = modify_group( group_id=group_id, @@ -281,7 +295,6 @@ def update_group(group_id: int, updates: Dict[str, Any], user_id: str) -> bool: def delete_group(group_id: int, user_id: str) -> bool: """ Delete group. - TODO: Clear user-group relationship, knowledgebases, agents, invitation codes under the group Args: group_id (int): Group ID diff --git a/backend/services/invitation_service.py b/backend/services/invitation_service.py index 13add6dd2..58a45d369 100644 --- a/backend/services/invitation_service.py +++ b/backend/services/invitation_service.py @@ -19,7 +19,7 @@ ) from database.user_tenant_db import get_user_tenant_by_user_id from database.group_db import query_group_ids_by_user -from consts.exceptions import NotFoundException, UnauthorizedError +from consts.exceptions import NotFoundException, UnauthorizedError, DuplicateError from services.group_service import get_tenant_default_group_id from utils.str_utils import convert_string_to_list @@ -93,6 +93,10 @@ def create_invitation_code( # Change to upper case by default invitation_code = invitation_code.upper() + # Check if invitation code already exists + if query_invitation_by_code(invitation_code): + raise DuplicateError(f"Invitation code '{invitation_code}' already exists") + # Create invitation (status will be set automatically) invitation_id = add_invitation( tenant_id=tenant_id, @@ -298,7 +302,8 @@ def _calculate_current_status(invitation_data: Dict[str, Any]) -> Dict[str, Any] else: expiry_datetime = datetime.fromisoformat( str(expiry_date).replace('Z', '+00:00')) - if current_time > expiry_datetime: + # Treat same date as not expired - only expire when current date is strictly after expiry date + if current_time.date() > expiry_datetime.date(): new_status = "EXPIRE" except (ValueError, AttributeError, TypeError): logger.warning(f"Invalid expiry_date format for invitation {invitation_id}: {expiry_date}") @@ -417,7 +422,8 @@ def update_invitation_code_status(invitation_id: int) -> bool: else: expiry_datetime = datetime.fromisoformat( str(expiry_date).replace('Z', '+00:00')) - if current_time > expiry_datetime: + # Treat same date as not expired - only expire when current date is strictly after expiry date + if current_time.date() > expiry_datetime.date(): new_status = "EXPIRE" except (ValueError, AttributeError, TypeError): logger.warning(f"Invalid expiry_date format for invitation {invitation_id}: {expiry_date}") diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index dc38026d8..4b8265028 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -1,5 +1,5 @@ import logging -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from consts.const import LOCALHOST_IP, LOCALHOST_NAME, DOCKER_INTERNAL_HOST from consts.model import ModelConnectStatusEnum @@ -398,5 +398,84 @@ async def list_llm_models_for_tenant(tenant_id: str): raise Exception(f"Failed to retrieve model list: {str(e)}") +async def list_models_for_admin( + tenant_id: str, + model_type: Optional[str] = None, + page: int = 1, + page_size: int = 20 +) -> Dict[str, Any]: + """Get models for a specified tenant (admin operation) with pagination. + + Args: + tenant_id: Target tenant ID to query models for + model_type: Optional model type filter (e.g., 'llm', 'embedding') + page: Page number for pagination (1-indexed) + page_size: Number of items per page + + Returns: + Dict containing tenant_id, tenant_name, paginated models list, and pagination info + """ + try: + # Build filters + filters = None + if model_type: + filters = {"model_type": model_type} + + # Get model records for the specified tenant + records = get_model_records(filters, tenant_id) + + # Type mapping for backwards compatibility + type_map = { + "chat": "llm", + } + + # Normalize model records + normalized_models: List[Dict[str, Any]] = [] + for record in records: + record["model_name"] = add_repo_to_name( + model_repo=record["model_repo"], + model_name=record["model_name"], + ) + record["connect_status"] = ModelConnectStatusEnum.get_value( + record.get("connect_status")) + + # Map model_type if necessary + if record.get("model_type") in type_map: + record["model_type"] = type_map[record["model_type"]] + + normalized_models.append(record) + + # Calculate pagination + total = len(normalized_models) + total_pages = (total + page_size - 1) // page_size if page_size > 0 else 0 + start_index = (page - 1) * page_size + end_index = start_index + page_size + paginated_models = normalized_models[start_index:end_index] + + # Get tenant name + from services.tenant_service import get_tenant_info + try: + tenant_info = get_tenant_info(tenant_id) + tenant_name = tenant_info.get("tenant_name", "") + except Exception: + tenant_name = "" + + result = { + "tenant_id": tenant_id, + "tenant_name": tenant_name, + "models": paginated_models, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages + } + + logging.debug(f"Successfully retrieved admin model list for tenant: {tenant_id}, page: {page}, page_size: {page_size}") + return result + except Exception as e: + logging.error(f"Failed to retrieve admin model list: {str(e)}") + raise Exception(f"Failed to retrieve admin model list: {str(e)}") + + diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index 3e67a804f..a302eb999 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -1,144 +1,52 @@ import logging -from abc import ABC, abstractmethod -from typing import Dict, List - -import httpx -import aiohttp +from typing import List from consts.const import ( - DEFAULT_LLM_MAX_TOKENS, DEFAULT_EXPECTED_CHUNK_SIZE, DEFAULT_MAXIMUM_CHUNK_SIZE, ) from consts.model import ModelConnectStatusEnum, ModelRequest -from consts.provider import SILICON_GET_URL, ProviderEnum +from consts.provider import ProviderEnum from database.model_management_db import get_models_by_tenant_factory_type from services.model_health_service import embedding_dimension_check +from services.providers.base import AbstractModelProvider +from services.providers.silicon_provider import SiliconModelProvider +from services.providers.modelengine_provider import ModelEngineProvider, get_model_engine_raw_url, MODEL_ENGINE_NORTH_PREFIX from utils.model_name_utils import split_repo_name, add_repo_to_name -logger = logging.getLogger("model_provider_service") - -MODEL_ENGINE_NORTH_PREFIX = "open/router/v1" - -class AbstractModelProvider(ABC): - """Common interface that all model provider integrations must implement.""" - - @abstractmethod - async def get_models(self, provider_config: Dict) -> List[Dict]: - """Return a list of models provided by the concrete provider.""" - raise NotImplementedError - - -class SiliconModelProvider(AbstractModelProvider): - """Concrete implementation for SiliconFlow provider.""" - - async def get_models(self, provider_config: Dict) -> List[Dict]: - try: - model_type: str = provider_config["model_type"] - model_api_key: str = provider_config["api_key"] - - headers = {"Authorization": f"Bearer {model_api_key}"} - - # Choose endpoint by model type - if model_type in ("llm", "vlm"): - silicon_url = f"{SILICON_GET_URL}?sub_type=chat" - elif model_type in ("embedding", "multi_embedding"): - silicon_url = f"{SILICON_GET_URL}?sub_type=embedding" - else: - silicon_url = SILICON_GET_URL - - async with httpx.AsyncClient(verify=False) as client: - response = await client.get(silicon_url, headers=headers) - response.raise_for_status() - model_list: List[Dict] = response.json()["data"] - - # Annotate models with canonical fields expected downstream - if model_type in ("llm", "vlm"): - for item in model_list: - item["model_tag"] = "chat" - item["model_type"] = model_type - item["max_tokens"] = DEFAULT_LLM_MAX_TOKENS - elif model_type in ("embedding", "multi_embedding"): - for item in model_list: - item["model_tag"] = "embedding" - item["model_type"] = model_type - - return model_list - except Exception as e: - logger.error(f"Error getting models from silicon: {e}") - return [] - - -class ModelEngineProvider(AbstractModelProvider): - """Concrete implementation for ModelEngine provider.""" - - async def get_models(self, provider_config: Dict) -> List[Dict]: - """ - Fetch models from ModelEngine API. - - Args: - provider_config: Configuration dict containing model_type - - Returns: - List of models with canonical fields - """ - try: - model_type: str = provider_config.get("model_type", "") - host = provider_config.get("base_url") - api_key = provider_config.get("api_key") - model_engine_url = get_model_engine_raw_url(host) - if not host or not api_key: - logger.warning("ModelEngine host or api key not configured") - return [] - - headers = {"Authorization": f"Bearer {api_key}"} - - async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=30), - connector=aiohttp.TCPConnector(ssl=False) - ) as session: - async with session.get( - f"{model_engine_url.rstrip('/')}/{MODEL_ENGINE_NORTH_PREFIX}/models", - headers=headers - ) as response: - response.raise_for_status() - data = await response.json() - all_models = data.get("data", []) - - # Type mapping from ModelEngine to internal types - type_map = { - "embed": "embedding", - "chat": "llm", - "asr": "stt", - "tts": "tts", - "rerank": "rerank", - "multimodal": "vlm", - } - - # Filter models by type if specified - filtered_models = [] - for model in all_models: - me_type = model.get("type", "") - internal_type = type_map.get(me_type) - - # If model_type filter is provided, only include matching models - if model_type and internal_type != model_type: - continue - - if internal_type: - filtered_models.append({ - "id": model.get("id", ""), - "model_type": internal_type, - "model_tag": me_type, - "max_tokens": DEFAULT_LLM_MAX_TOKENS if internal_type in ("llm", "vlm") else 0, - "base_url": host, - "api_key": api_key, - }) - - return filtered_models - except Exception as e: - logger.error(f"Error getting models from ModelEngine: {e}") - return [] +logger = logging.getLogger("model_provider") + + +# ============================================================================= +# Provider Factory and Public API +# ============================================================================= + + +async def get_provider_models(model_data: dict) -> List[dict]: + """ + Get model list based on provider. + + Args: + model_data: Model data containing provider information + + Returns: + List of models from the specified provider + """ + model_list = [] + + if model_data["provider"] == ProviderEnum.SILICON.value: + provider = SiliconModelProvider() + model_list = await provider.get_models(model_data) + elif model_data["provider"] == ProviderEnum.MODELENGINE.value: + provider = ModelEngineProvider() + model_list = await provider.get_models(model_data) + + return model_list + + +# ============================================================================= +# Model Dictionary Preparation +# ============================================================================= async def prepare_model_dict(provider: str, model: dict, model_url: str, model_api_key: str) -> dict: @@ -157,7 +65,6 @@ async def prepare_model_dict(provider: str, model: dict, model_url: str, model_a Returns: A dictionary ready to be passed to *create_model_record*. """ - # Split repo/name once so it can be reused multiple times. model_repo, model_name = split_repo_name(model["id"]) model_display_name = add_repo_to_name(model_repo, model_name) @@ -169,8 +76,10 @@ async def prepare_model_dict(provider: str, model: dict, model_url: str, model_a # For embedding models, apply default values when chunk sizes are null if model["model_type"] in ["embedding", "multi_embedding"]: - expected_chunk_size = model.get("expected_chunk_size", DEFAULT_EXPECTED_CHUNK_SIZE) - maximum_chunk_size = model.get("maximum_chunk_size", DEFAULT_MAXIMUM_CHUNK_SIZE) + expected_chunk_size = model.get( + "expected_chunk_size", DEFAULT_EXPECTED_CHUNK_SIZE) + maximum_chunk_size = model.get( + "maximum_chunk_size", DEFAULT_MAXIMUM_CHUNK_SIZE) chunk_batch = model.get("chunk_batch", 10) # For ModelEngine provider, extract the host from model's base_url @@ -234,7 +143,7 @@ async def prepare_model_dict(provider: str, model: dict, model_url: str, model_a def merge_existing_model_tokens(model_list: List[dict], tenant_id: str, provider: str, model_type: str) -> List[dict]: """ - Merge existing model's max_tokens attribute into the model list + Merge existing model's max_tokens attribute into the model list. Args: model_list: List of models @@ -270,31 +179,13 @@ def merge_existing_model_tokens(model_list: List[dict], tenant_id: str, provider return model_list -async def get_provider_models(model_data: dict) -> List[dict]: - """ - Get model list based on provider - - Args: - model_data: Model data containing provider information - - Returns: - List[dict]: Model list - """ - model_list = [] - - if model_data["provider"] == ProviderEnum.SILICON.value: - provider = SiliconModelProvider() - model_list = await provider.get_models(model_data) - elif model_data["provider"] == ProviderEnum.MODELENGINE.value: - provider = ModelEngineProvider() - model_list = await provider.get_models(model_data) - - return model_list - -def get_model_engine_raw_url(model_engine_url: str) -> str: - # Strip any existing path to get just the host - model_engine_raw_url = model_engine_url - if model_engine_url: - # Remove any trailing /open/router/v1 or similar paths to get base host - model_engine_raw_url = model_engine_url.split("/open/")[0] if "/open/" in model_engine_url else model_engine_url - return model_engine_raw_url +# Re-export provider classes for backward compatibility +__all__ = [ + "AbstractModelProvider", + "SiliconModelProvider", + "ModelEngineProvider", + "prepare_model_dict", + "merge_existing_model_tokens", + "get_provider_models", + "get_model_engine_raw_url", +] diff --git a/backend/services/prompt_service.py b/backend/services/prompt_service.py index f1eb16e03..a505f28f4 100644 --- a/backend/services/prompt_service.py +++ b/backend/services/prompt_service.py @@ -198,23 +198,6 @@ def generate_and_save_system_prompt_impl(agent_id: int, else: logger.info( "Updating agent with business_description and prompt segments") - - agent_info = AgentInfoRequest( - agent_id=agent_id, - business_description=task_description, - duty_prompt=final_results["duty"], - constraint_prompt=final_results["constraint"], - few_shots_prompt=final_results["few_shots"], - name=final_results["agent_var_name"], - display_name=final_results["agent_display_name"], - description=final_results["agent_description"] - ) - update_agent( - agent_id=agent_id, - agent_info=agent_info, - tenant_id=tenant_id, - user_id=user_id - ) logger.info("Prompt generation and agent update completed successfully") diff --git a/backend/services/providers/__init__.py b/backend/services/providers/__init__.py new file mode 100644 index 000000000..9478043c2 --- /dev/null +++ b/backend/services/providers/__init__.py @@ -0,0 +1,11 @@ +# Provider exports +from services.providers.base import AbstractModelProvider +from services.providers.silicon_provider import SiliconModelProvider +from services.providers.modelengine_provider import ModelEngineProvider, get_model_engine_raw_url + +__all__ = [ + "AbstractModelProvider", + "SiliconModelProvider", + "ModelEngineProvider", + "get_model_engine_raw_url", +] diff --git a/backend/services/providers/base.py b/backend/services/providers/base.py new file mode 100644 index 000000000..4756bf6ad --- /dev/null +++ b/backend/services/providers/base.py @@ -0,0 +1,150 @@ +import logging +from abc import ABC, abstractmethod +from typing import Dict, List + +import aiohttp + +logger = logging.getLogger("model_provider") + + +# ============================================================================= +# Provider Error Handling Utilities +# ============================================================================= + + +def _create_error_response(error_code: str, message: str, http_code: int = None) -> List[Dict]: + """ + Create a standardized error response for provider API failures. + + Args: + error_code: Machine-readable error code (e.g., 'authentication_failed') + message: Human-readable error message + http_code: HTTP status code if available + + Returns: + List containing a single error dict with standardized format + """ + error_dict = {"_error": error_code, "_message": message} + if http_code: + error_dict["_http_code"] = http_code + return [error_dict] + + +def _classify_provider_error( + provider_name: str, + status_code: int = None, + error_message: str = None, + exception: Exception = None +) -> List[Dict]: + """ + Classify provider errors and return standardized error response. + + This function centralizes error classification logic for all model providers, + ensuring consistent error codes and messages across different providers. + + Args: + provider_name: Name of the provider (for logging and messages) + status_code: HTTP status code if available + error_message: Error message from API if available + exception: Exception object if available + + Returns: + List containing a single error dict with standardized format + """ + # Classify by HTTP status code + if status_code: + if status_code == 401: + logger.error( + f"{provider_name} API authentication failed: Invalid API key") + return _create_error_response( + "authentication_failed", + "Invalid API key or authentication failed", + status_code + ) + elif status_code == 403: + logger.error( + f"{provider_name} API access forbidden: Insufficient permissions") + return _create_error_response( + "access_forbidden", + "Access forbidden. Please check your permissions", + status_code + ) + elif status_code == 404: + logger.error( + f"{provider_name} API endpoint not found: URL may be incorrect") + return _create_error_response( + "endpoint_not_found", + "API endpoint not found. Please verify the URL", + status_code + ) + elif status_code >= 500: + logger.error(f"{provider_name} server error: HTTP {status_code}") + return _create_error_response( + "server_error", + f"Server error (HTTP {status_code})", + status_code + ) + elif status_code >= 400: + logger.error( + f"{provider_name} API error (HTTP {status_code}): {error_message}") + return _create_error_response( + "api_error", + f"API error (HTTP {status_code})", + status_code + ) + + # Classify by exception type + if exception: + # aiohttp exceptions + if isinstance(exception, aiohttp.ClientConnectorError): + error_str = str(exception).lower() + if "certificate" in error_str or "ssl" in error_str: + logger.error( + f"{provider_name} SSL certificate error: {exception}") + return _create_error_response( + "ssl_error", + "SSL certificate error. Please check the URL and SSL configuration" + ) + else: + logger.error(f"{provider_name} connection failed: {exception}") + return _create_error_response( + "connection_failed", + f"Failed to connect to {provider_name}. Please check the URL and network connection" + ) + elif isinstance(exception, aiohttp.ServerTimeoutError): + logger.error(f"{provider_name} server timeout: {exception}") + return _create_error_response( + "timeout", + "Connection timed out. Please check the URL and network connection" + ) + elif isinstance(exception, aiohttp.ServerDisconnectedError): + logger.error(f"{provider_name} server disconnected: {exception}") + return _create_error_response( + "connection_failed", + f"Connection to {provider_name} was interrupted. Please try again" + ) + elif isinstance(exception, aiohttp.ContentTypeError): + logger.error( + f"{provider_name} invalid response format: {exception}") + return _create_error_response( + "invalid_response", + "Invalid response from provider API" + ) + + # Generic connection error fallback + error_msg = error_message or str( + exception) if exception else "Unknown error" + logger.error(f"{provider_name} error: {error_msg}") + return _create_error_response( + "connection_failed", + f"Failed to connect to {provider_name}. Please check the URL and network connection" + ) + + +class AbstractModelProvider(ABC): + """Common interface that all model provider integrations must implement.""" + + @abstractmethod + async def get_models(self, provider_config: Dict) -> List[Dict]: + """Return a list of models provided by the concrete provider.""" + raise NotImplementedError diff --git a/backend/services/providers/modelengine_provider.py b/backend/services/providers/modelengine_provider.py new file mode 100644 index 000000000..276f84378 --- /dev/null +++ b/backend/services/providers/modelengine_provider.py @@ -0,0 +1,110 @@ +import logging +from typing import Dict, List + +import aiohttp + +from consts.const import DEFAULT_LLM_MAX_TOKENS +from services.providers.base import AbstractModelProvider, _classify_provider_error + +logger = logging.getLogger("model_provider") + +MODEL_ENGINE_NORTH_PREFIX = "open/router/v1" + + +def get_model_engine_raw_url(model_engine_url: str) -> str: + """ + Extract the raw base URL from a ModelEngine URL by stripping any API paths. + + Args: + model_engine_url: Full ModelEngine URL potentially containing API paths + + Returns: + Base URL without trailing paths + """ + if not model_engine_url: + return "" + # Remove any trailing /open/router/v1 or similar paths to get base host + raw_url = model_engine_url.split( + "/open/")[0] if "/open/" in model_engine_url else model_engine_url + # Remove trailing slash if present + return raw_url.rstrip('/') + + +class ModelEngineProvider(AbstractModelProvider): + """Concrete implementation for ModelEngine provider.""" + + async def get_models(self, provider_config: Dict) -> List[Dict]: + """ + Fetch models from ModelEngine API. + + Args: + provider_config: Configuration dict containing model_type, base_url, and api_key + + Returns: + List of models with canonical fields. Returns error dict if API call fails. + """ + try: + model_type: str = provider_config.get("model_type", "") + host = provider_config.get("base_url") + api_key = provider_config.get("api_key") + model_engine_url = get_model_engine_raw_url(host) + if not host or not api_key: + logger.warning("ModelEngine host or api key not configured") + return [] + + headers = {"Authorization": f"Bearer {api_key}"} + + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + connector=aiohttp.TCPConnector(ssl=False) + ) as session: + async with session.get( + f"{model_engine_url.rstrip('/')}/{MODEL_ENGINE_NORTH_PREFIX}/models", + headers=headers + ) as response: + # Use centralized error classification + if response.status >= 400: + error_text = await response.text() + return _classify_provider_error( + "ModelEngine", + status_code=response.status, + error_message=error_text + ) + + data = await response.json() + all_models = data.get("data", []) + logger.info( + f"ModelEngine API returned {len(all_models)} models") + + # Type mapping from ModelEngine to internal types + type_map = { + "embed": "embedding", + "chat": "llm", + "asr": "stt", + "tts": "tts", + "rerank": "rerank", + "multimodal": "vlm", + } + + filtered_models = [] + for model in all_models: + me_type = model.get("type", "") + internal_type = type_map.get(me_type) + + # If model_type filter is provided, only include matching models + if model_type and internal_type != model_type: + continue + + if internal_type: + filtered_models.append({ + "id": model.get("id", ""), + "model_type": internal_type, + "model_tag": me_type, + "max_tokens": DEFAULT_LLM_MAX_TOKENS if internal_type in ("llm", "vlm") else 0, + "base_url": host, + "api_key": api_key, + }) + + return filtered_models + except Exception as e: + return _classify_provider_error("ModelEngine", exception=e) diff --git a/backend/services/providers/silicon_provider.py b/backend/services/providers/silicon_provider.py new file mode 100644 index 000000000..29de51fce --- /dev/null +++ b/backend/services/providers/silicon_provider.py @@ -0,0 +1,58 @@ +import httpx +from typing import Dict, List + +from consts.const import DEFAULT_LLM_MAX_TOKENS +from consts.provider import SILICON_GET_URL +from services.providers.base import AbstractModelProvider, _classify_provider_error + + +class SiliconModelProvider(AbstractModelProvider): + """Concrete implementation for SiliconFlow provider.""" + + async def get_models(self, provider_config: Dict) -> List[Dict]: + """ + Fetch models from SiliconFlow API. + + Args: + provider_config: Configuration dict containing model_type and api_key + + Returns: + List of models with canonical fields. Returns error dict if API call fails. + """ + try: + model_type: str = provider_config["model_type"] + model_api_key: str = provider_config["api_key"] + + headers = {"Authorization": f"Bearer {model_api_key}"} + + # Choose endpoint by model type + if model_type in ("llm", "vlm"): + silicon_url = f"{SILICON_GET_URL}?sub_type=chat" + elif model_type in ("embedding", "multi_embedding"): + silicon_url = f"{SILICON_GET_URL}?sub_type=embedding" + else: + silicon_url = SILICON_GET_URL + + async with httpx.AsyncClient(verify=False) as client: + response = await client.get(silicon_url, headers=headers) + response.raise_for_status() + model_list: List[Dict] = response.json()["data"] + + # Annotate models with canonical fields expected downstream + if model_type in ("llm", "vlm"): + for item in model_list: + item["model_tag"] = "chat" + item["model_type"] = model_type + item["max_tokens"] = DEFAULT_LLM_MAX_TOKENS + elif model_type in ("embedding", "multi_embedding"): + for item in model_list: + item["model_tag"] = "embedding" + item["model_type"] = model_type + + # Return empty list to indicate successful API call but no models + if not model_list: + return [] + + return model_list + except (httpx.HTTPStatusError, httpx.ConnectTimeout, httpx.ConnectError, Exception) as e: + return _classify_provider_error("SiliconFlow", exception=e) diff --git a/backend/services/remote_mcp_service.py b/backend/services/remote_mcp_service.py index 543536e66..0c8e4576f 100644 --- a/backend/services/remote_mcp_service.py +++ b/backend/services/remote_mcp_service.py @@ -4,6 +4,7 @@ from fastmcp import Client +from consts.const import CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ from consts.exceptions import MCPConnectionError, MCPNameIllegal from database.remote_mcp_db import ( create_mcp_record, @@ -14,6 +15,7 @@ update_mcp_status_by_name_and_url, update_mcp_record_by_name_and_url, ) +from database.user_tenant_db import get_user_tenant_by_user_id from services.mcp_container_service import MCPContainerManager logger = logging.getLogger("remote_mcp_service") @@ -122,19 +124,83 @@ async def update_remote_mcp_server_list( ) -async def get_remote_mcp_server_list(tenant_id: str): +async def get_remote_mcp_server_list(tenant_id: str, user_id: str | None = None) -> list[dict]: mcp_records = get_mcp_records_by_tenant(tenant_id=tenant_id) mcp_records_list = [] + can_edit_all = False + if user_id: + user_tenant_record = get_user_tenant_by_user_id(user_id) or {} + user_role = str(user_tenant_record.get("user_role") or "").upper() + can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES for record in mcp_records: + created_by = record.get("created_by") or record.get("user_id") + if user_id is None: + permission = PERMISSION_READ + else: + permission = PERMISSION_EDIT if can_edit_all or str( + created_by) == str(user_id) else PERMISSION_READ + mcp_records_list.append({ "remote_mcp_server_name": record["mcp_name"], "remote_mcp_server": record["mcp_server"], - "status": record["status"] + "status": record["status"], + "permission": permission, }) return mcp_records_list +def attach_mcp_container_permissions( + *, + containers: list[dict], + tenant_id: str, + user_id: str | None = None, +) -> list[dict]: + """ + Attach permission (EDIT/READ) to each MCP container entry. + + Rules: + - If user's role is in CAN_EDIT_ALL_USER_ROLES => EDIT for all containers + - Otherwise => EDIT only if the container is associated with an MCP record created by this user + - If association cannot be determined => default to READ + """ + if not containers: + return [] + can_edit_all = False + if user_id: + user_tenant_record = get_user_tenant_by_user_id(user_id) or {} + user_role = str(user_tenant_record.get("user_role") or "").upper() + can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES + + created_by_by_container_id: dict[str, str] = {} + try: + for record in get_mcp_records_by_tenant(tenant_id=tenant_id) or []: + cid = record.get("container_id") + if not cid: + continue + created_by_by_container_id[str(cid)] = str( + record.get("created_by") or record.get("user_id") or "" + ) + except Exception as e: + logger.warning(f"Failed to load MCP records for permission mapping: {e}") + + enriched: list[dict] = [] + for container in containers: + container_id = str(container.get("container_id") or "") + created_by = created_by_by_container_id.get(container_id, "") + + if user_id is None: + permission = PERMISSION_READ + else: + permission = PERMISSION_EDIT if can_edit_all or ( + created_by and str(created_by) == str(user_id) + ) else PERMISSION_READ + + enriched.append({**container, "permission": permission}) + + return enriched + + async def check_mcp_health_and_update_db(mcp_url, service_name, tenant_id, user_id): # check the health of the MCP server try: diff --git a/backend/services/tenant_config_service.py b/backend/services/tenant_config_service.py deleted file mode 100644 index c0e4d4afb..000000000 --- a/backend/services/tenant_config_service.py +++ /dev/null @@ -1,94 +0,0 @@ -import logging -from typing import List, Optional - -from database.knowledge_db import get_knowledge_info_by_knowledge_ids, get_knowledge_ids_by_index_names -from database.tenant_config_db import get_tenant_config_info, insert_config, delete_config_by_tenant_config_id - -logger = logging.getLogger("tenant_config_service") - - -def get_selected_knowledge_list(tenant_id: str, user_id: str): - record_list = get_tenant_config_info( - tenant_id=tenant_id, user_id=user_id, select_key="selected_knowledge_id") - if len(record_list) == 0: - return [] - knowledge_id_list = [record["config_value"] for record in record_list] - knowledge_info = get_knowledge_info_by_knowledge_ids(knowledge_id_list) - return knowledge_info - - -def update_selected_knowledge(tenant_id: str, user_id: str, index_name_list: List[str], knowledge_sources: Optional[List[str]] = None): - # Validate that knowledge_sources length matches index_name_list if provided - if knowledge_sources and len(knowledge_sources) != len(index_name_list): - logger.error( - f"Knowledge sources length mismatch: sources={len(knowledge_sources)}, names={len(index_name_list)}") - return False - - logger.info( - f"Updating knowledge list for tenant {tenant_id}, user {user_id}: " - f"names={index_name_list}, sources={knowledge_sources}") - - knowledge_ids = get_knowledge_ids_by_index_names(index_name_list) - record_list = get_tenant_config_info( - tenant_id=tenant_id, user_id=user_id, select_key="selected_knowledge_id") - record_ids = [record["tenant_config_id"] for record in record_list] - - # if knowledge_ids is not in record_list, insert the record of knowledge_ids - for knowledge_id in knowledge_ids: - if knowledge_id not in record_ids: - result = insert_config({ - "user_id": user_id, - "tenant_id": tenant_id, - "config_key": "selected_knowledge_id", - "config_value": knowledge_id, - "value_type": "multi" - }) - if not result: - logger.error( - f"insert_config failed, tenant_id: {tenant_id}, user_id: {user_id}, knowledge_id: {knowledge_id}") - return False - - # if record_list is not in knowledge_ids, delete the record of record_list - for record in record_list: - if record["config_value"] not in knowledge_ids: - result = delete_config_by_tenant_config_id( - record["tenant_config_id"]) - if not result: - logger.error( - f"delete_config_by_tenant_config_id failed, tenant_id: {tenant_id}, user_id: {user_id}, knowledge_id: {record['config_value']}") - return False - - return True - - -def delete_selected_knowledge_by_index_name(tenant_id: str, user_id: str, index_name: str): - knowledge_ids = get_knowledge_ids_by_index_names([index_name]) - record_list = get_tenant_config_info( - tenant_id=tenant_id, user_id=user_id, select_key="selected_knowledge_id") - - for record in record_list: - if record["config_value"] == str(knowledge_ids[0]): - result = delete_config_by_tenant_config_id( - record["tenant_config_id"]) - if not result: - logger.error( - f"delete_config_by_tenant_config_id failed, tenant_id: {tenant_id}, user_id: {user_id}, knowledge_id: {record['config_value']}") - return False - - return True - - -def build_knowledge_name_mapping(tenant_id: str, user_id: str): - """ - Build mapping from user-facing knowledge_name to internal index_name for the selected knowledge bases. - Falls back to using index_name as key when knowledge_name is missing for backward compatibility. - """ - knowledge_info_list = get_selected_knowledge_list( - tenant_id=tenant_id, user_id=user_id) - mapping = {} - for info in knowledge_info_list: - key = info.get("knowledge_name") or info.get("index_name") - value = info.get("index_name") - if key and value: - mapping[key] = value - return mapping diff --git a/backend/services/tenant_service.py b/backend/services/tenant_service.py index b58ffc8b2..0da209b76 100644 --- a/backend/services/tenant_service.py +++ b/backend/services/tenant_service.py @@ -23,19 +23,25 @@ def get_tenant_info(tenant_id: str) -> Dict[str, Any]: """ Get tenant information by tenant ID + If TENANT_NAME config is missing, automatically create one with default name. + Args: tenant_id (str): Tenant ID Returns: Dict[str, Any]: Tenant information - - Raises: - NotFoundException: When tenant not found """ + if not tenant_id: + return {} + # Get tenant name name_config = get_single_config_info(tenant_id, TENANT_NAME) if not name_config: - logging.warning(f"The name of tenant {tenant_id} not found.") + logger.warning(f"The name of tenant {tenant_id} not found, creating default config.") + # Auto-create TENANT_NAME config with default name + _ensure_tenant_name_config(tenant_id) + # Re-fetch after creation + name_config = get_single_config_info(tenant_id, TENANT_NAME) group_config = get_single_config_info(tenant_id, DEFAULT_GROUP_ID) @@ -48,6 +54,64 @@ def get_tenant_info(tenant_id: str) -> Dict[str, Any]: return tenant_info +def _ensure_tenant_name_config(tenant_id: str) -> bool: + """ + Ensure TENANT_NAME config exists for the tenant. + Creates a default name config if it doesn't exist. + + Args: + tenant_id: Tenant ID + + Returns: + bool: True if config exists or was created successfully, False otherwise + """ + # Check if already exists (double-check in case of race condition) + existing = get_single_config_info(tenant_id, TENANT_NAME) + if existing: + return True + + # Create default TENANT_NAME config + tenant_name_data = { + "tenant_id": tenant_id, + "config_key": TENANT_NAME, + "config_value": "Unnamed Tenant", + "created_by": "system_auto_create", + "updated_by": "system_auto_create" + } + success = insert_config(tenant_name_data) + if success: + logger.info(f"Auto-created TENANT_NAME config for tenant {tenant_id}") + else: + logger.error(f"Failed to auto-create TENANT_NAME config for tenant {tenant_id}") + return success + + +def check_tenant_name_exists(tenant_name: str, exclude_tenant_id: Optional[str] = None) -> bool: + """ + Check if a tenant with the given name already exists + + Args: + tenant_name (str): Tenant name to check + exclude_tenant_id (Optional[str]): Tenant ID to exclude from check (for rename operations) + + Returns: + bool: True if tenant name already exists, False otherwise + """ + all_tenant_ids = get_all_tenant_ids() + + for tid in all_tenant_ids: + # Skip if this is the tenant being updated + if exclude_tenant_id and tid == exclude_tenant_id: + continue + + # Check if this tenant has the given name + name_config = get_single_config_info(tid, TENANT_NAME) + if name_config and name_config.get("config_value") == tenant_name: + return True + + return False + + def get_all_tenants() -> List[Dict[str, Any]]: """ Get all tenants @@ -87,24 +151,19 @@ def create_tenant(tenant_name: str, created_by: Optional[str] = None) -> Dict[st Dict[str, Any]: Created tenant information Raises: - ValidationError: When tenant creation fails + ValidationError: When tenant creation fails or tenant name already exists """ # Generate a random UUID for tenant_id tenant_id = str(uuid.uuid4()) - # Check if tenant already exists (extremely unlikely with UUID, but good practice) - try: - existing_tenant = get_tenant_info(tenant_id) - if existing_tenant: - raise ValidationError(f"Tenant {tenant_id} already exists") - except NotFoundException: - # Tenant doesn't exist, which is what we want - pass - # Validate tenant name if not tenant_name or not tenant_name.strip(): raise ValidationError("Tenant name cannot be empty") + # Check if tenant name already exists + if check_tenant_name_exists(tenant_name.strip()): + raise ValidationError(f"Tenant with name '{tenant_name.strip()}' already exists") + try: # Create default group first default_group_id = _create_default_group_for_tenant(tenant_id, created_by) @@ -163,6 +222,8 @@ def update_tenant_info(tenant_id: str, tenant_name: str, updated_by: Optional[st """ Update tenant information + If TENANT_NAME config doesn't exist, creates it with the provided name. + Args: tenant_id (str): Tenant ID tenant_name (str): New tenant name @@ -172,25 +233,39 @@ def update_tenant_info(tenant_id: str, tenant_name: str, updated_by: Optional[st Dict[str, Any]: Updated tenant information Raises: - NotFoundException: When tenant not found - ValidationError: When tenant name is invalid + ValidationError: When tenant name is invalid or update fails """ - # Check if tenant exists and get current name config - name_config = get_single_config_info(tenant_id, TENANT_NAME) - if not name_config: - raise NotFoundException(f"Tenant {tenant_id} not found") - # Validate tenant name if not tenant_name or not tenant_name.strip(): raise ValidationError("Tenant name cannot be empty") - # Update tenant name - success = update_config_by_tenant_config_id( - name_config["tenant_config_id"], - tenant_name.strip() - ) - if not success: - raise ValidationError("Failed to update tenant name") + # Check if tenant name already exists (exclude current tenant) + if check_tenant_name_exists(tenant_name.strip(), exclude_tenant_id=tenant_id): + raise ValidationError(f"Tenant with name '{tenant_name.strip()}' already exists") + + # Check if tenant name config exists + name_config = get_single_config_info(tenant_id, TENANT_NAME) + if not name_config: + # Tenant config doesn't exist, create it with the provided name + logger.info(f"TENANT_NAME config not found for {tenant_id}, creating new config.") + tenant_name_data = { + "tenant_id": tenant_id, + "config_key": TENANT_NAME, + "config_value": tenant_name.strip(), + "created_by": updated_by, + "updated_by": updated_by + } + success = insert_config(tenant_name_data) + if not success: + raise ValidationError("Failed to create tenant name configuration") + else: + # Update existing config + success = update_config_by_tenant_config_id( + name_config["tenant_config_id"], + tenant_name.strip() + ) + if not success: + raise ValidationError("Failed to update tenant name") # Return updated tenant information updated_tenant = get_tenant_info(tenant_id) diff --git a/backend/services/tool_configuration_service.py b/backend/services/tool_configuration_service.py index 9ee122757..588d2467e 100644 --- a/backend/services/tool_configuration_service.py +++ b/backend/services/tool_configuration_service.py @@ -1,4 +1,3 @@ -import asyncio import importlib import inspect import json @@ -21,11 +20,10 @@ query_tool_instances_by_id, update_tool_table_from_scan_tool_list, search_last_tool_instance_by_tool_id, + check_tool_list_initialized, ) from services.file_management_service import get_llm_model from services.vectordatabase_service import get_embedding_model, get_vector_db_core -from services.tenant_config_service import get_selected_knowledge_list, build_knowledge_name_mapping -from database.knowledge_db import get_index_name_by_knowledge_name from database.client import minio_client from services.image_service import get_vlm_model @@ -258,6 +256,8 @@ def update_tool_info_impl(tool_info: ToolInstanceInfoRequest, tenant_id: str, us Args: tool_info: ToolInstanceInfoRequest containing tool configuration data + tenant_id: Tenant ID + user_id: User ID Returns: Dictionary containing the updated tool instance @@ -265,8 +265,10 @@ def update_tool_info_impl(tool_info: ToolInstanceInfoRequest, tenant_id: str, us Raises: ValueError: If database update fails """ + # Use version_no from request if provided, otherwise default to 0 + version_no = getattr(tool_info, 'version_no', 0) tool_instance = create_or_update_tool_by_tool_info( - tool_info, tenant_id, user_id) + tool_info, tenant_id, user_id, version_no=version_no) return { "tool_instance": tool_instance } @@ -316,6 +318,28 @@ async def get_tool_from_remote_mcp_server(mcp_server_name: str, remote_mcp_serve f"failed to get tool from remote MCP server, detail: {e}") +async def init_tool_list_for_tenant(tenant_id: str, user_id: str): + """ + Initialize tool list for a new tenant. + This function scans and populates available tools from local, MCP, and LangChain sources. + + Args: + tenant_id: Tenant ID for MCP tools (required for MCP tools) + user_id: User ID for tracking who initiated the scan + + Returns: + Dictionary containing initialization result with tool count + """ + # Check if tools have already been initialized for this tenant + if check_tool_list_initialized(tenant_id): + logger.info(f"Tool list already initialized for tenant {tenant_id}, skipping") + return {"status": "already_initialized", "message": "Tool list already exists"} + + logger.info(f"Initializing tool list for new tenant: {tenant_id}") + await update_tool_list(tenant_id=tenant_id, user_id=user_id) + return {"status": "success", "message": "Tool list initialized successfully"} + + async def update_tool_list(tenant_id: str, user_id: str): """ Scan and gather all available tools from both local and MCP sources @@ -540,57 +564,14 @@ def _validate_local_tool( instantiation_params[param_name] = param.default if tool_name == "knowledge_base_search": - if not tenant_id or not user_id: - raise ToolExecutionException( - f"Tenant ID and User ID are required for {tool_name} validation") - knowledge_info_list = get_selected_knowledge_list( - tenant_id=tenant_id, user_id=user_id) - index_names = [knowledge_info.get("index_name") for knowledge_info in knowledge_info_list if knowledge_info.get( - 'knowledge_sources') == 'elasticsearch'] - name_resolver = build_knowledge_name_mapping( - tenant_id=tenant_id, user_id=user_id) - - # Fallback: if user provided index_names in inputs, try to resolve them even when no selection stored - if (not index_names) and inputs and inputs.get("index_names"): - raw_names = inputs.get("index_names") - if isinstance(raw_names, str): - raw_names = [raw_names] - resolved_indices = [] - for raw in raw_names: - try: - resolved = get_index_name_by_knowledge_name( - raw, tenant_id=tenant_id) - name_resolver[raw] = resolved - resolved_indices.append(resolved) - except Exception: - # If not found as knowledge_name, assume it's already an index_name - resolved_indices.append(raw) - index_names = resolved_indices - embedding_model = get_embedding_model(tenant_id=tenant_id) vdb_core = get_vector_db_core() params = { **instantiation_params, - 'index_names': index_names, - 'name_resolver': name_resolver, 'vdb_core': vdb_core, 'embedding_model': embedding_model, } tool_instance = tool_class(**params) - elif tool_name == "datamate_search": - if not tenant_id or not user_id: - raise ToolExecutionException( - f"Tenant ID and User ID are required for {tool_name} validation") - knowledge_info_list = get_selected_knowledge_list( - tenant_id=tenant_id, user_id=user_id) - index_names = [knowledge_info.get("index_name") for knowledge_info in knowledge_info_list if - knowledge_info.get('knowledge_sources') == 'datamate'] - - params = { - **instantiation_params, - 'index_names': index_names, - } - tool_instance = tool_class(**params) elif tool_name == "analyze_image": if not tenant_id or not user_id: raise ToolExecutionException( diff --git a/backend/services/user_management_service.py b/backend/services/user_management_service.py index 8ba133fad..bb27ca13d 100644 --- a/backend/services/user_management_service.py +++ b/backend/services/user_management_service.py @@ -8,7 +8,6 @@ from utils.auth_utils import ( get_supabase_client, - get_supabase_admin_client, calculate_expires_at, get_jwt_expiry_seconds, ) @@ -16,16 +15,14 @@ from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException, UnauthorizedError from database.model_management_db import create_model_record -from database.user_tenant_db import insert_user_tenant, soft_delete_user_tenant_by_user_id, get_user_tenant_by_user_id -from database.memory_config_db import soft_delete_all_configs_by_user_id -from database.conversation_db import soft_delete_all_conversations_by_user +from database.user_tenant_db import insert_user_tenant, get_user_tenant_by_user_id from database.group_db import query_group_ids_by_user from database.client import as_dict, get_db_session from database.db_models import RolePermission -from utils.memory_utils import build_memory_config -from nexent.memory.memory_service import clear_memory from services.invitation_service import use_invitation_code, check_invitation_available, get_invitation_by_code from services.group_service import add_user_to_groups +from services.tool_configuration_service import init_tool_list_for_tenant + logging.getLogger("user_management_service").setLevel(logging.DEBUG) @@ -157,6 +154,9 @@ async def signup_user(email: EmailStr, if is_admin: await generate_tts_stt_4_admin(tenant_id, user_id) + # Initialize tool list for the new tenant (only once per tenant) + await init_tool_list_for_tenant(tenant_id, user_id) + return await parse_supabase_response(is_admin, response, user_role) else: logging.error( @@ -271,6 +271,9 @@ async def signup_user_with_invitation(email: EmailStr, if user_role == "ADMIN": await generate_tts_stt_4_admin(tenant_id, user_id) + # Initialize tool list for the new tenant (only once per tenant) + await init_tool_list_for_tenant(tenant_id, user_id) + return await parse_supabase_response(False, response, user_role) else: logging.error( @@ -428,78 +431,6 @@ async def get_session_by_authorization(authorization): raise UnauthorizedError("Session is invalid or expired") -async def revoke_regular_user(user_id: str, tenant_id: str) -> None: - """Revoke a regular user's account and purge related data. - - Steps: - 1) Soft-delete user-tenant relation rows and memory user configs, and all conversations for the user in PostgreSQL. - 2) Clear user-level memories in memory store (levels: "user" and "user_agent"). - 3) Permanently delete the user from Supabase using service role key (admin API). - """ - try: - logging.debug(f"Start deleting user {user_id} related data...") - # 1) PostgreSQL soft-deletes - try: - soft_delete_user_tenant_by_user_id(user_id, actor=user_id) - logging.debug("\tTenant relationship deleted.") - except Exception as e: - logging.error( - f"Failed soft-deleting user-tenant for user {user_id}: {e}") - - try: - soft_delete_all_configs_by_user_id(user_id, actor=user_id) - logging.debug("\tMemory user configs deleted.") - except Exception as e: - logging.error( - f"Failed soft-deleting memory user configs for user {user_id}: {e}") - - try: - deleted_convs = soft_delete_all_conversations_by_user(user_id) - logging.debug(f"\t{deleted_convs} conversations deleted") - except Exception as e: - logging.error( - f"Failed soft-deleting conversations for user {user_id}: {e}") - - # 2) Clear memory records - try: - memory_config = build_memory_config(tenant_id) - # Clear user-level memory - await clear_memory( - memory_level="user", - memory_config=memory_config, - tenant_id=tenant_id, - user_id=user_id, - ) - # Also clear user_agent-level memory for all agents (API clears by user + any agent) - await clear_memory( - memory_level="user_agent", - memory_config=memory_config, - tenant_id=tenant_id, - user_id=user_id, - ) - logging.debug( - "\tMemories under current embedding configuration deleted") - except Exception as e: - logging.error(f"Failed clearing memory for user {user_id}: {e}") - - # 3) Delete Supabase user using admin API - try: - admin_client = get_supabase_admin_client() - if admin_client and hasattr(admin_client.auth, "admin"): - admin_client.auth.admin.delete_user(user_id) - else: - raise RuntimeError("Supabase admin client not available") - logging.debug("\tUser account deleted.") - except Exception as e: - logging.error(f"Failed deleting supabase user {user_id}: {e}") - # prior steps already purged local data - logging.info(f"Account {user_id} has been successfully deleted") - except Exception as e: - logging.error( - f"Unexpected error in revoke_regular_user for {user_id}: {e}") - # swallow to keep idempotent behavior - - async def get_user_info(user_id: str) -> Optional[Dict[str, Any]]: """ Get user information including user ID, group IDs, tenant ID, user role, permissions, and accessible routes. @@ -521,6 +452,7 @@ async def get_user_info(user_id: str) -> Optional[Dict[str, Any]]: tenant_id = user_tenant["tenant_id"] user_role = user_tenant["user_role"] + user_email = user_tenant["user_email"] # Get group IDs group_ids = query_group_ids_by_user(user_id) @@ -534,10 +466,6 @@ async def get_user_info(user_id: str) -> Optional[Dict[str, Any]]: permissions_data = format_role_permissions(permissions) - # Get user email from Supabase (placeholder for now) - # TODO: Implement user email retrieval from Supabase user object - user_email = "user@example.com" # Placeholder - return { "user": { "user_id": user_id, diff --git a/backend/services/user_service.py b/backend/services/user_service.py index 7b42e0d16..74e99c69c 100644 --- a/backend/services/user_service.py +++ b/backend/services/user_service.py @@ -10,8 +10,11 @@ soft_delete_user_tenant_by_user_id ) from database.group_db import remove_user_from_all_groups -from services.tenant_service import get_tenant_info +from database.memory_config_db import soft_delete_all_configs_by_user_id +from database.conversation_db import soft_delete_all_conversations_by_user from utils.auth_utils import get_supabase_admin_client +from utils.memory_utils import build_memory_config +from nexent.memory.memory_service import clear_memory logger = logging.getLogger(__name__) @@ -105,37 +108,80 @@ async def update_user(user_id: str, update_data: Dict[str, Any], updated_by: str raise -async def delete_user(user_id: str, deleted_by: str) -> bool: +async def delete_user_and_cleanup(user_id: str, tenant_id: str) -> None: """ - Soft delete user and remove from all groups + Permanently delete user account and all related data. + + This performs complete cleanup: + 1) Soft-delete user-tenant relation and remove from all groups + 2) Soft-delete memory user configs and all conversations + 3) Clear user-level memories in memory store + 4) Permanently delete user from Supabase Args: user_id (str): User ID to delete - deleted_by (str): ID of the user performing the deletion - - Returns: - bool: True if deletion successful - - Raises: - ValueError: When user not found + tenant_id (str): Tenant ID for memory operations """ try: - # Soft delete user-tenant relationship - tenant_deleted = soft_delete_user_tenant_by_user_id(user_id, deleted_by) + logger.debug(f"Start permanently deleting user {user_id} and all related data...") + + # 1) Core user deletion (soft-delete user-tenant and groups) + try: + tenant_deleted = soft_delete_user_tenant_by_user_id(user_id, user_id) + if not tenant_deleted: + raise ValueError(f"User {user_id} not found in any tenant") + + remove_user_from_all_groups(user_id, user_id) + logger.debug("\tUser tenant relationship and groups deleted.") + except Exception as e: + logger.error(f"Failed core deletion for user {user_id}: {e}") + + # 2) Soft-delete memory configs + try: + soft_delete_all_configs_by_user_id(user_id, actor=user_id) + logger.debug("\tMemory user configs deleted.") + except Exception as e: + logger.error(f"Failed deleting configs for user {user_id}: {e}") - if not tenant_deleted: - raise ValueError(f"User {user_id} not found in any tenant") + # 3) Soft-delete conversations + try: + deleted_convs = soft_delete_all_conversations_by_user(user_id) + logger.debug(f"\t{deleted_convs} conversations deleted.") + except Exception as e: + logger.error(f"Failed deleting conversations for user {user_id}: {e}") - # Remove user from all groups + # 4) Clear memory records + try: + memory_config = build_memory_config(tenant_id) + await clear_memory( + memory_level="user", + memory_config=memory_config, + tenant_id=tenant_id, + user_id=user_id, + ) + await clear_memory( + memory_level="user_agent", + memory_config=memory_config, + tenant_id=tenant_id, + user_id=user_id, + ) + logger.debug("\tUser memories cleared.") + except Exception as e: + logger.error(f"Failed clearing memory for user {user_id}: {e}") + + # 5) Delete from Supabase try: - remove_user_from_all_groups(user_id, deleted_by) - except Exception as group_exc: - # Log the error but don't fail the entire deletion - logger.warning(f"Failed to remove user {user_id} from groups: {str(group_exc)}") + admin_client = get_supabase_admin_client() + if admin_client and hasattr(admin_client.auth, "admin"): + admin_client.auth.admin.delete_user(user_id) + logger.debug("\tSupabase user deleted.") + else: + raise RuntimeError("Supabase admin client not available") + except Exception as e: + logger.error(f"Failed deleting Supabase user {user_id}: {e}") - logger.info(f"Soft deleted user {user_id} by user {deleted_by}") - return True + logger.info(f"Permanently deleted user {user_id} and all related data.") except Exception as exc: - logger.error(f"Failed to delete user {user_id}: {str(exc)}") + logger.error(f"Unexpected error in delete_user_and_cleanup for {user_id}: {str(exc)}") raise diff --git a/backend/services/vectordatabase_service.py b/backend/services/vectordatabase_service.py index c2c61408e..e4f51e15c 100644 --- a/backend/services/vectordatabase_service.py +++ b/backend/services/vectordatabase_service.py @@ -25,7 +25,7 @@ from nexent.vector_database.elasticsearch_core import ElasticSearchCore from nexent.vector_database.datamate_core import DataMateCore -from consts.const import DATAMATE_URL, ES_API_KEY, ES_HOST, LANGUAGE, VectorDatabaseType, IS_SPEED_MODE +from consts.const import DATAMATE_URL, ES_API_KEY, ES_HOST, LANGUAGE, VectorDatabaseType, IS_SPEED_MODE, PERMISSION_EDIT, PERMISSION_READ from consts.model import ChunkCreateRequest, ChunkUpdateRequest from database.attachment_db import delete_file from database.knowledge_db import ( @@ -39,6 +39,7 @@ from utils.str_utils import convert_list_to_string from database.user_tenant_db import get_user_tenant_by_user_id from database.group_db import query_group_ids_by_user +from database.model_management_db import get_model_records from services.redis_service import get_redis_service from services.group_service import get_tenant_default_group_id from utils.config_utils import tenant_config_manager, get_model_name_from_config @@ -170,8 +171,46 @@ def check_knowledge_base_exist_impl(knowledge_name: str, vdb_core: VectorDatabas return {"status": "available"} -def get_embedding_model(tenant_id: str): - # Get the tenant config +def get_embedding_model(tenant_id: str, model_name: Optional[str] = None): + """ + Get the embedding model for the tenant, optionally using a specific model name. + + Args: + tenant_id: Tenant ID + model_name: Optional specific model name to use (format: "model_repo/model_name" or just "model_name") + If provided, will try to find the model in the tenant's model list. + + Returns: + Embedding model instance or None + """ + # If model_name is provided, try to find it in the tenant's models + if model_name: + try: + models = get_model_records({"model_type": "embedding"}, tenant_id) + for model in models: + model_display_name = model.get("model_repo") + "/" + model["model_name"] if model.get("model_repo") else model["model_name"] + if model_display_name == model_name: + # Found the model, create embedding instance + model_config = { + "model_repo": model.get("model_repo", ""), + "model_name": model["model_name"], + "api_key": model.get("api_key", ""), + "base_url": model.get("base_url", ""), + "model_type": "embedding", + "max_tokens": model.get("max_tokens", 1024), + "ssl_verify": model.get("ssl_verify", True), + } + return OpenAICompatibleEmbedding( + api_key=model_config.get("api_key", ""), + base_url=model_config.get("base_url", ""), + model_name=get_model_name_from_config(model_config) or "", + embedding_dim=model_config.get("max_tokens", 1024), + ssl_verify=model_config.get("ssl_verify", True), + ) + except Exception as e: + logger.warning(f"Failed to get embedding model by name {model_name}: {e}") + + # Fall back to default embedding model (current behavior) model_config = tenant_config_manager.get_model_config( key="EMBEDDING_ID", tenant_id=tenant_id) @@ -350,6 +389,8 @@ def create_knowledge_base( vdb_core: VectorDatabaseCore, user_id: Optional[str], tenant_id: Optional[str], + ingroup_permission: Optional[str] = None, + group_ids: Optional[List[int]] = None, ): """ Create a new knowledge base with a user-facing name and an internal Elasticsearch index name. @@ -373,6 +414,13 @@ def create_knowledge_base( "tenant_id": tenant_id, "embedding_model_name": embedding_model.model if embedding_model else None, } + + # Add group permission and group IDs if provided + if ingroup_permission is not None: + knowledge_data["ingroup_permission"] = ingroup_permission + if group_ids is not None: + knowledge_data["group_ids"] = group_ids + record_info = create_knowledge_record(knowledge_data) index_name = record_info["index_name"] @@ -574,14 +622,14 @@ def list_indices( if effective_user_role in ["SU", "ADMIN", "SPEED"]: # SU, ADMIN and SPEED roles can see all knowledgebases - permission = "EDIT" + permission = PERMISSION_EDIT elif effective_user_role in ["USER", "DEV"]: # USER/DEV need group-based permission checking kb_group_ids_str = record.get("group_ids") kb_group_ids = convert_string_to_list(kb_group_ids_str or "") kb_created_by = record.get("created_by") kb_ingroup_permission = record.get( - "ingroup_permission") or "READ_ONLY" + "ingroup_permission") or PERMISSION_READ # Check if user belongs to any of the knowledgebase groups # Compatibility logic for legacy data: @@ -602,17 +650,17 @@ def list_indices( if has_group_intersection: # Determine permission level - permission = "READ_ONLY" # Default + permission = PERMISSION_READ # Default # User is creator: creator permission if kb_created_by == user_id: permission = "CREATOR" # Group permission allows editing - elif kb_ingroup_permission == "EDIT": - permission = "EDIT" + elif kb_ingroup_permission == PERMISSION_EDIT: + permission = PERMISSION_EDIT # Group permission is read-only: already set - elif kb_ingroup_permission == "READ_ONLY": - permission = "READ_ONLY" + elif kb_ingroup_permission == PERMISSION_READ: + permission = PERMISSION_READ # Group permission is private: not visible elif kb_ingroup_permission == "PRIVATE": permission = None @@ -1426,11 +1474,43 @@ def create_chunk( chunk_request: ChunkCreateRequest, vdb_core: VectorDatabaseCore = Depends(get_vector_db_core), user_id: Optional[str] = None, + tenant_id: Optional[str] = None, ): """ Create a manual chunk entry in the specified index. + Automatically generates and stores embedding for semantic search. """ try: + # Get knowledge base's embedding model name + embedding_model_name = None + if tenant_id: + try: + knowledge_record = get_knowledge_record({ + "index_name": index_name, + "tenant_id": tenant_id + }) + embedding_model_name = knowledge_record.get("embedding_model_name") if knowledge_record else None + except Exception as e: + logger.warning(f"Failed to get embedding model name for index {index_name}: {e}") + + # Generate embedding if we have content and can get embedding model + embedding_vector = None + if chunk_request.content: + try: + embedding_model = get_embedding_model(tenant_id, embedding_model_name) if tenant_id else None + if embedding_model: + embeddings = embedding_model.get_embeddings(chunk_request.content) + if embeddings and len(embeddings) > 0: + embedding_vector = embeddings[0] + logger.debug(f"Generated embedding for chunk in index {index_name}") + else: + logger.warning(f"Failed to generate embedding for chunk in index {index_name}") + else: + logger.warning(f"No embedding model available for index {index_name}") + except Exception as e: + logger.warning(f"Failed to generate embedding for chunk: {e}") + + # Build chunk payload chunk_payload = ElasticSearchService._build_chunk_payload( base_fields={ "id": chunk_request.chunk_id or ElasticSearchService._generate_chunk_id(), @@ -1443,6 +1523,13 @@ def create_chunk( metadata=chunk_request.metadata, ensure_create_time=True, ) + + # Add embedding if generated + if embedding_vector: + chunk_payload["embedding"] = embedding_vector + if embedding_model_name: + chunk_payload["embedding_model_name"] = embedding_model_name + result = vdb_core.create_chunk(index_name, chunk_payload) return { "status": "success", diff --git a/backend/utils/prompt_template_utils.py b/backend/utils/prompt_template_utils.py index dfbb2c89c..b3fb9cc6e 100644 --- a/backend/utils/prompt_template_utils.py +++ b/backend/utils/prompt_template_utils.py @@ -20,7 +20,6 @@ def get_prompt_template(template_type: str, language: str = LANGUAGE["ZH"], **kw - 'knowledge_summary': Knowledge summary template - 'analyze_file': File analysis template - 'generate_title': Title generation template - - 'file_processing_messages': File processing messages template - 'document_summary': Document summary template (Map stage) - 'cluster_summary_reduce': Cluster summary reduce template (Reduce stage) - 'cluster_summary_agent': Cluster summary agent template (legacy) @@ -36,13 +35,13 @@ def get_prompt_template(template_type: str, language: str = LANGUAGE["ZH"], **kw # Define template path mapping template_paths = { 'prompt_generate': { - LANGUAGE["ZH"]: 'backend/prompts/utils/prompt_generate.yaml', + LANGUAGE["ZH"]: 'backend/prompts/utils/prompt_generate_zh.yaml', LANGUAGE["EN"]: 'backend/prompts/utils/prompt_generate_en.yaml' }, 'agent': { LANGUAGE["ZH"]: { - 'manager': 'backend/prompts/manager_system_prompt_template.yaml', - 'managed': 'backend/prompts/managed_system_prompt_template.yaml' + 'manager': 'backend/prompts/manager_system_prompt_template_zh.yaml', + 'managed': 'backend/prompts/managed_system_prompt_template_zh.yaml' }, LANGUAGE["EN"]: { 'manager': 'backend/prompts/manager_system_prompt_template_en.yaml', @@ -50,32 +49,28 @@ def get_prompt_template(template_type: str, language: str = LANGUAGE["ZH"], **kw } }, 'knowledge_summary': { - LANGUAGE["ZH"]: 'backend/prompts/knowledge_summary_agent.yaml', + LANGUAGE["ZH"]: 'backend/prompts/knowledge_summary_agent_zh.yaml', LANGUAGE["EN"]: 'backend/prompts/knowledge_summary_agent_en.yaml' }, 'analyze_file': { - LANGUAGE["ZH"]: 'backend/prompts/analyze_file.yaml', + LANGUAGE["ZH"]: 'backend/prompts/analyze_file_zh.yaml', LANGUAGE["EN"]: 'backend/prompts/analyze_file_en.yaml' }, 'generate_title': { - LANGUAGE["ZH"]: 'backend/prompts/utils/generate_title.yaml', + LANGUAGE["ZH"]: 'backend/prompts/utils/generate_title_zh.yaml', LANGUAGE["EN"]: 'backend/prompts/utils/generate_title_en.yaml' }, - 'file_processing_messages': { - LANGUAGE["ZH"]: 'backend/prompts/utils/file_processing_messages.yaml', - LANGUAGE["EN"]: 'backend/prompts/utils/file_processing_messages_en.yaml' - }, 'document_summary': { LANGUAGE["ZH"]: 'backend/prompts/document_summary_agent_zh.yaml', - LANGUAGE["EN"]: 'backend/prompts/document_summary_agent.yaml' + LANGUAGE["EN"]: 'backend/prompts/document_summary_agent_en.yaml' }, 'cluster_summary_reduce': { LANGUAGE["ZH"]: 'backend/prompts/cluster_summary_reduce_zh.yaml', - LANGUAGE["EN"]: 'backend/prompts/cluster_summary_reduce.yaml' + LANGUAGE["EN"]: 'backend/prompts/cluster_summary_reduce_en.yaml' }, 'cluster_summary_agent': { - LANGUAGE["ZH"]: 'backend/prompts/cluster_summary_agent.yaml', - LANGUAGE["EN"]: 'backend/prompts/cluster_summary_agent.yaml' + LANGUAGE["ZH"]: 'backend/prompts/cluster_summary_agent_zh.yaml', + LANGUAGE["EN"]: 'backend/prompts/cluster_summary_agent_en.yaml' } } @@ -168,19 +163,6 @@ def get_generate_title_prompt_template(language: str = 'zh') -> Dict[str, Any]: return get_prompt_template('generate_title', language) -def get_file_processing_messages_template(language: str = 'zh') -> Dict[str, Any]: - """ - Get file processing messages template - - Args: - language: Language code ('zh' or 'en') - - Returns: - dict: Loaded file processing messages configuration - """ - return get_prompt_template('file_processing_messages', language) - - def get_document_summary_prompt_template(language: str = LANGUAGE["ZH"]) -> Dict[str, Any]: """ Get document summary prompt template (Map stage) diff --git a/docker/create-su.sh b/docker/create-su.sh new file mode 100644 index 000000000..8d290a726 --- /dev/null +++ b/docker/create-su.sh @@ -0,0 +1,157 @@ +#!/bin/bash + +# Script to create super admin user and insert into user_tenant_t table +# This script should be called from deploy.sh with necessary environment variables + +# Note: We don't use set -e here because we want to handle errors gracefully +# and return appropriate exit codes from functions + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source environment variables if .env file exists +if [ -f "$SCRIPT_DIR/.env" ]; then + set -a + source "$SCRIPT_DIR/.env" + set +a +fi + +generate_random_password() { + # Generate a URL/JSON safe random password (alphanumeric only) + local pwd="" + if command -v openssl >/dev/null 2>&1; then + pwd=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 20) + else + pwd=$(tr -dc 'A-Za-z0-9' /dev/null 2>&1; then + echo " ✅ PostgreSQL is now ready!" + return 0 + fi + echo "⏳ Waiting for PostgreSQL to become ready... (attempt $((retries + 1))/$max_retries)" + sleep 10 + retries=$((retries + 1)) + done + + if [ $retries -eq $max_retries ]; then + echo " ⚠️ Warning: PostgreSQL did not become ready within expected time" + echo " You may need to check the container logs and try again" + return 1 + fi +} + +create_default_super_admin_user() { + local email="suadmin@nexent.com" + local password + password="$(generate_random_password)" + + echo "🔧 Creating super admin user..." + RESPONSE=$(docker exec nexent-config bash -c "curl -s -X POST http://kong:8000/auth/v1/signup -H \"apikey: ${SUPABASE_KEY}\" -H \"Authorization: Bearer ${SUPABASE_KEY}\" -H \"Content-Type: application/json\" -d '{\"email\":\"${email}\",\"password\":\"${password}\",\"email_confirm\":true}'" 2>/dev/null) + + if [ -z "$RESPONSE" ]; then + echo " ❌ No response received from Supabase." + return 1 + elif echo "$RESPONSE" | grep -q '"access_token"' && echo "$RESPONSE" | grep -q '"user"'; then + echo " ✅ Default super admin user has been successfully created." + echo "" + echo " Please save the following credentials carefully, which would ONLY be shown once." + echo " 📧 Email: ${email}" + echo " 🔏 Password: ${password}" + + # Extract user.id from RESPONSE JSON + local user_id + # Try using Python to parse JSON (most reliable) + user_id=$(echo "$RESPONSE" | docker exec -i nexent-config python3 -c "import sys, json; data = json.load(sys.stdin); print(data.get('user', {}).get('id', ''))" 2>/dev/null) + + # Fallback to jq if Python fails + if [ -z "$user_id" ] && command -v jq >/dev/null 2>&1; then + user_id=$(echo "$RESPONSE" | jq -r '.user.id // empty' 2>/dev/null) + fi + + # Final fallback: use grep and sed + if [ -z "$user_id" ]; then + user_id=$(echo "$RESPONSE" | grep -o '"user"[^}]*"id":"[^"]*"' | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' 2>/dev/null) + fi + + if [ -z "$user_id" ]; then + echo " ⚠️ Warning: Could not extract user.id from response. Skipping database insertion." + else + # Wait for PostgreSQL to be ready + echo " ⏳ Waiting for PostgreSQL to be ready..." + if ! wait_for_postgresql_ready; then + echo " ⚠️ Warning: PostgreSQL is not ready. Skipping database insertion." + return 0 + fi + + # Insert user_tenant_t record + echo " 🔧 Inserting super admin user into user_tenant_t table..." + local sql="INSERT INTO nexent.user_tenant_t (user_id, tenant_id, user_role, user_email, created_by, updated_by) VALUES ('${user_id}', '', 'SU', '${email}', 'system', 'system') ON CONFLICT (user_id, tenant_id) DO NOTHING;" + + if docker exec -i nexent-postgresql psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "$sql" >/dev/null 2>&1; then + echo " ✅ Super admin user inserted into user_tenant_t table successfully." + else + echo " ⚠️ Warning: Failed to insert super admin user into user_tenant_t table." + fi + fi + elif echo "$RESPONSE" | grep -q '"error_code":"user_already_exists"' || echo "$RESPONSE" | grep -q '"code":422'; then + echo " 🚧 Default super admin user already exists. Skipping creation." + echo " 📧 Email: ${email}" + + # Even if user already exists, try to ensure the user_tenant_t record exists + # Get user_id from Supabase auth.users table + echo " 🔧 Retrieving user_id from Supabase database..." + local user_id + if [ "$DEPLOYMENT_VERSION" = "full" ] && docker ps | grep -q "supabase-db-mini"; then + # Query Supabase auth.users table to get user_id by email + user_id=$(docker exec supabase-db-mini psql -U postgres -d "$SUPABASE_POSTGRES_DB" -t -c "SELECT id FROM auth.users WHERE email = '${email}' LIMIT 1;" 2>/dev/null | tr -d '[:space:]') + fi + + if [ -z "$user_id" ]; then + echo " ⚠️ Warning: Could not retrieve user_id. Skipping database insertion." + echo " 💡 Note: If user_tenant_t record is missing, you may need to insert it manually." + return 0 + fi + + # Wait for PostgreSQL to be ready + echo " ⏳ Waiting for PostgreSQL to be ready..." + if ! wait_for_postgresql_ready; then + echo " ⚠️ Warning: PostgreSQL is not ready. Skipping database insertion." + return 0 + fi + + # Insert user_tenant_t record + echo " 🔧 Inserting super admin user into user_tenant_t table..." + local sql="INSERT INTO nexent.user_tenant_t (user_id, tenant_id, user_role, user_email, created_by, updated_by) VALUES ('${user_id}', '', 'SU', '${email}', 'system', 'system') ON CONFLICT (user_id, tenant_id) DO NOTHING;" + + if docker exec -i nexent-postgresql psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "$sql" >/dev/null 2>&1; then + echo " ✅ Super admin user inserted into user_tenant_t table successfully." + else + echo " ⚠️ Warning: Failed to insert super admin user into user_tenant_t table." + fi + else + echo " ❌ Response from Supabase does not contain 'access_token' or 'user'." + return 1 + fi + + echo "" + echo "--------------------------------" + echo "" +} + +# Main execution +if create_default_super_admin_user; then + exit 0 +else + exit 1 +fi diff --git a/docker/deploy.sh b/docker/deploy.sh index 2545bf2dc..83d3f7947 100755 --- a/docker/deploy.sh +++ b/docker/deploy.sh @@ -475,18 +475,18 @@ select_deployment_mode() { MODE_CHOICE_SAVED="$mode_choice" case $mode_choice in - 2) + 2|"infrastructure") export DEPLOYMENT_MODE="infrastructure" export COMPOSE_FILE_SUFFIX=".yml" echo "✅ Selected infrastructure mode 🏗️" ;; - 3) + 3|"production") export DEPLOYMENT_MODE="production" export COMPOSE_FILE_SUFFIX=".prod.yml" disable_dashboard echo "✅ Selected production mode 🚀" ;; - *) + 1|"development"|*) export DEPLOYMENT_MODE="development" export COMPOSE_FILE_SUFFIX=".yml" echo "✅ Selected development mode 🛠️" @@ -604,6 +604,11 @@ prepare_directory_and_data() { chmod -R 775 $ROOT_DIR/volumes echo " 📁 Directory $ROOT_DIR/volumes has been created and permissions set to 775." + # Copy sync_user_supabase2pg.py to ROOT_DIR for container access + cp -rn scripts $ROOT_DIR + chmod 644 "$ROOT_DIR/scripts/sync_user_supabase2pg.py" + echo " 📁 update scripts copied to $ROOT_DIR" + # Create nexent user workspace directory NEXENT_USER_DIR="$HOME/nexent" create_dir_with_permission "$NEXENT_USER_DIR" 775 @@ -686,11 +691,11 @@ select_deployment_version() { version_choice=$(sanitize_input "$version_choice") VERSION_CHOICE_SAVED="${version_choice}" case $version_choice in - 2) + 2|"full") export DEPLOYMENT_VERSION="full" echo "✅ Selected complete version 🎯" ;; - *) + 1|"speed"|*) export DEPLOYMENT_VERSION="speed" echo "✅ Selected speed version ⚡️" ;; @@ -755,6 +760,7 @@ wait_for_elasticsearch_healthy() { fi } + select_terminal_tool() { # Function to ask if user wants to create Terminal tool container echo "🔧 Terminal Tool Container Setup:" @@ -859,29 +865,46 @@ select_terminal_tool() { echo "" } -create_default_admin_user() { - echo "🔧 Creating admin user..." - RESPONSE=$(docker exec nexent-config bash -c "curl -X POST http://kong:8000/auth/v1/signup -H \"apikey: ${SUPABASE_KEY}\" -H \"Authorization: Bearer ${SUPABASE_KEY}\" -H \"Content-Type: application/json\" -d '{\"email\":\"nexent@example.com\",\"password\":\"nexent@4321\",\"email_confirm\":true,\"data\":{\"role\":\"admin\"}}'" 2>/dev/null) +generate_random_password() { + # Generate a URL/JSON safe random password (alphanumeric only) + local pwd="" + if command -v openssl >/dev/null 2>&1; then + pwd=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 20) + else + pwd=$(tr -dc 'A-Za-z0-9' =1 = published snapshot'; +COMMENT ON COLUMN nexent.ag_tenant_agent_t.current_version_no IS 'Current published version number. NULL means no version published yet'; -- Create index for is_new queries CREATE INDEX IF NOT EXISTS idx_ag_tenant_agent_t_is_new @@ -375,18 +380,20 @@ WHERE delete_flag = 'N'; -- Create the ag_tool_instance_t table in the nexent schema CREATE TABLE IF NOT EXISTS nexent.ag_tool_instance_t ( - tool_instance_id SERIAL PRIMARY KEY NOT NULL, + tool_instance_id INTEGER NOT NULL, tool_id INTEGER, agent_id INTEGER, params JSON, user_id VARCHAR(100), tenant_id VARCHAR(100), enabled BOOLEAN DEFAULT FALSE, + version_no INTEGER DEFAULT 0 NOT NULL, create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_by VARCHAR(100), updated_by VARCHAR(100), - delete_flag VARCHAR(1) DEFAULT 'N' + delete_flag VARCHAR(1) DEFAULT 'N', + PRIMARY KEY (tool_instance_id, version_no) ); -- Add comment to the table @@ -400,6 +407,7 @@ COMMENT ON COLUMN nexent.ag_tool_instance_t.params IS 'Parameter configuration'; COMMENT ON COLUMN nexent.ag_tool_instance_t.user_id IS 'User ID'; COMMENT ON COLUMN nexent.ag_tool_instance_t.tenant_id IS 'Tenant ID'; COMMENT ON COLUMN nexent.ag_tool_instance_t.enabled IS 'Enable flag'; +COMMENT ON COLUMN nexent.ag_tool_instance_t.version_no IS 'Version number. 0 = draft/editing state, >=1 = published snapshot'; COMMENT ON COLUMN nexent.ag_tool_instance_t.create_time IS 'Creation time'; COMMENT ON COLUMN nexent.ag_tool_instance_t.update_time IS 'Update time'; @@ -554,15 +562,17 @@ COMMENT ON COLUMN nexent.user_tenant_t.delete_flag IS 'Delete flag, Y/N'; -- Create the ag_agent_relation_t table in the nexent schema CREATE TABLE IF NOT EXISTS nexent.ag_agent_relation_t ( - relation_id SERIAL PRIMARY KEY NOT NULL, + relation_id INTEGER NOT NULL, selected_agent_id INTEGER, parent_agent_id INTEGER, tenant_id VARCHAR(100), + version_no INTEGER DEFAULT 0 NOT NULL, create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_by VARCHAR(100), updated_by VARCHAR(100), - delete_flag VARCHAR(1) DEFAULT 'N' + delete_flag VARCHAR(1) DEFAULT 'N', + PRIMARY KEY (relation_id, version_no) ); -- Create a function to update the update_time column @@ -588,6 +598,7 @@ COMMENT ON COLUMN nexent.ag_agent_relation_t.relation_id IS 'Relationship ID, pr COMMENT ON COLUMN nexent.ag_agent_relation_t.selected_agent_id IS 'Selected agent ID'; COMMENT ON COLUMN nexent.ag_agent_relation_t.parent_agent_id IS 'Parent agent ID'; COMMENT ON COLUMN nexent.ag_agent_relation_t.tenant_id IS 'Tenant ID'; +COMMENT ON COLUMN nexent.ag_agent_relation_t.version_no IS 'Version number. 0 = draft/editing state, >=1 = published snapshot'; COMMENT ON COLUMN nexent.ag_agent_relation_t.create_time IS 'Creation time, audit field'; COMMENT ON COLUMN nexent.ag_agent_relation_t.update_time IS 'Update time, audit field'; COMMENT ON COLUMN nexent.ag_agent_relation_t.created_by IS 'Creator ID, audit field'; @@ -782,14 +793,7 @@ COMMENT ON COLUMN nexent.tenant_group_user_t.created_by IS 'Created by'; COMMENT ON COLUMN nexent.tenant_group_user_t.updated_by IS 'Updated by'; COMMENT ON COLUMN nexent.tenant_group_user_t.delete_flag IS 'Delete flag, Y/N'; --- 5. Add fields to user_tenant_t table -ALTER TABLE nexent.user_tenant_t -ADD COLUMN IF NOT EXISTS user_role VARCHAR(30); - --- Add comments for new fields in user_tenant_t table -COMMENT ON COLUMN nexent.user_tenant_t.user_role IS 'User role: SU, ADMIN, DEV, USER'; - --- 6. Create role_permission_t table for role permissions +-- 5. Create role_permission_t table for role permissions CREATE TABLE IF NOT EXISTS nexent.role_permission_t ( role_permission_id SERIAL PRIMARY KEY, user_role VARCHAR(30) NOT NULL, @@ -806,225 +810,238 @@ COMMENT ON COLUMN nexent.role_permission_t.permission_category IS 'Permission ca COMMENT ON COLUMN nexent.role_permission_t.permission_type IS 'Permission type'; COMMENT ON COLUMN nexent.role_permission_t.permission_subtype IS 'Permission subtype'; --- Add primary key constraint for role_permission_t table -ALTER TABLE nexent.role_permission_t ADD CONSTRAINT role_permission_t_pkey PRIMARY KEY (role_permission_id); - +-- 6. Insert role permission data after clearing old data +DELETE FROM nexent.role_permission_t; --- Insert role permission data with conflict handling INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype) VALUES (1, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), -(2, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), -(3, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), -(4, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-tools'), -(5, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/monitoring'), -(6, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), -(7, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), -(8, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), -(9, 'SU', 'RESOURCE', 'AGENT', 'READ'), -(10, 'SU', 'RESOURCE', 'AGENT', 'DELETE'), -(11, 'SU', 'RESOURCE', 'KB', 'READ'), -(12, 'SU', 'RESOURCE', 'KB', 'DELETE'), -(13, 'SU', 'RESOURCE', 'KB.GROUPS', 'READ'), -(14, 'SU', 'RESOURCE', 'KB.GROUPS', 'UPDATE'), -(15, 'SU', 'RESOURCE', 'KB.GROUPS', 'DELETE'), -(16, 'SU', 'RESOURCE', 'USER.ROLE', 'READ'), -(17, 'SU', 'RESOURCE', 'USER.ROLE', 'UPDATE'), -(18, 'SU', 'RESOURCE', 'USER.ROLE', 'DELETE'), -(19, 'SU', 'RESOURCE', 'MCP', 'READ'), -(20, 'SU', 'RESOURCE', 'MCP', 'DELETE'), -(21, 'SU', 'RESOURCE', 'MEM.SETTING', 'READ'), -(22, 'SU', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), -(23, 'SU', 'RESOURCE', 'MEM.AGENT', 'READ'), -(24, 'SU', 'RESOURCE', 'MEM.AGENT', 'DELETE'), -(25, 'SU', 'RESOURCE', 'MEM.PRIVATE', 'READ'), -(26, 'SU', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), -(27, 'SU', 'RESOURCE', 'MODEL', 'CREATE'), -(28, 'SU', 'RESOURCE', 'MODEL', 'READ'), -(29, 'SU', 'RESOURCE', 'MODEL', 'UPDATE'), -(30, 'SU', 'RESOURCE', 'MODEL', 'DELETE'), -(31, 'SU', 'RESOURCE', 'TENANT', 'CREATE'), -(32, 'SU', 'RESOURCE', 'TENANT', 'READ'), -(33, 'SU', 'RESOURCE', 'TENANT', 'UPDATE'), -(34, 'SU', 'RESOURCE', 'TENANT', 'DELETE'), -(35, 'SU', 'RESOURCE', 'TENANT.INFO', 'READ'), -(36, 'SU', 'RESOURCE', 'TENANT.INFO', 'UPDATE'), -(37, 'SU', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), -(38, 'SU', 'RESOURCE', 'TENANT.INVITE', 'READ'), -(39, 'SU', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), -(40, 'SU', 'RESOURCE', 'TENANT.INVITE', 'DELETE'), -(41, 'SU', 'RESOURCE', 'GROUP', 'CREATE'), -(42, 'SU', 'RESOURCE', 'GROUP', 'READ'), -(43, 'SU', 'RESOURCE', 'GROUP', 'UPDATE'), -(44, 'SU', 'RESOURCE', 'GROUP', 'DELETE'), -(45, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), -(46, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), -(47, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/setup'), -(48, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), -(49, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), -(50, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), -(51, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), -(52, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-tools'), -(53, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/monitoring'), -(54, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), -(55, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), -(56, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), -(57, 'ADMIN', 'RESOURCE', 'AGENT', 'CREATE'), -(58, 'ADMIN', 'RESOURCE', 'AGENT', 'READ'), -(59, 'ADMIN', 'RESOURCE', 'AGENT', 'UPDATE'), -(60, 'ADMIN', 'RESOURCE', 'AGENT', 'DELETE'), -(61, 'ADMIN', 'RESOURCE', 'KB', 'CREATE'), -(62, 'ADMIN', 'RESOURCE', 'KB', 'READ'), -(63, 'ADMIN', 'RESOURCE', 'KB', 'UPDATE'), -(64, 'ADMIN', 'RESOURCE', 'KB', 'DELETE'), -(65, 'ADMIN', 'RESOURCE', 'KB.GROUPS', 'READ'), -(66, 'ADMIN', 'RESOURCE', 'KB.GROUPS', 'UPDATE'), -(67, 'ADMIN', 'RESOURCE', 'KB.GROUPS', 'DELETE'), -(68, 'ADMIN', 'RESOURCE', 'USER.ROLE', 'READ'), -(69, 'ADMIN', 'RESOURCE', 'MCP', 'CREATE'), -(70, 'ADMIN', 'RESOURCE', 'MCP', 'READ'), -(71, 'ADMIN', 'RESOURCE', 'MCP', 'UPDATE'), -(72, 'ADMIN', 'RESOURCE', 'MCP', 'DELETE'), -(73, 'ADMIN', 'RESOURCE', 'MEM.SETTING', 'READ'), -(74, 'ADMIN', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), -(75, 'ADMIN', 'RESOURCE', 'MEM.AGENT', 'CREATE'), -(76, 'ADMIN', 'RESOURCE', 'MEM.AGENT', 'READ'), -(77, 'ADMIN', 'RESOURCE', 'MEM.AGENT', 'DELETE'), -(78, 'ADMIN', 'RESOURCE', 'MEM.PRIVATE', 'CREATE'), -(79, 'ADMIN', 'RESOURCE', 'MEM.PRIVATE', 'READ'), -(80, 'ADMIN', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), -(81, 'ADMIN', 'RESOURCE', 'MODEL', 'CREATE'), -(82, 'ADMIN', 'RESOURCE', 'MODEL', 'READ'), -(83, 'ADMIN', 'RESOURCE', 'MODEL', 'UPDATE'), -(84, 'ADMIN', 'RESOURCE', 'MODEL', 'DELETE'), -(85, 'ADMIN', 'RESOURCE', 'TENANT.INFO', 'READ'), -(86, 'ADMIN', 'RESOURCE', 'TENANT.INFO', 'UPDATE'), -(87, 'ADMIN', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), -(88, 'ADMIN', 'RESOURCE', 'TENANT.INVITE', 'READ'), -(89, 'ADMIN', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), -(90, 'ADMIN', 'RESOURCE', 'TENANT.INVITE', 'DELETE'), -(91, 'ADMIN', 'RESOURCE', 'GROUP', 'CREATE'), -(92, 'ADMIN', 'RESOURCE', 'GROUP', 'READ'), -(93, 'ADMIN', 'RESOURCE', 'GROUP', 'UPDATE'), -(94, 'ADMIN', 'RESOURCE', 'GROUP', 'DELETE'), -(95, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), -(96, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), -(97, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/setup'), -(98, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), -(99, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), -(100, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), -(101, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), -(102, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-tools'), -(103, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/monitoring'), -(104, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), -(105, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), -(106, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), -(107, 'DEV', 'RESOURCE', 'AGENT', 'CREATE'), -(108, 'DEV', 'RESOURCE', 'AGENT', 'READ'), -(109, 'DEV', 'RESOURCE', 'AGENT', 'UPDATE'), -(110, 'DEV', 'RESOURCE', 'AGENT', 'DELETE'), -(111, 'DEV', 'RESOURCE', 'KB', 'CREATE'), -(112, 'DEV', 'RESOURCE', 'KB', 'READ'), -(113, 'DEV', 'RESOURCE', 'KB', 'UPDATE'), -(114, 'DEV', 'RESOURCE', 'KB', 'DELETE'), -(115, 'DEV', 'RESOURCE', 'KB.GROUPS', 'READ'), -(116, 'DEV', 'RESOURCE', 'KB.GROUPS', 'UPDATE'), -(117, 'DEV', 'RESOURCE', 'KB.GROUPS', 'DELETE'), -(118, 'DEV', 'RESOURCE', 'USER.ROLE', 'READ'), -(119, 'DEV', 'RESOURCE', 'MCP', 'CREATE'), -(120, 'DEV', 'RESOURCE', 'MCP', 'READ'), -(121, 'DEV', 'RESOURCE', 'MCP', 'UPDATE'), -(122, 'DEV', 'RESOURCE', 'MCP', 'DELETE'), -(123, 'DEV', 'RESOURCE', 'MEM.SETTING', 'READ'), -(124, 'DEV', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), -(125, 'DEV', 'RESOURCE', 'MEM.AGENT', 'READ'), -(126, 'DEV', 'RESOURCE', 'MEM.PRIVATE', 'CREATE'), -(127, 'DEV', 'RESOURCE', 'MEM.PRIVATE', 'READ'), -(128, 'DEV', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), -(129, 'DEV', 'RESOURCE', 'MODEL', 'READ'), -(130, 'DEV', 'RESOURCE', 'TENANT.INFO', 'READ'), -(131, 'DEV', 'RESOURCE', 'GROUP', 'READ'), -(132, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), -(133, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), -(134, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), -(135, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), -(136, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), -(137, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), -(138, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), -(139, 'USER', 'RESOURCE', 'AGENT', 'READ'), -(140, 'USER', 'RESOURCE', 'KB', 'CREATE'), -(141, 'USER', 'RESOURCE', 'KB', 'READ'), -(142, 'USER', 'RESOURCE', 'KB', 'UPDATE'), -(143, 'USER', 'RESOURCE', 'KB', 'DELETE'), -(144, 'USER', 'RESOURCE', 'KB.GROUPS', 'READ'), -(145, 'USER', 'RESOURCE', 'KB.GROUPS', 'UPDATE'), -(146, 'USER', 'RESOURCE', 'KB.GROUPS', 'DELETE'), -(147, 'USER', 'RESOURCE', 'USER.ROLE', 'READ'), -(148, 'USER', 'RESOURCE', 'MCP', 'CREATE'), -(149, 'USER', 'RESOURCE', 'MCP', 'READ'), -(150, 'USER', 'RESOURCE', 'MCP', 'UPDATE'), -(151, 'USER', 'RESOURCE', 'MCP', 'DELETE'), -(152, 'USER', 'RESOURCE', 'MEM.SETTING', 'READ'), -(153, 'USER', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), -(154, 'USER', 'RESOURCE', 'MEM.AGENT', 'READ'), -(155, 'USER', 'RESOURCE', 'MEM.PRIVATE', 'CREATE'), -(156, 'USER', 'RESOURCE', 'MEM.PRIVATE', 'READ'), -(157, 'USER', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), -(158, 'USER', 'RESOURCE', 'MODEL', 'READ'), -(159, 'USER', 'RESOURCE', 'TENANT.INFO', 'READ'), -(160, 'USER', 'RESOURCE', 'GROUP', 'READ'), -(161, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), -(162, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), -(163, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/setup'), -(164, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), -(165, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), -(166, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), -(167, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), -(168, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-tools'), -(169, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/monitoring'), -(170, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), -(171, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), -(172, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), -(173, 'SPEED', 'RESOURCE', 'AGENT', 'CREATE'), -(174, 'SPEED', 'RESOURCE', 'AGENT', 'READ'), -(175, 'SPEED', 'RESOURCE', 'AGENT', 'UPDATE'), -(176, 'SPEED', 'RESOURCE', 'AGENT', 'DELETE'), -(177, 'SPEED', 'RESOURCE', 'KB', 'CREATE'), -(178, 'SPEED', 'RESOURCE', 'KB', 'READ'), -(179, 'SPEED', 'RESOURCE', 'KB', 'UPDATE'), -(180, 'SPEED', 'RESOURCE', 'KB', 'DELETE'), -(181, 'SPEED', 'RESOURCE', 'KB.GROUPS', 'READ'), -(182, 'SPEED', 'RESOURCE', 'KB.GROUPS', 'UPDATE'), -(183, 'SPEED', 'RESOURCE', 'KB.GROUPS', 'DELETE'), -(184, 'SPEED', 'RESOURCE', 'USER.ROLE', 'READ'), -(185, 'SPEED', 'RESOURCE', 'MCP', 'CREATE'), -(186, 'SPEED', 'RESOURCE', 'MCP', 'READ'), -(187, 'SPEED', 'RESOURCE', 'MCP', 'UPDATE'), -(188, 'SPEED', 'RESOURCE', 'MCP', 'DELETE'), -(189, 'SPEED', 'RESOURCE', 'MEM.SETTING', 'READ'), -(190, 'SPEED', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), -(191, 'SPEED', 'RESOURCE', 'MEM.AGENT', 'CREATE'), -(192, 'SPEED', 'RESOURCE', 'MEM.AGENT', 'READ'), -(193, 'SPEED', 'RESOURCE', 'MEM.AGENT', 'DELETE'), -(194, 'SPEED', 'RESOURCE', 'MEM.PRIVATE', 'CREATE'), -(195, 'SPEED', 'RESOURCE', 'MEM.PRIVATE', 'READ'), -(196, 'SPEED', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), -(197, 'SPEED', 'RESOURCE', 'MODEL', 'CREATE'), -(198, 'SPEED', 'RESOURCE', 'MODEL', 'READ'), -(199, 'SPEED', 'RESOURCE', 'MODEL', 'UPDATE'), -(200, 'SPEED', 'RESOURCE', 'MODEL', 'DELETE'), -(201, 'SPEED', 'RESOURCE', 'TENANT.INFO', 'READ'), -(202, 'SPEED', 'RESOURCE', 'TENANT.INFO', 'UPDATE'), -(203, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), -(204, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'READ'), -(205, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), -(206, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'DELETE'), -(207, 'SPEED', 'RESOURCE', 'GROUP', 'CREATE'), -(208, 'SPEED', 'RESOURCE', 'GROUP', 'READ'), -(209, 'SPEED', 'RESOURCE', 'GROUP', 'UPDATE'), -(210, 'SPEED', 'RESOURCE', 'GROUP', 'DELETE') -ON CONFLICT (role_permission_id) DO NOTHING; +(2, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/monitoring'), +(3, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/tenant-resources'), +(4, 'SU', 'RESOURCE', 'AGENT', 'READ'), +(5, 'SU', 'RESOURCE', 'AGENT', 'DELETE'), +(6, 'SU', 'RESOURCE', 'KB', 'READ'), +(7, 'SU', 'RESOURCE', 'KB', 'DELETE'), +(8, 'SU', 'RESOURCE', 'KB.GROUPS', 'READ'), +(9, 'SU', 'RESOURCE', 'KB.GROUPS', 'UPDATE'), +(10, 'SU', 'RESOURCE', 'KB.GROUPS', 'DELETE'), +(11, 'SU', 'RESOURCE', 'USER.ROLE', 'READ'), +(12, 'SU', 'RESOURCE', 'USER.ROLE', 'UPDATE'), +(13, 'SU', 'RESOURCE', 'USER.ROLE', 'DELETE'), +(14, 'SU', 'RESOURCE', 'MCP', 'READ'), +(15, 'SU', 'RESOURCE', 'MCP', 'DELETE'), +(16, 'SU', 'RESOURCE', 'MEM.SETTING', 'READ'), +(17, 'SU', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), +(18, 'SU', 'RESOURCE', 'MEM.AGENT', 'READ'), +(19, 'SU', 'RESOURCE', 'MEM.AGENT', 'DELETE'), +(20, 'SU', 'RESOURCE', 'MEM.PRIVATE', 'READ'), +(21, 'SU', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), +(22, 'SU', 'RESOURCE', 'MODEL', 'CREATE'), +(23, 'SU', 'RESOURCE', 'MODEL', 'READ'), +(24, 'SU', 'RESOURCE', 'MODEL', 'UPDATE'), +(25, 'SU', 'RESOURCE', 'MODEL', 'DELETE'), +(26, 'SU', 'RESOURCE', 'TENANT', 'CREATE'), +(27, 'SU', 'RESOURCE', 'TENANT', 'READ'), +(28, 'SU', 'RESOURCE', 'TENANT', 'UPDATE'), +(29, 'SU', 'RESOURCE', 'TENANT', 'DELETE'), +(30, 'SU', 'RESOURCE', 'TENANT.LIST', 'READ'), +(31, 'SU', 'RESOURCE', 'TENANT.INFO', 'READ'), +(32, 'SU', 'RESOURCE', 'TENANT.INFO', 'UPDATE'), +(33, 'SU', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), +(34, 'SU', 'RESOURCE', 'TENANT.INVITE', 'READ'), +(35, 'SU', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), +(36, 'SU', 'RESOURCE', 'TENANT.INVITE', 'DELETE'), +(37, 'SU', 'RESOURCE', 'GROUP', 'CREATE'), +(38, 'SU', 'RESOURCE', 'GROUP', 'READ'), +(39, 'SU', 'RESOURCE', 'GROUP', 'UPDATE'), +(40, 'SU', 'RESOURCE', 'GROUP', 'DELETE'), +(41, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), +(42, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), +(43, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/setup'), +(44, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), +(45, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), +(46, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), +(47, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), +(48, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-tools'), +(49, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/monitoring'), +(50, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), +(51, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), +(52, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), +(53, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/tenant-resources'), +(54, 'ADMIN', 'RESOURCE', 'AGENT', 'CREATE'), +(55, 'ADMIN', 'RESOURCE', 'AGENT', 'READ'), +(56, 'ADMIN', 'RESOURCE', 'AGENT', 'UPDATE'), +(57, 'ADMIN', 'RESOURCE', 'AGENT', 'DELETE'), +(58, 'ADMIN', 'RESOURCE', 'KB', 'CREATE'), +(59, 'ADMIN', 'RESOURCE', 'KB', 'READ'), +(60, 'ADMIN', 'RESOURCE', 'KB', 'UPDATE'), +(61, 'ADMIN', 'RESOURCE', 'KB', 'DELETE'), +(62, 'ADMIN', 'RESOURCE', 'KB.GROUPS', 'READ'), +(63, 'ADMIN', 'RESOURCE', 'KB.GROUPS', 'UPDATE'), +(64, 'ADMIN', 'RESOURCE', 'KB.GROUPS', 'DELETE'), +(65, 'ADMIN', 'RESOURCE', 'USER.ROLE', 'READ'), +(66, 'ADMIN', 'RESOURCE', 'MCP', 'CREATE'), +(67, 'ADMIN', 'RESOURCE', 'MCP', 'READ'), +(68, 'ADMIN', 'RESOURCE', 'MCP', 'UPDATE'), +(69, 'ADMIN', 'RESOURCE', 'MCP', 'DELETE'), +(70, 'ADMIN', 'RESOURCE', 'MEM.SETTING', 'READ'), +(71, 'ADMIN', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), +(72, 'ADMIN', 'RESOURCE', 'MEM.AGENT', 'CREATE'), +(73, 'ADMIN', 'RESOURCE', 'MEM.AGENT', 'READ'), +(74, 'ADMIN', 'RESOURCE', 'MEM.AGENT', 'DELETE'), +(75, 'ADMIN', 'RESOURCE', 'MEM.PRIVATE', 'CREATE'), +(76, 'ADMIN', 'RESOURCE', 'MEM.PRIVATE', 'READ'), +(77, 'ADMIN', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), +(78, 'ADMIN', 'RESOURCE', 'MODEL', 'CREATE'), +(79, 'ADMIN', 'RESOURCE', 'MODEL', 'READ'), +(80, 'ADMIN', 'RESOURCE', 'MODEL', 'UPDATE'), +(81, 'ADMIN', 'RESOURCE', 'MODEL', 'DELETE'), +(82, 'ADMIN', 'RESOURCE', 'TENANT.INFO', 'READ'), +(83, 'ADMIN', 'RESOURCE', 'TENANT.INFO', 'UPDATE'), +(84, 'ADMIN', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), +(85, 'ADMIN', 'RESOURCE', 'TENANT.INVITE', 'READ'), +(86, 'ADMIN', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), +(87, 'ADMIN', 'RESOURCE', 'TENANT.INVITE', 'DELETE'), +(88, 'ADMIN', 'RESOURCE', 'GROUP', 'CREATE'), +(89, 'ADMIN', 'RESOURCE', 'GROUP', 'READ'), +(90, 'ADMIN', 'RESOURCE', 'GROUP', 'UPDATE'), +(91, 'ADMIN', 'RESOURCE', 'GROUP', 'DELETE'), +(92, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), +(93, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), +(94, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/setup'), +(95, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), +(96, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), +(97, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), +(98, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), +(99, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-tools'), +(100, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/monitoring'), +(101, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), +(102, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), +(103, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), +(104, 'DEV', 'RESOURCE', 'AGENT', 'CREATE'), +(105, 'DEV', 'RESOURCE', 'AGENT', 'READ'), +(106, 'DEV', 'RESOURCE', 'AGENT', 'UPDATE'), +(107, 'DEV', 'RESOURCE', 'AGENT', 'DELETE'), +(108, 'DEV', 'RESOURCE', 'KB', 'CREATE'), +(109, 'DEV', 'RESOURCE', 'KB', 'READ'), +(110, 'DEV', 'RESOURCE', 'KB', 'UPDATE'), +(111, 'DEV', 'RESOURCE', 'KB', 'DELETE'), +(112, 'DEV', 'RESOURCE', 'KB.GROUPS', 'READ'), +(113, 'DEV', 'RESOURCE', 'KB.GROUPS', 'UPDATE'), +(114, 'DEV', 'RESOURCE', 'KB.GROUPS', 'DELETE'), +(115, 'DEV', 'RESOURCE', 'USER.ROLE', 'READ'), +(116, 'DEV', 'RESOURCE', 'MCP', 'CREATE'), +(117, 'DEV', 'RESOURCE', 'MCP', 'READ'), +(118, 'DEV', 'RESOURCE', 'MCP', 'UPDATE'), +(119, 'DEV', 'RESOURCE', 'MCP', 'DELETE'), +(120, 'DEV', 'RESOURCE', 'MEM.SETTING', 'READ'), +(121, 'DEV', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), +(122, 'DEV', 'RESOURCE', 'MEM.AGENT', 'READ'), +(123, 'DEV', 'RESOURCE', 'MEM.PRIVATE', 'CREATE'), +(124, 'DEV', 'RESOURCE', 'MEM.PRIVATE', 'READ'), +(125, 'DEV', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), +(126, 'DEV', 'RESOURCE', 'MODEL', 'READ'), +(127, 'DEV', 'RESOURCE', 'TENANT.INFO', 'READ'), +(128, 'DEV', 'RESOURCE', 'GROUP', 'READ'), +(129, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), +(130, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), +(131, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), +(132, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), +(133, 'USER', 'RESOURCE', 'AGENT', 'READ'), +(134, 'USER', 'RESOURCE', 'USER.ROLE', 'READ'), +(135, 'USER', 'RESOURCE', 'MEM.SETTING', 'READ'), +(136, 'USER', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), +(137, 'USER', 'RESOURCE', 'MEM.AGENT', 'READ'), +(138, 'USER', 'RESOURCE', 'MEM.PRIVATE', 'CREATE'), +(139, 'USER', 'RESOURCE', 'MEM.PRIVATE', 'READ'), +(140, 'USER', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), +(141, 'USER', 'RESOURCE', 'TENANT.INFO', 'READ'), +(142, 'USER', 'RESOURCE', 'GROUP', 'READ'), +(143, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), +(144, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), +(145, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/setup'), +(146, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), +(147, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), +(148, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), +(149, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), +(150, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-tools'), +(151, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/monitoring'), +(152, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), +(153, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), +(154, 'SPEED', 'RESOURCE', 'AGENT', 'CREATE'), +(155, 'SPEED', 'RESOURCE', 'AGENT', 'READ'), +(156, 'SPEED', 'RESOURCE', 'AGENT', 'UPDATE'), +(157, 'SPEED', 'RESOURCE', 'AGENT', 'DELETE'), +(158, 'SPEED', 'RESOURCE', 'KB', 'CREATE'), +(159, 'SPEED', 'RESOURCE', 'KB', 'READ'), +(160, 'SPEED', 'RESOURCE', 'KB', 'UPDATE'), +(161, 'SPEED', 'RESOURCE', 'KB', 'DELETE'), +(166, 'SPEED', 'RESOURCE', 'MCP', 'CREATE'), +(167, 'SPEED', 'RESOURCE', 'MCP', 'READ'), +(168, 'SPEED', 'RESOURCE', 'MCP', 'UPDATE'), +(169, 'SPEED', 'RESOURCE', 'MCP', 'DELETE'), +(170, 'SPEED', 'RESOURCE', 'MEM.SETTING', 'READ'), +(171, 'SPEED', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), +(172, 'SPEED', 'RESOURCE', 'MEM.AGENT', 'CREATE'), +(173, 'SPEED', 'RESOURCE', 'MEM.AGENT', 'READ'), +(174, 'SPEED', 'RESOURCE', 'MEM.AGENT', 'DELETE'), +(175, 'SPEED', 'RESOURCE', 'MEM.PRIVATE', 'CREATE'), +(176, 'SPEED', 'RESOURCE', 'MEM.PRIVATE', 'READ'), +(177, 'SPEED', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), +(178, 'SPEED', 'RESOURCE', 'MODEL', 'CREATE'), +(179, 'SPEED', 'RESOURCE', 'MODEL', 'READ'), +(180, 'SPEED', 'RESOURCE', 'MODEL', 'UPDATE'), +(181, 'SPEED', 'RESOURCE', 'MODEL', 'DELETE'), +(182, 'SPEED', 'RESOURCE', 'TENANT.INFO', 'READ'), +(183, 'SPEED', 'RESOURCE', 'TENANT.INFO', 'UPDATE'), +(184, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), +(185, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'READ'), +(186, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), +(187, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'DELETE'), -- Insert SPEED role user into user_tenant_t table if not exists INSERT INTO nexent.user_tenant_t (user_id, tenant_id, user_role, user_email, created_by, updated_by) -VALUES ('user_id', 'tenant_id', 'SPEED', NULL, 'system', 'system') +VALUES ('user_id', 'tenant_id', 'SPEED', '', 'system', 'system') ON CONFLICT (user_id, tenant_id) DO NOTHING; + +-- Create the ag_tenant_agent_version_t table for agent version management +CREATE TABLE IF NOT EXISTS nexent.ag_tenant_agent_version_t ( + id BIGSERIAL PRIMARY KEY, + tenant_id VARCHAR(100) NOT NULL, + agent_id INTEGER NOT NULL, + version_no INTEGER NOT NULL, + version_name VARCHAR(100), + release_note TEXT, + source_version_no INTEGER NULL, + source_type VARCHAR(30) NULL, + status VARCHAR(30) DEFAULT 'RELEASED', + created_by VARCHAR(100) NOT NULL, + create_time TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(100), + update_time TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP, + delete_flag VARCHAR(1) DEFAULT 'N' +); + +ALTER TABLE nexent.ag_tenant_agent_version_t OWNER TO "root"; + +-- Add comments for version fields in existing tables +COMMENT ON COLUMN nexent.ag_tenant_agent_t.version_no IS 'Version number. 0 = draft/editing state, >=1 = published snapshot'; +COMMENT ON COLUMN nexent.ag_tenant_agent_t.current_version_no IS 'Current published version number. NULL means no version published yet'; +COMMENT ON COLUMN nexent.ag_tool_instance_t.version_no IS 'Version number. 0 = draft/editing state, >=1 = published snapshot'; +COMMENT ON COLUMN nexent.ag_agent_relation_t.version_no IS 'Version number. 0 = draft/editing state, >=1 = published snapshot'; + +-- Add comments for ag_tenant_agent_version_t table +COMMENT ON TABLE nexent.ag_tenant_agent_version_t IS 'Agent version metadata table. Stores version info, release notes, and version lineage.'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.id IS 'Primary key, auto-increment'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.tenant_id IS 'Tenant ID'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.agent_id IS 'Agent ID'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.version_no IS 'Version number, starts from 1. Does not include 0 (draft)'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.version_name IS 'User-defined version name for display (e.g., "Stable v2.1", "Hotfix-001"). NULL means use version_no as display.'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.release_note IS 'Release notes / publish remarks'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.source_version_no IS 'Source version number. If this version is a rollback, record the source version number.'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.source_type IS 'Source type: NORMAL (normal publish) / ROLLBACK (rollback and republish).'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.status IS 'Version status: RELEASED / DISABLED / ARCHIVED'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.created_by IS 'User who published this version'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.create_time IS 'Version creation timestamp'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.updated_by IS 'Last user who updated this version'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.update_time IS 'Last update timestamp'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.delete_flag IS 'Soft delete flag: Y/N'; diff --git a/docker/scripts/sync_user_supabase2pg.py b/docker/scripts/sync_user_supabase2pg.py new file mode 100644 index 000000000..43c3b2e15 --- /dev/null +++ b/docker/scripts/sync_user_supabase2pg.py @@ -0,0 +1,585 @@ +#!/usr/bin/env python3 +""" +Update user data script for v1.8.0 upgrade. +This script updates user_email and user_role in the user_tenant_t table. + +Usage (run inside nexent-config container): + python sync_user_supabase2pg.py [--dry-run] + +Options: + --dry-run: Show what would be updated without making changes + --verbose: Enable verbose debug output + +Environment variables are loaded from Docker container environment. +""" + +import os +import sys +import argparse +import logging +import requests + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Constants +DEFAULT_TENANT_ID = "tenant_id" +DEFAULT_USER_ID = "user_id" +LEGACY_ADMIN_EMAIL = "nexent@example.com" + + +def check_docker_containers(): + """Check if required Docker containers are running""" + try: + import subprocess + result = subprocess.run( + ['docker', 'ps', '--format', '{{.Names}}'], + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode == 0: + containers = result.stdout.strip().split('\n') + logger.info(f"Running containers: {containers}") + + required_containers = ['nexent-postgresql'] + missing = [c for c in required_containers if c not in containers] + + if missing: + logger.warning(f"Missing required containers: {missing}") + logger.info("Please ensure Docker containers are running with: docker compose up -d") + return False + + return True + else: + logger.warning("Could not query Docker containers") + return None + except FileNotFoundError: + logger.warning("Docker not available on this system") + return None + except Exception as e: + logger.warning(f"Error checking Docker containers: {e}") + return None + + +def test_connection_with_psql(conn_params): + """Test connection using psql command if available""" + try: + import subprocess + + password = conn_params.get('password', '') + env = os.environ.copy() + + cmd = [ + 'psql', + '-h', conn_params.get('host', 'localhost'), + '-p', str(conn_params.get('port', 5434)), + '-U', conn_params.get('user', 'nexent'), + '-d', conn_params.get('database', 'nexent'), + '-c', 'SELECT 1;' + ] + + if password: + env['PGPASSWORD'] = password + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10, + env=env + ) + + if result.returncode == 0: + logger.info("psql connection test: SUCCESS") + return True + else: + logger.warning(f"psql connection test failed: {result.stderr}") + return False + except FileNotFoundError: + logger.debug("psql not available, skipping command-line test") + return None + except Exception as e: + logger.debug(f"psql test error: {e}") + return None + + +def load_environment_from_container(): + """ + Validate and display environment variables from container environment. + Environment variables are already set by Docker via env_file directive. + """ + required_vars = [ + 'POSTGRES_DB', + 'POSTGRES_USER', + 'NEXENT_POSTGRES_PASSWORD', + 'POSTGRES_HOST', + 'POSTGRES_PORT', + 'SERVICE_ROLE_KEY' + ] + + missing = [var for var in required_vars if not os.getenv(var)] + if missing: + logger.error(f"Missing required environment variables: {missing}") + return False + + logger.info("Environment variables loaded from container") + return True + + +def get_postgres_connection_params(): + """Get PostgreSQL connection parameters from environment""" + # Validate environment variables are set + load_environment_from_container() + + # Default port for docker-compose is 5434 + params = { + 'host': os.getenv('POSTGRES_HOST', '127.0.0.1'), + 'port': os.getenv('POSTGRES_PORT', '5434'), + 'database': os.getenv('POSTGRES_DB', 'nexent'), + 'user': os.getenv('POSTGRES_USER', 'nexent'), + 'password': os.getenv('NEXENT_POSTGRES_PASSWORD', '') + } + + logger.info("Database connection parameters:") + logger.info(f" Host: {params['host']}") + logger.info(f" Port: {params['port']}") + logger.info(f" Database: {params['database']}") + logger.info(f" User: {params['user']}") + logger.info(f" Password: {'*' * len(params['password']) if params['password'] else '(empty)'}") + + return params + + +def get_supabase_params(): + """Get Supabase connection parameters from environment""" + service_role_key = os.getenv('SERVICE_ROLE_KEY', '') + service_role_key = service_role_key.strip('"').strip("'") + + supabase_url = os.getenv('SUPABASE_URL', 'http://127.0.0.1:8000') + + params = { + 'url': supabase_url, + 'key': service_role_key + } + + if not params['key']: + logger.warning("SERVICE_ROLE_KEY is not set") + + return params + + +def get_db_connection(conn_params): + """Get database connection""" + import psycopg2 + try: + # First test basic connectivity + logger.info(f"Attempting to connect to PostgreSQL at {conn_params.get('host')}:{conn_params.get('port')}...") + conn = psycopg2.connect(**conn_params) + logger.info("Database connection established successfully") + return conn + except psycopg2.OperationalError as e: + logger.error(f"Database connection failed: {e}") + logger.error("Please check:") + logger.error(" 1. PostgreSQL is running") + logger.error(" 2. Host/port configuration is correct") + logger.error(" 3. Credentials are correct") + logger.error(" 4. Network is accessible") + return None + except Exception as e: + logger.error(f"Unexpected database error: {e}") + return None + + +def fetch_all_user_tenant_records(conn): + """Fetch all user_tenant records from database""" + try: + cursor = conn.cursor() + query = """ + SELECT user_id, tenant_id, user_role, user_email + FROM nexent.user_tenant_t + WHERE delete_flag = 'N' + ORDER BY user_id \ + """ + cursor.execute(query) + records = cursor.fetchall() + cursor.close() + + # Convert to list of dicts + result = [] + for row in records: + result.append({ + 'user_id': row[0], + 'tenant_id': row[1], + 'user_role': row[2], + 'user_email': row[3] + }) + + logger.info(f"Fetched {len(result)} user_tenant records from database") + return result + except Exception as e: + logger.error(f"Failed to fetch user_tenant records: {e}") + return [] + + +def get_user_email_from_supabase(user_id, supabase_url, service_role_key): + """ + Get user email from Supabase by user ID using REST API. + + Args: + user_id: The user's UUID + supabase_url: Supabase API URL + service_role_key: Service role key for admin access + + Returns: + User's email address or None if not found + + Note: SPEED system user (user_id="user_id") is virtual and doesn't exist in Supabase. + """ + # Skip Supabase lookup for virtual SPEED system user + if user_id == DEFAULT_USER_ID: + logger.debug(f"User {user_id} is virtual SPEED user, skipping Supabase lookup") + return None + + if not supabase_url or not service_role_key: + logger.warning("Supabase URL or service role key not configured") + return None + + # Clean up URL (remove trailing slash) + supabase_url = supabase_url.rstrip('/') + + try: + headers = { + 'Authorization': f'Bearer {service_role_key}', + 'apikey': service_role_key, + 'Content-Type': 'application/json' + } + + # Get user by ID via REST API + response = requests.get( + f'{supabase_url}/auth/v1/admin/users/{user_id}', + headers=headers, + timeout=10 + ) + + if response.status_code == 200: + user_data = response.json() + email = user_data.get('email') + if email: + logger.debug(f"Fetched email for user {user_id}: {email}") + return email + else: + logger.warning(f"User {user_id} has no email in Supabase") + return None + elif response.status_code == 404: + logger.warning(f"User {user_id} not found in Supabase") + return None + elif response.status_code == 401: + logger.error("Unauthorized: Check your SERVICE_ROLE_KEY") + return None + else: + logger.warning(f"Failed to fetch user {user_id}: HTTP {response.status_code} - {response.text}") + return None + + except requests.exceptions.ConnectionError as e: + logger.warning(f"Cannot connect to Supabase for user {user_id}: {e}") + return None + except requests.exceptions.Timeout as e: + logger.warning(f"Request timeout for user {user_id}: {e}") + return None + except Exception as e: + logger.warning(f"Error fetching user {user_id} from Supabase: {e}") + return None + + +def determine_user_role(user_id, tenant_id, user_email): + """ + Determine user_role based on rules: + 1. Special case: user_id == "user_id" AND tenant_id == "tenant_id" → SPEED (default system user) + 2. If user_id == tenant_id → ADMIN + 3. If user_email == LEGACY_ADMIN_EMAIL → ADMIN + 4. Otherwise → USER + """ + # Rule 0: Default system user (user_id="user_id", tenant_id="tenant_id") → SPEED + if user_id == DEFAULT_USER_ID and tenant_id == DEFAULT_TENANT_ID: + return "SPEED" + + # Rule 1: user_id == tenant_id → ADMIN + if user_id == tenant_id: + return "ADMIN" + + # Rule 2: Special admin email → ADMIN + if user_email and user_email.lower() == LEGACY_ADMIN_EMAIL.lower(): + return "ADMIN" + + # Default: USER + return "USER" + + +def update_user_record(conn, user_id, user_email, user_role): + """Update a single user record in database""" + try: + cursor = conn.cursor() + query = """ + UPDATE nexent.user_tenant_t + SET user_email = %s, + user_role = %s, + updated_by = 'system', + update_time = NOW() + WHERE user_id = %s \ + AND delete_flag = 'N' \ + """ + cursor.execute(query, (user_email, user_role, user_id)) + affected = cursor.rowcount + cursor.close() + conn.commit() + return affected > 0 + except Exception as e: + logger.error(f"Failed to update user {user_id}: {e}") + conn.rollback() + return False + + +def process_user_records(conn, supabase_params, records, dry_run=False): + """ + Process all user records: + 1. Fetch email from Supabase (if not already set or overwrite is True) + 2. Determine user_role based on rules + 3. Update database + """ + supabase_url = supabase_params['url'] + service_role_key = supabase_params['key'] + + results = { + 'total': len(records), + 'updated': 0, + 'skipped': 0, + 'failed': 0, + 'details': [] + } + + for record in records: + user_id = record['user_id'] + tenant_id = record['tenant_id'] + old_email = record.get('user_email') + old_role = record.get('user_role') + + # Get email from Supabase using REST API + user_email = get_user_email_from_supabase(user_id, supabase_url, service_role_key) + + if not user_email: + # Keep existing email if no new email from Supabase + user_email = old_email + if not old_email: + logger.warning(f"Could not fetch email from Supabase for user {user_id}, and no existing email") + + # Determine user_role + user_role = determine_user_role(user_id, tenant_id, user_email) + + # Check if update is needed + email_changed = user_email != old_email + role_changed = user_role != old_role + + if not email_changed and not role_changed: + results['skipped'] += 1 + results['details'].append({ + 'user_id': user_id, + 'status': 'skipped', + 'reason': 'No changes needed' + }) + continue + + if dry_run: + logger.info(f"[DRY-RUN] Would update user {user_id}:") + logger.info(f" Email: {old_email} -> {user_email}") + logger.info(f" Role: {old_role} -> {user_role}") + results['updated'] += 1 + results['details'].append({ + 'user_id': user_id, + 'status': 'dry-run', + 'old_email': old_email, + 'new_email': user_email, + 'old_role': old_role, + 'new_role': user_role + }) + else: + if update_user_record(conn, user_id, user_email, user_role): + logger.info(f"Updated user {user_id}: email={user_email}, role={user_role}") + results['updated'] += 1 + results['details'].append({ + 'user_id': user_id, + 'status': 'success', + 'old_email': old_email, + 'new_email': user_email, + 'old_role': old_role, + 'new_role': user_role + }) + else: + results['failed'] += 1 + results['details'].append({ + 'user_id': user_id, + 'status': 'failed', + 'reason': 'Update failed' + }) + + return results + + +def print_results(results): + """Print processing results""" + logger.info("=" * 60) + logger.info("Processing Results:") + logger.info(f" Total records: {results['total']}") + logger.info(f" Updated: {results['updated']}") + logger.info(f" Skipped: {results['skipped']}") + logger.info(f" Failed: {results['failed']}") + logger.info("=" * 60) + + # Print details for updated records + if results['details']: + logger.info("\nUpdated/Skipped Records:") + for detail in results['details']: + if detail['status'] in ['success', 'dry-run']: + logger.info(f" User {detail['user_id']}:") + if 'new_email' in detail: + logger.info(f" Email: {detail['old_email']} -> {detail['new_email']}") + if 'new_role' in detail: + logger.info(f" Role: {detail['old_role']} -> {detail['new_role']}") + + +def test_supabase_connection(supabase_params): + """Test Supabase connection by listing users""" + supabase_url = supabase_params['url'].rstrip('/') + service_role_key = supabase_params['key'] + + try: + headers = { + 'Authorization': f'Bearer {service_role_key}', + 'apikey': service_role_key, + 'Content-Type': 'application/json' + } + + # Test by listing users (limit 1) + response = requests.get( + f'{supabase_url}/auth/v1/admin/users?page=1&per_page=1', + headers=headers, + timeout=10 + ) + + if response.status_code == 200: + logger.info("Supabase connection test: SUCCESS") + return True + elif response.status_code == 401: + logger.error("Supabase connection test: FAILED (401 Unauthorized)") + logger.error("Please check your SERVICE_ROLE_KEY") + return False + else: + logger.warning(f"Supabase connection test: HTTP {response.status_code}") + return False + + except requests.exceptions.ConnectionError as e: + logger.error(f"Cannot connect to Supabase: {e}") + return False + except Exception as e: + logger.error(f"Supabase connection test failed: {e}") + return False + + +def main(): + """Main function""" + parser = argparse.ArgumentParser( + description='Update user data for v2 upgrade' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be updated without making changes' + ) + parser.add_argument( + '--verbose', + action='store_true', + help='Enable verbose debug output' + ) + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + logger.info("=" * 60) + logger.info("User Data Update Script (v2 upgrade)") + logger.info("=" * 60) + + if args.dry_run: + logger.info("Mode: DRY-RUN (no changes will be made)") + + # Step 0: Check Docker containers + logger.info("\n[Step 0/6] Checking Docker containers...") + docker_status = check_docker_containers() + if docker_status is False: + logger.error("Required Docker containers are not running") + logger.info("Ensure nexent-postgresql container is running") + sys.exit(1) + + # Step 1: Validate environment variables + logger.info("\n[Step 1/6] Loading environment variables...") + if not load_environment_from_container(): + logger.error("Failed to load environment variables") + sys.exit(1) + + # Step 2: Get Supabase parameters and test connection + logger.info("\n[Step 2/6] Testing Supabase connection...") + supabase_params = get_supabase_params() + if not supabase_params['url'] or not supabase_params['key']: + logger.error("SUPABASE_URL and SERVICE_ROLE_KEY must be set in environment") + sys.exit(1) + + logger.info(f" Supabase URL: {supabase_params['url']}") + logger.info(f" Service Role Key: {supabase_params['key'][:20]}...{supabase_params['key'][-10:]}") + + if not test_supabase_connection(supabase_params): + logger.error("Failed to connect to Supabase") + sys.exit(1) + + # Step 3: Connect to database + logger.info("\n[Step 3/6] Connecting to PostgreSQL database...") + conn_params = get_postgres_connection_params() + conn = get_db_connection(conn_params) + if not conn: + logger.error("Failed to connect to database") + # Try psql as fallback + test_connection_with_psql(conn_params) + sys.exit(1) + + try: + # Step 4: Fetch all user_tenant records + logger.info("\n[Step 4/6] Fetching user_tenant records...") + records = fetch_all_user_tenant_records(conn) + if not records: + logger.warning("No user_tenant records found") + return + + # Step 5: Process records + logger.info("\n[Step 5/6] Processing records...") + results = process_user_records(conn, supabase_params, records, dry_run=args.dry_run) + print_results(results) + + # Step 6: Summary + logger.info("\n[Step 6/6] Upgrade completed") + + if args.dry_run: + logger.info("\nTo apply these changes, run without --dry-run flag") + + finally: + # Close database connection + if conn: + conn.close() + logger.info("\nDatabase connection closed") + + +if __name__ == "__main__": + main() diff --git a/docker/scripts/v180_sync_user_metadata.sh b/docker/scripts/v180_sync_user_metadata.sh new file mode 100644 index 000000000..7a89e03af --- /dev/null +++ b/docker/scripts/v180_sync_user_metadata.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# +# v1.8.0 User Metadata Sync Script +# This script executes the user data update script inside the nexent-config container. +# +# Usage: +# ./v180_sync_user_metadata.sh [--dry-run] +# +# Options: +# --dry-run Show what would be updated without making changes +# + +set -e + +CONTAINER_NAME="nexent-config" +SCRIPT_PATH="/opt/sync_user_supabase2pg.py" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Clear Windows Git Bash path variables that cause path resolution issues in containers +# These variables contain Windows-style paths (e.g., C:/Program Files/Git) which break +# container execution when inherited + +# Check if nexent-config container is running +DRY_RUN=false +for arg in "$@"; do + case $arg in + --dry-run) + DRY_RUN=true + shift + ;; + *) + ;; + esac +done + +# Check if nexent-config container is running +log_info "Checking if container '$CONTAINER_NAME' is running..." +if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + log_error "Container '$CONTAINER_NAME' is not running" + log_info "Please start the containers with: cd docker && docker compose up -d" + exit 1 +fi + +log_info "Container '$CONTAINER_NAME' is running" + +# Execute the script inside the container +log_info "Executing sync script inside container..." + +# Use 'sh -c' wrapper to execute the command inside the container. +# This is a workaround for Windows Git Bash's execve() argument parsing issue +# where paths containing forward slashes get incorrectly interpreted. +# By wrapping the command in 'sh -c', the container's shell handles argument parsing. +if [ "$DRY_RUN" = true ]; then + log_info "Mode: DRY-RUN (no changes will be made)" + docker exec "$CONTAINER_NAME" sh -c "python $SCRIPT_PATH --dry-run" +else + docker exec "$CONTAINER_NAME" sh -c "python $SCRIPT_PATH" +fi + +EXIT_CODE=$? + +if [ $EXIT_CODE -eq 0 ]; then + log_info "Script executed successfully" +else + log_error "Script failed with exit code: $EXIT_CODE" + exit $EXIT_CODE +fi \ No newline at end of file diff --git a/docker/sql/v1.7.9.3_0123_add_speed_user_tenant_t.sql b/docker/sql/v1.7.9.3_0123_add_speed_user_tenant_t.sql index 5740ec9cd..e0d5b3ce6 100644 --- a/docker/sql/v1.7.9.3_0123_add_speed_user_tenant_t.sql +++ b/docker/sql/v1.7.9.3_0123_add_speed_user_tenant_t.sql @@ -1,15 +1,10 @@ -- Add user_email column to user_tenant_t table ALTER TABLE nexent.user_tenant_t -ADD COLUMN user_email VARCHAR(255); +ADD COLUMN IF NOT EXISTS user_email VARCHAR(255); -- Add comment to the new column COMMENT ON COLUMN nexent.user_tenant_t.user_email IS 'User email address'; --- Create index on user_email for faster queries (optional) -CREATE INDEX IF NOT EXISTS idx_user_tenant_t_user_email -ON nexent.user_tenant_t(user_email); - - INSERT INTO nexent.user_tenant_t (user_id, tenant_id, user_role, user_email, created_by, updated_by) VALUES ('user_id', 'tenant_id', 'SPEED', NULL, 'system', 'system') ON CONFLICT (user_id, tenant_id) DO NOTHING; diff --git a/docker/sql/v1.8.0_0204_init_tenant_group.sql b/docker/sql/v1.8.0_0204_init_tenant_group.sql new file mode 100644 index 000000000..fde946cb9 --- /dev/null +++ b/docker/sql/v1.8.0_0204_init_tenant_group.sql @@ -0,0 +1,76 @@ +-- Initialize tenant group and default configuration for existing tenants +-- This migration adds default group and basic config for tenants that lack them +-- Trigger condition: tenant has no TENANT_ID config_key in tenant_config_t + +DO $$ +DECLARE + target_tenant_id VARCHAR(100); + new_group_id INTEGER; +BEGIN + -- Loop through each distinct tenant_id from user_tenant_t + FOR target_tenant_id IN + SELECT DISTINCT tenant_id + FROM nexent.user_tenant_t + WHERE tenant_id IS NOT NULL + LOOP + -- Check if tenant already has TENANT_ID config_key + IF NOT EXISTS ( + SELECT 1 FROM nexent.tenant_config_t + WHERE tenant_id = target_tenant_id + AND config_key = 'TENANT_ID' + AND delete_flag = 'N' + ) THEN + -- Insert TENANT_ID config + INSERT INTO nexent.tenant_config_t ( + tenant_id, user_id, value_type, config_key, config_value, + create_time, update_time, created_by, updated_by, delete_flag + ) VALUES ( + target_tenant_id, NULL, 'single', 'TENANT_ID', target_tenant_id, + NOW(), NOW(), 'system', 'system', 'N' + ); + + -- Insert TENANT_NAME config if not exists + IF NOT EXISTS ( + SELECT 1 FROM nexent.tenant_config_t + WHERE tenant_id = target_tenant_id + AND config_key = 'TENANT_NAME' + AND delete_flag = 'N' + ) THEN + INSERT INTO nexent.tenant_config_t ( + tenant_id, user_id, value_type, config_key, config_value, + create_time, update_time, created_by, updated_by, delete_flag + ) VALUES ( + target_tenant_id, NULL, 'single', 'TENANT_NAME', 'Unnamed Tenant', + NOW(), NOW(), 'system', 'system', 'N' + ); + END IF; + + -- Check if tenant already has a group + IF NOT EXISTS ( + SELECT 1 FROM nexent.tenant_group_info_t + WHERE tenant_id = target_tenant_id + AND delete_flag = 'N' + ) THEN + -- Insert default group + INSERT INTO nexent.tenant_group_info_t ( + tenant_id, group_name, group_description, + create_time, update_time, created_by, updated_by, delete_flag + ) VALUES ( + target_tenant_id, 'Default Group', 'Default group for tenant', + NOW(), NOW(), 'system', 'system', 'N' + ) RETURNING group_id INTO new_group_id; + + -- Insert DEFAULT_GROUP_ID config + IF new_group_id IS NOT NULL THEN + INSERT INTO nexent.tenant_config_t ( + tenant_id, user_id, value_type, config_key, config_value, + create_time, update_time, created_by, updated_by, delete_flag + ) VALUES ( + target_tenant_id, NULL, 'single', 'DEFAULT_GROUP_ID', new_group_id::VARCHAR, + NOW(), NOW(), 'system', 'system', 'N' + ); + END IF; + END IF; + END IF; + END LOOP; +END $$; diff --git a/docker/sql/v1.8.0_0206_add_ag_tenant_agent_version_t .sql b/docker/sql/v1.8.0_0206_add_ag_tenant_agent_version_t .sql new file mode 100644 index 000000000..40fc22df0 --- /dev/null +++ b/docker/sql/v1.8.0_0206_add_ag_tenant_agent_version_t .sql @@ -0,0 +1,84 @@ +-- 步骤 1:添加 nullable 的 version_no 字段(不设默认值,让显式赋值) +ALTER TABLE nexent.ag_tenant_agent_t +ADD COLUMN IF NOT EXISTS version_no INTEGER NULL; + +ALTER TABLE nexent.ag_tool_instance_t +ADD COLUMN IF NOT EXISTS version_no INTEGER NULL; + +ALTER TABLE nexent.ag_agent_relation_t +ADD COLUMN IF NOT EXISTS version_no INTEGER NULL; + +-- 步骤 2:更新所有历史数据的 version_no 为 0 +UPDATE nexent.ag_tenant_agent_t SET version_no = 0 WHERE version_no IS NULL; +UPDATE nexent.ag_tool_instance_t SET version_no = 0 WHERE version_no IS NULL; +UPDATE nexent.ag_agent_relation_t SET version_no = 0 WHERE version_no IS NULL; + +-- 步骤 3:将字段设为 NOT NULL,并设置默认值 0 +ALTER TABLE nexent.ag_tenant_agent_t ALTER COLUMN version_no SET NOT NULL; +ALTER TABLE nexent.ag_tenant_agent_t ALTER COLUMN version_no SET DEFAULT 0; + +ALTER TABLE nexent.ag_tool_instance_t ALTER COLUMN version_no SET NOT NULL; +ALTER TABLE nexent.ag_tool_instance_t ALTER COLUMN version_no SET DEFAULT 0; + +ALTER TABLE nexent.ag_agent_relation_t ALTER COLUMN version_no SET NOT NULL; +ALTER TABLE nexent.ag_agent_relation_t ALTER COLUMN version_no SET DEFAULT 0; + +-- 步骤 4:为 ag_tenant_agent_t 添加 current_version_no 字段 +ALTER TABLE nexent.ag_tenant_agent_t +ADD COLUMN IF NOT EXISTS current_version_no INTEGER NULL; + +-- 步骤5:修改主键 +ALTER TABLE nexent.ag_tenant_agent_t DROP CONSTRAINT ag_tenant_agent_t_pkey; +ALTER TABLE nexent.ag_tenant_agent_t ADD CONSTRAINT ag_tenant_agent_t_pkey PRIMARY KEY (agent_id, version_no); + +ALTER TABLE nexent.ag_tool_instance_t DROP CONSTRAINT ag_tool_instance_t_pkey; +ALTER TABLE nexent.ag_tool_instance_t ADD CONSTRAINT ag_tool_instance_t_pkey PRIMARY KEY (tool_instance_id, version_no); + +ALTER TABLE nexent.ag_agent_relation_t DROP CONSTRAINT ag_agent_relation_t_pkey; +ALTER TABLE nexent.ag_agent_relation_t ADD CONSTRAINT ag_agent_relation_t_pkey PRIMARY KEY (relation_id, version_no); + +-- 步骤6:新增agent版本管理表 +CREATE TABLE IF NOT EXISTS nexent.ag_tenant_agent_version_t ( + id BIGSERIAL PRIMARY KEY, + tenant_id VARCHAR(100) NOT NULL, + agent_id INTEGER NOT NULL, + version_no INTEGER NOT NULL, + version_name VARCHAR(100), -- 用户自定义版本名称 + release_note TEXT, -- 发布备注 + + source_version_no INTEGER NULL, -- 来源版本号(回滚时记录) + source_type VARCHAR(30) NULL, -- 来源类型:NORMAL(正常发布) / ROLLBACK(回滚产生) + + status VARCHAR(30) DEFAULT 'RELEASED', -- 版本状态:RELEASED / DISABLED / ARCHIVED + + created_by VARCHAR(100) NOT NULL, + create_time TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(100), + update_time TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP, + delete_flag VARCHAR(1) DEFAULT 'N' +); + +ALTER TABLE nexent.ag_tenant_agent_version_t OWNER TO "root"; + +-- 步骤 7:添加COMMENT +COMMENT ON COLUMN nexent.ag_tenant_agent_t.version_no IS 'Version number. 0 = draft/editing state, >=1 = published snapshot'; +COMMENT ON COLUMN nexent.ag_tenant_agent_t.current_version_no IS 'Current published version number. NULL means no version published yet'; +COMMENT ON COLUMN nexent.ag_tool_instance_t.version_no IS 'Version number. 0 = draft/editing state, >=1 = published snapshot'; +COMMENT ON COLUMN nexent.ag_agent_relation_t.version_no IS 'Version number. 0 = draft/editing state, >=1 = published snapshot'; + +COMMENT ON TABLE nexent.ag_tenant_agent_version_t IS 'Agent version metadata table. Stores version info, release notes, and version lineage.'; + +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.id IS 'Primary key, auto-increment'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.tenant_id IS 'Tenant ID'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.agent_id IS 'Agent ID'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.version_no IS 'Version number, starts from 1. Does not include 0 (draft)'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.version_name IS 'User-defined version name for display (e.g., "Stable v2.1", "Hotfix-001"). NULL means use version_no as display.'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.release_note IS 'Release notes / publish remarks'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.source_version_no IS 'Source version number. If this version is a rollback, record the source version number.'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.source_type IS 'Source type: NORMAL (normal publish) / ROLLBACK (rollback and republish).'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.status IS 'Version status: RELEASED / DISABLED / ARCHIVED'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.created_by IS 'User who published this version'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.create_time IS 'Version creation timestamp'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.updated_by IS 'Last user who updated this version'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.update_time IS 'Last update timestamp'; +COMMENT ON COLUMN nexent.ag_tenant_agent_version_t.delete_flag IS 'Soft delete flag: Y/N'; diff --git a/docker/sql/v1.8.0_0206_init_role_permission_t.sql b/docker/sql/v1.8.0_0206_init_role_permission_t.sql new file mode 100644 index 000000000..6b9409503 --- /dev/null +++ b/docker/sql/v1.8.0_0206_init_role_permission_t.sql @@ -0,0 +1,186 @@ +DELETE FROM nexent.role_permission_t; + +INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype) VALUES +(1, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), +(2, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/monitoring'), +(3, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/tenant-resources'), +(4, 'SU', 'RESOURCE', 'AGENT', 'READ'), +(5, 'SU', 'RESOURCE', 'AGENT', 'DELETE'), +(6, 'SU', 'RESOURCE', 'KB', 'READ'), +(7, 'SU', 'RESOURCE', 'KB', 'DELETE'), +(8, 'SU', 'RESOURCE', 'KB.GROUPS', 'READ'), +(9, 'SU', 'RESOURCE', 'KB.GROUPS', 'UPDATE'), +(10, 'SU', 'RESOURCE', 'KB.GROUPS', 'DELETE'), +(11, 'SU', 'RESOURCE', 'USER.ROLE', 'READ'), +(12, 'SU', 'RESOURCE', 'USER.ROLE', 'UPDATE'), +(13, 'SU', 'RESOURCE', 'USER.ROLE', 'DELETE'), +(14, 'SU', 'RESOURCE', 'MCP', 'READ'), +(15, 'SU', 'RESOURCE', 'MCP', 'DELETE'), +(16, 'SU', 'RESOURCE', 'MEM.SETTING', 'READ'), +(17, 'SU', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), +(18, 'SU', 'RESOURCE', 'MEM.AGENT', 'READ'), +(19, 'SU', 'RESOURCE', 'MEM.AGENT', 'DELETE'), +(20, 'SU', 'RESOURCE', 'MEM.PRIVATE', 'READ'), +(21, 'SU', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), +(22, 'SU', 'RESOURCE', 'MODEL', 'CREATE'), +(23, 'SU', 'RESOURCE', 'MODEL', 'READ'), +(24, 'SU', 'RESOURCE', 'MODEL', 'UPDATE'), +(25, 'SU', 'RESOURCE', 'MODEL', 'DELETE'), +(26, 'SU', 'RESOURCE', 'TENANT', 'CREATE'), +(27, 'SU', 'RESOURCE', 'TENANT', 'READ'), +(28, 'SU', 'RESOURCE', 'TENANT', 'UPDATE'), +(29, 'SU', 'RESOURCE', 'TENANT', 'DELETE'), +(30, 'SU', 'RESOURCE', 'TENANT.LIST', 'READ'), +(31, 'SU', 'RESOURCE', 'TENANT.INFO', 'READ'), +(32, 'SU', 'RESOURCE', 'TENANT.INFO', 'UPDATE'), +(33, 'SU', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), +(34, 'SU', 'RESOURCE', 'TENANT.INVITE', 'READ'), +(35, 'SU', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), +(36, 'SU', 'RESOURCE', 'TENANT.INVITE', 'DELETE'), +(37, 'SU', 'RESOURCE', 'GROUP', 'CREATE'), +(38, 'SU', 'RESOURCE', 'GROUP', 'READ'), +(39, 'SU', 'RESOURCE', 'GROUP', 'UPDATE'), +(40, 'SU', 'RESOURCE', 'GROUP', 'DELETE'), +(41, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), +(42, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), +(43, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/setup'), +(44, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), +(45, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), +(46, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), +(47, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), +(48, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-tools'), +(49, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/monitoring'), +(50, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), +(51, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), +(52, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), +(53, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/tenant-resources'), +(54, 'ADMIN', 'RESOURCE', 'AGENT', 'CREATE'), +(55, 'ADMIN', 'RESOURCE', 'AGENT', 'READ'), +(56, 'ADMIN', 'RESOURCE', 'AGENT', 'UPDATE'), +(57, 'ADMIN', 'RESOURCE', 'AGENT', 'DELETE'), +(58, 'ADMIN', 'RESOURCE', 'KB', 'CREATE'), +(59, 'ADMIN', 'RESOURCE', 'KB', 'READ'), +(60, 'ADMIN', 'RESOURCE', 'KB', 'UPDATE'), +(61, 'ADMIN', 'RESOURCE', 'KB', 'DELETE'), +(62, 'ADMIN', 'RESOURCE', 'KB.GROUPS', 'READ'), +(63, 'ADMIN', 'RESOURCE', 'KB.GROUPS', 'UPDATE'), +(64, 'ADMIN', 'RESOURCE', 'KB.GROUPS', 'DELETE'), +(65, 'ADMIN', 'RESOURCE', 'USER.ROLE', 'READ'), +(66, 'ADMIN', 'RESOURCE', 'MCP', 'CREATE'), +(67, 'ADMIN', 'RESOURCE', 'MCP', 'READ'), +(68, 'ADMIN', 'RESOURCE', 'MCP', 'UPDATE'), +(69, 'ADMIN', 'RESOURCE', 'MCP', 'DELETE'), +(70, 'ADMIN', 'RESOURCE', 'MEM.SETTING', 'READ'), +(71, 'ADMIN', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), +(72, 'ADMIN', 'RESOURCE', 'MEM.AGENT', 'CREATE'), +(73, 'ADMIN', 'RESOURCE', 'MEM.AGENT', 'READ'), +(74, 'ADMIN', 'RESOURCE', 'MEM.AGENT', 'DELETE'), +(75, 'ADMIN', 'RESOURCE', 'MEM.PRIVATE', 'CREATE'), +(76, 'ADMIN', 'RESOURCE', 'MEM.PRIVATE', 'READ'), +(77, 'ADMIN', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), +(78, 'ADMIN', 'RESOURCE', 'MODEL', 'CREATE'), +(79, 'ADMIN', 'RESOURCE', 'MODEL', 'READ'), +(80, 'ADMIN', 'RESOURCE', 'MODEL', 'UPDATE'), +(81, 'ADMIN', 'RESOURCE', 'MODEL', 'DELETE'), +(82, 'ADMIN', 'RESOURCE', 'TENANT.INFO', 'READ'), +(83, 'ADMIN', 'RESOURCE', 'TENANT.INFO', 'UPDATE'), +(84, 'ADMIN', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), +(85, 'ADMIN', 'RESOURCE', 'TENANT.INVITE', 'READ'), +(86, 'ADMIN', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), +(87, 'ADMIN', 'RESOURCE', 'TENANT.INVITE', 'DELETE'), +(88, 'ADMIN', 'RESOURCE', 'GROUP', 'CREATE'), +(89, 'ADMIN', 'RESOURCE', 'GROUP', 'READ'), +(90, 'ADMIN', 'RESOURCE', 'GROUP', 'UPDATE'), +(91, 'ADMIN', 'RESOURCE', 'GROUP', 'DELETE'), +(92, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), +(93, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), +(94, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/setup'), +(95, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), +(96, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), +(97, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), +(98, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), +(99, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-tools'), +(100, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/monitoring'), +(101, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), +(102, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), +(103, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), +(104, 'DEV', 'RESOURCE', 'AGENT', 'CREATE'), +(105, 'DEV', 'RESOURCE', 'AGENT', 'READ'), +(106, 'DEV', 'RESOURCE', 'AGENT', 'UPDATE'), +(107, 'DEV', 'RESOURCE', 'AGENT', 'DELETE'), +(108, 'DEV', 'RESOURCE', 'KB', 'CREATE'), +(109, 'DEV', 'RESOURCE', 'KB', 'READ'), +(110, 'DEV', 'RESOURCE', 'KB', 'UPDATE'), +(111, 'DEV', 'RESOURCE', 'KB', 'DELETE'), +(112, 'DEV', 'RESOURCE', 'KB.GROUPS', 'READ'), +(113, 'DEV', 'RESOURCE', 'KB.GROUPS', 'UPDATE'), +(114, 'DEV', 'RESOURCE', 'KB.GROUPS', 'DELETE'), +(115, 'DEV', 'RESOURCE', 'USER.ROLE', 'READ'), +(116, 'DEV', 'RESOURCE', 'MCP', 'CREATE'), +(117, 'DEV', 'RESOURCE', 'MCP', 'READ'), +(118, 'DEV', 'RESOURCE', 'MCP', 'UPDATE'), +(119, 'DEV', 'RESOURCE', 'MCP', 'DELETE'), +(120, 'DEV', 'RESOURCE', 'MEM.SETTING', 'READ'), +(121, 'DEV', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), +(122, 'DEV', 'RESOURCE', 'MEM.AGENT', 'READ'), +(123, 'DEV', 'RESOURCE', 'MEM.PRIVATE', 'CREATE'), +(124, 'DEV', 'RESOURCE', 'MEM.PRIVATE', 'READ'), +(125, 'DEV', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), +(126, 'DEV', 'RESOURCE', 'MODEL', 'READ'), +(127, 'DEV', 'RESOURCE', 'TENANT.INFO', 'READ'), +(128, 'DEV', 'RESOURCE', 'GROUP', 'READ'), +(129, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), +(130, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), +(131, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), +(132, 'USER', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), +(133, 'USER', 'RESOURCE', 'AGENT', 'READ'), +(134, 'USER', 'RESOURCE', 'USER.ROLE', 'READ'), +(135, 'USER', 'RESOURCE', 'MEM.SETTING', 'READ'), +(136, 'USER', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), +(137, 'USER', 'RESOURCE', 'MEM.AGENT', 'READ'), +(138, 'USER', 'RESOURCE', 'MEM.PRIVATE', 'CREATE'), +(139, 'USER', 'RESOURCE', 'MEM.PRIVATE', 'READ'), +(140, 'USER', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), +(141, 'USER', 'RESOURCE', 'TENANT.INFO', 'READ'), +(142, 'USER', 'RESOURCE', 'GROUP', 'READ'), +(143, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), +(144, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), +(145, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/setup'), +(146, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), +(147, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), +(148, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), +(149, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), +(150, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-tools'), +(151, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/monitoring'), +(152, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), +(153, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory'), +(154, 'SPEED', 'RESOURCE', 'AGENT', 'CREATE'), +(155, 'SPEED', 'RESOURCE', 'AGENT', 'READ'), +(156, 'SPEED', 'RESOURCE', 'AGENT', 'UPDATE'), +(157, 'SPEED', 'RESOURCE', 'AGENT', 'DELETE'), +(158, 'SPEED', 'RESOURCE', 'KB', 'CREATE'), +(159, 'SPEED', 'RESOURCE', 'KB', 'READ'), +(160, 'SPEED', 'RESOURCE', 'KB', 'UPDATE'), +(161, 'SPEED', 'RESOURCE', 'KB', 'DELETE'), +(166, 'SPEED', 'RESOURCE', 'MCP', 'CREATE'), +(167, 'SPEED', 'RESOURCE', 'MCP', 'READ'), +(168, 'SPEED', 'RESOURCE', 'MCP', 'UPDATE'), +(169, 'SPEED', 'RESOURCE', 'MCP', 'DELETE'), +(170, 'SPEED', 'RESOURCE', 'MEM.SETTING', 'READ'), +(171, 'SPEED', 'RESOURCE', 'MEM.SETTING', 'UPDATE'), +(172, 'SPEED', 'RESOURCE', 'MEM.AGENT', 'CREATE'), +(173, 'SPEED', 'RESOURCE', 'MEM.AGENT', 'READ'), +(174, 'SPEED', 'RESOURCE', 'MEM.AGENT', 'DELETE'), +(175, 'SPEED', 'RESOURCE', 'MEM.PRIVATE', 'CREATE'), +(176, 'SPEED', 'RESOURCE', 'MEM.PRIVATE', 'READ'), +(177, 'SPEED', 'RESOURCE', 'MEM.PRIVATE', 'DELETE'), +(178, 'SPEED', 'RESOURCE', 'MODEL', 'CREATE'), +(179, 'SPEED', 'RESOURCE', 'MODEL', 'READ'), +(180, 'SPEED', 'RESOURCE', 'MODEL', 'UPDATE'), +(181, 'SPEED', 'RESOURCE', 'MODEL', 'DELETE'), +(182, 'SPEED', 'RESOURCE', 'TENANT.INFO', 'READ'), +(183, 'SPEED', 'RESOURCE', 'TENANT.INFO', 'UPDATE'), +(184, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), +(185, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'READ'), +(186, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), +(187, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'DELETE') diff --git a/docker/upgrade.sh b/docker/upgrade.sh index 821636485..38684dae0 100644 --- a/docker/upgrade.sh +++ b/docker/upgrade.sh @@ -9,6 +9,8 @@ CONST_FILE="$PROJECT_ROOT/backend/consts/const.py" DEPLOY_SCRIPT="$SCRIPT_DIR/deploy.sh" SQL_DIR="$SCRIPT_DIR/sql" ENV_FILE="$SCRIPT_DIR/.env" +V180_SCRIPT="$SCRIPT_DIR/scripts/v180_sync_user_metadata.sh" +V180_VERSION="1.8.0" declare -A DEPLOY_OPTIONS UPGRADE_SQL_FILES=() @@ -64,12 +66,12 @@ prompt_option_value() { read -rp "${prompt_msg}: " input input="$(trim_quotes "$input")" - + # Handle yes/no type inputs if [[ "$input_type" == "boolean" ]]; then # Convert to uppercase for consistency input=$(echo "$input" | tr '[:lower:]' '[:upper:]') - + # Validate input if [[ "$input" =~ ^[YN]$ ]]; then DEPLOY_OPTIONS[$key]="$input" @@ -233,6 +235,47 @@ update_option_value() { fi } +# Check if the upgrade version span includes v1.8.0 +# Returns 0 (success) if span includes v1.8.0, 1 otherwise +check_version_spans_v180() { + local cmp_with_v180 + local cmp_current + + # Check if current version is less than v1.8.0 + cmp_current="$(compare_versions "$CURRENT_APP_VERSION" "$V180_VERSION")" + if [ "$cmp_current" -ge 0 ]; then + # Current version is >= v1.8.0, no need to run v180 sync + return 1 + fi + + # Check if target version is >= v1.8.0 + cmp_with_v180="$(compare_versions "$NEW_APP_VERSION" "$V180_VERSION")" + if [ "$cmp_with_v180" -lt 0 ]; then + # Target version is < v1.8.0, no need to run v180 sync + return 1 + fi + + # Version span includes v1.8.0 + return 0 +} + +# Execute the v1.8.0 user metadata sync script +run_v180_sync_script() { + if [ ! -f "$V180_SCRIPT" ]; then + log "WARN" "⚠️ v180_sync_user_metadata.sh not found, skipping v1.8.0 metadata sync." + return + fi + + log "INFO" "🗄️ Detected version span includes v1.8.0, executing user metadata sync script..." + + if ! bash "$V180_SCRIPT"; then + log "ERROR" "❌ Failed to execute v180_sync_user_metadata.sh, please verify the script." + exit 1 + fi + + log "INFO" "✅ v1.8.0 user metadata sync completed successfully." +} + prompt_deploy_options() { # Only prompt for options that already exist in DEPLOY_OPTIONS @@ -275,7 +318,7 @@ _get_option_description() { _get_option_value_description() { local key="$1" local value="$2" - + case "$key" in "MODE_CHOICE") case "$value" in @@ -299,7 +342,7 @@ _get_option_value_description() { main() { ensure_docker load_options - + # Ensure required options are present require_option "APP_VERSION" "APP_VERSION not detected, please enter the current deployed version" require_option "ROOT_DIR" "ROOT_DIR not detected, please enter the absolute deployment directory path" @@ -332,12 +375,12 @@ main() { max_desc_width=$desc_length fi done - + # Ensure minimum width for better readability if (( max_desc_width < 20 )); then max_desc_width=20 fi - + # Display current deployment options in a readable format log "INFO" "📋 Current deployment options:" echo "" @@ -348,7 +391,7 @@ main() { printf " • %-${max_desc_width}s : %s\n" "$desc" "$value_desc" done echo "" - + read -rp "🔄 Do you want to inherit previous deployment options? [Y/N] (default: Y): " inherit_choice inherit_choice="${inherit_choice:-Y}" inherit_choice="$(trim_quotes "$inherit_choice")" @@ -361,6 +404,12 @@ main() { build_deploy_args run_deploy + + # Check if version span includes v1.8.0 and run sync script if needed + if check_version_spans_v180; then + run_v180_sync_script + fi + collect_upgrade_sqls run_sql_scripts diff --git a/frontend/app/[locale]/agents/AgentVersionCard.tsx b/frontend/app/[locale]/agents/AgentVersionCard.tsx new file mode 100644 index 000000000..bc38ad7c8 --- /dev/null +++ b/frontend/app/[locale]/agents/AgentVersionCard.tsx @@ -0,0 +1,634 @@ +"use client"; +import { useMemo, useState } from "react"; +import { + CheckCircle, + Archive, + Clock, + ChevronDown, + ChevronRight, + Rocket, + RotateCcw, + Eye, + Wrench, + Network, + AlertTriangle, + EllipsisVertical, + Trash2, + ArchiveRestore +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + Flex, + Button, + Tag, + Typography, + Card, + Descriptions, + DescriptionsProps, + Modal, + Space, + Spin, + Empty, + Table, + Dropdown, + theme +} from "antd"; +import { ExclamationCircleFilled } from '@ant-design/icons'; + +const { useToken } = theme; +import type { AgentVersion, Agent as AgentVersionAgent, ToolInstance, AgentVersionDetail, VersionCompareResponse } from "@/services/agentVersionService"; +import type { Agent, Tool } from "@/types/agentConfig"; +import { useToolList } from "@/hooks/agent/useToolList"; +import { useAgentList } from "@/hooks/agent/useAgentList"; +import { useAgentVersionList } from "@/hooks/agent/useAgentVersionList"; +import { useAgentInfo } from "@/hooks/agent/useAgentInfo"; +import { useAgentVersionDetail } from "@/hooks/agent/useAgentVersionDetail"; +import { rollbackVersion, compareVersions, deleteVersion } from "@/services/agentVersionService"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import log from "@/lib/logger"; +import { message } from "antd"; +import { useQueryClient } from "@tanstack/react-query"; + +const { Text } = Typography; + +const formatter = new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false +}); + +/** + * Get status configuration based on isCurrentVersion flag + */ +function getStatusConfig(isCurrentVersion: boolean) { + if (isCurrentVersion) { + return { + color: "green", + icon: ( +
+ +
+ ), + labelKey: "agent.version.currentVersion", + }; + } + + return { + color: "default", + icon: ( +
+ +
+ ), + labelKey: "", + }; +} + +/** + * Version card item component + */ +export function VersionCardItem({ + version, + agentId, + currentVersionNo, +}: { + version: AgentVersion; + agentId: number; + currentVersionNo?: number; +}) { + // Calculate isCurrentVersion based on version.version_no and currentVersionNo + const isCurrentVersion = currentVersionNo === version.version_no; + const statusConfig = getStatusConfig(isCurrentVersion); + const { t } = useTranslation("common"); + + // Local expanded state for this version card + const [isExpanded, setIsExpanded] = useState(false); + + // Get user context for tenantId + const { user } = useAuthorizationContext(); + const queryClient = useQueryClient(); + + // Get invalidate functions for refreshing data + const { invalidate: invalidateAgentVersionList } = useAgentVersionList(agentId); + const { invalidate: invalidateAgentInfo } = useAgentInfo(agentId); + + // Fetch version detail when expanded + const { agentVersionDetail } = useAgentVersionDetail( + agentId, + isExpanded ? version.version_no : null + ); + + const { tools: toolList } = useToolList(); + const { agents: agentList } = useAgentList(user?.tenantId ?? null); + + // Modal state + const [compareModalOpen, setCompareModalOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [rollbackLoading, setRollbackLoading] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + const [compareData, setCompareData] = useState(null); + + // Get theme token for styling + const { token } = theme.useToken(); + + // Generate display date and operator from version data + const displayDate = useMemo(() => { + return formatter.format(new Date(version.create_time)); + }, [version.create_time]); + + /** + * Handle rollback button click - show comparison modal + */ + const handleRollbackClick = async () => { + if (!agentId || agentId === 0) { + message.error(t("agent.error.agentNotFound")); + return; + } + setCompareModalOpen(true); + await loadComparison(); + }; + + /** + * Load version comparison data between current version and selected version + */ + const loadComparison = async () => { + setLoading(true); + try { + // Compare current version (currentVersionNo) with the version being rolled back to (version.version_no) + const versionNoA = currentVersionNo || 0; // Use current version, fallback to 0 (draft) if not available + const versionNoB = version.version_no; + const result = await compareVersions(agentId, versionNoA, versionNoB); + setCompareData(result); + } catch (error) { + log.error("Failed to load version comparison:", error); + message.error(t("agent.version.compareError")); + } finally { + setLoading(false); + } + }; + + /** + * Handle rollback confirmation + * Rollback updates current_version_no to point to the target version + * The user can then click publish to create an actual new version + */ + const handleRollbackConfirm = async () => { + setRollbackLoading(true); + try { + const result = await rollbackVersion(agentId, version.version_no); + + if (result.success) { + message.success(t("agent.version.rollbackSuccess")); + setCompareModalOpen(false); + invalidateAgentVersionList?.(); + invalidateAgentInfo?.(); + queryClient.invalidateQueries({ queryKey: ["agents"] }); + } else { + message.error(result.message || t("agent.version.rollbackError")); + } + } catch (error) { + log.error("Failed to rollback version:", error); + message.error(t("agent.version.rollbackError")); + } finally { + setRollbackLoading(false); + } + }; + + /** + * Handle delete version button click - show confirmation modal + */ + const handleDeleteClick = () => { + if (!agentId || agentId === 0) { + message.error(t("agent.error.agentNotFound")); + return; + } + setDeleteModalOpen(true); + }; + + /** + * Handle delete confirmation - actually delete the version + */ + const handleDeleteConfirm = async () => { + setDeleteLoading(true); + try { + const result = await deleteVersion(agentId, version.version_no); + + if (result.success) { + message.success(t("agent.version.deleteSuccess")); + setDeleteModalOpen(false); + invalidateAgentVersionList?.(); + invalidateAgentInfo?.(); + queryClient.invalidateQueries({ queryKey: ["agents"] }); + } else { + message.error(result.message || t("agent.version.deleteError")); + } + } catch (error) { + log.error("Failed to delete version:", error); + message.error(t("agent.version.deleteError")); + } finally { + setDeleteLoading(false); + } + }; + + const agentConfigurationItems: DescriptionsProps['items'] = [ + { + key: '1', + label: t("agent.version.field.name"), + children: {agentVersionDetail?.name}, + }, + { + key: '2', + label: t("agent.version.field.modelName"), + children: {agentVersionDetail?.model_name}, + }, + ]; + + return ( +
+ + + {/* Left: Status icon with timeline */} + + + {statusConfig.icon} + +
+ + + {/* Middle: Version info */} + + + + {version.version_name || `V${version.version_no}`} + + + {t(statusConfig.labelKey)} + + + + + + + + {displayDate} + + + + + + {version.release_note && ( + + {version.release_note} + + )} + + + {/* Right: Actions */} + + , + , + ]} + width={800} + centered + > + + {compareData?.success && compareData?.data ? ( + + {/* Comparison Table */} + {(() => { + const { version_a, version_b } = compareData.data; + + const columns = [ + { + title: t("agent.version.versionName"), + dataIndex: 'field', + key: 'field', + width: '25%', + className: 'bg-gray-50 text-gray-600 font-medium', + }, + { + title: version_a.version.version_name, + dataIndex: 'current', + key: 'current', + width: '37%', + }, + { + title: version_b.version.version_name, + dataIndex: 'version', + key: 'version', + width: '38%', + }, + ]; + + const data = [ + { + key: 'name', + field: t("agent.version.field.name"), + current: ( + + {version_a.name} + + ), + version: ( + + {version_b.name} + + ), + }, + { + key: 'model_name', + field: t("agent.version.field.modelName"), + current: ( + + {version_a.model_name || '-'} + + ), + version: ( + + {version_b.model_name || '-'} + + ), + }, + { + key: 'description', + field: t("agent.version.field.description"), + current: ( + + {version_a.description || '-'} + + ), + version: ( + + {version_b.description || '-'} + + ), + }, + { + key: 'duty_prompt', + field: t("agent.version.field.dutyPrompt"), + current: ( + + {version_a.duty_prompt?.slice(0, 100) || '-'} + {version_a.duty_prompt && version_a.duty_prompt.length > 100 && '...'} + + ), + version: ( + + {version_b.duty_prompt?.slice(0, 100) || '-'} + {version_b.duty_prompt && version_b.duty_prompt.length > 100 && '...'} + + ), + }, + { + key: 'tools', + field: t("agent.version.field.tools"), + current: ( + + {version_a.tools?.length || 0} + + ), + version: ( + + {version_b.tools?.length || 0} + + ), + }, + { + key: 'sub_agents', + field: t("agent.version.field.subAgents"), + current: ( + + {version_a.sub_agent_id_list?.length || 0} + + ), + version: ( + + {version_b.sub_agent_id_list?.length || 0} + + ), + }, + ]; + + return ( + + ); + })()} + + ) : ( + + )} + + + + {/* Delete Version Confirmation Modal */} + setDeleteModalOpen(false)} + footer={[ + , + , + ]} + centered + > + +
+ +
+
+
+ {t("agent.version.deleteConfirmContent", { versionName: version.version_name || `V${version.version_no}` })} +
+
+ {t("agent.version.deleteWarning")} +
+
+
+
+ + ); +} diff --git a/frontend/app/[locale]/agents/AgentVersionManage.tsx b/frontend/app/[locale]/agents/AgentVersionManage.tsx new file mode 100644 index 000000000..38f13eca5 --- /dev/null +++ b/frontend/app/[locale]/agents/AgentVersionManage.tsx @@ -0,0 +1,197 @@ +"use client"; +import { useState } from "react"; +import { + GitBranch, + GitCompare, + Rocket, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + Card, + Flex, + Button, + Tag, + Typography, + Empty, + Spin, + Modal, + Form, + Input, + message, +} from "antd"; +import { useAgentVersionList } from "@/hooks/agent/useAgentVersionList"; +import { publishVersion } from "@/services/agentVersionService"; +import { useAgentInfo } from "@/hooks/agent/useAgentInfo"; +import { useAgentConfigStore } from "@/stores/agentConfigStore"; +import { VersionCardItem } from "./AgentVersionCard"; +import log from "@/lib/logger"; +import { useQueryClient } from "@tanstack/react-query"; + +const { TextArea } = Input; + +export default function AgentVersionManage() { + const { t } = useTranslation("common"); + const queryClient = useQueryClient(); + + const currentAgentId = useAgentConfigStore((state) => state.currentAgentId); + + const { agentVersionList, total, isLoading, invalidate: invalidateAgentVersionList } = useAgentVersionList(currentAgentId); + const { agentInfo, invalidate: invalidateAgentInfo } = useAgentInfo(currentAgentId); + + + const [isPublishModalOpen, setIsPublishModalOpen] = useState(false); + const [isPublishing, setIsPublishing] = useState(false); + const [publishForm] = Form.useForm(); + + // Open publish modal + const handlePublishClick = () => { + setIsPublishModalOpen(true); + }; + + // Handle publish version + const handlePublish = async (values: { version_name?: string; release_note?: string }) => { + if (!currentAgentId) { + message.error(t("agent.error.agentNotFound")); + return; + } + + // Prevent duplicate submissions + if (isPublishing) { + log.warn("Publish request already in progress, ignoring duplicate click"); + return; + } + + try { + setIsPublishing(true); + await publishVersion(currentAgentId, values); + message.success(t("agent.version.publishSuccess")); + setIsPublishModalOpen(false); + publishForm.resetFields(); + invalidateAgentVersionList(); + invalidateAgentInfo(); + queryClient.invalidateQueries({ queryKey: ["agents"] }); + } catch (error) { + log.error("Failed to publish version:", error); + message.error(t("agent.version.publishFailed")); + } finally { + setIsPublishing(false); + } + }; + + const footer = [ + + + {t("agent.version.totalVersions", { count: total })} + + + , + ]; + + return ( + <> + + + {t("agent.version.manage")} + + } + extra={ + + } + actions={footer} + styles={{ + body: { + height: "calc(100% - 112px)", + overflow: "auto", + }, + }} + > + {/* Desktop: Timeline style version list */} +
+ + {agentVersionList.length === 0 ? ( + + + + ) : ( + + {agentVersionList.map((version) => ( + + ))} + + )} + +
+
+ + {/* Publish Version Modal */} + setIsPublishModalOpen(false)} + footer={null} + destroyOnHidden + > +
+ + + + +