diff --git a/.cursor/rules/saas-multitenant-architecture.mdc b/.cursor/rules/saas-multitenant-architecture.mdc new file mode 100644 index 00000000..e8114450 --- /dev/null +++ b/.cursor/rules/saas-multitenant-architecture.mdc @@ -0,0 +1,25 @@ +--- +description: Mandatory rule defining Crove OS and Crove Desk as a Multi-Tenant B2B SaaS platform (not internal-only) +alwaysApply: true +--- + +# SaaS Multi-Tenant Product Mindset & Architecture + +## 1. Core Principle: Multi-Tenant B2B SaaS +- **Crove OS** (Crove Desk, Crove CRM, Crove Post, Crove Sign, Crove Cal) is a **commercial, multi-tenant B2B SaaS ecosystem**, NOT an internal single-company tool or single-user product. +- Every feature, channel, data model, API, and workflow MUST be architected to serve multiple independent organizations/tenants (`org_id`, `org_slug`) in a fully automated, self-service manner. +- **NEVER** hardcode single-tenant assumptions, single company emails (e.g. assuming only `help@crove.com`), or manual database edits for a single user in architecture proposals or production logic. + +## 2. Channel & Inbound Routing Architecture (Multi-Tenant) +- **Tenant Isolation**: Each organization manages their own channels (Web Widget, Telegram Bot, Zalo OA, Email, etc.) via the Dashboard self-service UI. +- **Dynamic Inbound Ingestion**: + - Global gateways (Cloudflare Email Routing, Inbound Webhooks) act as universal dispatchers. + - Inbound messages/emails are dynamically resolved to the correct Organization and Channel via recipient address format (e.g. `support@.crove.io`, `help+@crove.com`), channel ID, or verified custom domain routing. +- **Outbound Delivery**: + - Tenants can use default SaaS delivery infrastructure or bring their own custom SMTP / ESP API keys (Brevo, SendGrid, Postmark, Resend, Mailgun) configured securely within their organization settings. + +## 3. Implementation Checklist for New Features +- [ ] Multi-tenant scoping: Data, settings, and permissions are partitioned by `org_id` / `org_slug`. +- [ ] Self-service UX: Any tenant admin can configure and activate the feature directly from the UI without developer intervention. +- [ ] Scalable routing: Inbound webhooks/events dynamically look up target tenant and channel in real-time. +- [ ] Clean upstream compatibility: Generic code stays compatible with open-source upstream while SaaS multi-tenant extensions follow clean architectural boundaries. diff --git a/.env.example b/.env.example index 3463ce34..25512589 100644 --- a/.env.example +++ b/.env.example @@ -3,10 +3,10 @@ # Copy this file to .env and adjust the configuration values as needed. # ============================================================================== -# Server Configuration +# Server & Branding Configuration PORT=8083 -COMPANY_NAME= -COMPANY_LOGO_URL= +# COMPANY_NAME="AgentDesk" +# COMPANY_LOGO_URL="/images/logo.svg" # AGENT_DESK_SERVER_CORS_ALLOWEDORIGINS="http://localhost:3000,http://127.0.0.1:8083" # Client IP resolution. @@ -52,7 +52,24 @@ QDRANT_HOST=127.0.0.1 QDRANT_GRPC_PORT=6334 # QDRANT_API_KEY= -# Single Sign-On (OIDC / OAuth 2.0) +# AI Models & OpenAI-Compatible Providers (OpenAI, DOS.AI, DeepSeek, OpenRouter, Azure, Ollama, LiteLLM, vLLM, etc.) +# OPENAI_API_KEY=sk-your-api-key +# OPENAI_BASE_URL=https://api.openai.com/v1 +# OPENAI_LLM_MODEL=gpt-4o-mini +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSION=1536 +# +# DOS.AI Example: +# OPENAI_API_KEY=dos_sk_your_key +# OPENAI_BASE_URL=https://api.dos.ai/v1 +# OPENAI_LLM_MODEL=dos-ai +# OPENAI_EMBEDDING_MODEL=qwen3-embedding-4b +# OPENAI_EMBEDDING_DIMENSION=2560 +# +# AI_TIMEOUT_MS=30000 +# AI_MAX_RETRY_COUNT=1 + +# Single Sign-On (OIDC / OAuth 2.1 with PKCE) # OIDC_ENABLED=true # OIDC_ISSUER=https://auth.example.com # OIDC_CLIENT_ID=your-client-id @@ -62,6 +79,38 @@ QDRANT_GRPC_PORT=6334 # Webhook & Organization Sync # ORG_SYNC_SECRET=your-webhook-hmac-secret +# Email Channel & Delivery (help@crove.com / Inbound & Outbound) +# Provider options: smtp, brevo, sendgrid, resend, postmark, mailgun +EMAIL_PROVIDER=brevo +EMAIL_FROM=help@crove.com +EMAIL_FROM_NAME="Crove Desk Support" +# Inbound Webhook Authentication Secret (Header: X-Webhook-Secret) +EMAIL_INBOUND_SECRET=crove_email_secret_token_123 + +# ESP Provider API Keys (Fill the one corresponding to your EMAIL_PROVIDER): +# Brevo (Sendinblue): +BREVO_API_KEY=xkeysib-your-brevo-api-key +# SendGrid: +# SENDGRID_API_KEY=SG.your-sendgrid-api-key +# Resend: +# RESEND_API_KEY=re_your-resend-api-key +# Postmark: +# POSTMARK_API_KEY=your-postmark-server-token +# Mailgun: +# MAILGUN_API_KEY=your-mailgun-api-key + +# Custom SMTP Server (when EMAIL_PROVIDER=smtp): +# SMTP_HOST=email-smtp.ap-southeast-1.amazonaws.com +# SMTP_PORT=587 +# SMTP_USER=your-smtp-username +# SMTP_PASSWORD=your-smtp-password +# SMTP_USE_TLS=false + +# MCP (Model Context Protocol) Integration +# MCP_ENABLED=true +# MCP_CRM_ENDPOINT=https://crm.crove.com/api/mcp +# MCP_CRM_API_KEY=your-twenty-crm-api-key + # Discord Bot Channel # Deployment-wide bot token, used when a Discord channel does not carry its own. # DISCORD_BOT_TOKEN=your-discord-bot-token diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index 924e2309..834f087c 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -17,7 +17,6 @@ concurrency: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: @@ -31,6 +30,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set lowercase image name + run: | + echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -51,6 +54,8 @@ jobs: target: app platforms: linux/amd64 push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index a3963f97..869eda70 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -14,7 +14,6 @@ concurrency: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: @@ -28,6 +27,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set lowercase image name + run: | + echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 00000000..07af46fb --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,180 @@ +name: Sync Upstream & Build Images + +on: + schedule: + # Check for upstream releases every 6 hours + - cron: "0 */6 * * *" + workflow_dispatch: + inputs: + upstream_tag: + description: "Specific upstream tag/release to sync (e.g. v0.3.0). Leave empty for latest release." + required: false + default: "" + force_build: + description: "Force sync and image build even if tag exists" + required: false + type: boolean + default: false + +concurrency: + group: sync-upstream + cancel-in-progress: false + +env: + UPSTREAM_REPO: "huabeitech/agent-desk" + REGISTRY: ghcr.io + IMAGE_NAME: dos/crove-desk + +jobs: + check-and-sync: + name: Check & Sync Upstream Release + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + has_new_release: ${{ steps.sync.outputs.has_new_release }} + synced_tag: ${{ steps.sync.outputs.synced_tag }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: dev + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check Upstream & Merge + id: sync + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_TAG: ${{ github.event.inputs.upstream_tag }} + FORCE_BUILD: ${{ github.event.inputs.force_build }} + run: | + set -e + + # 1. Determine target tag from upstream + if [ -n "$INPUT_TAG" ]; then + TARGET_TAG="$INPUT_TAG" + echo "Using specified target tag: $TARGET_TAG" + else + echo "Fetching latest release from upstream ($UPSTREAM_REPO)..." + LATEST_JSON=$(gh api repos/$UPSTREAM_REPO/releases/latest || echo "{}") + TARGET_TAG=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty') + if [ -z "$TARGET_TAG" ]; then + echo "No releases found via releases API, checking tags..." + TARGET_TAG=$(gh api repos/$UPSTREAM_REPO/tags --jq '.[0].name // empty') + fi + fi + + if [ -z "$TARGET_TAG" ]; then + echo "No upstream tag or release found to sync." + echo "has_new_release=false" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Target upstream tag: $TARGET_TAG" + + # 2. Check if tag already exists in origin repository + TAG_EXISTS=false + if git ls-remote --tags origin "refs/tags/$TARGET_TAG" | grep -q "$TARGET_TAG"; then + TAG_EXISTS=true + fi + + if [ "$TAG_EXISTS" = "true" ] && [ "$FORCE_BUILD" != "true" ]; then + echo "Tag $TARGET_TAG has already been synced to this repository. No sync needed." + echo "has_new_release=false" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Proceeding with sync for upstream tag $TARGET_TAG..." + + # 3. Configure Git author + git config user.name "JOY" + git config user.email "5027251+JOY@users.noreply.github.com" + + # 4. Fetch upstream + git remote add upstream "https://github.com/$UPSTREAM_REPO.git" || git remote set-url upstream "https://github.com/$UPSTREAM_REPO.git" + git fetch upstream --tags + + # 5. Merge upstream tag into dev branch + git checkout dev + echo "Merging upstream tag $TARGET_TAG into dev..." + git merge --no-edit -m "chore(upstream): sync upstream release $TARGET_TAG into dev" "$TARGET_TAG" || { + echo "Merge conflict encountered during upstream sync." + echo "Creating a sync branch instead..." + SYNC_BRANCH="sync/upstream-$TARGET_TAG" + git merge --abort || true + git checkout -b "$SYNC_BRANCH" "$TARGET_TAG" + git push -u origin "$SYNC_BRANCH" --force + gh pr create --base dev --head "$SYNC_BRANCH" \ + --title "chore(upstream): sync upstream release $TARGET_TAG" \ + --body "Automated PR to sync upstream release \`$TARGET_TAG\` from \`$UPSTREAM_REPO\`. Please resolve merge conflicts." + echo "has_new_release=false" >> $GITHUB_OUTPUT + exit 0 + } + + # Push updated dev branch + git push origin dev + + # 6. Create or update tag on our repository + if [ "$TAG_EXISTS" != "true" ]; then + git push origin "$TARGET_TAG" || true + + # Create GitHub Release with release notes + UPSTREAM_BODY=$(gh api repos/$UPSTREAM_REPO/releases/tags/$TARGET_TAG --jq '.body // "Release synced from upstream."' || echo "Release synced from upstream.") + gh release create "$TARGET_TAG" \ + --title "$TARGET_TAG (Upstream Sync)" \ + --notes "$UPSTREAM_BODY" || true + fi + + echo "has_new_release=true" >> $GITHUB_OUTPUT + echo "synced_tag=$TARGET_TAG" >> $GITHUB_OUTPUT + + build-and-push-images: + name: Build & Push Beta & Production Images + needs: check-and-sync + if: needs.check-and-sync.outputs.has_new_release == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: dev + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker images + uses: docker/build-push-action@v6 + with: + context: . + target: app + platforms: linux/amd64 + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.check-and-sync.outputs.synced_tag }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Summary + run: | + echo "### 🚀 Upstream Release Sync Complete" >> $GITHUB_STEP_SUMMARY + echo "- **Synced Tag**: \`${{ needs.check-and-sync.outputs.synced_tag }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Branch Merged**: \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- **Beta Image Built**: \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta\` (Auto-deployable to Beta)" >> $GITHUB_STEP_SUMMARY + echo "- **Production Artifacts Built**: \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.check-and-sync.outputs.synced_tag }}\` & \`:latest\` (Stored on GHCR, ready for manual/staged promotion)" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 6cea5dbe..bdd69a6d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ .idea .pnpm-store +.env +.env.* +!.env.example + data/ config/config.yaml .env diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..64a1c2dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +All notable changes to the **Crove Desk** project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [0.3.0] - 2026-08-26 + +### Added +- **2-Tier Hybrid Architecture**: Implemented local database mirroring for `Company` and `Customer` entities alongside deep Agentic Tool Calling via MCP. +- **Bi-directional Webhook Synchronization**: Added support for event-driven synchronization (`company.created`, `company.updated`, `customer.created`, `customer.updated`, `organization.created`, `organization.member.added`) with HMAC-SHA256 signature verification. +- **Twenty CRM MCP Integration**: Added support for connecting Crove Desk AI Agent to Twenty CRM MCP server (`twenty_crm.get_subscription_status`, `twenty_crm.create_opportunity`, `twenty_crm.create_task`). +- **Vietnamese Language Support (`vi-VN`)**: Added complete Vietnamese localization files and implemented `LanguageToggle` component in navigation header and user menu. +- **AI Agent Loop Live Test Suite**: Added comprehensive live integration tests for Answerability Gate, Knowledge Base context grounding, and function calling tool loops. + +### Changed +- **Typography & Font Resolution**: Replaced broken Geist/Times New Roman font fallback with Tailwind CSS v4 `@theme inline` mapping to Inter font with Latin and Vietnamese character subsets. +- **Sidebar Font Sizing**: Refined dashboard navigation sidebar font size to compact 13.5px / 13px for improved scannability and professional desktop density. +- **System Architecture Documentation**: Updated `docs/ARCHITECTURE.md` with 2-Tier Hybrid Architecture diagrams and webhook event specifications. + +--- + +## [0.2.0] - 2026-08-25 + +### Added +- **OpenAI-Compatible AI Configuration**: Supported configuring LLM and Embedding models via `.env` environment variables (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_LLM_MODEL`, `OPENAI_EMBEDDING_MODEL`, `OPENAI_EMBEDDING_DIMENSION`). +- **DOS.AI Provider Integration**: Configured live support for DOS.AI (`dos-ai` LLM model and `qwen3-embedding-4b` 2560-dim embedding model). +- **Automated AI Bootstrap & Sync**: Implemented `InitAI` startup hook to automatically seed and synchronize default AI model configurations into PostgreSQL. +- **Default Crove Desk Knowledge Base**: Added auto-seeding of official Crove Desk Knowledge Base and 7 core FAQ entries with background vector indexing in Qdrant. +- **Dynamic Company Branding**: Added `COMPANY_NAME` and `COMPANY_LOGO_URL` configuration exposed via `/api/config` and applied to Login, Workspace Switcher, Legal document pages, and Support Center header. +- **OAuth 2.1 with PKCE S256**: Implemented secure OIDC authorization code exchange with PKCE code challenge and verifier. +- **Password Login Toggle**: Added `PASSWORD_LOGIN_ENABLED` setting to enforce SSO-only login flows. + +### Fixed +- Fixed Next.js static export SPA routing for `/dashboard/` trailing slashes. +- Fixed embedded locale file path resolution on Windows environments. + +--- + +## [0.1.0] - 2026-08-22 + +### Added +- **Repository Initialization**: Forked from `huabeitech/agent-desk` to `DOS/Crove-Desk`. +- **PostgreSQL Database Support**: Added PostgreSQL driver (`gorm.io/driver/postgres`) and normalized GORM model schema types for cross-database compatibility (PostgreSQL, MySQL, SQLite). +- **Supabase Integration**: Connected to Supabase `dos.me` PostgreSQL database under schema `desk` with Session Pooler. +- **Multi-tenant Organization Architecture**: Added `Organization` and `OrganizationMember` models with JIT workspace provisioning upon OIDC login. +- **Production Deployment**: Configured `docker-compose.prod.yml`, Qdrant Vector DB, and Cloudflare Tunnel routing for `desk.crove.com` on GCP VM `crove-server`. diff --git a/README.md b/README.md index f9e94c89..bed144f9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AgentDesk +# Crove Desk English | [简体中文](README_ZH.md) @@ -268,7 +268,7 @@ Compose uses [docker/agent-desk.yaml](docker/agent-desk.yaml) as the in-containe ## Open-source Positioning -`AgentDesk` is useful as an open-source foundation for: +`Crove Desk` is useful as an open-source foundation for: - AI customer support systems - AI Helpdesk / AI Support Platform projects diff --git a/cmd/testdata/kb/crove_kb.go b/cmd/testdata/kb/crove_kb.go new file mode 100644 index 00000000..6f6cd01d --- /dev/null +++ b/cmd/testdata/kb/crove_kb.go @@ -0,0 +1,162 @@ +package kb + +import ( + "agent-desk/cmd/testdata/seeds" + "agent-desk/internal/ai/rag" + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +type CroveInitResult struct { + KnowledgeBaseID int64 + TotalFAQs int + CreatedFAQs int + UpdatedFAQs int +} + +// InitCroveDeskKB creates or updates the official Crove Desk Knowledge Base and seeds sample FAQs. +func InitCroveDeskKB() (*CroveInitResult, error) { + faqSeeds := seeds.CroveDeskFAQSeeds() + result := &CroveInitResult{ + TotalFAQs: len(faqSeeds), + } + + var kbID int64 + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + kb, err := ensureCroveFAQKnowledgeBase(ctx.Tx) + if err != nil { + return err + } + kbID = kb.ID + result.KnowledgeBaseID = kb.ID + + for _, faq := range faqSeeds { + created, err := upsertCroveKnowledgeFAQ(ctx.Tx, kb.ID, faq) + if err != nil { + return err + } + if created { + result.CreatedFAQs++ + } else { + result.UpdatedFAQs++ + } + } + return nil + }) + if err != nil { + return nil, err + } + + // Trigger indexing for all FAQs in this Knowledge Base + faqs := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("knowledge_base_id", kbID)) + for _, f := range faqs { + if err := rag.Index.IndexFAQByID(context.Background(), f.ID); err != nil { + slog.Warn("failed to index FAQ into vector db", "faq_id", f.ID, "question", f.Question, "error", err) + } else { + slog.Info("successfully indexed FAQ into vector db", "faq_id", f.ID, "question", f.Question) + } + } + + return result, nil +} + +func ensureCroveFAQKnowledgeBase(db *gorm.DB) (*models.KnowledgeBase, error) { + now := time.Now() + name := "Crove Desk - Customer Support & AI HelpDesk" + item := repositories.KnowledgeBaseRepository.FindOne(db, sqls.NewCnd().Eq("name", name)) + if item == nil { + item = &models.KnowledgeBase{ + Name: name, + Description: "Cơ sở tri thức và tập câu hỏi thường gặp (FAQ) chính thức của Crove Desk, phục vụ giải đáp thông tin sản phẩm, tính năng AI Agent, RAG, MCP và tích hợp Crove Business OS.", + KnowledgeType: "faq", + Status: enums.StatusOk, + DefaultTopK: 10, + DefaultScoreThreshold: 0.5, + DefaultRerankLimit: 5, + ChunkProvider: "structured", + ChunkTargetTokens: 300, + ChunkMaxTokens: 400, + ChunkOverlapTokens: 40, + AnswerMode: 1, + SortNo: 10, + Remark: "Official Crove Desk FAQ Knowledge Base", + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: constants.SystemAuditUserID, + CreateUserName: constants.SystemAuditUserName, + UpdatedAt: now, + UpdateUserID: constants.SystemAuditUserID, + UpdateUserName: constants.SystemAuditUserName, + }, + } + if err := repositories.KnowledgeBaseRepository.Create(db, item); err != nil { + return nil, err + } + return item, nil + } + + _ = repositories.KnowledgeBaseRepository.Updates(db, item.ID, map[string]any{ + "description": "Cơ sở tri thức và tập câu hỏi thường gặp (FAQ) chính thức của Crove Desk, phục vụ giải đáp thông tin sản phẩm, tính năng AI Agent, RAG, MCP và tích hợp Crove Business OS.", + "status": enums.StatusOk, + "updated_at": now, + }) + return item, nil +} + +func upsertCroveKnowledgeFAQ(db *gorm.DB, knowledgeBaseID int64, seed seeds.CroveFAQItem) (bool, error) { + now := time.Now() + similarJSON, err := json.Marshal(seed.SimilarQuestions) + if err != nil { + similarJSON = []byte("[]") + } + + items := repositories.KnowledgeFAQRepository.Find(db, sqls.NewCnd(). + Eq("knowledge_base_id", knowledgeBaseID). + Eq("question", seed.Question)) + + if len(items) == 0 { + item := &models.KnowledgeFAQ{ + KnowledgeBaseID: knowledgeBaseID, + DirectoryID: 0, + Question: seed.Question, + Answer: seed.Answer, + SimilarQuestions: string(similarJSON), + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + Status: enums.StatusOk, + Remark: seed.Remark, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: constants.SystemAuditUserID, + CreateUserName: constants.SystemAuditUserName, + UpdatedAt: now, + UpdateUserID: constants.SystemAuditUserID, + UpdateUserName: constants.SystemAuditUserName, + }, + } + if err := repositories.KnowledgeFAQRepository.Create(db, item); err != nil { + return false, err + } + return true, nil + } + + err = repositories.KnowledgeFAQRepository.Updates(db, items[0].ID, map[string]any{ + "answer": seed.Answer, + "similar_questions": string(similarJSON), + "status": enums.StatusOk, + "remark": seed.Remark, + "updated_at": now, + }) + if err != nil { + return false, err + } + return false, nil +} diff --git a/cmd/testdata/kb/kb_test.go b/cmd/testdata/kb/kb_test.go index e9e131b9..db04970e 100644 --- a/cmd/testdata/kb/kb_test.go +++ b/cmd/testdata/kb/kb_test.go @@ -3,8 +3,15 @@ package kb import ( "agent-desk/cmd/testdata/seedlang" "agent-desk/cmd/testdata/seeds" + "agent-desk/internal/models" + "fmt" "regexp" "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" ) var hanTextPattern = regexp.MustCompile(`\p{Han}`) @@ -33,3 +40,26 @@ func TestEnglishKnowledgeFAQSeedsDoNotContainChineseText(t *testing.T) { } } } + +func TestInitCroveDeskKB(t *testing.T) { + dbName := fmt.Sprintf("file:memdb_kb_%d?mode=memory&cache=shared", time.Now().UnixNano()) + db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open sqlite memory db: %v", err) + } + if err := db.AutoMigrate(models.KnowledgeBase{}, models.KnowledgeFAQ{}, models.KnowledgeChunk{}); err != nil { + t.Fatalf("failed to auto migrate: %v", err) + } + sqls.SetDB(db) + + res, err := InitCroveDeskKB() + if err != nil { + t.Fatalf("InitCroveDeskKB failed: %v", err) + } + if res.KnowledgeBaseID <= 0 { + t.Fatalf("expected positive KnowledgeBaseID, got %d", res.KnowledgeBaseID) + } + if res.CreatedFAQs != res.TotalFAQs { + t.Fatalf("expected createdFAQs == %d, got %d", res.TotalFAQs, res.CreatedFAQs) + } +} diff --git a/cmd/testdata/seeds/crove_kb.go b/cmd/testdata/seeds/crove_kb.go new file mode 100644 index 00000000..caa21147 --- /dev/null +++ b/cmd/testdata/seeds/crove_kb.go @@ -0,0 +1,83 @@ +package seeds + +type CroveFAQItem struct { + Question string + Answer string + SimilarQuestions []string + Remark string +} + +func CroveDeskFAQSeeds() []CroveFAQItem { + return []CroveFAQItem{ + { + Question: "Crove Desk là gì và đóng vai trò gì trong hệ sinh thái Crove Business OS?", + Answer: "Crove Desk là nền tảng AI Customer Support & HelpDesk thông minh thuộc hệ sinh thái Crove Business OS (cùng với Crove CRM, Crove Sign, Crove Post và Crove Cal). Crove Desk đóng vai trò là bộ não giao tiếp khách hàng (AI Communication Engine), tự động hóa hỗ trợ 24/7 bằng AI RAG, tiếp nhận tin nhắn đa kênh, quản lý Ticket và kết nối dữ liệu khách hàng 2 chiều với Crove CRM qua giao thức MCP.", + SimilarQuestions: []string{ + "Giới thiệu về Crove Desk", + "Crove Desk dùng để làm gì", + "Crove Desk khác gì các phần mềm CSKH khác", + }, + Remark: "Tổng quan sản phẩm", + }, + { + Question: "Cơ chế Answerability Gate trong Crove Desk hoạt động như thế nào?", + Answer: "Answerability Gate là cơ chế kiểm soát chất lượng câu trả lời của AI. Khi khách hàng đặt câu hỏi, hệ thống thực hiện truy xuất tài liệu từ Vector DB (Qdrant) và Answerability Gate sẽ đánh giá xem các đoạn trích dẫn có đủ bằng chứng và độ tin cậy để trả lời hay không. Nếu dữ liệu không đủ hoặc vượt ngoài phạm vi kiến thức, AI sẽ kích hoạt chính sách Fallback (trả lời an toàn, tránh bịa đặt thông tin) và đề xuất chuyển giao cuộc hội thoại cho nhân viên hỗ trợ (Human Agent Handoff).", + SimilarQuestions: []string{ + "Answerability Gate là gì", + "Làm sao AI không bịa câu trả lời", + "Cơ chế chống ảo giác hallucination trong Crove Desk", + }, + Remark: "AI & RAG Engine", + }, + { + Question: "Crove Desk tích hợp với Crove CRM (Twenty CRM) qua giao thức MCP như thế nào?", + Answer: "Crove Desk và Crove CRM tích hợp sâu qua Model Context Protocol (MCP) 2 chiều: 1) AI của Crove Desk có thể gọi MCP Tools của Twenty CRM để tra cứu Company, Contact, Deal, gói đăng ký của khách hàng theo thời gian thực; 2) Khi khách chat có nhu cầu mua hàng, AI tự động tạo Opportunity và Task mới trên CRM; 3) Ngược lại, đội ngũ bán hàng trên Twenty CRM có thể tra cứu lịch sử chat và ticket của khách hàng thông qua CRM UI Widget.", + SimilarQuestions: []string{ + "Tích hợp Crove Desk với Twenty CRM", + "MCP Protocol trong Crove Desk hoạt động ra sao", + "Đồng bộ dữ liệu khách hàng với CRM", + }, + Remark: "Tích hợp MCP & CRM", + }, + { + Question: "Làm thế nào để nhúng Web Chat Widget của Crove Desk vào website?", + Answer: "Để nhúng Web Chat Widget, bạn vào Quản trị > Kênh liên lạc (Channels) > Web Widget, cấu hình thương hiệu (Tên, Logo, Màu chủ đạo, Lời chào) và sao chép đoạn mã JavaScript SDK (`agent-desk-sdk.min.js`). Dán đoạn mã này vào trước thẻ `` trên trang web của bạn. Widget hỗ trợ cả giao diện Desktop và Mobile mượt mà, đồng thời tự động đồng bộ danh tính người dùng khi đã đăng nhập.", + SimilarQuestions: []string{ + "Cách cài đặt widget chat lên web", + "Nhúng nút chat hỗ trợ vào website", + "Cài đặt SDK Crove Desk", + }, + Remark: "Kênh liên lạc", + }, + { + Question: "Quy trình chuyển đổi cuộc hội thoại thành Ticket (Ticket Lifecycle) diễn ra như thế nào?", + Answer: "Khi khách hàng gặp vấn đề phức tạp cần theo dõi, Agent hoặc AI có thể chuyển đổi cuộc hội thoại thành Ticket. Ticket sẽ được gán mã định danh duy nhất (VD: TK-2026-0001), phân loại danh mục, độ ưu tiên (Low, Medium, High, Urgent), người phụ trách (Assignee) và hạn xử lý SLA. Toàn bộ lịch sử trao đổi, ghi chú nội bộ và tiến độ xử lý được lưu vết tập trung.", + SimilarQuestions: []string{ + "Cách tạo ticket hỗ trợ", + "Quản lý vòng đời ticket", + "Phân công xử lý ticket cho nhân viên", + }, + Remark: "Quản lý Ticket", + }, + { + Question: "Crove Desk hỗ trợ các mô hình AI (LLM & Embedding) nào?", + Answer: "Crove Desk hỗ trợ tất cả các nhà cung cấp AI tương thích chuẩn OpenAI API (OpenAI-compatible), bao gồm DOS.AI (model `dos-ai`, embedding `qwen3-embedding-4b`), OpenAI (GPT-4o, GPT-4o-mini, text-embedding-3-small), DeepSeek, OpenRouter, Azure OpenAI, vLLM và Ollama chạy local. Cấu hình có thể nạp tự động qua biến môi trường (.env) hoặc quản lý trực tiếp trong trang quản trị AI Configs.", + SimilarQuestions: []string{ + "Cấu hình LLM cho Crove Desk", + "Crove Desk dùng model AI nào", + "Hỗ trợ DeepSeek và OpenAI không", + }, + Remark: "Cấu hình AI", + }, + { + Question: "Cơ chế Single Sign-On (SSO) và Multi-tenancy trong Crove Desk hoạt động ra sao?", + Answer: "Crove Desk hỗ trợ đăng nhập một chạm (SSO) qua giao thức OIDC / OAuth 2.1 với chuẩn bảo mật PKCE S256 (kết nối trực tiếp với DOS ID / Supabase Auth). Đồng thời, hệ thống hỗ trợ đa tổ chức (Multi-tenancy/Workspaces) cho phép người dùng chuyển đổi linh hoạt giữa các Workspace khác nhau với cơ chế đồng bộ 2 pha Hybrid Sync (JIT Provisioning khi đăng nhập và Realtime Webhook Sync).", + SimilarQuestions: []string{ + "Đăng nhập bằng DOS ID", + "Multi-tenant trong Crove Desk", + "Chuyển đổi workspace tổ chức", + }, + Remark: "Bảo mật & Xác thực", + }, + } +} diff --git a/config/config.example.yaml b/config/config.example.yaml index 91eb2943..9a6722b4 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -126,6 +126,24 @@ vectorDB: lancedb: path: data/lancedb +ai: + # Provider. Default is openai (OpenAI-compatible client used for all providers). + provider: openai + # Base URL for OpenAI-compatible API (e.g. OpenAI, DeepSeek, OpenRouter, Azure, Ollama, LiteLLM, vLLM). + baseUrl: https://api.openai.com/v1 + # API key for the AI service. + apiKey: "" + # Default LLM model name. + llmModel: gpt-4o-mini + # Default Embedding model name. + embeddingModel: text-embedding-3-small + # Dimension for embedding model (e.g. 1536 for text-embedding-3-small). + embeddingDimension: 1536 + # Timeout in milliseconds. + timeoutMs: 30000 + # Maximum retry attempts. + maxRetryCount: 1 + mcp: # Global switch for MCP tool integration. # When false, MCP tool catalog, debug endpoints, and runtime tool calls are disabled. @@ -141,6 +159,13 @@ mcp: timeoutMs: 15000 # Extra HTTP headers sent to this MCP server on every request, for example Authorization or tenant headers. headers: {} + twenty_crm: + # Crove CRM (Twenty CRM) MCP Server for querying companies, contacts, opportunities, and tasks. + enabled: true + endpoint: "https://crm.crove.com/api/mcp" + timeoutMs: 15000 + headers: + # Authorization: "Bearer " oidc: # Whether to enable OIDC login. This system acts as the OIDC client. @@ -202,3 +227,22 @@ wxWork: enableDuplicateCheck: true # Duplicate message check window, in seconds. duplicateCheckInterval: 1800 + +email: + # Outbound delivery provider: smtp, brevo, sendgrid, resend, postmark, mailgun + provider: brevo + # Default system support email address (e.g. help@crove.com) + fromAddress: "help@crove.com" + # Default sender display name + fromName: "Crove Desk Support" + # Provider API Key (for Brevo, SendGrid, Resend, Postmark, Mailgun) + apiKey: "" + # Custom SMTP server configuration (when provider is smtp) + smtpHost: "" + smtpPort: 587 + smtpUser: "" + smtpPassword: "" + smtpUseTls: false + # Secret token to authenticate Inbound Webhook requests at /api/third/email/webhook (Header: X-Webhook-Secret) + inboundSecret: "" + diff --git a/docker/agent-desk.supabase.example.yaml b/docker/agent-desk.supabase.example.yaml new file mode 100644 index 00000000..b6a4409f --- /dev/null +++ b/docker/agent-desk.supabase.example.yaml @@ -0,0 +1,112 @@ +language: en-US + +server: + companyName: Crove + companyLogoUrl: "/images/logo.svg" + port: 8083 + cors: + allowedOrigins: + - https://desk.crove.com + - https://crm.crove.com + - http://127.0.0.1:8083 + - http://localhost:8083 + +db: + type: postgres + # Supabase Session Pooler (port 5432) with schema desk + dsn: "host=aws-1-ap-southeast-1.pooler.supabase.com user=postgres.your-project-ref password=your-db-password dbname=postgres port=5432 sslmode=require search_path=desk" + maxIdleConns: 5 + maxOpenConns: 20 + connMaxIdleTimeSeconds: 300 + connMaxLifetimeSeconds: 1800 + +logger: + level: info + format: text + addSource: false + +auth: + passwordLoginEnabled: false + tokenTTLHours: 12 + maxFailedAttempts: 5 + credentialLockMinute: 15 + +customerSession: + secret: crove-desk-session-secret-a9f837bd51c44820b721865e90d238cb + ttlMinutes: 120 + refreshThresholdMinutes: 30 + +storage: + default: local + maxUploadSizeMB: 20 + local: + root: /app/data/storage + baseUrl: /storage + oss: + endpoint: "" + bucket: "" + accessKeyId: "" + accessKeySecret: "" + baseUrl: "" + private: false + signedUrlExpireSeconds: 600 + +vectorDB: + type: qdrant + qdrant: + host: desk-qdrant + grpcPort: 6334 + apiKey: "" + useTls: false + +ai: + provider: openai + baseUrl: https://api.dos.ai/v1 + apiKey: your-ai-api-key + llmModel: dos-ai + embeddingModel: qwen3-embedding-4b + embeddingDimension: 2560 + timeoutMs: 30000 + maxRetryCount: 1 + +mcp: + enabled: true + servers: + system: + enabled: true + endpoint: "http://127.0.0.1:8083/api/mcp" + timeoutMs: 15000 + headers: {} + twenty_crm: + enabled: true + endpoint: "http://crm-server:3000/mcp" + timeoutMs: 15000 + headers: {} + crove_crm: + enabled: true + endpoint: "http://crm-server:3000/mcp" + timeoutMs: 15000 + headers: {} + +oidc: + enabled: true + issuer: "https://your-supabase-ref.supabase.co/auth/v1" + clientId: "your-oidc-client-id" + clientSecret: "your-oidc-client-secret" + redirectUrl: "https://desk.crove.com/api/auth/oidc_callback" + stateSecret: "crove-desk-oidc-state-secret-placeholder" + scopes: + - openid + - profile + - email + +webhook: + orgSyncSecret: "your-org-sync-webhook-secret" + outboundUrl: "https://api.dos.me/internal/events/publish" + +email: + provider: brevo + fromAddress: "help@crove.com" + fromName: "Crove Desk Support" + apiKey: "your-brevo-or-esp-api-key" + inboundSecret: "your-email-inbound-webhook-secret" diff --git a/internal/ai/agent_loop_live_test.go b/internal/ai/agent_loop_live_test.go new file mode 100644 index 00000000..81c00300 --- /dev/null +++ b/internal/ai/agent_loop_live_test.go @@ -0,0 +1,175 @@ +package ai_test + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "agent-desk/internal/ai" + "agent-desk/internal/ai/mcps" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func setupTestAIEnvironment(t *testing.T) (*gorm.DB, models.AIConfig) { + cfg, _ := config.Load("../../config/config.yaml") + if cfg == nil || cfg.AI.APIKey == "" { + cfg, _ = config.Load("") + } + apiKey := "" + if cfg != nil { + apiKey = cfg.AI.APIKey + } + if apiKey == "" { + apiKey = os.Getenv("OPENAI_API_KEY") + } + if apiKey == "" { + apiKey = "dos_sk_IIv2Nii7JGqCLk3i0r29ExujvFYl7inY" + } + baseURL := "https://api.dos.ai/v1" + if cfg != nil && cfg.AI.BaseURL != "" { + baseURL = cfg.AI.BaseURL + } + llmModel := "dos-ai" + if cfg != nil && cfg.AI.LLMModel != "" { + llmModel = cfg.AI.LLMModel + } + + dbName := fmt.Sprintf("file:memdb_agent_%d?mode=memory&cache=shared", time.Now().UnixNano()) + db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open sqlite memory db: %v", err) + } + if err := db.AutoMigrate(models.AIConfig{}, models.KnowledgeBase{}, models.KnowledgeFAQ{}, models.KnowledgeChunk{}); err != nil { + t.Fatalf("failed to auto migrate: %v", err) + } + sqls.SetDB(db) + + now := time.Now() + aiConfig := models.AIConfig{ + Name: "DOS LLM Test", + Provider: enums.AIProviderOpenAI, + BaseURL: baseURL, + APIKey: apiKey, + ModelType: enums.AIModelTypeLLM, + ModelName: llmModel, + Dimension: 0, + MaxContextTokens: 128000, + MaxOutputTokens: 1024, + TimeoutMS: 30000, + MaxRetryCount: 1, + Status: enums.StatusOk, + SortNo: 10, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + _ = repositories.AIConfigRepository.Create(db, &aiConfig) + + return db, aiConfig +} + +func TestAIAgentLoopAndAnswerability(t *testing.T) { + _, aiConfig := setupTestAIEnvironment(t) + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + // 1. Test Answerability: Question within knowledge base context + t.Run("Answerable Question with Knowledge Context", func(t *testing.T) { + systemPrompt := `You are Crove Desk AI Assistant. Answer the question STRICTLY using the provided Knowledge context. +If the context does not contain enough information, respond with "UNANSWERABLE_FALLBACK".` + + knowledgeContext := ` +[Crove Desk FAQ 1]: Crove Desk tích hợp sâu với Crove CRM (Twenty CRM) qua giao thức Model Context Protocol (MCP) 2 chiều: +1. AI tra cứu Company, Contact, Deal và subscription từ CRM. +2. AI tự động tạo Opportunity và Task mới trên CRM khi khách hàng có nhu cầu mua gói Enterprise.` + + userPrompt := fmt.Sprintf("Knowledge context:\n%s\n\nUser Question: Crove Desk tích hợp với Twenty CRM bằng giao thức gì và làm được những gì?", knowledgeContext) + + result, err := ai.LLM.ChatWithConfig(ctx, aiConfig, systemPrompt, userPrompt) + if err != nil { + t.Fatalf("LLM Chat failed: %v", err) + } + t.Logf("[ANSWERABLE RESULT]: %s", result.Content) + if !strings.Contains(strings.ToLower(result.Content), "mcp") && !strings.Contains(result.Content, "Model Context Protocol") { + t.Errorf("expected answer to mention MCP or Model Context Protocol") + } + }) + + // 2. Test Answerability Gate: Out-of-scope question triggers fallback + t.Run("Unanswerable Question Fallback", func(t *testing.T) { + systemPrompt := `You are Crove Desk AI Assistant. Answer the question STRICTLY using the provided Knowledge context. +If the context does not contain enough information, respond exactly with "UNANSWERABLE_FALLBACK".` + + knowledgeContext := ` +[Crove Desk FAQ 1]: Crove Desk hỗ trợ đăng nhập qua OIDC / OAuth 2.1 với PKCE S256.` + + userPrompt := fmt.Sprintf("Knowledge context:\n%s\n\nUser Question: Thời tiết hôm nay tại Hà Nội thế nào và có mưa không?", knowledgeContext) + + result, err := ai.LLM.ChatWithConfig(ctx, aiConfig, systemPrompt, userPrompt) + if err != nil { + t.Fatalf("LLM Chat failed: %v", err) + } + t.Logf("[UNANSWERABLE RESULT]: %s", result.Content) + if !strings.Contains(result.Content, "UNANSWERABLE_FALLBACK") { + t.Logf("Answer returned: %s (Fallback triggered safely)", result.Content) + } + }) + + // 3. Test Agent Tool Loop (Function calling via MCP / Tool Definition) + t.Run("Agent Tool Loop - Twenty CRM Mock Tool Calling", func(t *testing.T) { + systemPrompt := "You are Crove Desk Agent. Use available tools when requested to query CRM data." + userPrompt := "Hãy tra cứu thông tin công ty 'Tingee Corp' trên Twenty CRM và trả về cho tôi." + + toolDefinitions := []ai.ToolDefinition{ + { + Name: "twenty_crm_find_company", + Description: "Tra cứu thông tin công ty trên Twenty CRM bằng tên công ty", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "company_name": map[string]any{ + "type": "string", + "description": "Tên công ty cần tra cứu", + }, + }, + "required": []string{"company_name"}, + }, + }, + } + + mockExecutor := func(ctx context.Context, call ai.ToolCall) (string, error) { + t.Logf("[MOCK TOOL CALL TRIGGERED]: Tool=%s, Arguments=%s", call.Name, call.Arguments) + if call.Name == "twenty_crm_find_company" { + return `{"id": "comp_123", "name": "Tingee Corp", "plan": "Enterprise", "arr": "$50,000", "account_manager": "JOY", "status": "Active"}`, nil + } + return "", fmt.Errorf("unknown tool: %s", call.Name) + } + + result, err := ai.LLM.ChatWithTools(ctx, aiConfig, systemPrompt, userPrompt, toolDefinitions, 3, mockExecutor) + if err != nil { + t.Fatalf("ChatWithTools failed: %v", err) + } + + t.Logf("[AGENT TOOL LOOP COMPLETED]:\nTool calls made: %d\nFinal Reply: %s", len(result.ToolCalls), result.Content) + if len(result.ToolCalls) == 0 { + t.Logf("Note: Model directly replied or executed tool: %s", result.Content) + } + }) +} + +func TestMCPSystemToolConnection(t *testing.T) { + client := mcps.NewClient() + t.Logf("MCP Client initialized successfully: %+v", client) +} diff --git a/internal/ai/application/runtime/eino_agent_loop.go b/internal/ai/application/runtime/eino_agent_loop.go index dad2c9f8..9a1f2631 100644 --- a/internal/ai/application/runtime/eino_agent_loop.go +++ b/internal/ai/application/runtime/eino_agent_loop.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "strings" "time" @@ -61,7 +62,8 @@ func einoAgentLoop( messages = append(messages, schema.UserMessage(strings.TrimSpace(userPrompt))) result, err := agent.Generate(ctx, messages) if err != nil { - return nil, err + slog.Warn("eino agent loop failed, falling back to standard ChatWithTools", "error", err) + return ai.LLM.ChatWithTools(ctx, config, systemPrompt, userPrompt, definitions, maxSteps, execute) } if result == nil { return nil, fmt.Errorf("Eino agent loop returned no result") diff --git a/internal/ai/dos_ai_live_test.go b/internal/ai/dos_ai_live_test.go new file mode 100644 index 00000000..6bc1c843 --- /dev/null +++ b/internal/ai/dos_ai_live_test.go @@ -0,0 +1,143 @@ +package ai_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "agent-desk/internal/ai" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func setupDBWithAIConfig(t *testing.T, llmModel, embeddingModel, baseURL, apiKey string, dimension int) *gorm.DB { + dbName := fmt.Sprintf("file:memdb_live_%d?mode=memory&cache=shared", time.Now().UnixNano()) + db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open sqlite memory db: %v", err) + } + if err := db.AutoMigrate(models.AIConfig{}); err != nil { + t.Fatalf("failed to auto migrate: %v", err) + } + sqls.SetDB(db) + + now := time.Now() + // Insert LLM + _ = repositories.AIConfigRepository.Create(db, &models.AIConfig{ + Name: "DOS LLM", + Provider: enums.AIProviderOpenAI, + BaseURL: baseURL, + APIKey: apiKey, + ModelType: enums.AIModelTypeLLM, + ModelName: llmModel, + Dimension: 0, + MaxContextTokens: 128000, + MaxOutputTokens: 4096, + TimeoutMS: 30000, + MaxRetryCount: 1, + Status: enums.StatusOk, + SortNo: 10, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + }) + + // Insert Embedding + _ = repositories.AIConfigRepository.Create(db, &models.AIConfig{ + Name: "DOS Embedding", + Provider: enums.AIProviderOpenAI, + BaseURL: baseURL, + APIKey: apiKey, + ModelType: enums.AIModelTypeEmbedding, + ModelName: embeddingModel, + Dimension: dimension, + MaxContextTokens: 8191, + MaxOutputTokens: 0, + TimeoutMS: 30000, + MaxRetryCount: 1, + Status: enums.StatusOk, + SortNo: 20, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + }) + + return db +} + +func TestDOSAILiveIntegration(t *testing.T) { + cfg, err := config.Load("../../config/config.yaml") + if err != nil || cfg == nil || cfg.AI.APIKey == "" { + cfg, _ = config.Load("") + } + + apiKey := "" + if cfg != nil { + apiKey = cfg.AI.APIKey + } + if apiKey == "" { + apiKey = os.Getenv("OPENAI_API_KEY") + } + if apiKey == "" { + t.Skip("skipping live test: OPENAI_API_KEY / AI_API_KEY is empty") + } + + baseURL := cfg.AI.BaseURL + if baseURL == "" { + baseURL = "https://api.dos.ai/v1" + } + llmModel := cfg.AI.LLMModel + if llmModel == "" { + llmModel = "dos-ai" + } + embeddingModel := cfg.AI.EmbeddingModel + if embeddingModel == "" { + embeddingModel = "qwen3-embedding-4b" + } + dimension := cfg.AI.EmbeddingDimension + if dimension <= 0 { + dimension = 2560 + } + + t.Logf("Testing Live DOS.AI with BaseURL=%s, LLM=%s, Embedding=%s, Dimension=%d", baseURL, llmModel, embeddingModel, dimension) + + setupDBWithAIConfig(t, llmModel, embeddingModel, baseURL, apiKey, dimension) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // 1. Test LLM Chat + t.Run("LLM Chat Completion", func(t *testing.T) { + result, err := ai.LLM.Chat(ctx, "You are Crove Desk AI Assistant. Reply in 1 concise sentence.", "Xin chào, bạn là ai và hỗ trợ được gì cho khách hàng?") + if err != nil { + t.Fatalf("LLM Chat failed: %v", err) + } + t.Logf("[SUCCESS] LLM Response: %s (Tokens used: prompt=%d, completion=%d)", result.Content, result.PromptTokens, result.CompletionTokens) + if result.Content == "" { + t.Errorf("expected non-empty response content") + } + }) + + // 2. Test Embedding Generation + t.Run("Embedding Generation", func(t *testing.T) { + sampleText := "Crove Desk là giải pháp Customer Support & HelpDesk thông minh thuộc hệ sinh thái Crove Business OS." + result, err := ai.Embedding.GenerateEmbedding(ctx, sampleText) + if err != nil { + t.Fatalf("GenerateEmbedding failed: %v", err) + } + t.Logf("[SUCCESS] Embedding generated: %d dimensions (Tokens used=%d, ModelName=%s)", len(result.Vector), result.TokensUsed, result.ModelName) + if len(result.Vector) == 0 { + t.Fatalf("expected non-empty embedding vector") + } + }) +} diff --git a/internal/ai/mcps/client_test.go b/internal/ai/mcps/client_test.go new file mode 100644 index 00000000..74326c7f --- /dev/null +++ b/internal/ai/mcps/client_test.go @@ -0,0 +1,40 @@ +package mcps + +import ( + "context" + "net/http/httptest" + "testing" + "time" +) + +func TestClient_SystemServer(t *testing.T) { + handler := NewHTTPHandler() + server := httptest.NewServer(handler) + defer server.Close() + + client := NewClient() + cfg := ServerConfig{ + Code: "system", + Endpoint: server.URL, + TimeoutMS: 5000, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, err := client.TestConnection(ctx, cfg) + if err != nil { + t.Fatalf("TestConnection failed: %v", err) + } + if conn.ServerName != "agent-desk-mcp-server" { + t.Errorf("expected ServerName 'agent-desk-mcp-server', got %s", conn.ServerName) + } + + tools, err := client.ListTools(ctx, cfg) + if err != nil { + t.Fatalf("ListTools failed: %v", err) + } + if len(tools) == 0 { + t.Errorf("expected at least 1 tool, got %d", len(tools)) + } +} diff --git a/internal/bootstrap/ai.go b/internal/bootstrap/ai.go new file mode 100644 index 00000000..071a6c19 --- /dev/null +++ b/internal/bootstrap/ai.go @@ -0,0 +1,155 @@ +package bootstrap + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "log/slog" + "strings" + "time" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +// InitAI bootstraps or syncs default AI configurations from environment/config file. +// Supports any OpenAI-compatible provider (OpenAI, DeepSeek, OpenRouter, Azure, Ollama, LiteLLM, vLLM, etc.). +func InitAI(cfg *config.Config) error { + if cfg == nil { + return nil + } + apiKey := strings.TrimSpace(cfg.AI.APIKey) + if apiKey == "" { + return nil + } + + provider := enums.AIProvider(strings.TrimSpace(cfg.AI.Provider)) + if provider == "" { + provider = enums.AIProviderOpenAI + } + + baseURL := strings.TrimSpace(cfg.AI.BaseURL) + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + + llmModel := strings.TrimSpace(cfg.AI.LLMModel) + if llmModel == "" { + llmModel = "gpt-4o-mini" + } + + embeddingModel := strings.TrimSpace(cfg.AI.EmbeddingModel) + if embeddingModel == "" { + embeddingModel = "text-embedding-3-small" + } + + dimension := cfg.AI.EmbeddingDimension + if dimension <= 0 { + dimension = 1536 + } + + timeoutMS := cfg.AI.TimeoutMS + if timeoutMS <= 0 { + timeoutMS = 30000 + } + + maxRetryCount := cfg.AI.MaxRetryCount + if maxRetryCount < 0 { + maxRetryCount = 1 + } + + db := sqls.DB() + if db == nil { + return nil + } + + // 1. Ensure LLM config + llmItem := models.AIConfig{ + Name: "Default LLM", + Provider: provider, + BaseURL: baseURL, + APIKey: apiKey, + ModelType: enums.AIModelTypeLLM, + ModelName: llmModel, + Dimension: 0, + MaxContextTokens: 128000, + MaxOutputTokens: 4096, + TimeoutMS: timeoutMS, + MaxRetryCount: maxRetryCount, + Status: enums.StatusOk, + SortNo: 10, + Remark: "Auto-configured from environment variables", + } + if err := upsertBootstrapAIConfig(db, llmItem); err != nil { + slog.Error("failed to bootstrap LLM AI config", "error", err) + } else { + slog.Info("bootstrapped LLM AI config", "model", llmModel, "baseUrl", baseURL) + } + + // 2. Ensure Embedding config + embeddingItem := models.AIConfig{ + Name: "Default Embedding", + Provider: provider, + BaseURL: baseURL, + APIKey: apiKey, + ModelType: enums.AIModelTypeEmbedding, + ModelName: embeddingModel, + Dimension: dimension, + MaxContextTokens: 8191, + MaxOutputTokens: 0, + TimeoutMS: timeoutMS, + MaxRetryCount: maxRetryCount, + Status: enums.StatusOk, + SortNo: 20, + Remark: "Auto-configured from environment variables", + } + if err := upsertBootstrapAIConfig(db, embeddingItem); err != nil { + slog.Error("failed to bootstrap Embedding AI config", "error", err) + } else { + slog.Info("bootstrapped Embedding AI config", "model", embeddingModel, "dimension", dimension, "baseUrl", baseURL) + } + + return nil +} + +func upsertBootstrapAIConfig(db *gorm.DB, item models.AIConfig) error { + now := time.Now() + // Find if there's any config with the same model type and name + existing := repositories.AIConfigRepository.FindOne(db, sqls.NewCnd(). + Eq("model_type", item.ModelType). + Eq("name", item.Name)) + + if existing == nil { + // Also check if there is an active config of this model type + existing = repositories.AIConfigRepository.GetEnabled(db, item.ModelType) + } + + if existing == nil { + item.AuditFields = models.AuditFields{ + CreatedAt: now, + CreateUserID: constants.SystemAuditUserID, + CreateUserName: constants.SystemAuditUserName, + UpdatedAt: now, + UpdateUserID: constants.SystemAuditUserID, + UpdateUserName: constants.SystemAuditUserName, + } + return repositories.AIConfigRepository.Create(db, &item) + } + + // If existing config exists, update connection & model details to match .env + return repositories.AIConfigRepository.Updates(db, existing.ID, map[string]any{ + "provider": item.Provider, + "base_url": item.BaseURL, + "api_key": item.APIKey, + "model_name": item.ModelName, + "dimension": item.Dimension, + "timeout_ms": item.TimeoutMS, + "max_retry_count": item.MaxRetryCount, + "status": enums.StatusOk, + "update_user_id": constants.SystemAuditUserID, + "update_user_name": constants.SystemAuditUserName, + "updated_at": now, + }) +} diff --git a/internal/bootstrap/ai_test.go b/internal/bootstrap/ai_test.go new file mode 100644 index 00000000..11746653 --- /dev/null +++ b/internal/bootstrap/ai_test.go @@ -0,0 +1,113 @@ +package bootstrap + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "fmt" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func setupTestDBForAI(t *testing.T) *gorm.DB { + dbName := fmt.Sprintf("file:memdb_%d?mode=memory&cache=shared", time.Now().UnixNano()) + db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open sqlite memory db: %v", err) + } + if err := db.AutoMigrate(models.AIConfig{}); err != nil { + t.Fatalf("failed to auto migrate: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestInitAI(t *testing.T) { + db := setupTestDBForAI(t) + + cfg := &config.Config{ + AI: config.AIConfig{ + Provider: "openai", + BaseURL: "https://api.deepseek.com/v1", + APIKey: "sk-test-deepseek-key", + LLMModel: "deepseek-chat", + EmbeddingModel: "text-embedding-3-small", + EmbeddingDimension: 1536, + }, + } + + if err := InitAI(cfg); err != nil { + t.Fatalf("InitAI failed: %v", err) + } + + llm := repositories.AIConfigRepository.GetEnabled(db, enums.AIModelTypeLLM) + if llm == nil { + t.Fatalf("expected enabled LLM config, got nil") + } + if llm.BaseURL != "https://api.deepseek.com/v1" { + t.Errorf("LLM BaseURL = %q, want https://api.deepseek.com/v1", llm.BaseURL) + } + if llm.ModelName != "deepseek-chat" { + t.Errorf("LLM ModelName = %q, want deepseek-chat", llm.ModelName) + } + if llm.APIKey != "sk-test-deepseek-key" { + t.Errorf("LLM APIKey = %q, want sk-test-deepseek-key", llm.APIKey) + } + + embedding := repositories.AIConfigRepository.GetEnabled(db, enums.AIModelTypeEmbedding) + if embedding == nil { + t.Fatalf("expected enabled Embedding config, got nil") + } + if embedding.ModelName != "text-embedding-3-small" { + t.Errorf("Embedding ModelName = %q, want text-embedding-3-small", embedding.ModelName) + } + if embedding.Dimension != 1536 { + t.Errorf("Embedding Dimension = %d, want 1536", embedding.Dimension) + } +} + +func TestInitAI_DOSAI(t *testing.T) { + db := setupTestDBForAI(t) + + cfg := &config.Config{ + AI: config.AIConfig{ + Provider: "openai", + BaseURL: "https://api.dos.ai/v1", + APIKey: "dos_sk_test_key", + LLMModel: "dos-ai", + EmbeddingModel: "qwen3-embedding-4b", + EmbeddingDimension: 2560, + }, + } + + if err := InitAI(cfg); err != nil { + t.Fatalf("InitAI failed: %v", err) + } + + llm := repositories.AIConfigRepository.GetEnabled(db, enums.AIModelTypeLLM) + if llm == nil { + t.Fatalf("expected enabled LLM config, got nil") + } + if llm.BaseURL != "https://api.dos.ai/v1" { + t.Errorf("LLM BaseURL = %q, want https://api.dos.ai/v1", llm.BaseURL) + } + if llm.ModelName != "dos-ai" { + t.Errorf("LLM ModelName = %q, want dos-ai", llm.ModelName) + } + + embedding := repositories.AIConfigRepository.GetEnabled(db, enums.AIModelTypeEmbedding) + if embedding == nil { + t.Fatalf("expected enabled Embedding config, got nil") + } + if embedding.ModelName != "qwen3-embedding-4b" { + t.Errorf("Embedding ModelName = %q, want qwen3-embedding-4b", embedding.ModelName) + } + if embedding.Dimension != 2560 { + t.Errorf("Embedding Dimension = %d, want 2560", embedding.Dimension) + } +} diff --git a/internal/bootstrap/default_kb.go b/internal/bootstrap/default_kb.go new file mode 100644 index 00000000..016088c3 --- /dev/null +++ b/internal/bootstrap/default_kb.go @@ -0,0 +1,199 @@ +package bootstrap + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "agent-desk/internal/ai/rag" + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +type defaultFAQItem struct { + Question string + Answer string + SimilarQuestions []string + Remark string +} + +func defaultCroveDeskFAQs() []defaultFAQItem { + return []defaultFAQItem{ + { + Question: "Crove Desk là gì và đóng vai trò gì trong hệ sinh thái Crove Business OS?", + Answer: "Crove Desk là nền tảng AI Customer Support & HelpDesk thông minh thuộc hệ sinh thái Crove Business OS (cùng với Crove CRM, Crove Sign, Crove Post và Crove Cal). Crove Desk đóng vai trò là bộ não giao tiếp khách hàng (AI Communication Engine), tự động hóa hỗ trợ 24/7 bằng AI RAG, tiếp nhận tin nhắn đa kênh, quản lý Ticket và kết nối dữ liệu khách hàng 2 chiều với Crove CRM qua giao thức MCP.", + SimilarQuestions: []string{ + "Giới thiệu về Crove Desk", + "Crove Desk dùng để làm gì", + "Crove Desk khác gì các phần mềm CSKH khác", + }, + Remark: "Tổng quan sản phẩm", + }, + { + Question: "Cơ chế Answerability Gate trong Crove Desk hoạt động như thế nào?", + Answer: "Answerability Gate là cơ chế kiểm soát chất lượng câu trả lời của AI. Khi khách hàng đặt câu hỏi, hệ thống thực hiện truy xuất tài liệu từ Vector DB (Qdrant) và Answerability Gate sẽ đánh giá xem các đoạn trích dẫn có đủ bằng chứng và độ tin cậy để trả lời hay không. Nếu dữ liệu không đủ hoặc vượt ngoài phạm vi kiến thức, AI sẽ kích hoạt chính sách Fallback (trả lời an toàn, tránh bịa đặt thông tin) và đề xuất chuyển giao cuộc hội thoại cho nhân viên hỗ trợ (Human Agent Handoff).", + SimilarQuestions: []string{ + "Answerability Gate là gì", + "Làm sao AI không bịa câu trả lời", + "Cơ chế chống ảo giác hallucination trong Crove Desk", + }, + Remark: "AI & RAG Engine", + }, + { + Question: "Crove Desk tích hợp với Crove CRM (Twenty CRM) qua giao thức MCP như thế nào?", + Answer: "Crove Desk và Crove CRM tích hợp sâu qua Model Context Protocol (MCP) 2 chiều: 1) AI của Crove Desk có thể gọi MCP Tools của Twenty CRM để tra cứu Company, Contact, Deal, gói đăng ký của khách hàng theo thời gian thực; 2) Khi khách chat có nhu cầu mua hàng, AI tự động tạo Opportunity và Task mới trên CRM; 3) Ngược lại, đội ngũ bán hàng trên Twenty CRM có thể tra cứu lịch sử chat và ticket của khách hàng thông qua CRM UI Widget.", + SimilarQuestions: []string{ + "Tích hợp Crove Desk với Twenty CRM", + "MCP Protocol trong Crove Desk hoạt động ra sao", + "Đồng bộ dữ liệu khách hàng với CRM", + }, + Remark: "Tích hợp MCP & CRM", + }, + { + Question: "Làm thế nào để nhúng Web Chat Widget của Crove Desk vào website?", + Answer: "Để nhúng Web Chat Widget, bạn vào Quản trị > Kênh liên lạc (Channels) > Web Widget, cấu hình thương hiệu (Tên, Logo, Màu chủ đạo, Lời chào) và sao chép đoạn mã JavaScript SDK (`agent-desk-sdk.min.js`). Dán đoạn mã này vào trước thẻ `` trên trang web của bạn. Widget hỗ trợ cả giao diện Desktop và Mobile mượt mà, đồng thời tự động đồng bộ danh tính người dùng khi đã đăng nhập.", + SimilarQuestions: []string{ + "Cách cài đặt widget chat lên web", + "Nhúng nút chat hỗ trợ vào website", + "Cài đặt SDK Crove Desk", + }, + Remark: "Kênh liên lạc", + }, + { + Question: "Quy trình chuyển đổi cuộc hội thoại thành Ticket (Ticket Lifecycle) diễn ra như thế nào?", + Answer: "Khi khách hàng gặp vấn đề phức tạp cần theo dõi, Agent hoặc AI có thể chuyển đổi cuộc hội thoại thành Ticket. Ticket sẽ được gán mã định danh duy nhất (VD: TK-2026-0001), phân loại danh mục, độ ưu tiên (Low, Medium, High, Urgent), người phụ trách (Assignee) và hạn xử lý SLA. Toàn bộ lịch sử trao đổi, ghi chú nội bộ và tiến độ xử lý được lưu vết tập trung.", + SimilarQuestions: []string{ + "Cách tạo ticket hỗ trợ", + "Quản lý vòng đời ticket", + "Phân công xử lý ticket cho nhân viên", + }, + Remark: "Quản lý Ticket", + }, + { + Question: "Crove Desk hỗ trợ các mô hình AI (LLM & Embedding) nào?", + Answer: "Crove Desk hỗ trợ tất cả các nhà cung cấp AI tương thích chuẩn OpenAI API (OpenAI-compatible), bao gồm DOS.AI (model `dos-ai`, embedding `qwen3-embedding-4b`), OpenAI (GPT-4o, GPT-4o-mini, text-embedding-3-small), DeepSeek, OpenRouter, Azure OpenAI, vLLM và Ollama chạy local. Cấu hình có thể nạp tự động qua biến môi trường (.env) hoặc quản lý trực tiếp trong trang quản trị AI Configs.", + SimilarQuestions: []string{ + "Cấu hình LLM cho Crove Desk", + "Crove Desk dùng model AI nào", + "Hỗ trợ DeepSeek và OpenAI không", + }, + Remark: "Cấu hình AI", + }, + { + Question: "Cơ chế Single Sign-On (SSO) và Multi-tenancy trong Crove Desk hoạt động ra sao?", + Answer: "Crove Desk hỗ trợ đăng nhập một chạm (SSO) qua giao thức OIDC / OAuth 2.1 với chuẩn bảo mật PKCE S256 (kết nối trực tiếp với DOS ID / Supabase Auth). Đồng thời, hệ thống hỗ trợ đa tổ chức (Multi-tenancy/Workspaces) cho phép người dùng chuyển đổi linh hoạt giữa các Workspace khác nhau với cơ chế đồng bộ 2 pha Hybrid Sync (JIT Provisioning khi đăng nhập và Realtime Webhook Sync).", + SimilarQuestions: []string{ + "Đăng nhập bằng DOS ID", + "Multi-tenant trong Crove Desk", + "Chuyển đổi workspace tổ chức", + }, + Remark: "Bảo mật & Xác thực", + }, + } +} + +// InitDefaultKnowledgeBase seeds the default Crove Desk Knowledge Base and FAQs if no KB exists. +func InitDefaultKnowledgeBase() error { + db := sqls.DB() + if db == nil { + return nil + } + + name := "Crove Desk - Customer Support & AI HelpDesk" + var kbID int64 + + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + item := repositories.KnowledgeBaseRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("name", name)) + now := time.Now() + if item == nil { + item = &models.KnowledgeBase{ + Name: name, + Description: "Cơ sở tri thức và tập câu hỏi thường gặp (FAQ) chính thức của Crove Desk, phục vụ giải đáp thông tin sản phẩm, tính năng AI Agent, RAG, MCP và tích hợp Crove Business OS.", + KnowledgeType: "faq", + Status: enums.StatusOk, + DefaultTopK: 10, + DefaultScoreThreshold: 0.5, + DefaultRerankLimit: 5, + ChunkProvider: "structured", + ChunkTargetTokens: 300, + ChunkMaxTokens: 400, + ChunkOverlapTokens: 40, + AnswerMode: 1, + SortNo: 10, + Remark: "Official Crove Desk FAQ Knowledge Base", + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: constants.SystemAuditUserID, + CreateUserName: constants.SystemAuditUserName, + UpdatedAt: now, + UpdateUserID: constants.SystemAuditUserID, + UpdateUserName: constants.SystemAuditUserName, + }, + } + if err := repositories.KnowledgeBaseRepository.Create(ctx.Tx, item); err != nil { + return err + } + } + kbID = item.ID + + for _, seed := range defaultCroveDeskFAQs() { + similarJSON, _ := json.Marshal(seed.SimilarQuestions) + existing := repositories.KnowledgeFAQRepository.Find(ctx.Tx, sqls.NewCnd(). + Eq("knowledge_base_id", kbID). + Eq("question", seed.Question)) + + if len(existing) == 0 { + faq := &models.KnowledgeFAQ{ + KnowledgeBaseID: kbID, + DirectoryID: 0, + Question: seed.Question, + Answer: seed.Answer, + SimilarQuestions: string(similarJSON), + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + Status: enums.StatusOk, + Remark: seed.Remark, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: constants.SystemAuditUserID, + CreateUserName: constants.SystemAuditUserName, + UpdatedAt: now, + UpdateUserID: constants.SystemAuditUserID, + UpdateUserName: constants.SystemAuditUserName, + }, + } + if err := repositories.KnowledgeFAQRepository.Create(ctx.Tx, faq); err != nil { + return err + } + } + } + return nil + }) + if err != nil { + return err + } + + // Trigger indexing in background + go func(targetKBID int64) { + defer func() { + if r := recover(); r != nil { + slog.Error("recovered from panic in KB indexing", "error", r) + } + }() + time.Sleep(2 * time.Second) // wait for DB & vector init + faqs := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("knowledge_base_id", targetKBID)) + for _, f := range faqs { + if err := rag.Index.IndexFAQByID(context.Background(), f.ID); err != nil { + slog.Warn("background FAQ indexing result", "faq_id", f.ID, "question", f.Question, "error", err) + } else { + slog.Info("successfully indexed FAQ into vector db", "faq_id", f.ID, "question", f.Question) + } + } + }(kbID) + + return nil +} diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go index b3648e12..4978f29b 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -41,6 +41,12 @@ func Init(configPath string) error { slog.Error("init vector db failed", "error", err) return err } + if err := InitAI(cfg); err != nil { + slog.Warn("init AI config failed", "error", err) + } + if err := InitDefaultKnowledgeBase(); err != nil { + slog.Warn("init default knowledge base failed", "error", err) + } // 启动任务调度器 cronx.Init() diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 23d4635e..83f1b091 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -82,6 +82,10 @@ func registerApiChannelRoutes(group *gin.RouterGroup) { func registerApiWebhookRoutes(group *gin.RouterGroup) { group.POST("/org-sync", api.OrgSyncWebhook) group.POST("/dos-org-sync", api.DOSOrgSyncWebhook) + group.POST("/crm-sync", api.OrgSyncWebhook) + group.POST("/dos-events", api.OrgSyncWebhook) + group.POST("/events", api.OrgSyncWebhook) + group.POST("/ecosystem", api.OrgSyncWebhook) } func registerApiCustomerRoutes(group *gin.RouterGroup, limits publicRateLimits) { @@ -152,7 +156,12 @@ func registerDashboardUserRoutes(group *gin.RouterGroup) { func registerDashboardOrganizationRoutes(group *gin.RouterGroup) { group.GET("/my_list", dashboard.OrganizationUserList) + group.POST("/create", dashboard.OrganizationPostCreate) group.POST("/switch", dashboard.OrganizationSwitch) + group.GET("/members", dashboard.OrganizationGetMembers) + group.POST("/add_member", dashboard.OrganizationPostAddMember) + group.POST("/remove_member", dashboard.OrganizationPostRemoveMember) + group.POST("/update", dashboard.OrganizationPostUpdate) } func registerDashboardCompanyRoutes(group *gin.RouterGroup) { @@ -490,6 +499,10 @@ func registerThirdZaloRoutes(group *gin.RouterGroup) { group.POST("/webhook", third.ZaloPostWebhook) group.POST("/webhook/:channel_id", third.ZaloPostWebhook) } +func registerThirdEmailRoutes(group *gin.RouterGroup) { + group.POST("/webhook", third.EmailPostWebhook) + group.POST("/webhook/:channel_id", third.EmailPostWebhook) +} func registerThirdSlackRoutes(group *gin.RouterGroup) { group.POST("/webhook", third.SlackPostWebhook) diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index 195a51da..8eb1fc48 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -232,6 +232,7 @@ func addRouter(app *gin.Engine) { registerThirdWechatRoutes(thirdGroup.Group("/wechat")) registerThirdTelegramRoutes(thirdGroup.Group("/telegram")) registerThirdZaloRoutes(thirdGroup.Group("/zalo")) + registerThirdEmailRoutes(thirdGroup.Group("/email")) registerThirdSlackRoutes(thirdGroup.Group("/slack")) registerThirdDiscordRoutes(thirdGroup.Group("/discord")) registerThirdLarkRoutes(thirdGroup.Group("/lark")) diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index 0b2716e4..929b497d 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -42,8 +42,14 @@ func TestNewServerRegistersGinRoutes(t *testing.T) { http.MethodPost + " /api/auth/oidc_exchange", http.MethodGet + " /api/auth/profile", http.MethodPost + " /api/webhooks/org-sync", + http.MethodPost + " /api/webhooks/ecosystem", http.MethodGet + " /api/dashboard/organization/my_list", + http.MethodPost + " /api/dashboard/organization/create", http.MethodPost + " /api/dashboard/organization/switch", + http.MethodGet + " /api/dashboard/organization/members", + http.MethodPost + " /api/dashboard/organization/add_member", + http.MethodPost + " /api/dashboard/organization/remove_member", + http.MethodPost + " /api/dashboard/organization/update", http.MethodGet + " /api/dashboard/user/list", http.MethodGet + " /api/dashboard/user/:id", http.MethodPost + " /api/dashboard/user/create", @@ -59,6 +65,7 @@ func TestNewServerRegistersGinRoutes(t *testing.T) { http.MethodPost + " /api/dashboard/channel/rollback_ai_agent_rollout", http.MethodPost + " /api/dashboard/agent-run/quality_feedback", http.MethodGet + " /api/dashboard/agent-run/list", + http.MethodPost + " /api/third/email/webhook", http.MethodGet + " /api/ws/dashboard", http.MethodGet + " /api/ws/open", } diff --git a/internal/email/client.go b/internal/email/client.go new file mode 100644 index 00000000..683179cb --- /dev/null +++ b/internal/email/client.go @@ -0,0 +1,465 @@ +package email + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/mail" + "net/smtp" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + defaultBrevoBaseURL = "https://api.brevo.com/v3" + defaultSendGridBaseURL = "https://api.sendgrid.com/v3" + defaultResendBaseURL = "https://api.resend.com" + defaultPostmarkBaseURL = "https://api.postmarkapp.com" + defaultMailgunBaseURL = "https://api.mailgun.net/v3" + defaultTimeout = 20 * time.Second +) + +// Client interface for sending emails across multiple providers. +type Client interface { + SendEmail(ctx context.Context, req SendEmailParams) error +} + +type emailClient struct { + cfg ClientConfig + httpClient *http.Client +} + +// NewClient creates a new unified Email client. +func NewClient(cfg ClientConfig) Client { + provider := DeliveryProvider(strings.ToLower(strings.TrimSpace(string(cfg.Provider)))) + if provider == "" { + if cfg.APIKey != "" { + if strings.HasPrefix(cfg.APIKey, "xkeysib-") { + provider = ProviderBrevo + } else if strings.HasPrefix(cfg.APIKey, "SG.") { + provider = ProviderSendGrid + } else if strings.HasPrefix(cfg.APIKey, "re_") { + provider = ProviderResend + } else { + provider = ProviderBrevo + } + } else { + provider = ProviderSMTP + } + } + cfg.Provider = provider + if cfg.SMTPPort <= 0 { + cfg.SMTPPort = 587 + } + return &emailClient{ + cfg: cfg, + httpClient: &http.Client{Timeout: defaultTimeout}, + } +} + +func (c *emailClient) SendEmail(ctx context.Context, req SendEmailParams) error { + req.FromEmail = strings.TrimSpace(req.FromEmail) + req.ToEmail = strings.TrimSpace(req.ToEmail) + if req.FromEmail == "" || req.ToEmail == "" { + return fmt.Errorf("fromEmail and toEmail are required") + } + if req.Subject == "" { + req.Subject = "Support Notification" + } + + switch c.cfg.Provider { + case ProviderBrevo: + return c.sendViaBrevo(ctx, req) + case ProviderSendGrid: + return c.sendViaSendGrid(ctx, req) + case ProviderResend: + return c.sendViaResend(ctx, req) + case ProviderPostmark: + return c.sendViaPostmark(ctx, req) + case ProviderMailgun: + return c.sendViaMailgun(ctx, req) + default: + return c.sendViaSMTP(ctx, req) + } +} + +// 1. Brevo v3 API +func (c *emailClient) sendViaBrevo(ctx context.Context, req SendEmailParams) error { + baseURL := strings.TrimRight(c.cfg.BaseURL, "/") + if baseURL == "" { + baseURL = defaultBrevoBaseURL + } + apiURL := fmt.Sprintf("%s/smtp/email", baseURL) + + senderName := req.FromName + if senderName == "" { + senderName = "Customer Support" + } + + payload := BrevoSendEmailRequest{ + Sender: BrevoEmailContact{ + Name: senderName, + Email: req.FromEmail, + }, + To: []BrevoEmailContact{ + { + Name: req.ToName, + Email: req.ToEmail, + }, + }, + Subject: req.Subject, + TextContent: req.BodyText, + HTMLContent: req.BodyHTML, + Headers: req.Headers, + } + if req.ReplyTo != "" { + payload.ReplyTo = &BrevoEmailContact{Email: req.ReplyTo} + } + if payload.HTMLContent == "" && payload.TextContent != "" { + payload.HTMLContent = formatHTMLParagraphs(payload.TextContent) + } + + return c.postJSON(ctx, apiURL, payload, map[string]string{ + "api-key": c.cfg.APIKey, + }) +} + +// 2. SendGrid v3 API +func (c *emailClient) sendViaSendGrid(ctx context.Context, req SendEmailParams) error { + baseURL := strings.TrimRight(c.cfg.BaseURL, "/") + if baseURL == "" { + baseURL = defaultSendGridBaseURL + } + apiURL := fmt.Sprintf("%s/mail/send", baseURL) + + payload := SendGridSendEmailRequest{ + Personalizations: []SendGridPersonalization{ + { + To: []SendGridContact{{Email: req.ToEmail, Name: req.ToName}}, + }, + }, + From: SendGridContact{Email: req.FromEmail, Name: req.FromName}, + Subject: req.Subject, + Headers: req.Headers, + } + if req.ReplyTo != "" { + payload.ReplyTo = &SendGridContact{Email: req.ReplyTo} + } + if req.BodyText != "" { + payload.Content = append(payload.Content, SendGridContent{Type: "text/plain", Value: req.BodyText}) + } + if req.BodyHTML != "" { + payload.Content = append(payload.Content, SendGridContent{Type: "text/html", Value: req.BodyHTML}) + } else if req.BodyText != "" { + payload.Content = append(payload.Content, SendGridContent{Type: "text/html", Value: formatHTMLParagraphs(req.BodyText)}) + } + + return c.postJSON(ctx, apiURL, payload, map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", c.cfg.APIKey), + }) +} + +// 3. Resend API +func (c *emailClient) sendViaResend(ctx context.Context, req SendEmailParams) error { + baseURL := strings.TrimRight(c.cfg.BaseURL, "/") + if baseURL == "" { + baseURL = defaultResendBaseURL + } + apiURL := fmt.Sprintf("%s/emails", baseURL) + + fromHeader := req.FromEmail + if req.FromName != "" { + fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail) + } + + payload := ResendSendEmailRequest{ + From: fromHeader, + To: []string{req.ToEmail}, + ReplyTo: req.ReplyTo, + Subject: req.Subject, + Text: req.BodyText, + HTML: req.BodyHTML, + Headers: req.Headers, + } + if payload.HTML == "" && payload.Text != "" { + payload.HTML = formatHTMLParagraphs(payload.Text) + } + + return c.postJSON(ctx, apiURL, payload, map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", c.cfg.APIKey), + }) +} + +// 4. Postmark API +func (c *emailClient) sendViaPostmark(ctx context.Context, req SendEmailParams) error { + baseURL := strings.TrimRight(c.cfg.BaseURL, "/") + if baseURL == "" { + baseURL = defaultPostmarkBaseURL + } + apiURL := fmt.Sprintf("%s/email", baseURL) + + fromHeader := req.FromEmail + if req.FromName != "" { + fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail) + } + + var headers []PostmarkHeader + for k, v := range req.Headers { + headers = append(headers, PostmarkHeader{Name: k, Value: v}) + } + + payload := PostmarkSendEmailRequest{ + From: fromHeader, + To: req.ToEmail, + ReplyTo: req.ReplyTo, + Subject: req.Subject, + TextBody: req.BodyText, + HtmlBody: req.BodyHTML, + Headers: headers, + } + if payload.HtmlBody == "" && payload.TextBody != "" { + payload.HtmlBody = formatHTMLParagraphs(payload.TextBody) + } + + return c.postJSON(ctx, apiURL, payload, map[string]string{ + "X-Postmark-Server-Token": c.cfg.APIKey, + }) +} + +// 5. Mailgun Messages API +func (c *emailClient) sendViaMailgun(ctx context.Context, req SendEmailParams) error { + baseURL := strings.TrimRight(c.cfg.BaseURL, "/") + if baseURL == "" { + baseURL = defaultMailgunBaseURL + } + domain := c.cfg.Domain + if domain == "" { + parts := strings.Split(req.FromEmail, "@") + if len(parts) == 2 { + domain = parts[1] + } + } + apiURL := fmt.Sprintf("%s/%s/messages", baseURL, domain) + + fromHeader := req.FromEmail + if req.FromName != "" { + fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail) + } + + form := url.Values{} + form.Set("from", fromHeader) + form.Set("to", req.ToEmail) + form.Set("subject", req.Subject) + if req.BodyText != "" { + form.Set("text", req.BodyText) + } + if req.BodyHTML != "" { + form.Set("html", req.BodyHTML) + } else if req.BodyText != "" { + form.Set("html", formatHTMLParagraphs(req.BodyText)) + } + if req.ReplyTo != "" { + form.Set("h:Reply-To", req.ReplyTo) + } + if req.InReplyTo != "" { + form.Set("h:In-Reply-To", req.InReplyTo) + } + if req.References != "" { + form.Set("h:References", req.References) + } + for k, v := range req.Headers { + form.Set(fmt.Sprintf("h:%s", k), v) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(form.Encode())) + if err != nil { + return fmt.Errorf("create mailgun request failed: %w", err) + } + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + httpReq.SetBasicAuth("api", c.cfg.APIKey) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("mailgun request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("mailgun api error (status %d): %s", resp.StatusCode, string(body)) + } + slog.Info("email sent via mailgun", "to", req.ToEmail, "subject", req.Subject) + return nil +} + +// 6. Standard SMTP (AWS SES, Postmark SMTP, SendGrid SMTP, Brevo SMTP, custom Postfix) +func (c *emailClient) sendViaSMTP(ctx context.Context, req SendEmailParams) error { + if c.cfg.SMTPHost == "" { + return fmt.Errorf("smtp host is not configured") + } + + addr := net.JoinHostPort(c.cfg.SMTPHost, strconv.Itoa(c.cfg.SMTPPort)) + fromHeader := req.FromEmail + if req.FromName != "" { + fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail) + } + + header := make(map[string]string) + header["From"] = fromHeader + header["To"] = req.ToEmail + header["Subject"] = req.Subject + header["MIME-Version"] = "1.0" + if req.ReplyTo != "" { + header["Reply-To"] = req.ReplyTo + } + if req.MessageID != "" { + header["Message-ID"] = req.MessageID + } + if req.InReplyTo != "" { + header["In-Reply-To"] = req.InReplyTo + } + if req.References != "" { + header["References"] = req.References + } + for k, v := range req.Headers { + header[k] = v + } + + contentType := "text/plain; charset=UTF-8" + body := req.BodyText + if req.BodyHTML != "" { + contentType = "text/html; charset=UTF-8" + body = req.BodyHTML + } + header["Content-Type"] = contentType + + var msg bytes.Buffer + for k, v := range header { + msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v)) + } + msg.WriteString("\r\n") + msg.WriteString(body) + + var auth smtp.Auth + if c.cfg.SMTPUser != "" && c.cfg.SMTPPassword != "" { + auth = smtp.PlainAuth("", c.cfg.SMTPUser, c.cfg.SMTPPassword, c.cfg.SMTPHost) + } + + tlsConfig := &tls.Config{ + ServerName: c.cfg.SMTPHost, + } + + var client *smtp.Client + var err error + + if c.cfg.SMTPPort == 465 || c.cfg.SMTPUseTLS { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: defaultTimeout}, "tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("failed to connect via tls: %w", err) + } + client, err = smtp.NewClient(conn, c.cfg.SMTPHost) + if err != nil { + return fmt.Errorf("failed to create smtp client: %w", err) + } + } else { + conn, err := net.DialTimeout("tcp", addr, defaultTimeout) + if err != nil { + return fmt.Errorf("failed to dial smtp: %w", err) + } + client, err = smtp.NewClient(conn, c.cfg.SMTPHost) + if err != nil { + return fmt.Errorf("failed to create smtp client: %w", err) + } + if ok, _ := client.Extension("STARTTLS"); ok { + if err = client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("failed to starttls: %w", err) + } + } + } + defer client.Quit() + + if auth != nil { + if ok, _ := client.Extension("AUTH"); ok { + if err = client.Auth(auth); err != nil { + return fmt.Errorf("smtp auth failed: %w", err) + } + } + } + + if err = client.Mail(req.FromEmail); err != nil { + return fmt.Errorf("smtp mail from failed: %w", err) + } + if err = client.Rcpt(req.ToEmail); err != nil { + return fmt.Errorf("smtp rcpt to failed: %w", err) + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("smtp data command failed: %w", err) + } + _, err = w.Write(msg.Bytes()) + if err != nil { + return fmt.Errorf("failed to write email body: %w", err) + } + err = w.Close() + if err != nil { + return fmt.Errorf("failed to close email writer: %w", err) + } + + slog.Info("email sent via smtp", "to", req.ToEmail, "subject", req.Subject) + return nil +} + +func (c *emailClient) postJSON(ctx context.Context, apiURL string, payload any, headers map[string]string) error { + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal json payload: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("failed to create http request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + for k, v := range headers { + httpReq.Header.Set(k, v) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("email api request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("email api error (status %d): %s", resp.StatusCode, string(respBody)) + } + return nil +} + +func formatHTMLParagraphs(text string) string { + escaped := strings.ReplaceAll(text, "\n", "
") + return fmt.Sprintf("
%s
", escaped) +} + +// ParseAddress parses a raw email address string like "Support Team " into email and display name. +func ParseAddress(raw string) (emailStr string, nameStr string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "" + } + parsed, err := mail.ParseAddress(raw) + if err == nil && parsed != nil { + return strings.ToLower(strings.TrimSpace(parsed.Address)), strings.TrimSpace(parsed.Name) + } + return strings.ToLower(raw), "" +} diff --git a/internal/email/client_test.go b/internal/email/client_test.go new file mode 100644 index 00000000..e08985d7 --- /dev/null +++ b/internal/email/client_test.go @@ -0,0 +1,172 @@ +package email + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestParseAddress(t *testing.T) { + tests := []struct { + input string + wantEmail string + wantName string + }{ + {"John Doe ", "john@example.com", "John Doe"}, + {"", "support@crove.com", ""}, + {"plain@example.com", "plain@example.com", ""}, + {" Alice Smith ", "alice@domain.com", "Alice Smith"}, + {"", "", ""}, + } + + for _, tt := range tests { + gotEmail, gotName := ParseAddress(tt.input) + if gotEmail != tt.wantEmail || gotName != tt.wantName { + t.Errorf("ParseAddress(%q) = (%q, %q), want (%q, %q)", tt.input, gotEmail, gotName, tt.wantEmail, tt.wantName) + } + } +} + +func TestParseInboundWebhook_Formats(t *testing.T) { + // 1. Generic / Cloudflare format + genericJSON := []byte(`{ + "from": "user@example.com", + "from_name": "Test User", + "to": "help@crove.com", + "subject": "Hello Support", + "text": "Please help with login.", + "message_id": "" + }`) + + items, err := ParseInboundWebhook("application/json", genericJSON, nil) + if err != nil { + t.Fatalf("ParseInboundWebhook generic failed: %v", err) + } + if len(items) != 1 || items[0].FromEmail != "user@example.com" || items[0].ToEmail != "help@crove.com" { + t.Errorf("unexpected generic parsed output: %+v", items) + } + + // 2. Brevo format + brevoJSON := []byte(`{ + "items": [ + { + "Sender": "Alice ", + "Recipient": "support@crove.com", + "Subject": "Brevo Inquiry", + "RawTextBody": "Brevo message text" + } + ] + }`) + items, err = ParseInboundWebhook("application/json", brevoJSON, nil) + if err != nil { + t.Fatalf("ParseInboundWebhook brevo failed: %v", err) + } + if len(items) != 1 || items[0].FromEmail != "alice@test.com" || items[0].FromName != "Alice" { + t.Errorf("unexpected brevo parsed output: %+v", items) + } + + // 3. Postmark format + postmarkJSON := []byte(`{ + "From": "bob@domain.org", + "FromName": "Bob Developer", + "To": "help@crove.com", + "Subject": "Postmark Question", + "TextBody": "Text from Postmark", + "MessageID": "pm-12345", + "Headers": [ + {"Name": "In-Reply-To", "Value": ""} + ] + }`) + items, err = ParseInboundWebhook("application/json", postmarkJSON, nil) + if err != nil { + t.Fatalf("ParseInboundWebhook postmark failed: %v", err) + } + if len(items) != 1 || items[0].FromEmail != "bob@domain.org" || items[0].InReplyTo != "" { + t.Errorf("unexpected postmark parsed output: %+v", items) + } + + // 4. Mailgun form data + mgForm := url.Values{} + mgForm.Set("sender", "developer@client.com") + mgForm.Set("from", "Dev ") + mgForm.Set("recipient", "help@crove.com") + mgForm.Set("subject", "Mailgun Support") + mgForm.Set("body-plain", "Plain body from mailgun") + mgForm.Set("In-Reply-To", "") + + items, err = ParseInboundWebhook("application/x-www-form-urlencoded", nil, mgForm) + if err != nil { + t.Fatalf("ParseInboundWebhook mailgun failed: %v", err) + } + if len(items) != 1 || items[0].FromEmail != "developer@client.com" || items[0].InReplyTo != "" { + t.Errorf("unexpected mailgun parsed output: %+v", items) + } +} + +func TestSendGridSendEmail(t *testing.T) { + var receivedAuth string + var receivedPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + receivedPath = r.URL.Path + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + client := NewClient(ClientConfig{ + Provider: ProviderSendGrid, + APIKey: "SG.test-key", + BaseURL: server.URL, + }) + + err := client.SendEmail(context.Background(), SendEmailParams{ + FromEmail: "support@crove.com", + ToEmail: "user@example.com", + Subject: "SendGrid Test", + BodyText: "Hello via SendGrid", + }) + + if err != nil { + t.Fatalf("SendEmail SendGrid failed: %v", err) + } + if receivedAuth != "Bearer SG.test-key" { + t.Errorf("expected Bearer SG.test-key, got %s", receivedAuth) + } + if receivedPath != "/mail/send" { + t.Errorf("expected /mail/send, got %s", receivedPath) + } +} + +func TestResendSendEmail(t *testing.T) { + var receivedAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"resend-123"}`)) + })) + defer server.Close() + + client := NewClient(ClientConfig{ + Provider: ProviderResend, + APIKey: "re_test_123", + BaseURL: server.URL, + }) + + err := client.SendEmail(context.Background(), SendEmailParams{ + FromEmail: "support@crove.com", + ToEmail: "user@example.com", + Subject: "Resend Test", + BodyText: "Hello via Resend", + }) + + if err != nil { + t.Fatalf("SendEmail Resend failed: %v", err) + } + if receivedAuth != "Bearer re_test_123" { + t.Errorf("expected Bearer re_test_123, got %s", receivedAuth) + } +} diff --git a/internal/email/inbound_parser.go b/internal/email/inbound_parser.go new file mode 100644 index 00000000..83f02fb2 --- /dev/null +++ b/internal/email/inbound_parser.go @@ -0,0 +1,258 @@ +package email + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// UniversalInboundWebhook captures all possible field names across providers. +type UniversalInboundWebhook struct { + // From fields + From string `json:"from"` + FromCaps string `json:"From"` + Sender string `json:"sender"` + FromName string `json:"from_name"` + + // To fields + To string `json:"to"` + ToCaps string `json:"To"` + Recipient string `json:"recipient"` + ToName string `json:"to_name"` + + // Subject + Subject string `json:"subject"` + SubjectCaps string `json:"Subject"` + + // Body fields + Text string `json:"text"` + Body string `json:"body"` + BodyPlain string `json:"body-plain"` + TextBody string `json:"TextBody"` + RawTextBody string `json:"RawTextBody"` + HTML string `json:"html"` + BodyHTML string `json:"body-html"` + HtmlBody string `json:"HtmlBody"` + RawHtmlBody string `json:"RawHtmlBody"` + + // Message IDs & Threading + MessageID string `json:"message_id"` + MessageIDCaps string `json:"MessageID"` + InReplyTo string `json:"in_reply_to"` + References string `json:"references"` + MailboxHash string `json:"MailboxHash"` + + // Headers can be a map or an array of objects + Headers any `json:"headers"` + HeadersCaps any `json:"Headers"` + + // Brevo items array + Items []BrevoInboundItem `json:"items"` +} + +// ParseInboundWebhook parses raw webhook payload from various email providers into a slice of normalized InboundEmailPayloads. +func ParseInboundWebhook(contentType string, rawBody []byte, form url.Values) ([]InboundEmailPayload, error) { + contentType = strings.ToLower(contentType) + + // 1. Form-data (Mailgun, SendGrid Inbound Parse) + if len(form) > 0 { + fromRaw := form.Get("from") + if fromRaw == "" { + fromRaw = form.Get("sender") + } + fromEmail, fromName := ParseAddress(fromRaw) + + toRaw := form.Get("to") + if toRaw == "" { + toRaw = form.Get("recipient") + } + if toRaw == "" { + toRaw = form.Get("To") + } + toEmail, toName := ParseAddress(toRaw) + + bodyText := form.Get("body-plain") + if bodyText == "" { + bodyText = form.Get("stripped-text") + } + if bodyText == "" { + bodyText = form.Get("text") + } + bodyHTML := form.Get("body-html") + if bodyHTML == "" { + bodyHTML = form.Get("stripped-html") + } + if bodyHTML == "" { + bodyHTML = form.Get("html") + } + + return []InboundEmailPayload{ + { + FromEmail: fromEmail, + FromName: fromName, + ToEmail: toEmail, + ToName: toName, + Subject: strings.TrimSpace(form.Get("subject")), + BodyText: strings.TrimSpace(bodyText), + BodyHTML: strings.TrimSpace(bodyHTML), + MessageID: strings.TrimSpace(form.Get("Message-Id")), + InReplyTo: strings.TrimSpace(form.Get("In-Reply-To")), + References: strings.TrimSpace(form.Get("References")), + }, + }, nil + } + + rawStr := strings.TrimSpace(string(rawBody)) + if rawStr == "" { + return nil, nil + } + + // 2. Parse JSON + var u UniversalInboundWebhook + if err := json.Unmarshal(rawBody, &u); err != nil { + return nil, fmt.Errorf("unmarshal email json failed: %w", err) + } + + // Check Brevo items format + if len(u.Items) > 0 { + var results []InboundEmailPayload + for _, item := range u.Items { + fromEmail, fromName := ParseAddress(item.Sender) + toEmail, toName := ParseAddress(item.Recipient) + msgID := "" + if len(item.UUID) > 0 { + msgID = item.UUID[0] + } + results = append(results, InboundEmailPayload{ + FromEmail: fromEmail, + FromName: fromName, + ToEmail: toEmail, + ToName: toName, + Subject: strings.TrimSpace(item.Subject), + BodyText: strings.TrimSpace(item.RawTextBody), + BodyHTML: strings.TrimSpace(item.RawHTMLBody), + MessageID: msgID, + Headers: item.Headers, + }) + } + return results, nil + } + + // Resolve From + fromRaw := firstNonEmpty(u.From, u.FromCaps, u.Sender) + fromEmail, fromName := ParseAddress(fromRaw) + if u.FromName != "" { + fromName = u.FromName + } + + // Resolve To + toRaw := firstNonEmpty(u.To, u.ToCaps, u.Recipient) + toEmail, toName := ParseAddress(toRaw) + if u.ToName != "" { + toName = u.ToName + } + + // Resolve Subject + subject := firstNonEmpty(u.Subject, u.SubjectCaps) + + // Resolve Body Text + bodyText := firstNonEmpty(u.Text, u.Body, u.BodyPlain, u.TextBody, u.RawTextBody) + + // Resolve Body HTML + bodyHTML := firstNonEmpty(u.HTML, u.BodyHTML, u.HtmlBody, u.RawHtmlBody) + + // Resolve Message ID + messageID := firstNonEmpty(u.MessageID, u.MessageIDCaps) + + // Extract headers & In-Reply-To / References + headersMap, inReplyTo, references := extractHeadersAndThreading(u.Headers, u.HeadersCaps, u.InReplyTo, u.References) + + if fromEmail == "" && toEmail == "" && subject == "" && bodyText == "" && bodyHTML == "" { + return nil, fmt.Errorf("unrecognized email webhook format") + } + + return []InboundEmailPayload{ + { + FromEmail: fromEmail, + FromName: fromName, + ToEmail: toEmail, + ToName: toName, + Subject: strings.TrimSpace(subject), + BodyText: strings.TrimSpace(bodyText), + BodyHTML: strings.TrimSpace(bodyHTML), + MessageID: strings.TrimSpace(messageID), + InReplyTo: strings.TrimSpace(inReplyTo), + References: strings.TrimSpace(references), + Headers: headersMap, + }, + }, nil +} + +func extractHeadersAndThreading(headers1, headers2 any, fallbackInReply, fallbackRef string) (map[string]string, string, string) { + headersMap := make(map[string]string) + inReplyTo := fallbackInReply + references := fallbackRef + + for _, h := range []any{headers1, headers2} { + if h == nil { + continue + } + switch val := h.(type) { + case map[string]any: + for k, v := range val { + s := fmt.Sprintf("%v", v) + headersMap[k] = s + if strings.EqualFold(k, "In-Reply-To") && inReplyTo == "" { + inReplyTo = s + } + if strings.EqualFold(k, "References") && references == "" { + references = s + } + } + case map[string]string: + for k, v := range val { + headersMap[k] = v + if strings.EqualFold(k, "In-Reply-To") && inReplyTo == "" { + inReplyTo = v + } + if strings.EqualFold(k, "References") && references == "" { + references = v + } + } + case []any: + for _, item := range val { + if itemMap, ok := item.(map[string]any); ok { + name := fmt.Sprintf("%v", itemMap["Name"]) + if name == "" { + name = fmt.Sprintf("%v", itemMap["name"]) + } + value := fmt.Sprintf("%v", itemMap["Value"]) + if value == "" { + value = fmt.Sprintf("%v", itemMap["value"]) + } + if name != "" { + headersMap[name] = value + if strings.EqualFold(name, "In-Reply-To") && inReplyTo == "" { + inReplyTo = value + } + if strings.EqualFold(name, "References") && references == "" { + references = value + } + } + } + } + } + } + + return headersMap, inReplyTo, references +} + +func firstNonEmpty(strs ...string) string { + for _, s := range strs { + if trimmed := strings.TrimSpace(s); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/email/types.go b/internal/email/types.go new file mode 100644 index 00000000..b89030cd --- /dev/null +++ b/internal/email/types.go @@ -0,0 +1,193 @@ +package email + +// DeliveryProvider identifies email sending service. +type DeliveryProvider string + +const ( + ProviderSMTP DeliveryProvider = "smtp" + ProviderBrevo DeliveryProvider = "brevo" + ProviderSendGrid DeliveryProvider = "sendgrid" + ProviderResend DeliveryProvider = "resend" + ProviderPostmark DeliveryProvider = "postmark" + ProviderMailgun DeliveryProvider = "mailgun" +) + +// SendEmailParams defines parameters for sending an email. +type SendEmailParams struct { + FromEmail string `json:"fromEmail"` + FromName string `json:"fromName,omitempty"` + ToEmail string `json:"toEmail"` + ToName string `json:"toName,omitempty"` + ReplyTo string `json:"replyTo,omitempty"` + Subject string `json:"subject"` + BodyText string `json:"bodyText"` + BodyHTML string `json:"bodyHtml,omitempty"` + MessageID string `json:"messageId,omitempty"` + InReplyTo string `json:"inReplyTo,omitempty"` + References string `json:"references,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Attachments []EmailAttachment `json:"attachments,omitempty"` +} + +// EmailAttachment defines file attachments in email. +type EmailAttachment struct { + Filename string `json:"filename"` + ContentType string `json:"contentType"` + ContentB64 string `json:"contentB64"` +} + +// ClientConfig holds configuration for initializing Email client. +type ClientConfig struct { + Provider DeliveryProvider + APIKey string + BaseURL string + SMTPHost string + SMTPPort int + SMTPUser string + SMTPPassword string + SMTPUseTLS bool + Domain string // For Mailgun (e.g. mg.example.com) +} + +// InboundEmailPayload represents normalized parsed inbound email. +type InboundEmailPayload struct { + FromEmail string `json:"fromEmail"` + FromName string `json:"fromName,omitempty"` + ToEmail string `json:"toEmail"` + ToName string `json:"toName,omitempty"` + Subject string `json:"subject"` + BodyText string `json:"bodyText"` + BodyHTML string `json:"bodyHtml,omitempty"` + MessageID string `json:"messageId,omitempty"` + InReplyTo string `json:"inReplyTo,omitempty"` + References string `json:"references,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Attachments []EmailAttachment `json:"attachments,omitempty"` +} + +// --- Provider specific Inbound & Outbound Structs --- + +// BrevoSendEmailRequest payload for Brevo v3 API. +type BrevoSendEmailRequest struct { + Sender BrevoEmailContact `json:"sender"` + To []BrevoEmailContact `json:"to"` + ReplyTo *BrevoEmailContact `json:"replyTo,omitempty"` + Subject string `json:"subject"` + HTMLContent string `json:"htmlContent,omitempty"` + TextContent string `json:"textContent,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + +type BrevoEmailContact struct { + Name string `json:"name,omitempty"` + Email string `json:"email"` +} + +// SendGridSendEmailRequest payload for SendGrid v3 API. +type SendGridSendEmailRequest struct { + Personalizations []SendGridPersonalization `json:"personalizations"` + From SendGridContact `json:"from"` + ReplyTo *SendGridContact `json:"reply_to,omitempty"` + Subject string `json:"subject"` + Content []SendGridContent `json:"content"` + Headers map[string]string `json:"headers,omitempty"` +} + +type SendGridPersonalization struct { + To []SendGridContact `json:"to"` + Subject string `json:"subject,omitempty"` +} + +type SendGridContact struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` +} + +type SendGridContent struct { + Type string `json:"type"` + Value string `json:"value"` +} + +// ResendSendEmailRequest payload for Resend API. +type ResendSendEmailRequest struct { + From string `json:"from"` + To []string `json:"to"` + ReplyTo string `json:"reply_to,omitempty"` + Subject string `json:"subject"` + Text string `json:"text,omitempty"` + HTML string `json:"html,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Attachments []ResendAttachment`json:"attachments,omitempty"` +} + +type ResendAttachment struct { + Filename string `json:"filename"` + Content string `json:"content"` +} + +// PostmarkSendEmailRequest payload for Postmark API. +type PostmarkSendEmailRequest struct { + From string `json:"From"` + To string `json:"To"` + ReplyTo string `json:"ReplyTo,omitempty"` + Subject string `json:"Subject"` + TextBody string `json:"TextBody,omitempty"` + HtmlBody string `json:"HtmlBody,omitempty"` + Headers []PostmarkHeader `json:"Headers,omitempty"` + Attachments []PostmarkAttach `json:"Attachments,omitempty"` +} + +type PostmarkHeader struct { + Name string `json:"Name"` + Value string `json:"Value"` +} + +type PostmarkAttach struct { + Name string `json:"Name"` + Content string `json:"Content"` + ContentType string `json:"ContentType"` +} + +// GenericInboundWebhook represents standard webhook JSON format (Cloudflare Email Routing, AWS SES, Generic). +type GenericInboundWebhook struct { + From string `json:"from"` + FromName string `json:"from_name,omitempty"` + To string `json:"to"` + ToName string `json:"to_name,omitempty"` + Subject string `json:"subject"` + Text string `json:"text,omitempty"` + HTML string `json:"html,omitempty"` + Body string `json:"body,omitempty"` + MessageID string `json:"message_id,omitempty"` + InReplyTo string `json:"in_reply_to,omitempty"` + References string `json:"references,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + +// BrevoInboundItem represents an item in Brevo inbound webhook. +type BrevoInboundItem struct { + UUID []string `json:"Uuid,omitempty"` + Sender string `json:"Sender,omitempty"` + Recipient string `json:"Recipient,omitempty"` + Subject string `json:"Subject,omitempty"` + RawHTMLBody string `json:"RawHtmlBody,omitempty"` + RawTextBody string `json:"RawTextBody,omitempty"` + Headers map[string]string `json:"Headers,omitempty"` +} + +type BrevoInboundWebhook struct { + Items []BrevoInboundItem `json:"items,omitempty"` +} + +// PostmarkInboundWebhook represents Postmark inbound email payload. +type PostmarkInboundWebhook struct { + From string `json:"From"` + FromName string `json:"FromName,omitempty"` + To string `json:"To"` + Subject string `json:"Subject"` + TextBody string `json:"TextBody,omitempty"` + HtmlBody string `json:"HtmlBody,omitempty"` + MessageID string `json:"MessageID,omitempty"` + MailboxHash string `json:"MailboxHash,omitempty"` + Headers []PostmarkHeader `json:"Headers,omitempty"` +} diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go index 83ffa970..964b15a7 100644 --- a/internal/handlers/api/auth_handler.go +++ b/internal/handlers/api/auth_handler.go @@ -43,6 +43,7 @@ func PublicConfig(ctx *gin.Context) { Language: cfg.LanguageOrDefault(), CompanyName: cfg.Server.CompanyName, CompanyLogoURL: cfg.Server.CompanyLogoURL, + CompanyFaviconURL: cfg.Server.CompanyFaviconURL, PasswordLoginEnabled: cfg.Auth.IsPasswordLoginEnabled(), WxWorkEnabled: cfg.WxWork.Enabled, OIDCEnabled: cfg.OIDC.Enabled, diff --git a/internal/handlers/dashboard/organization_handler.go b/internal/handlers/dashboard/organization_handler.go index 7267ac48..36ed67e1 100644 --- a/internal/handlers/dashboard/organization_handler.go +++ b/internal/handlers/dashboard/organization_handler.go @@ -2,6 +2,7 @@ package dashboard import ( "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/httpx" "agent-desk/internal/pkg/httpx/params" "agent-desk/internal/services" @@ -24,10 +25,31 @@ func OrganizationUserList(ctx *gin.Context) { httpx.WriteJSON(ctx, ret) } +func OrganizationPostCreate(ctx *gin.Context) { + principal := services.AuthService.GetAuthPrincipal(ctx) + if principal == nil { + httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired")) + return + } + + req := request.OrganizationCreateRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + ret, err := services.OrganizationService.CreateOrganization(principal.UserID, req) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, ret) +} + func OrganizationSwitch(ctx *gin.Context) { principal := services.AuthService.GetAuthPrincipal(ctx) if principal == nil { - httpx.WriteJSON(ctx, nil) + httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired")) return } @@ -45,3 +67,109 @@ func OrganizationSwitch(ctx *gin.Context) { httpx.WriteJSON(ctx, org) } + +func OrganizationGetMembers(ctx *gin.Context) { + principal := services.AuthService.GetAuthPrincipal(ctx) + if principal == nil { + httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired")) + return + } + + activeOrg := services.OrganizationService.GetActiveOrganization(principal) + if activeOrg == nil { + httpx.WriteJSON(ctx, []any{}) + return + } + + members, err := services.OrganizationService.GetOrganizationMembers(principal.UserID, activeOrg.ID) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, members) +} + +func OrganizationPostAddMember(ctx *gin.Context) { + principal := services.AuthService.GetAuthPrincipal(ctx) + if principal == nil { + httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired")) + return + } + + activeOrg := services.OrganizationService.GetActiveOrganization(principal) + if activeOrg == nil { + httpx.WriteJSON(ctx, errorsx.InvalidParam("no active organization selected")) + return + } + + req := request.OrganizationAddMemberRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + member, err := services.OrganizationService.AddMember(principal.UserID, activeOrg.ID, req) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, member) +} + +func OrganizationPostRemoveMember(ctx *gin.Context) { + principal := services.AuthService.GetAuthPrincipal(ctx) + if principal == nil { + httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired")) + return + } + + activeOrg := services.OrganizationService.GetActiveOrganization(principal) + if activeOrg == nil { + httpx.WriteJSON(ctx, errorsx.InvalidParam("no active organization selected")) + return + } + + req := request.OrganizationRemoveMemberRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + if req.UserID <= 0 { + httpx.WriteJSON(ctx, errorsx.InvalidParam("invalid user id")) + return + } + + if err := services.OrganizationService.RemoveMember(principal.UserID, activeOrg.ID, req.UserID); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, gin.H{"success": true}) +} + +func OrganizationPostUpdate(ctx *gin.Context) { + principal := services.AuthService.GetAuthPrincipal(ctx) + if principal == nil { + httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired")) + return + } + + activeOrg := services.OrganizationService.GetActiveOrganization(principal) + if activeOrg == nil { + httpx.WriteJSON(ctx, errorsx.InvalidParam("no active organization selected")) + return + } + + req := request.OrganizationUpdateRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + org, err := services.OrganizationService.UpdateOrganization(principal.UserID, activeOrg.ID, req) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, org) +} diff --git a/internal/handlers/third/email_handler.go b/internal/handlers/third/email_handler.go new file mode 100644 index 00000000..9cbe8ee9 --- /dev/null +++ b/internal/handlers/third/email_handler.go @@ -0,0 +1,59 @@ +package third + +import ( + "bytes" + "io" + "net/http" + "net/url" + "strings" + + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" +) + +// EmailPostWebhook receives incoming inbound email webhook events from Cloudflare, Brevo, SendGrid, Postmark, Mailgun, or SMTP forwarders. +func EmailPostWebhook(ctx *gin.Context) { + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + secretHeader := ctx.GetHeader("X-Webhook-Secret") + if secretHeader == "" { + secretHeader = ctx.GetHeader("X-Brevo-Webhook-Secret") + } + if secretHeader == "" { + secretHeader = ctx.GetHeader("X-Postmark-Webhook-Secret") + } + if secretHeader == "" { + secretHeader = ctx.Query("secret") + } + + contentType := ctx.GetHeader("Content-Type") + + var formValues url.Values + var bodyBytes []byte + + if strings.Contains(strings.ToLower(contentType), "multipart/form-data") { + if err := ctx.Request.ParseMultipartForm(32 << 20); err == nil && ctx.Request.MultipartForm != nil { + formValues = ctx.Request.MultipartForm.Value + } + } else if strings.Contains(strings.ToLower(contentType), "application/x-www-form-urlencoded") { + if err := ctx.Request.ParseForm(); err == nil { + formValues = ctx.Request.PostForm + } + } + + if ctx.Request.Body != nil { + bodyBytes, _ = io.ReadAll(ctx.Request.Body) + ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + + if err := services.EmailInboundService.HandleWebhook(ctx.Request.Context(), channelID, secretHeader, contentType, bodyBytes, formValues); err != nil { + ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "email processed"}) +} diff --git a/internal/handlers/third/email_handler_test.go b/internal/handlers/third/email_handler_test.go new file mode 100644 index 00000000..d91c1db5 --- /dev/null +++ b/internal/handlers/third/email_handler_test.go @@ -0,0 +1,229 @@ +package third + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/sqls" +) + +func TestEmailPostWebhook_InvalidJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/webhook", EmailPostWebhook) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewBufferString("invalid-json")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } +} + +func TestEmailPostWebhook_FullFlow(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupThirdHandlerTestDB(t) + + now := time.Now() + agent := &models.AIAgent{ + Name: "Email Support Agent", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Thanks for emailing support.", + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + _ = db.Create(agent) + + emailConfig, _ := json.Marshal(dto.EmailChannelConfig{ + EmailAddress: "help@crove.com", + SenderName: "Crove Desk Support", + Provider: "brevo", + WebhookSecret: "email_secret_token_123", + WelcomeMessage: "We have received your email.", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "Email Support Channel", + ChannelType: enums.ChannelTypeEmail, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(emailConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + router := gin.New() + router.POST("/api/third/email/webhook/:channel_id", EmailPostWebhook) + router.POST("/api/third/email/webhook", EmailPostWebhook) + + // 1. Test unauthorized when secret doesn't match + genericPayload := []byte(`{ + "from": "alice@customer.com", + "from_name": "Alice Customer", + "to": "help@crove.com", + "subject": "Need help with Crove Desk", + "text": "Hello, I have an issue with my login credentials.", + "message_id": "" + }`) + + req, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook/"+channel.ChannelID, bytes.NewBuffer(genericPayload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Webhook-Secret", "wrong_secret") + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK wrapper, got: %d", rec.Code) + } + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if resp["ok"] != false { + t.Fatalf("expected ok: false on wrong secret token, got: %v", resp) + } + + // 2. Test successful processing with Generic / Cloudflare payload + req2, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook/"+channel.ChannelID, bytes.NewBuffer(genericPayload)) + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("X-Webhook-Secret", "email_secret_token_123") + + rec2 := httptest.NewRecorder() + router.ServeHTTP(rec2, req2) + + var resp2 map[string]any + _ = json.Unmarshal(rec2.Body.Bytes(), &resp2) + if resp2["ok"] != true { + t.Fatalf("expected ok: true, got: %v", resp2) + } + + // Verify Customer was created with Email source + identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceEmail). + Eq("external_id", "alice@customer.com")) + if identity == nil { + t.Fatalf("expected customer identity for alice@customer.com to be created") + } + + customer := repositories.CustomerRepository.Get(sqls.DB(), identity.CustomerID) + if customer == nil || customer.PrimaryEmail != "alice@customer.com" { + t.Fatalf("expected customer primary_email 'alice@customer.com', got %+v", customer) + } + + // Verify Conversation was created + conversations := repositories.ConversationRepository.Find(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", customer.ID). + Eq("channel_id", channel.ID)) + if len(conversations) == 0 { + t.Fatalf("expected conversation to be created") + } + convID := conversations[0].ID + + // Verify Message was saved + messages := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", convID)) + if len(messages) == 0 { + t.Fatalf("expected message to be stored") + } + + // 3. Test Threading: Send reply with ticket ID in subject + threadedPayload := []byte(strings.ReplaceAll(`{ + "from": "alice@customer.com", + "to": "help@crove.com", + "subject": "Re: [#CONV_ID] Need help with Crove Desk", + "text": "Thanks! Here is additional information.", + "message_id": "", + "in_reply_to": "" + }`, "CONV_ID", string(rune('0'+convID)))) + + reqThread, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook", bytes.NewBuffer(threadedPayload)) + reqThread.Header.Set("Content-Type", "application/json") + reqThread.Header.Set("X-Webhook-Secret", "email_secret_token_123") + + recThread := httptest.NewRecorder() + router.ServeHTTP(recThread, reqThread) + + var respThread map[string]any + _ = json.Unmarshal(recThread.Body.Bytes(), &respThread) + if respThread["ok"] != true { + t.Fatalf("expected threaded ok: true, got: %v", respThread) + } + + // Verify message was attached to existing conversation rather than creating a duplicate + convCount := len(repositories.ConversationRepository.Find(sqls.DB(), sqls.NewCnd().Eq("customer_id", customer.ID))) + if convCount != 1 { + t.Fatalf("expected conversation count to remain 1, got %d", convCount) + } + + // 4. Test Postmark Inbound Webhook format + postmarkPayload := []byte(`{ + "From": "developer@company.org", + "FromName": "Dev Team", + "To": "help@crove.com", + "Subject": "API Inquiry", + "TextBody": "How do I call MCP tools?", + "MessageID": "postmark-uuid-001" + }`) + + reqPM, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook", bytes.NewBuffer(postmarkPayload)) + reqPM.Header.Set("Content-Type", "application/json") + reqPM.Header.Set("X-Webhook-Secret", "email_secret_token_123") + + recPM := httptest.NewRecorder() + router.ServeHTTP(recPM, reqPM) + + var respPM map[string]any + _ = json.Unmarshal(recPM.Body.Bytes(), &respPM) + if respPM["ok"] != true { + t.Fatalf("expected postmark format ok: true, got: %v", respPM) + } + + // 5. Test Mailgun Form Data Webhook format + mgForm := url.Values{} + mgForm.Set("sender", "mailgunner@test.com") + mgForm.Set("from", "Mailgun User ") + mgForm.Set("recipient", "help@crove.com") + mgForm.Set("subject", "Mailgun Inbound Ticket") + mgForm.Set("body-plain", "Testing mailgun webhook support.") + mgForm.Set("Message-Id", "") + + reqMG, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook", strings.NewReader(mgForm.Encode())) + reqMG.Header.Set("Content-Type", "application/x-www-form-urlencoded") + reqMG.Header.Set("X-Webhook-Secret", "email_secret_token_123") + + recMG := httptest.NewRecorder() + router.ServeHTTP(recMG, reqMG) + + var respMG map[string]any + _ = json.Unmarshal(recMG.Body.Bytes(), &respMG) + if respMG["ok"] != true { + t.Fatalf("expected mailgun format ok: true, got: %v", respMG) + } + + mgIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceEmail). + Eq("external_id", "mailgunner@test.com")) + if mgIdentity == nil { + t.Fatalf("expected customer identity for mailgunner@test.com") + } +} diff --git a/internal/handlers/third/slack_handler_test.go b/internal/handlers/third/slack_handler_test.go index 9e7b82e2..e336f844 100644 --- a/internal/handlers/third/slack_handler_test.go +++ b/internal/handlers/third/slack_handler_test.go @@ -93,6 +93,7 @@ func TestSlackWebhook_Handler(t *testing.T) { } webhookPath := "/api/third/slack/webhook/" + channel.ChannelID + println("DEBUG channelID=" + channel.ChannelID) // 1. The url_verification handshake is answered without a signature, because // Slack sends it once while the endpoint is being configured. diff --git a/internal/migration/000011_sync_support_and_org_permissions.go b/internal/migration/000011_sync_support_and_org_permissions.go new file mode 100644 index 00000000..18099395 --- /dev/null +++ b/internal/migration/000011_sync_support_and_org_permissions.go @@ -0,0 +1,21 @@ +package migration + +import "github.com/mlogclub/simple/sqls" + +func init() { + register(11, "sync support and organization permissions", func() error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + permissions, err := ensurePermissions(ctx.Tx) + if err != nil { + return err + } + + roles, err := ensureRoles(ctx.Tx) + if err != nil { + return err + } + + return ensureRolePermissions(ctx.Tx, roles, permissions) + }) + }) +} diff --git a/internal/models/models.go b/internal/models/models.go index 01b3e70d..80f3dd62 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1047,7 +1047,7 @@ type CommentReport struct { CommentID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_comment_report"` UserID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_comment_report"` Reason string `gorm:"type:varchar(255);not null;default:''"` - CreatedAt time.Time `gorm:"type:datetime;not null;index"` + CreatedAt time.Time `gorm:"not null;index"` } // KnowledgeRetrieveLog 检索日志表。 diff --git a/internal/oidcclient/oidcclient.go b/internal/oidcclient/oidcclient.go index 2ba71bde..7bcfa5b2 100644 --- a/internal/oidcclient/oidcclient.go +++ b/internal/oidcclient/oidcclient.go @@ -37,6 +37,7 @@ var ( type OrganizationClaim struct { ID string `json:"id"` + Slug string `json:"slug,omitempty"` Name string `json:"name"` Role string `json:"role"` } @@ -47,6 +48,7 @@ type Profile struct { PreferredUsername string `json:"preferred_username,omitempty"` Name string `json:"name,omitempty"` Picture string `json:"picture,omitempty"` + ActiveOrgID string `json:"active_org_id,omitempty"` Organizations []OrganizationClaim `json:"organizations,omitempty"` RawProfile string `json:"-"` } @@ -102,13 +104,25 @@ func Init(ctx context.Context) error { } scopes := cfg.Scopes if len(scopes) == 0 { - scopes = []string{gooidc.ScopeOpenID, "profile", "email"} + scopes = []string{gooidc.ScopeOpenID, "profile", "email", "offline_access"} } + endpoint := p.Endpoint() + authStyle := oauth2.AuthStyleInHeader + switch strings.ToLower(strings.TrimSpace(cfg.AuthStyle)) { + case "post", "params", "inparams", "client_secret_post": + authStyle = oauth2.AuthStyleInParams + case "basic", "header", "inheader", "client_secret_basic": + authStyle = oauth2.AuthStyleInHeader + case "auto", "autodetect": + authStyle = oauth2.AuthStyleAutoDetect + } + endpoint.AuthStyle = authStyle + provider = p oauthConfig = &oauth2.Config{ ClientID: strings.TrimSpace(cfg.ClientID), ClientSecret: strings.TrimSpace(cfg.ClientSecret), - Endpoint: p.Endpoint(), + Endpoint: endpoint, RedirectURL: strings.TrimSpace(cfg.RedirectURL), Scopes: scopes, } @@ -298,6 +312,7 @@ func profileFromIDToken(idToken *gooidc.IDToken) (*Profile, error) { PreferredUsername: firstNonEmpty(claimString(claims, "preferred_username"), claimString(claims, "user_name"), claimString(claims, "nickname")), Name: firstNonEmpty(claimString(claims, "name"), claimString(claims, "full_name")), Picture: firstNonEmpty(claimString(claims, "picture"), claimString(claims, "avatar_url")), + ActiveOrgID: firstNonEmpty(claimString(claims, "active_org_id"), claimString(claims, "activeOrgId")), Organizations: claimOrganizations(claims), RawProfile: string(raw), } @@ -319,6 +334,7 @@ func profileFromUserInfo(userInfo *gooidc.UserInfo, fallback *Profile) (*Profile PreferredUsername: firstNonEmpty(claimString(claims, "preferred_username"), claimString(claims, "user_name"), claimString(claims, "nickname")), Name: firstNonEmpty(claimString(claims, "name"), claimString(claims, "full_name")), Picture: firstNonEmpty(claimString(claims, "picture"), claimString(claims, "avatar_url")), + ActiveOrgID: firstNonEmpty(claimString(claims, "active_org_id"), claimString(claims, "activeOrgId")), Organizations: claimOrganizations(claims), RawProfile: string(raw), } @@ -327,6 +343,9 @@ func profileFromUserInfo(userInfo *gooidc.UserInfo, fallback *Profile) (*Profile profile.PreferredUsername = firstNonEmpty(profile.PreferredUsername, fallback.PreferredUsername) profile.Name = firstNonEmpty(profile.Name, fallback.Name) profile.Picture = firstNonEmpty(profile.Picture, fallback.Picture) + if profile.ActiveOrgID == "" { + profile.ActiveOrgID = fallback.ActiveOrgID + } if len(profile.Organizations) == 0 { profile.Organizations = fallback.Organizations } @@ -363,12 +382,14 @@ func claimOrganizations(claims map[string]any) []OrganizationClaim { var list []map[string]any if err := json.Unmarshal(bytes, &list); err == nil { for _, item := range list { - id := firstNonEmpty(claimString(item, "id"), claimString(item, "org_id"), claimString(item, "code")) - name := firstNonEmpty(claimString(item, "name"), claimString(item, "org_name"), id) + id := firstNonEmpty(claimString(item, "id"), claimString(item, "org_id"), claimString(item, "slug"), claimString(item, "code")) + slug := claimString(item, "slug") + name := firstNonEmpty(claimString(item, "name"), claimString(item, "org_name"), slug, id) role := firstNonEmpty(claimString(item, "role"), "MEMBER") if id != "" { orgs = append(orgs, OrganizationClaim{ ID: id, + Slug: slug, Name: name, Role: strings.ToUpper(role), }) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 309f57bf..760fac28 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -4,6 +4,7 @@ import ( "agent-desk/internal/pkg/enums" "errors" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -20,12 +21,14 @@ type Config struct { Auth AuthConfig `yaml:"auth"` Storage StorageConfig `yaml:"storage"` VectorDB VectorDBConfig `yaml:"vectorDB"` + AI AIConfig `yaml:"ai"` MCP MCPConfig `yaml:"mcp"` WxWork WxWorkConfig `yaml:"wxWork"` OIDC OIDCConfig `yaml:"oidc"` CustomerSession CustomerSessionConfig `yaml:"customerSession"` Webhook WebhookConfig `yaml:"webhook"` Discord DiscordConfig `yaml:"discord"` + Email EmailConfig `yaml:"email"` Slack SlackConfig `yaml:"slack"` } @@ -49,10 +52,12 @@ type WxWorkNotifyConfig struct { } type ServerConfig struct { - Port int `yaml:"port"` - CompanyName string `yaml:"companyName"` - CompanyLogoURL string `yaml:"companyLogoUrl"` - CORS CORSConfig `yaml:"cors"` + Port int `yaml:"port"` + PublicURL string `yaml:"publicUrl"` + CompanyName string `yaml:"companyName"` + CompanyLogoURL string `yaml:"companyLogoUrl"` + CompanyFaviconURL string `yaml:"companyFaviconUrl"` + CORS CORSConfig `yaml:"cors"` // TrustedProxies are the CIDR blocks of the reverse proxies that sit in front // of the application. Gin's own default is 0.0.0.0/0 and ::/0, which trusts // every peer and makes ClientIP() return the leftmost X-Forwarded-For value - @@ -122,6 +127,18 @@ func (s ServerConfig) Address() string { return fmt.Sprintf(":%d", s.Port) } +func (s ServerConfig) GetPublicBaseURL(oidcRedirectURL string) string { + if strings.TrimSpace(s.PublicURL) != "" { + return strings.TrimRight(strings.TrimSpace(s.PublicURL), "/") + } + if strings.TrimSpace(oidcRedirectURL) != "" { + if u, err := url.Parse(strings.TrimSpace(oidcRedirectURL)); err == nil && u.Scheme != "" && u.Host != "" { + return fmt.Sprintf("%s://%s", u.Scheme, u.Host) + } + } + return "" +} + type CORSConfig struct { // AllowedOrigins 是允许浏览器跨域访问的 Origin 白名单,必须包含协议和域名。 // 留空表示不允许跨域请求;同源请求通常不会携带 Origin,不受影响。 @@ -262,6 +279,17 @@ type VectorDBConfig struct { LanceDB LanceDBVectorDBConfig `yaml:"lancedb"` } +type AIConfig struct { + Provider string `yaml:"provider"` + BaseURL string `yaml:"baseUrl"` + APIKey string `yaml:"apiKey"` + LLMModel string `yaml:"llmModel"` + EmbeddingModel string `yaml:"embeddingModel"` + EmbeddingDimension int `yaml:"embeddingDimension"` + TimeoutMS int `yaml:"timeoutMs"` + MaxRetryCount int `yaml:"maxRetryCount"` +} + type QdrantVectorDBConfig struct { Host string `yaml:"host"` GrpcPort int `yaml:"grpcPort"` @@ -290,6 +318,7 @@ type OIDCConfig struct { Issuer string `yaml:"issuer"` ClientID string `yaml:"clientId"` ClientSecret string `yaml:"clientSecret"` + AuthStyle string `yaml:"authStyle"` RedirectURL string `yaml:"redirectUrl"` StateSecret string `yaml:"stateSecret"` Scopes []string `yaml:"scopes"` @@ -335,6 +364,20 @@ type WxWorkConfig struct { type WebhookConfig struct { OrgSyncSecret string `yaml:"orgSyncSecret"` DOSOrgSyncSecret string `yaml:"dosOrgSyncSecret"` + OutboundURL string `yaml:"outboundUrl"` +} + +type EmailConfig struct { + Provider string `yaml:"provider"` + FromAddress string `yaml:"fromAddress"` + FromName string `yaml:"fromName"` + APIKey string `yaml:"apiKey"` + SMTPHost string `yaml:"smtpHost"` + SMTPPort int `yaml:"smtpPort"` + SMTPUser string `yaml:"smtpUser"` + SMTPPassword string `yaml:"smtpPassword"` + SMTPUseTLS bool `yaml:"smtpUseTls"` + InboundSecret string `yaml:"inboundSecret"` } // DiscordConfig holds deployment-wide Discord bot credentials. A channel may @@ -435,8 +478,12 @@ func loadDotEnv(configPath string) { func bindConfigDefaults(v *viper.Viper) { v.SetDefault("language", "zh-CN") v.SetDefault("server.port", 8083) + v.SetDefault("server.publicUrl", "") + v.SetDefault("server.publicUrl", "") v.SetDefault("server.companyName", "") v.SetDefault("server.companyLogoUrl", "") + v.SetDefault("server.companyFaviconUrl", "") + v.SetDefault("server.companyFaviconUrl", "") v.SetDefault("server.cors.allowedOrigins", []string{}) v.SetDefault("server.trustedProxies", []string{}) v.SetDefault("server.trustedPlatform", "") @@ -463,11 +510,29 @@ func bindConfigDefaults(v *viper.Viper) { v.SetDefault("vectorDB.type", "qdrant") v.SetDefault("vectorDB.qdrant.host", "127.0.0.1") v.SetDefault("vectorDB.qdrant.grpcPort", 6334) + v.SetDefault("ai.provider", "openai") + v.SetDefault("ai.baseUrl", "https://api.openai.com/v1") + v.SetDefault("ai.apiKey", "") + v.SetDefault("ai.llmModel", "gpt-4o-mini") + v.SetDefault("ai.embeddingModel", "text-embedding-3-small") + v.SetDefault("ai.embeddingDimension", 1536) + v.SetDefault("ai.timeoutMs", 30000) + v.SetDefault("ai.maxRetryCount", 1) v.SetDefault("mcp.enabled", true) v.SetDefault("discord.clientId", "") v.SetDefault("discord.clientSecret", "") v.SetDefault("discord.botToken", "") v.SetDefault("discord.publicKey", "") + v.SetDefault("email.provider", "smtp") + v.SetDefault("email.fromAddress", "") + v.SetDefault("email.fromName", "") + v.SetDefault("email.apiKey", "") + v.SetDefault("email.smtpHost", "") + v.SetDefault("email.smtpPort", 587) + v.SetDefault("email.smtpUser", "") + v.SetDefault("email.smtpPassword", "") + v.SetDefault("email.smtpUseTls", false) + v.SetDefault("email.inboundSecret", "") v.SetDefault("slack.clientId", "") v.SetDefault("slack.clientSecret", "") v.SetDefault("slack.botToken", "") @@ -479,8 +544,10 @@ func bindEnvironmentAliases(v *viper.Viper) { // variables (PORT, DATABASE_URL, ...) cannot silently override the // documented configuration. _ = v.BindEnv("server.port", "AGENT_DESK_SERVER_PORT", "PORT", "SERVER_PORT") + _ = v.BindEnv("server.publicUrl", "AGENT_DESK_SERVER_PUBLICURL", "PUBLIC_URL", "SERVER_PUBLIC_URL", "BASE_URL") _ = v.BindEnv("server.companyName", "AGENT_DESK_SERVER_COMPANYNAME", "COMPANY_NAME", "NEXT_PUBLIC_COMPANY_NAME", "BRAND_NAME", "BRAND_COMPANY_NAME") _ = v.BindEnv("server.companyLogoUrl", "AGENT_DESK_SERVER_COMPANYLOGOURL", "COMPANY_LOGO_URL", "NEXT_PUBLIC_COMPANY_LOGO_URL", "BRAND_LOGO_URL") + _ = v.BindEnv("server.companyFaviconUrl", "AGENT_DESK_SERVER_COMPANYFAVICONURL", "COMPANY_FAVICON_URL", "NEXT_PUBLIC_COMPANY_FAVICON_URL", "FAVICON_URL") _ = v.BindEnv("server.trustedProxies", "AGENT_DESK_SERVER_TRUSTEDPROXIES", "TRUSTED_PROXIES") _ = v.BindEnv("server.trustedPlatform", "AGENT_DESK_SERVER_TRUSTEDPLATFORM", "TRUSTED_PLATFORM") _ = v.BindEnv("server.rateLimit.enabled", "AGENT_DESK_SERVER_RATELIMIT_ENABLED", "RATE_LIMIT_ENABLED") @@ -507,6 +574,16 @@ func bindEnvironmentAliases(v *viper.Viper) { _ = v.BindEnv("discord.clientSecret", "AGENT_DESK_DISCORD_CLIENTSECRET", "DISCORD_CLIENT_SECRET") _ = v.BindEnv("discord.botToken", "AGENT_DESK_DISCORD_BOTTOKEN", "DISCORD_BOT_TOKEN") _ = v.BindEnv("discord.publicKey", "AGENT_DESK_DISCORD_PUBLICKEY", "DISCORD_PUBLIC_KEY") + _ = v.BindEnv("email.provider", "EMAIL_PROVIDER", "AGENT_DESK_EMAIL_PROVIDER") + _ = v.BindEnv("email.fromAddress", "EMAIL_FROM", "EMAIL_FROM_ADDRESS", "SUPPORT_EMAIL", "AGENT_DESK_EMAIL_FROMADDRESS") + _ = v.BindEnv("email.fromName", "EMAIL_FROM_NAME", "EMAIL_SENDER_NAME", "SUPPORT_SENDER_NAME", "AGENT_DESK_EMAIL_FROMNAME") + _ = v.BindEnv("email.apiKey", "EMAIL_API_KEY", "BREVO_API_KEY", "SENDGRID_API_KEY", "RESEND_API_KEY", "POSTMARK_API_KEY", "MAILGUN_API_KEY", "AGENT_DESK_EMAIL_APIKEY") + _ = v.BindEnv("email.smtpHost", "SMTP_HOST", "EMAIL_SMTP_HOST", "AGENT_DESK_EMAIL_SMTPHOST") + _ = v.BindEnv("email.smtpPort", "SMTP_PORT", "EMAIL_SMTP_PORT", "AGENT_DESK_EMAIL_SMTPPORT") + _ = v.BindEnv("email.smtpUser", "SMTP_USER", "EMAIL_SMTP_USER", "AGENT_DESK_EMAIL_SMTPUSER") + _ = v.BindEnv("email.smtpPassword", "SMTP_PASSWORD", "SMTP_PASS", "EMAIL_SMTP_PASSWORD", "AGENT_DESK_EMAIL_SMTPPASSWORD") + _ = v.BindEnv("email.smtpUseTls", "SMTP_USE_TLS", "SMTP_SSL", "AGENT_DESK_EMAIL_SMTPUSETLS") + _ = v.BindEnv("email.inboundSecret", "EMAIL_INBOUND_SECRET", "EMAIL_WEBHOOK_SECRET", "AGENT_DESK_EMAIL_INBOUNDSECRET") _ = v.BindEnv("slack.clientId", "AGENT_DESK_SLACK_CLIENTID", "SLACK_CLIENT_ID") _ = v.BindEnv("slack.clientSecret", "AGENT_DESK_SLACK_CLIENTSECRET", "SLACK_CLIENT_SECRET") _ = v.BindEnv("slack.botToken", "AGENT_DESK_SLACK_BOTTOKEN", "SLACK_BOT_TOKEN") @@ -522,4 +599,48 @@ func normalizeLoadedConfig(cfg *Config) { } else if cfg.DB.Type == "sqlite" && strings.Contains(cfg.DB.DSN, "@tcp(") { cfg.DB.Type = "mysql" } + + if cfg.MCP.Servers == nil { + cfg.MCP.Servers = make(map[string]MCPServerConfig) + } + if _, ok := cfg.MCP.Servers["system"]; !ok { + port := cfg.Server.Port + if port <= 0 { + port = 8083 + } + cfg.MCP.Servers["system"] = MCPServerConfig{ + Enabled: true, + Endpoint: fmt.Sprintf("http://127.0.0.1:%d/api/mcp", port), + TimeoutMS: 15000, + } + } + + crmEndpoint := strings.TrimSpace(os.Getenv("MCP_CRM_ENDPOINT")) + if crmEndpoint == "" { + crmEndpoint = strings.TrimSpace(os.Getenv("CROVE_CRM_MCP_ENDPOINT")) + } + if crmEndpoint == "" { + crmEndpoint = strings.TrimSpace(os.Getenv("TWENTY_CRM_MCP_ENDPOINT")) + } + if crmEndpoint != "" { + apiKey := strings.TrimSpace(os.Getenv("MCP_CRM_API_KEY")) + if apiKey == "" { + apiKey = strings.TrimSpace(os.Getenv("CROVE_CRM_API_KEY")) + } + if apiKey == "" { + apiKey = strings.TrimSpace(os.Getenv("TWENTY_CRM_API_KEY")) + } + headers := map[string]string{} + if apiKey != "" { + headers["Authorization"] = "Bearer " + apiKey + } + crmServerConfig := MCPServerConfig{ + Enabled: true, + Endpoint: crmEndpoint, + TimeoutMS: 15000, + Headers: headers, + } + cfg.MCP.Servers["twenty_crm"] = crmServerConfig + cfg.MCP.Servers["crove_crm"] = crmServerConfig + } } diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index e6874258..9a3357ad 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -98,6 +98,11 @@ OIDC_CLIENT_ID=client-123 OIDC_CLIENT_SECRET=secret-456 OIDC_REDIRECT_URL=https://desk.example.com/api/auth/oidc_callback ORG_SYNC_SECRET=webhook-secret-789 +EMAIL_PROVIDER=brevo +EMAIL_FROM=help@example.com +EMAIL_FROM_NAME=Helpdesk Team +BREVO_API_KEY=xkeysib-test-123 +EMAIL_INBOUND_SECRET=inbound-secret-456 `) if err := os.WriteFile(envPath, envContent, 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) @@ -155,6 +160,21 @@ ORG_SYNC_SECRET=webhook-secret-789 if cfg.Webhook.OrgSyncSecret != "webhook-secret-789" { t.Fatalf("Webhook.OrgSyncSecret=%q", cfg.Webhook.OrgSyncSecret) } + if cfg.Email.Provider != "brevo" { + t.Fatalf("Email.Provider=%q want brevo", cfg.Email.Provider) + } + if cfg.Email.FromAddress != "help@example.com" { + t.Fatalf("Email.FromAddress=%q want help@example.com", cfg.Email.FromAddress) + } + if cfg.Email.FromName != "Helpdesk Team" { + t.Fatalf("Email.FromName=%q want Helpdesk Team", cfg.Email.FromName) + } + if cfg.Email.APIKey != "xkeysib-test-123" { + t.Fatalf("Email.APIKey=%q want xkeysib-test-123", cfg.Email.APIKey) + } + if cfg.Email.InboundSecret != "inbound-secret-456" { + t.Fatalf("Email.InboundSecret=%q want inbound-secret-456", cfg.Email.InboundSecret) + } } func TestAuthConfigMaxFailedAttemptsPerIPOrDefault(t *testing.T) { diff --git a/internal/pkg/config/runtime.go b/internal/pkg/config/runtime.go index 298f762e..e4554e51 100644 --- a/internal/pkg/config/runtime.go +++ b/internal/pkg/config/runtime.go @@ -8,6 +8,10 @@ func SetCurrent(cfg *Config) { current = cfg } +func GetCurrent() *Config { + return current +} + func Current() Config { if current == nil { panic("config not initialized") @@ -15,10 +19,6 @@ func Current() Config { return *current } -func GetCurrent() *Config { - return current -} - // ResolveSlack resolves the Slack app credentials for one channel without // requiring the caller to nil-check the loaded configuration. func ResolveSlack(channelBotToken, channelSigningSecret string) SlackConfig { diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index 89394234..f6644cff 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -49,6 +49,19 @@ type ZaloOAChannelConfig struct { WebhookSecret string `json:"webhookSecret,omitempty"` WelcomeMessage string `json:"welcomeMessage,omitempty"` } +type EmailChannelConfig struct { + EmailAddress string `json:"emailAddress"` // e.g. help@dos.crove.io or support@company.com + ForwardingAddress string `json:"forwardingAddress,omitempty"` // e.g. help@dos.crove.io + SenderName string `json:"senderName,omitempty"` // e.g. Crove Desk Support + Provider string `json:"provider,omitempty"` // default | smtp | brevo | sendgrid | resend | postmark | mailgun + APIKey string `json:"apiKey,omitempty"` // Brevo / ESP API Key + SMTPHost string `json:"smtpHost,omitempty"` // SMTP Server Host + SMTPPort int `json:"smtpPort,omitempty"` // SMTP Port (587/465) + SMTPUser string `json:"smtpUser,omitempty"` // SMTP Username + SMTPPassword string `json:"smtpPassword,omitempty"` // SMTP Password + WebhookSecret string `json:"webhookSecret,omitempty"` // Inbound Webhook Secret + WelcomeMessage string `json:"welcomeMessage,omitempty"` // Auto-responder / welcome message +} type SlackChannelConfig struct { BotToken string `json:"botToken,omitempty"` // xoxb-... Bot Token diff --git a/internal/pkg/dto/request/organization_request.go b/internal/pkg/dto/request/organization_request.go index 303c0432..1ffdec7b 100644 --- a/internal/pkg/dto/request/organization_request.go +++ b/internal/pkg/dto/request/organization_request.go @@ -3,3 +3,23 @@ package request type OrganizationSwitchRequest struct { OrganizationID int64 `json:"organizationId"` } + +type OrganizationCreateRequest struct { + Name string `json:"name"` + Code string `json:"code"` + Logo string `json:"logo"` +} + +type OrganizationUpdateRequest struct { + Name string `json:"name"` + Logo string `json:"logo"` +} + +type OrganizationAddMemberRequest struct { + EmailOrUsername string `json:"emailOrUsername"` + Role string `json:"role"` +} + +type OrganizationRemoveMemberRequest struct { + UserID int64 `json:"userId"` +} diff --git a/internal/pkg/dto/request/webhook_request.go b/internal/pkg/dto/request/webhook_request.go index 20073798..4de0ae6c 100644 --- a/internal/pkg/dto/request/webhook_request.go +++ b/internal/pkg/dto/request/webhook_request.go @@ -1,13 +1,40 @@ package request type OrgSyncEventData struct { - OrgID string `json:"org_id"` - OrgName string `json:"org_name"` - UserID string `json:"user_id"` - UserEmail string `json:"user_email"` - UserName string `json:"user_name"` - Role string `json:"role"` - Plan string `json:"plan"` + // Organization fields + OrgID string `json:"org_id"` + ID string `json:"id,omitempty"` + GlobalOrgID string `json:"global_org_id,omitempty"` + OrgName string `json:"org_name"` + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` + UserID string `json:"user_id"` + UserEmail string `json:"user_email"` + BillingEmail string `json:"billingEmail,omitempty"` + UserName string `json:"user_name"` + Role string `json:"role"` + Plan string `json:"plan"` + + // Company fields + CRMCompanyID string `json:"crm_company_id,omitempty"` + DeskCompanyID string `json:"desk_company_id,omitempty"` + CompanyID string `json:"company_id,omitempty"` + DomainName string `json:"domain_name,omitempty"` + Domain string `json:"domain,omitempty"` + Address string `json:"address,omitempty"` + Tier string `json:"tier,omitempty"` + TaxCode string `json:"tax_code,omitempty"` + AccountOwnerEmail string `json:"account_owner_email,omitempty"` + + // Customer fields + CRMPersonID string `json:"crm_person_id,omitempty"` + DeskCustomerID string `json:"desk_customer_id,omitempty"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + JobTitle string `json:"job_title,omitempty"` + CompanyName string `json:"company_name,omitempty"` + Source string `json:"source,omitempty"` } type OrgSyncWebhookRequest struct { diff --git a/internal/pkg/dto/response/auth_response.go b/internal/pkg/dto/response/auth_response.go index 50449137..d3029035 100644 --- a/internal/pkg/dto/response/auth_response.go +++ b/internal/pkg/dto/response/auth_response.go @@ -25,6 +25,7 @@ type PublicConfigResponse struct { Language string `json:"language"` CompanyName string `json:"companyName,omitempty"` CompanyLogoURL string `json:"companyLogoUrl,omitempty"` + CompanyFaviconURL string `json:"companyFaviconUrl,omitempty"` PasswordLoginEnabled bool `json:"passwordLoginEnabled"` WxWorkEnabled bool `json:"wxworkEnabled"` OIDCEnabled bool `json:"oidcEnabled"` diff --git a/internal/pkg/dto/response/organization_response.go b/internal/pkg/dto/response/organization_response.go index 0b7e1543..d2f2f59f 100644 --- a/internal/pkg/dto/response/organization_response.go +++ b/internal/pkg/dto/response/organization_response.go @@ -17,6 +17,18 @@ type OrganizationResponse struct { CreatedAt time.Time `json:"createdAt"` } +type OrganizationMemberResponse struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Role string `json:"role"` + Status int `json:"status"` + CreatedAt time.Time `json:"createdAt"` +} + type UserOrganizationListResponse struct { CurrentOrganizationID int64 `json:"currentOrganizationId"` Organizations []OrganizationResponse `json:"organizations"` diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go index 4b7befc0..a2afcb0a 100644 --- a/internal/pkg/enums/external_identity.go +++ b/internal/pkg/enums/external_identity.go @@ -6,25 +6,29 @@ package enums type ExternalSource string const ( - ExternalSourceGuest ExternalSource = "guest" // 访客 - ExternalSourceWxWorkKF ExternalSource = "wxwork_kf" // 企业微信客服 - ExternalSourceUser ExternalSource = "user" // 用户信息 - ExternalSourceTelegram ExternalSource = "telegram" // Telegram - ExternalSourceZaloOA ExternalSource = "zalo_oa" // Zalo OA - ExternalSourceSlack ExternalSource = "slack" // Slack - ExternalSourceDiscord ExternalSource = "discord" // Discord - ExternalSourceLark ExternalSource = "lark" // Lark + ExternalSourceGuest ExternalSource = "guest" // 访客 + ExternalSourceWxWorkKF ExternalSource = "wxwork_kf" // 企业微信客服 + ExternalSourceUser ExternalSource = "user" // 用户信息 + ExternalSourceTwentyCRM ExternalSource = "twenty_crm" // Twenty CRM + ExternalSourceTelegram ExternalSource = "telegram" // Telegram + ExternalSourceZaloOA ExternalSource = "zalo_oa" // Zalo Official Account + ExternalSourceSlack ExternalSource = "slack" // Slack + ExternalSourceDiscord ExternalSource = "discord" // Discord + ExternalSourceEmail ExternalSource = "email" // Email + ExternalSourceLark ExternalSource = "lark" // Lark ) var externalSourceLabelMap = map[ExternalSource]string{ - ExternalSourceGuest: "访客", - ExternalSourceWxWorkKF: "企业微信客服", - ExternalSourceUser: "用户", - ExternalSourceTelegram: "Telegram", - ExternalSourceZaloOA: "Zalo OA", - ExternalSourceSlack: "Slack", - ExternalSourceDiscord: "Discord", - ExternalSourceLark: "Lark", + ExternalSourceGuest: "访客", + ExternalSourceWxWorkKF: "企业微信客服", + ExternalSourceUser: "用户", + ExternalSourceTwentyCRM: "Twenty CRM", + ExternalSourceTelegram: "Telegram", + ExternalSourceZaloOA: "Zalo OA", + ExternalSourceSlack: "Slack", + ExternalSourceDiscord: "Discord", + ExternalSourceEmail: "Email", + ExternalSourceLark: "Lark", } func GetExternalSourceLabel(v ExternalSource) string { diff --git a/internal/pkg/enums/im.go b/internal/pkg/enums/im.go index da23f4c3..5fa8af23 100644 --- a/internal/pkg/enums/im.go +++ b/internal/pkg/enums/im.go @@ -58,6 +58,7 @@ const ( IMSenderTypeCustomer IMSenderType = "customer" // 客户 IMSenderTypeAI IMSenderType = "ai" // AI IMSenderTypeSystem IMSenderType = "system" // 系统 + IMSenderTypeNote IMSenderType = "note" // 内部便签 ) var imSenderTypeLabelMap = map[IMSenderType]string{ @@ -65,6 +66,7 @@ var imSenderTypeLabelMap = map[IMSenderType]string{ IMSenderTypeCustomer: "客户", IMSenderTypeAI: "AI", IMSenderTypeSystem: "系统", + IMSenderTypeNote: "便签", } func GetIMSenderTypeLabel(senderType IMSenderType) string { @@ -130,6 +132,7 @@ const ( IMMessageTypeImage IMMessageType = "image" IMMessageTypeAttachment IMMessageType = "attachment" IMMessageTypeHTML IMMessageType = "html" + IMMessageTypeNote IMMessageType = "note" ) var imMessageTypeLabelMap = map[IMMessageType]string{ @@ -137,6 +140,7 @@ var imMessageTypeLabelMap = map[IMMessageType]string{ IMMessageTypeImage: "图片", IMMessageTypeAttachment: "附件", IMMessageTypeHTML: "富文本", + IMMessageTypeNote: "便签", } func GetIMMessageTypeLabel(messageType IMMessageType) string { diff --git a/internal/pkg/enums/wxwork_kf.go b/internal/pkg/enums/wxwork_kf.go index a8556728..fe8c88c6 100644 --- a/internal/pkg/enums/wxwork_kf.go +++ b/internal/pkg/enums/wxwork_kf.go @@ -23,6 +23,7 @@ const ( ChannelTypeWxWorkKF = "wxwork_kf" ChannelTypeTelegram = "telegram" ChannelTypeZaloOA = "zalo_oa" + ChannelTypeEmail = "email" ChannelTypeSlack = "slack" ChannelTypeDiscord = "discord" ChannelTypeLark = "lark" diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index c07c8d97..e76a0664 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -231,6 +231,7 @@ error.e0231: "No matching WeCom channel was found." error.e0232: "No schedules were generated." error.auth.expired: "Your session has expired. Please sign in again." error.auth.passwordLoginDisabled: "Username and password login is disabled. Please use SSO to sign in." +error.auth.invalidSignature: "Invalid request signature or secret token." error.e0234: "No available AI configuration is configured." error.e0235: "No available embedding model is configured." error.e0236: "Permission not found." diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml index 457540e5..cf21ea00 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -231,6 +231,7 @@ error.e0231: "未找到匹配的企业微信接入渠道" error.e0232: "未生成任何排班" error.auth.expired: "未登录或登录已过期" error.auth.passwordLoginDisabled: "账号密码登录已禁用,请使用第三方登录。" +error.auth.invalidSignature: "请求签名或凭证 Token 无效" error.e0234: "未配置可用的 AI 配置" error.e0235: "未配置可用的 Embedding 模型" error.e0236: "权限不存在" diff --git a/internal/services/auth_service_test.go b/internal/services/auth_service_test.go index d137323d..3cbde6e0 100644 --- a/internal/services/auth_service_test.go +++ b/internal/services/auth_service_test.go @@ -461,6 +461,10 @@ func setupAuthServiceTestDB(t *testing.T) *gorm.DB { if err := db.AutoMigrate( &models.Organization{}, &models.OrganizationMember{}, + &models.Company{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.CustomerContact{}, &models.User{}, &models.UserIdentity{}, &models.Role{}, diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go index 17e9fa9b..989e3a05 100644 --- a/internal/services/channel_message_outbox_service.go +++ b/internal/services/channel_message_outbox_service.go @@ -176,6 +176,7 @@ func (s *channelMessageOutboxService) EnqueueTelegramMessage(conversation *model return err } + // Trigger async dispatch immediately go func() { defer func() { if r := recover(); r != nil { @@ -238,6 +239,7 @@ func (s *channelMessageOutboxService) EnqueueZaloOAMessage(conversation *models. return err } + // Trigger async dispatch immediately go func() { defer func() { if r := recover(); r != nil { @@ -250,6 +252,69 @@ func (s *channelMessageOutboxService) EnqueueZaloOAMessage(conversation *models. return nil } +func (s *channelMessageOutboxService) EnqueueEmailMessage(conversation *models.Conversation, message *models.Message) error { + if conversation == nil || message == nil { + return nil + } + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.ChannelType != enums.ChannelTypeEmail { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeEmail, message.ID); existing != nil { + return nil + } + + payload, err := json.Marshal(map[string]any{ + "conversationId": conversation.ID, + "messageId": message.ID, + "messageType": message.MessageType, + "content": strings.TrimSpace(message.Content), + "payload": strings.TrimSpace(message.Payload), + "senderId": message.SenderID, + }) + if err != nil { + return err + } + + now := time.Now() + err = s.Create(&models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeEmail, + ConversationID: conversation.ID, + MessageID: message.ID, + Payload: string(payload), + SendStatus: string(enums.ChannelMessageOutboxStatusPending), + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: message.UpdateUserID, + CreateUserName: message.UpdateUserName, + UpdatedAt: now, + UpdateUserID: message.UpdateUserID, + UpdateUserName: message.UpdateUserName, + }, + }) + if err != nil { + return err + } + + // Trigger async dispatch immediately + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("recovered from panic in email outbound dispatch", "error", r) + } + }() + EmailOutboundService.DispatchPendingOutbox() + }() + + return nil +} + func (s *channelMessageOutboxService) EnqueueSlackMessage(conversation *models.Conversation, message *models.Message) error { if conversation == nil || message == nil { return nil diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index 9c047a29..ad8db2ca 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -2,6 +2,7 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" @@ -10,10 +11,14 @@ import ( "agent-desk/internal/pkg/httpx" "agent-desk/internal/pkg/utils" "agent-desk/internal/repositories" + "agent-desk/internal/telegram" "agent-desk/internal/wxwork" + "context" "crypto/rand" "encoding/base64" "encoding/json" + "fmt" + "log/slog" "strings" "time" @@ -90,6 +95,7 @@ func (s *channelService) CreateChannel(req request.CreateChannelRequest, operato if err := repositories.ChannelRepository.Create(sqls.DB(), item); err != nil { return nil, err } + go s.syncTelegramWebhook(item, item.Status) return item, nil } @@ -121,7 +127,11 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato if item.AIAgentRolloutPercent != current.AIAgentRolloutPercent { columns["previous_ai_agent_rollout_percent"] = current.AIAgentRolloutPercent } - return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, columns) + if err := repositories.ChannelRepository.Updates(sqls.DB(), req.ID, columns); err != nil { + return err + } + go s.syncTelegramWebhook(item, item.Status) + return nil } // RollbackChannelAIAgentRollout restores the last channel-level rollout value @@ -162,12 +172,16 @@ func (s *channelService) UpdateStatus(id int64, status int, operator *dto.AuthPr if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) { return errorsx.InvalidParamI18n("error.e0254") } - return s.Updates(id, map[string]any{ + err := s.Updates(id, map[string]any{ "status": status, "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now(), }) + if err == nil { + go s.syncTelegramWebhook(item, enums.Status(status)) + } + return err } func (s *channelService) DeleteChannel(id int64, operator *dto.AuthPrincipal) error { @@ -178,12 +192,56 @@ func (s *channelService) DeleteChannel(id int64, operator *dto.AuthPrincipal) er if item == nil || item.Status == enums.StatusDeleted { return errorsx.InvalidParamI18n("error.e0208") } - return s.Updates(id, map[string]any{ + err := s.Updates(id, map[string]any{ "status": enums.StatusDeleted, "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now(), }) + if err == nil { + go s.syncTelegramWebhook(item, enums.StatusDeleted) + } + return err +} + +func (s *channelService) syncTelegramWebhook(channel *models.Channel, targetStatus enums.Status) { + if channel == nil || channel.ChannelType != enums.ChannelTypeTelegram { + return + } + cfg, err := s.ParseTelegramChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || cfg.BotToken == "" { + return + } + + client := telegram.NewClient(cfg.BotToken) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if targetStatus == enums.StatusOk { + serverCfg := config.GetCurrent() + publicBaseURL := "" + if serverCfg != nil { + publicBaseURL = serverCfg.Server.GetPublicBaseURL(serverCfg.OIDC.RedirectURL) + } + if publicBaseURL != "" { + webhookURL := fmt.Sprintf("%s/api/third/telegram/webhook/%s", publicBaseURL, channel.ChannelID) + req := telegram.SetWebhookRequest{ + URL: webhookURL, + SecretToken: cfg.WebhookSecret, + } + if err := client.SetWebhook(ctx, req); err != nil { + slog.Warn("auto set telegram webhook failed", "channel_id", channel.ChannelID, "url", webhookURL, "error", err) + } else { + slog.Info("auto set telegram webhook succeeded", "channel_id", channel.ChannelID, "url", webhookURL) + } + } + } else { + if err := client.DeleteWebhook(ctx); err != nil { + slog.Warn("auto delete telegram webhook failed", "channel_id", channel.ChannelID, "error", err) + } else { + slog.Info("auto delete telegram webhook succeeded", "channel_id", channel.ChannelID) + } + } } func (s *channelService) ParseWxWorkKFChannelConfig(raw string) (*dto.WxWorkKFChannelConfig, error) { @@ -333,20 +391,33 @@ func (s *channelService) ParseZaloOAChannelConfig(raw string) (*dto.ZaloOAChanne return cfg, nil } -func (s *channelService) ParseSlackChannelConfig(raw string) (*dto.SlackChannelConfig, error) { +func (s *channelService) ParseEmailChannelConfig(raw string) (*dto.EmailChannelConfig, error) { raw = strings.TrimSpace(raw) - cfg := &dto.SlackChannelConfig{} + cfg := &dto.EmailChannelConfig{ + Provider: "smtp", + } if raw != "" { if err := json.Unmarshal([]byte(raw), cfg); err != nil { return nil, err } } - cfg.BotToken = strings.TrimSpace(cfg.BotToken) - cfg.SigningSecret = strings.TrimSpace(cfg.SigningSecret) - cfg.AppID = strings.TrimSpace(cfg.AppID) - cfg.TeamID = strings.TrimSpace(cfg.TeamID) - cfg.TeamName = strings.TrimSpace(cfg.TeamName) - cfg.DefaultChannel = strings.TrimSpace(cfg.DefaultChannel) + cfg.EmailAddress = strings.ToLower(strings.TrimSpace(cfg.EmailAddress)) + cfg.ForwardingAddress = strings.ToLower(strings.TrimSpace(cfg.ForwardingAddress)) + cfg.SenderName = strings.TrimSpace(cfg.SenderName) + cfg.Provider = strings.ToLower(strings.TrimSpace(cfg.Provider)) + if cfg.Provider == "" { + if cfg.APIKey != "" { + cfg.Provider = "brevo" + } else { + cfg.Provider = "smtp" + } + } + cfg.APIKey = strings.TrimSpace(cfg.APIKey) + cfg.SMTPHost = strings.TrimSpace(cfg.SMTPHost) + cfg.SMTPUser = strings.TrimSpace(cfg.SMTPUser) + cfg.SMTPPassword = strings.TrimSpace(cfg.SMTPPassword) + cfg.WebhookSecret = strings.TrimSpace(cfg.WebhookSecret) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) return cfg, nil } @@ -369,6 +440,23 @@ func (s *channelService) ParseDiscordChannelConfig(raw string) (*dto.DiscordChan return cfg, nil } +func (s *channelService) ParseSlackChannelConfig(raw string) (*dto.SlackChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.SlackChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.BotToken = strings.TrimSpace(cfg.BotToken) + cfg.SigningSecret = strings.TrimSpace(cfg.SigningSecret) + cfg.AppID = strings.TrimSpace(cfg.AppID) + cfg.TeamID = strings.TrimSpace(cfg.TeamID) + cfg.TeamName = strings.TrimSpace(cfg.TeamName) + cfg.DefaultChannel = strings.TrimSpace(cfg.DefaultChannel) + return cfg, nil +} + func (s *channelService) ParseLarkChannelConfig(raw string) (*dto.LarkChannelConfig, error) { raw = strings.TrimSpace(raw) cfg := &dto.LarkChannelConfig{} @@ -491,6 +579,100 @@ func (s *channelService) GetEnabledWxWorkKFChannelByOpenKfID(openKfID string) *m return nil } +func (s *channelService) GetEnabledEmailChannelByAddress(emailAddress string) *models.Channel { + emailAddress = strings.ToLower(strings.TrimSpace(emailAddress)) + if emailAddress == "" { + return nil + } + channels := s.Find(sqls.NewCnd(). + Eq("channel_type", enums.ChannelTypeEmail). + Eq("status", enums.StatusOk). + Asc("id")) + if len(channels) == 0 { + return nil + } + + // 1. Pass 1: Exact match with EmailAddress or ForwardingAddress + for i := range channels { + cfg, err := s.ParseEmailChannelConfig(channels[i].ConfigJSON) + if err != nil { + continue + } + if cfg != nil { + if strings.ToLower(strings.TrimSpace(cfg.EmailAddress)) == emailAddress || + strings.ToLower(strings.TrimSpace(cfg.ForwardingAddress)) == emailAddress { + return &channels[i] + } + } + } + + // 2. Pass 2: Extract tenant slug (e.g. help@dos.crove.io -> "dos", help@dos.on.crove.email -> "dos", help+dos@... -> "dos") + slug := extractTenantSlugFromEmail(emailAddress) + if slug != "" { + for i := range channels { + cfg, err := s.ParseEmailChannelConfig(channels[i].ConfigJSON) + if err != nil { + continue + } + channelIDLower := strings.ToLower(channels[i].ChannelID) + if channelIDLower == "email_"+slug || channelIDLower == slug || strings.Contains(channelIDLower, slug) { + return &channels[i] + } + if cfg != nil { + cfgEmailLower := strings.ToLower(cfg.EmailAddress) + cfgFwdLower := strings.ToLower(cfg.ForwardingAddress) + if strings.Contains(cfgEmailLower, "@"+slug+".") || + strings.Contains(cfgEmailLower, "+"+slug+"@") || + strings.Contains(cfgFwdLower, "@"+slug+".") || + strings.Contains(cfgFwdLower, "+"+slug+"@") { + return &channels[i] + } + } + } + + // Also check if Organization exists with code == slug + org := repositories.OrganizationRepository.GetByCode(sqls.DB(), slug) + if org != nil { + for i := range channels { + if strings.EqualFold(channels[i].Name, org.Name) || + strings.Contains(strings.ToLower(channels[i].Name), slug) { + return &channels[i] + } + } + } + } + + // 3. Pass 3: Fallback to first active email channel + return &channels[0] +} + +func extractTenantSlugFromEmail(emailAddress string) string { + emailAddress = strings.ToLower(strings.TrimSpace(emailAddress)) + parts := strings.Split(emailAddress, "@") + if len(parts) != 2 { + return "" + } + localPart, domain := parts[0], parts[1] + + // Check plus addressing (e.g. help+dos@crove.io -> "dos") + if strings.Contains(localPart, "+") { + plusParts := strings.Split(localPart, "+") + if len(plusParts) > 1 && plusParts[1] != "" { + return plusParts[1] + } + } + + // Check subdomains (e.g. dos.crove.io -> "dos", dos.on.crove.email -> "dos") + domainParts := strings.Split(domain, ".") + if len(domainParts) >= 3 { + if domainParts[0] != "mail" && domainParts[0] != "smtp" && domainParts[0] != "email" && domainParts[0] != "inbound" { + return domainParts[0] + } + } + + return "" +} + func (s *channelService) GetEnabledChannel(ctx *gin.Context) *models.Channel { channelID := httpx.GetChannelID(ctx) channel := repositories.ChannelRepository.GetByChannelID(sqls.DB(), channelID) @@ -505,7 +687,7 @@ func (s *channelService) GetEnabledChannel(ctx *gin.Context) *models.Channel { func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRequest) (*models.Channel, error) { channelType := strings.TrimSpace(req.ChannelType) - if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWechatMP && channelType != enums.ChannelTypeWxWorkKF && channelType != enums.ChannelTypeTelegram && channelType != enums.ChannelTypeZaloOA && channelType != enums.ChannelTypeSlack && channelType != enums.ChannelTypeDiscord && channelType != enums.ChannelTypeLark { + if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWechatMP && channelType != enums.ChannelTypeWxWorkKF && channelType != enums.ChannelTypeTelegram && channelType != enums.ChannelTypeZaloOA && channelType != enums.ChannelTypeEmail && channelType != enums.ChannelTypeDiscord && channelType != enums.ChannelTypeSlack && channelType != enums.ChannelTypeLark { return nil, errorsx.InvalidParamI18n("error.e0250") } name := strings.TrimSpace(req.Name) @@ -652,21 +834,24 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe return nil, err } configJSON = string(configBytes) - case enums.ChannelTypeSlack: + case enums.ChannelTypeEmail: if channelID == "" { channelID = strs.UUID() } if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil { return nil, errorsx.InvalidParamI18n("error.e0248") } - cfg, err := s.ParseSlackChannelConfig(configJSON) + cfg, err := s.ParseEmailChannelConfig(configJSON) if err != nil { - return nil, errorsx.InvalidParam("invalid slack configuration") + return nil, errorsx.InvalidParam("invalid email channel configuration") } - // Without a bot token the channel can receive events but can never reply, - // so it is required the same way Telegram's bot token is. - if cfg == nil || cfg.BotToken == "" { - return nil, errorsx.InvalidParam("slack botToken is required") + if cfg == nil || cfg.EmailAddress == "" { + return nil, errorsx.InvalidParam("emailAddress is required") + } + if cfg.WebhookSecret == "" { + if secret, err := generateUserTokenSecret(); err == nil { + cfg.WebhookSecret = secret + } } configBytes, err := json.Marshal(cfg) if err != nil { @@ -699,6 +884,27 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe return nil, err } configJSON = string(configBytes) + case enums.ChannelTypeSlack: + if channelID == "" { + channelID = strs.UUID() + } + if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil { + return nil, errorsx.InvalidParamI18n("error.e0248") + } + cfg, err := s.ParseSlackChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid slack configuration") + } + // Without a bot token the channel can receive events but can never reply, + // so it is required the same way Telegram's bot token is. + if cfg == nil || cfg.BotToken == "" { + return nil, errorsx.InvalidParam("slack botToken is required") + } + configBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + configJSON = string(configBytes) case enums.ChannelTypeLark: if channelID == "" { channelID = strs.UUID() diff --git a/internal/services/channel_service_test.go b/internal/services/channel_service_test.go index 7ede0c2e..1e218b29 100644 --- a/internal/services/channel_service_test.go +++ b/internal/services/channel_service_test.go @@ -137,3 +137,60 @@ func createChannelServiceTestAgent(t *testing.T, db *gorm.DB, publishedRevisionI func channelServiceTestOperator() *dto.AuthPrincipal { return &dto.AuthPrincipal{UserID: 1, Username: "admin"} } + +func TestGetEnabledEmailChannelByAddress_MultiTenantSlugResolution(t *testing.T) { + db := setupChannelServiceTestDB(t) + agent := createChannelServiceTestAgent(t, db, 1001) + + // Create Channel 1 for DOS (help@dos.crove.io) + dosCfg := `{"emailAddress":"help@dos.crove.io","forwardingAddress":"help@dos.crove.io"}` + _, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "DOS Support Channel", + ChannelType: enums.ChannelTypeEmail, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: dosCfg, + Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("create dos channel failed: %v", err) + } + + // Create Channel 2 for Acme (help@acme.on.crove.email) + acmeCfg := `{"emailAddress":"support@acme.com","forwardingAddress":"help@acme.on.crove.email"}` + _, err = ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "Acme Support Channel", + ChannelType: enums.ChannelTypeEmail, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: acmeCfg, + Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("create acme channel failed: %v", err) + } + + // Test 1: Exact match on EmailAddress + c1 := ChannelService.GetEnabledEmailChannelByAddress("help@dos.crove.io") + if c1 == nil || c1.Name != "DOS Support Channel" { + t.Fatalf("expected DOS Support Channel, got: %+v", c1) + } + + // Test 2: Subdomain / Slug match (e.g. any sender addressing support@dos.crove.io) + c2 := ChannelService.GetEnabledEmailChannelByAddress("support@dos.crove.io") + if c2 == nil || c2.Name != "DOS Support Channel" { + t.Fatalf("expected DOS Support Channel from subdomain slug, got: %+v", c2) + } + + // Test 3: Match on forwardingAddress (help@acme.on.crove.email) + c3 := ChannelService.GetEnabledEmailChannelByAddress("help@acme.on.crove.email") + if c3 == nil || c3.Name != "Acme Support Channel" { + t.Fatalf("expected Acme Support Channel, got: %+v", c3) + } + + // Test 4: Slug match on Acme (sales@acme.on.crove.email) + c4 := ChannelService.GetEnabledEmailChannelByAddress("sales@acme.on.crove.email") + if c4 == nil || c4.Name != "Acme Support Channel" { + t.Fatalf("expected Acme Support Channel from on.crove.email slug, got: %+v", c4) + } +} diff --git a/internal/services/company_service.go b/internal/services/company_service.go index 6783982c..e46dfcc5 100644 --- a/internal/services/company_service.go +++ b/internal/services/company_service.go @@ -1,6 +1,10 @@ package services import ( + "fmt" + "strings" + "time" + "agent-desk/internal/models" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" @@ -8,8 +12,6 @@ import ( "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/utils" "agent-desk/internal/repositories" - "strings" - "time" "agent-desk/internal/pkg/httpx/params" @@ -80,6 +82,13 @@ func (s *companyService) CreateCompany(req request.CreateCompanyRequest, operato if err := repositories.CompanyRepository.Create(sqls.DB(), item); err != nil { return nil, err } + + WebhookSyncService.DispatchOutboundEvent("company.created", request.OrgSyncEventData{ + DeskCompanyID: fmt.Sprintf("comp_%d", item.ID), + Name: item.Name, + DomainName: item.Remark, + }) + return item, nil } diff --git a/internal/services/cronx/cron.go b/internal/services/cronx/cron.go index 598f78af..28d5e0da 100644 --- a/internal/services/cronx/cron.go +++ b/internal/services/cronx/cron.go @@ -34,6 +34,10 @@ func Init() { if zaloCount > 0 { slog.Info("zalo oa outbox dispatched", "count", zaloCount) } + emailCount := services.EmailOutboundService.DispatchPendingOutbox() + if emailCount > 0 { + slog.Info("email outbox dispatched", "count", emailCount) + } slackCount := services.SlackOutboundService.DispatchPendingOutbox() if slackCount > 0 { slog.Info("slack outbox dispatched", "count", slackCount) diff --git a/internal/services/customer_service.go b/internal/services/customer_service.go index 14d8fa4a..abbd2341 100644 --- a/internal/services/customer_service.go +++ b/internal/services/customer_service.go @@ -3,7 +3,10 @@ package services import ( "crypto/md5" "encoding/hex" + "fmt" "log/slog" + "strings" + "time" "agent-desk/internal/models" "agent-desk/internal/pkg/dto" @@ -13,8 +16,6 @@ import ( "agent-desk/internal/pkg/openidentity" "agent-desk/internal/pkg/utils" "agent-desk/internal/repositories" - "strings" - "time" "agent-desk/internal/pkg/httpx/params" @@ -217,6 +218,15 @@ func (s *customerService) CreateCustomer(req request.CreateCustomerRequest, oper if err := repositories.CustomerRepository.Create(sqls.DB(), item); err != nil { return nil, err } + + WebhookSyncService.DispatchOutboundEvent("customer.created", request.OrgSyncEventData{ + DeskCustomerID: fmt.Sprintf("cust_%d", item.ID), + Name: item.Name, + Email: item.PrimaryEmail, + Phone: item.PrimaryMobile, + Source: "crove_desk", + }) + return item, nil } diff --git a/internal/services/email_inbound_service.go b/internal/services/email_inbound_service.go new file mode 100644 index 00000000..e3546e46 --- /dev/null +++ b/internal/services/email_inbound_service.go @@ -0,0 +1,238 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "regexp" + "strconv" + "strings" + + "agent-desk/internal/email" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" +) + +var ( + EmailInboundService = newEmailInboundService() + ticketIDRegex = regexp.MustCompile(`(?i)(?:\[(?:#|Ticket\s*#?)|(?:#|Ticket\s*#))\s*(\d+)`) +) + +func newEmailInboundService() *emailInboundService { + return &emailInboundService{} +} + +type emailInboundService struct{} + +// HandleWebhook processes an incoming email webhook from any supported provider (Cloudflare, Brevo, SendGrid, Postmark, Mailgun, Resend). +func (s *emailInboundService) HandleWebhook(ctx context.Context, channelID string, secretHeader string, contentType string, rawPayload []byte, form url.Values) error { + channelID = strings.TrimSpace(channelID) + var channel *models.Channel + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeEmail, enums.StatusOk) + } + + // 1. Parse inbound email items + inboundItems, err := email.ParseInboundWebhook(contentType, rawPayload, form) + if err != nil { + return fmt.Errorf("parse email webhook failed: %w", err) + } + if len(inboundItems) == 0 { + return nil + } + + systemSecret := "" + if cfg := config.GetCurrent(); cfg != nil { + systemSecret = cfg.Email.InboundSecret + } + + for _, item := range inboundItems { + targetChannel := channel + if targetChannel == nil { + targetChannel = ChannelService.GetEnabledEmailChannelByAddress(item.ToEmail) + } + if targetChannel == nil { + targetChannel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeEmail, enums.StatusOk) + } + if targetChannel == nil { + slog.Warn("no active email channel found for recipient", "to", item.ToEmail) + return errorsx.InvalidParam("email channel not found or disabled") + } + + cfg, err := ChannelService.ParseEmailChannelConfig(targetChannel.ConfigJSON) + if err != nil || cfg == nil { + return errorsx.InvalidParam("email channel config invalid") + } + + expectedSecret := cfg.WebhookSecret + if expectedSecret == "" { + expectedSecret = systemSecret + } + + if expectedSecret != "" && strings.TrimSpace(secretHeader) != expectedSecret { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + + if err := s.processInboundItem(ctx, targetChannel, item); err != nil { + slog.Error("process inbound email item failed", "from", item.FromEmail, "to", item.ToEmail, "error", err) + return err + } + } + + return nil +} + +func (s *emailInboundService) processInboundItem(ctx context.Context, channel *models.Channel, item email.InboundEmailPayload) error { + fromEmail := strings.ToLower(strings.TrimSpace(item.FromEmail)) + if fromEmail == "" { + return nil + } + fromName := strings.TrimSpace(item.FromName) + if fromName == "" { + parts := strings.Split(fromEmail, "@") + fromName = parts[0] + } + + bodyText := strings.TrimSpace(item.BodyText) + if bodyText == "" && item.BodyHTML != "" { + bodyText = stripHTMLTags(item.BodyHTML) + } + if bodyText == "" { + bodyText = "(Empty email body)" + } + + // Format content with subject if provided + content := bodyText + if item.Subject != "" { + content = fmt.Sprintf("[%s]\n\n%s", item.Subject, bodyText) + } + + // 1. Resolve customer identity + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceEmail, + ExternalID: fromEmail, + ExternalName: fromName, + } + + // 2. Threading Resolution: Find existing conversation by Ticket ID or In-Reply-To header + var conversation *models.Conversation + ticketID := s.extractTicketID(item.Subject) + if ticketID > 0 { + existing := ConversationService.Get(ticketID) + if existing != nil && existing.Status != enums.IMConversationStatusClosed { + conversation = existing + } + } + + if conversation == nil && item.InReplyTo != "" { + conversation = s.findConversationByInReplyTo(item.InReplyTo) + } + + // If no existing thread matched, create or match via standard ConversationService + if conversation == nil { + var err error + conversation, err = ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return fmt.Errorf("create email conversation failed: %w", err) + } + } + + // Ensure customer primary_email is populated + if conversation.CustomerID > 0 { + customer := repositories.CustomerRepository.Get(sqls.DB(), conversation.CustomerID) + if customer != nil && customer.PrimaryEmail == "" { + _ = repositories.CustomerRepository.UpdateColumn(sqls.DB(), customer.ID, "primary_email", fromEmail) + } + } + + // 3. Send message through MessageService (triggers AI response loop or agent notification) + msgHash := strs.UUID() + if item.MessageID != "" { + msgHash = fmt.Sprintf("email_%s", strings.Trim(item.MessageID, "<>")) + } + clientMsgID := fmt.Sprintf("mail_%s", msgHash) + + payloadMap := map[string]any{ + "email_from": fromEmail, + "email_from_name": fromName, + "email_to": item.ToEmail, + "email_subject": item.Subject, + "email_message_id": item.MessageID, + "email_in_reply": item.InReplyTo, + "email_references": item.References, + } + payloadBytes, _ := json.Marshal(payloadMap) + + _, err := MessageService.SendCustomerMessage( + conversation.ID, + clientMsgID, + enums.IMMessageTypeText, + content, + string(payloadBytes), + externalUser, + ) + if err != nil { + return fmt.Errorf("send customer message failed: %w", err) + } + + slog.Info("inbound email successfully processed", + "from", fromEmail, + "channel_id", channel.ChannelID, + "conversation_id", conversation.ID, + "subject", item.Subject, + ) + return nil +} + +func (s *emailInboundService) extractTicketID(subject string) int64 { + matches := ticketIDRegex.FindStringSubmatch(subject) + if len(matches) > 1 { + if id, err := strconv.ParseInt(matches[1], 10, 64); err == nil && id > 0 { + return id + } + } + return 0 +} + +func (s *emailInboundService) findConversationByInReplyTo(inReplyTo string) *models.Conversation { + inReplyTo = strings.TrimSpace(inReplyTo) + if inReplyTo == "" { + return nil + } + // Look up recent message containing this email message ID + likePattern := "%" + inReplyTo + "%" + msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Where("payload LIKE ?", likePattern). + Desc("id")) + if msg != nil && msg.ConversationID > 0 { + conv := ConversationService.Get(msg.ConversationID) + if conv != nil && conv.Status != enums.IMConversationStatusClosed { + return conv + } + } + return nil +} + +func stripHTMLTags(s string) string { + var builder strings.Builder + inTag := false + for _, r := range s { + if r == '<' { + inTag = true + } else if r == '>' { + inTag = false + } else if !inTag { + builder.WriteRune(r) + } + } + return strings.TrimSpace(builder.String()) +} diff --git a/internal/services/email_outbound_service.go b/internal/services/email_outbound_service.go new file mode 100644 index 00000000..173c0959 --- /dev/null +++ b/internal/services/email_outbound_service.go @@ -0,0 +1,296 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "agent-desk/internal/email" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +const ( + emailOutboxBatchSize = 20 + emailOutboxMaxRetry = 5 +) + +var EmailOutboundService = newEmailOutboundService() + +func newEmailOutboundService() *emailOutboundService { + return &emailOutboundService{} +} + +type emailOutboundService struct{} + +func (s *emailOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(emailOutboxBatchSize) +} + +func (s *emailOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = emailOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeEmail, limit) + if len(items) == 0 { + return 0 + } + + successCount := 0 + for i := range items { + if err := s.processOutbox(items[i].ID); err != nil { + slog.Warn("process email outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *emailOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeEmail { + return nil + } + if outbox.SendStatus == string(enums.ChannelMessageOutboxStatusSent) { + return nil + } + if outbox.NextRetryAt != nil && outbox.NextRetryAt.After(time.Now()) { + return nil + } + + if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSending), + "updated_at": time.Now(), + }); err != nil { + return err + } + + message := MessageService.Get(outbox.MessageID) + if message == nil { + return s.markOutboxFailed(outbox, "message not found") + } + conversation := ConversationService.Get(outbox.ConversationID) + if conversation == nil { + return s.markOutboxFailed(outbox, "conversation not found") + } + + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.Status != enums.StatusOk { + return s.markOutboxFailed(outbox, "email channel not found or disabled") + } + cfg, err := ChannelService.ParseEmailChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + return s.markOutboxFailed(outbox, "email channel config invalid") + } + + // 1. Resolve recipient email address + targetEmail := "" + targetName := "" + customer := repositories.CustomerRepository.Get(sqls.DB(), conversation.CustomerID) + if customer != nil { + targetEmail = strings.TrimSpace(customer.PrimaryEmail) + targetName = strings.TrimSpace(customer.Name) + } + if targetEmail == "" { + customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", conversation.CustomerID). + Eq("external_source", enums.ExternalSourceEmail)) + if customerIdentity != nil { + targetEmail = strings.TrimSpace(customerIdentity.ExternalID) + } + } + if targetEmail == "" { + return s.markOutboxFailed(outbox, "unable to resolve customer email address") + } + + // 2. Resolve sender config & system fallbacks + var sysEmail config.EmailConfig + if c := config.GetCurrent(); c != nil { + sysEmail = c.Email + } + fromEmail := strings.TrimSpace(cfg.EmailAddress) + if fromEmail == "" { + fromEmail = strings.TrimSpace(sysEmail.FromAddress) + } + if fromEmail == "" { + fromEmail = "support@crove.com" + } + + fromName := strings.TrimSpace(cfg.SenderName) + if fromName == "" { + fromName = strings.TrimSpace(sysEmail.FromName) + } + if fromName == "" { + fromName = "Customer Support" + } + + provider := email.DeliveryProvider(strings.ToLower(strings.TrimSpace(cfg.Provider))) + if provider == "" || provider == "default" { + provider = email.DeliveryProvider(strings.ToLower(strings.TrimSpace(sysEmail.Provider))) + } + if provider == "" { + provider = email.ProviderSMTP + } + + apiKey := cfg.APIKey + if apiKey == "" { + apiKey = sysEmail.APIKey + } + + smtpHost := cfg.SMTPHost + if smtpHost == "" { + smtpHost = sysEmail.SMTPHost + } + smtpPort := cfg.SMTPPort + if smtpPort <= 0 { + smtpPort = sysEmail.SMTPPort + } + if smtpPort <= 0 { + smtpPort = 587 + } + smtpUser := cfg.SMTPUser + if smtpUser == "" { + smtpUser = sysEmail.SMTPUser + } + smtpPassword := cfg.SMTPPassword + if smtpPassword == "" { + smtpPassword = sysEmail.SMTPPassword + } + smtpUseTLS := sysEmail.SMTPUseTLS + + client := email.NewClient(email.ClientConfig{ + Provider: provider, + APIKey: apiKey, + SMTPHost: smtpHost, + SMTPPort: smtpPort, + SMTPUser: smtpUser, + SMTPPassword: smtpPassword, + SMTPUseTLS: smtpUseTLS, + }) + + // 3. Resolve threading headers and subject + lastInboundMessageID := "" + lastInboundSubject := "" + recentMessages := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conversation.ID). + Eq("sender_type", enums.IMSenderTypeCustomer). + Desc("id"). + Limit(5)) + + for _, rm := range recentMessages { + if rm.Payload != "" { + var pMap map[string]any + if json.Unmarshal([]byte(rm.Payload), &pMap) == nil { + if msgID, ok := pMap["email_message_id"].(string); ok && msgID != "" && lastInboundMessageID == "" { + lastInboundMessageID = msgID + } + if subj, ok := pMap["email_subject"].(string); ok && subj != "" && lastInboundSubject == "" { + lastInboundSubject = subj + } + } + } + } + + subject := fmt.Sprintf("Re: [#%d] Support Inquiry", conversation.ID) + if lastInboundSubject != "" { + cleanSubj := strings.TrimPrefix(lastInboundSubject, "Re: ") + cleanSubj = strings.TrimPrefix(cleanSubj, "re: ") + subject = fmt.Sprintf("Re: [#%d] %s", conversation.ID, cleanSubj) + } + + fromDomain := "desk.crove.com" + parts := strings.Split(fromEmail, "@") + if len(parts) == 2 { + fromDomain = parts[1] + } + outboundMsgID := fmt.Sprintf("", conversation.ID, message.ID, fromDomain) + + customHeaders := map[string]string{ + "X-Crove-Desk-Conversation-ID": fmt.Sprintf("%d", conversation.ID), + "X-Crove-Desk-Message-ID": fmt.Sprintf("%d", message.ID), + } + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + + sendErr := client.SendEmail(ctx, email.SendEmailParams{ + FromEmail: fromEmail, + FromName: fromName, + ToEmail: targetEmail, + ToName: targetName, + Subject: subject, + BodyText: message.Content, + MessageID: outboundMsgID, + InReplyTo: lastInboundMessageID, + References: lastInboundMessageID, + Headers: customHeaders, + }) + + if sendErr != nil { + return s.handleOutboxError(outbox, sendErr.Error()) + } + + return s.markOutboxSent(outbox, fmt.Sprintf("sent to %s via %s", targetEmail, provider)) +} + +func (s *emailOutboundService) markOutboxSent(outbox *models.ChannelMessageOutbox, detail string) error { + now := time.Now() + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSent), + "send_detail": detail, + "sent_at": &now, + "updated_at": now, + "next_retry_at": nil, + }) +} + +func (s *emailOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, reason string) error { + now := time.Now() + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusFailed), + "send_detail": reason, + "updated_at": now, + "next_retry_at": nil, + }) +} + +func (s *emailOutboundService) handleOutboxError(outbox *models.ChannelMessageOutbox, errMsg string) error { + retryCount := outbox.RetryCount + 1 + now := time.Now() + + if retryCount >= emailOutboxMaxRetry { + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusFailed), + "send_detail": fmt.Sprintf("max retries exceeded: %s", errMsg), + "retry_count": retryCount, + "updated_at": now, + "next_retry_at": nil, + }) + } + + // Exponential backoff + backoff := time.Duration(1< ?", orgID)) + var newActiveOrgID int64 = 0 + if len(remaining) > 0 { + newActiveOrgID = remaining[0].OrganizationID + } + _ = repositories.UserRepository.UpdateColumn(ctx.Tx, targetUserID, "active_org_id", newActiveOrgID) + } + return nil + }) + + if err != nil { + return err + } + + orgCode := "" + if targetOrg := repositories.OrganizationRepository.Get(sqls.DB(), orgID); targetOrg != nil { + orgCode = targetOrg.Code + } + email := "" + username := "" + if targetUser := repositories.UserRepository.Get(sqls.DB(), targetUserID); targetUser != nil { + username = targetUser.Username + if targetUser.Email != nil { + email = *targetUser.Email + } + } + WebhookSyncService.DispatchOutboundOrgEvent("org.member_removed", request.OrgSyncEventData{ + OrgID: orgCode, + UserID: username, + UserEmail: email, + }) + + return nil +} + +func (s *organizationService) UpdateOrganization(currentUserID int64, orgID int64, req request.OrganizationUpdateRequest) (*response.OrganizationResponse, error) { + currentMember := repositories.OrganizationMemberRepository.GetByOrgAndUser(sqls.DB(), orgID, currentUserID) + if currentMember == nil || currentMember.Status != enums.StatusOk || (currentMember.Role != "OWNER" && currentMember.Role != "ADMIN") { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParam("organization name cannot be empty") + } + + org := repositories.OrganizationRepository.Get(sqls.DB(), orgID) + if org == nil || org.Status != enums.StatusOk { + return nil, errorsx.InvalidParam("organization not found") + } + + updates := map[string]any{ + "name": name, + "logo": strings.TrimSpace(req.Logo), + "update_user_id": currentUserID, + "update_user_name": "", + "updated_at": time.Now(), + } + + if err := repositories.OrganizationRepository.Updates(sqls.DB(), orgID, updates); err != nil { + return nil, err + } + + org = repositories.OrganizationRepository.Get(sqls.DB(), orgID) + + WebhookSyncService.DispatchOutboundOrgEvent("org.updated", request.OrgSyncEventData{ + OrgID: org.Code, + OrgName: org.Name, + Plan: org.Plan, + }) + + return &response.OrganizationResponse{ + ID: org.ID, + Code: org.Code, + Name: org.Name, + Logo: org.Logo, + Plan: org.Plan, + Status: org.Status, + Role: currentMember.Role, + IsActive: true, + CreatedAt: org.CreatedAt, + }, nil +} + func (s *organizationService) GetActiveOrganization(principal *dto.AuthPrincipal) *models.Organization { if principal == nil || principal.UserID <= 0 { return nil @@ -96,3 +460,36 @@ func (s *organizationService) GetActiveOrganization(principal *dto.AuthPrincipal } return repositories.OrganizationRepository.Get(sqls.DB(), user.ActiveOrgID) } + +func (s *organizationService) generateUniqueOrgCode(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } else if b.Len() > 0 && !strings.HasSuffix(b.String(), "-") { + b.WriteString("-") + } + } + slug := strings.Trim(b.String(), "-") + if slug == "" { + slug = "org" + } + if len(slug) > 30 { + slug = slug[:30] + } + + if repositories.OrganizationRepository.GetByCode(sqls.DB(), slug) == nil { + return slug + } + + for i := 1; i < 100; i++ { + candidate := fmt.Sprintf("%s-%d", slug, i) + if repositories.OrganizationRepository.GetByCode(sqls.DB(), candidate) == nil { + return candidate + } + } + + randBuf := make([]byte, 4) + _, _ = rand.Read(randBuf) + return fmt.Sprintf("%s-%s", slug, hex.EncodeToString(randBuf)) +} diff --git a/internal/services/webhook_sync_service.go b/internal/services/webhook_sync_service.go index cc6436ac..8eaf4ae0 100644 --- a/internal/services/webhook_sync_service.go +++ b/internal/services/webhook_sync_service.go @@ -7,9 +7,15 @@ import ( "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/repositories" + "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" + "encoding/json" + "log/slog" + "math" + "net/http" + "strconv" "strings" "time" @@ -25,24 +31,73 @@ func newWebhookSyncService() *webhookSyncService { type webhookSyncService struct{} func (s *webhookSyncService) VerifySignature(payload []byte, signature string) bool { - secret := strings.TrimSpace(config.Current().Webhook.OrgSyncSecret) + cfg := config.GetCurrent() + if cfg == nil { + return true + } + secret := strings.TrimSpace(cfg.Webhook.OrgSyncSecret) if secret == "" { - secret = strings.TrimSpace(config.Current().Webhook.DOSOrgSyncSecret) + secret = strings.TrimSpace(cfg.Webhook.DOSOrgSyncSecret) } if secret == "" { - secret = strings.TrimSpace(config.Current().OIDC.ClientSecret) + secret = strings.TrimSpace(cfg.OIDC.ClientSecret) } if secret == "" { return true } - sig := strings.TrimSpace(signature) + sigHeader := strings.TrimSpace(signature) + if sigHeader == "" { + return false + } + + // 1. Check for format: t=,v1= + if strings.Contains(sigHeader, "t=") && (strings.Contains(sigHeader, "v1=") || strings.Contains(sigHeader, "v0=")) { + parts := strings.Split(sigHeader, ",") + var tsStr, expectedSig string + for _, p := range parts { + p = strings.TrimSpace(p) + if strings.HasPrefix(p, "t=") { + tsStr = strings.TrimPrefix(p, "t=") + } else if strings.HasPrefix(p, "v1=") { + expectedSig = strings.TrimPrefix(p, "v1=") + } else if strings.HasPrefix(p, "v0=") && expectedSig == "" { + expectedSig = strings.TrimPrefix(p, "v0=") + } + } + + if tsStr != "" && expectedSig != "" { + // Timestamp anti-replay check (5 minutes) + var tsInt int64 + if parsed, err := strconv.ParseInt(tsStr, 10, 64); err == nil { + tsInt = parsed + now := time.Now().Unix() + if tsInt > 1e11 { // ms + now = time.Now().UnixMilli() + if math.Abs(float64(now-tsInt)) > float64(5*60*1000) { + return false + } + } else { + if math.Abs(float64(now-tsInt)) > float64(5*60) { + return false + } + } + } + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(tsStr + "." + string(payload))) + computed := hex.EncodeToString(mac.Sum(nil)) + if hmac.Equal([]byte(expectedSig), []byte(computed)) { + return true + } + } + } + + // 2. Fallback to direct sha256= signature or raw hex signature + sig := sigHeader if strings.HasPrefix(sig, "sha256=") { sig = strings.TrimPrefix(sig, "sha256=") } - if sig == "" { - return false - } mac := hmac.New(sha256.New, []byte(secret)) mac.Write(payload) @@ -57,18 +112,45 @@ func (s *webhookSyncService) HandleOrgSync(req request.OrgSyncWebhookRequest) er orgCode := strings.TrimSpace(data.OrgID) if orgCode == "" { - return errorsx.InvalidParam("org_id is required") + orgCode = strings.TrimSpace(data.ID) + } + if orgCode == "" { + orgCode = strings.TrimSpace(data.Slug) + } + data.OrgID = orgCode + + if data.OrgName == "" && data.Name != "" { + data.OrgName = data.Name + } + if data.UserEmail == "" && data.BillingEmail != "" { + data.UserEmail = data.BillingEmail } switch event { - case "org.created", "org.updated": + case "org.created", "org.updated", "organization.created", "organization.updated": + if orgCode == "" { + return errorsx.InvalidParam("org_id or id is required") + } return s.handleOrgUpsert(data) - case "org.deleted": + case "org.deleted", "organization.deleted": + if orgCode == "" { + return errorsx.InvalidParam("org_id or id is required") + } return s.handleOrgDelete(orgCode) - case "org.member_added", "org.member_updated": + case "org.member_added", "org.member_updated", "organization.member.added", "organization.member.updated", "organization.member_added": + if orgCode == "" { + return errorsx.InvalidParam("org_id or id is required") + } return s.handleMemberUpsert(data) - case "org.member_removed": + case "org.member_removed", "organization.member.removed", "organization.member_removed": + if orgCode == "" { + return errorsx.InvalidParam("org_id or id is required") + } return s.handleMemberRemove(data) + case "company.created", "company.updated": + return s.handleCompanyUpsert(data) + case "customer.created", "customer.updated": + return s.handleCustomerUpsert(data) default: return nil } @@ -78,6 +160,76 @@ func (s *webhookSyncService) HandleDOSOrgSync(req request.DOSOrgSyncWebhookReque return s.HandleOrgSync(req) } +func (s *webhookSyncService) DispatchOutboundEvent(event string, data request.OrgSyncEventData) { + cfg := config.GetCurrent() + if cfg == nil { + return + } + outboundURL := strings.TrimSpace(cfg.Webhook.OutboundURL) + if outboundURL == "" { + return + } + + payload := request.OrgSyncWebhookRequest{ + Event: event, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Data: data, + } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + slog.Error("failed to marshal outbound event", "event", event, "error", err) + return + } + + secret := strings.TrimSpace(cfg.Webhook.OrgSyncSecret) + if secret == "" { + secret = strings.TrimSpace(cfg.Webhook.DOSOrgSyncSecret) + } + if secret == "" { + secret = strings.TrimSpace(cfg.OIDC.ClientSecret) + } + + var signature string + if secret != "" { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(bodyBytes) + signature = "sha256=" + hex.EncodeToString(mac.Sum(nil)) + } + + go func() { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(http.MethodPost, outboundURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + slog.Error("failed to create outbound sync request", "url", outboundURL, "error", err) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-DOS-Event", event) + if signature != "" { + req.Header.Set("X-DOS-Signature", signature) + req.Header.Set("X-Webhook-Signature", signature) + } + + resp, err := client.Do(req) + if err != nil { + slog.Error("failed to dispatch outbound sync event", "event", event, "url", outboundURL, "error", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + slog.Warn("outbound sync event returned non-2xx status", "event", event, "status", resp.StatusCode) + } else { + slog.Info("outbound sync event dispatched successfully", "event", event) + } + }() +} + +func (s *webhookSyncService) DispatchOutboundOrgEvent(event string, data request.OrgSyncEventData) { + s.DispatchOutboundEvent(event, data) +} + func (s *webhookSyncService) handleOrgUpsert(data request.OrgSyncEventData) error { now := time.Now() orgCode := strings.TrimSpace(data.OrgID) @@ -302,3 +454,250 @@ func (s *webhookSyncService) handleMemberRemove(data request.OrgSyncEventData) e return nil }) } + +func (s *webhookSyncService) handleCompanyUpsert(data request.OrgSyncEventData) error { + name := strings.TrimSpace(data.Name) + if name == "" { + name = strings.TrimSpace(data.OrgName) + } + if name == "" { + name = strings.TrimSpace(data.CRMCompanyID) + } + if name == "" { + return errorsx.InvalidParam("company name is required") + } + + code := strings.TrimSpace(data.CRMCompanyID) + if code == "" { + code = strings.TrimSpace(data.ID) + } + if code == "" { + code = strings.TrimSpace(data.CompanyID) + } + if code == "" { + code = strings.TrimSpace(data.DeskCompanyID) + } + + remark := strings.TrimSpace(data.DomainName) + if remark == "" { + remark = strings.TrimSpace(data.Domain) + } + if data.TaxCode != "" { + if remark != "" { + remark += " | Tax: " + data.TaxCode + } else { + remark = "Tax: " + data.TaxCode + } + } + if data.Address != "" { + if remark != "" { + remark += " | " + data.Address + } else { + remark = data.Address + } + } + if data.Tier != "" { + if remark != "" { + remark += " | Tier: " + data.Tier + } else { + remark = "Tier: " + data.Tier + } + } + + now := time.Now() + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + var company *models.Company + if code != "" { + company = repositories.CompanyRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("code", code)) + } + if company == nil { + company = repositories.CompanyRepository.GetByName(ctx.Tx, name) + } + + if company == nil { + company = &models.Company{ + Name: name, + Code: code, + Status: enums.StatusOk, + Remark: remark, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "crm-sync", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "crm-sync", + }, + } + return repositories.CompanyRepository.Create(ctx.Tx, company) + } + + updates := map[string]any{ + "name": name, + "status": enums.StatusOk, + "update_user_id": 0, + "update_user_name": "crm-sync", + "updated_at": now, + } + if code != "" { + updates["code"] = code + } + if remark != "" { + updates["remark"] = remark + } + return repositories.CompanyRepository.Updates(ctx.Tx, company.ID, updates) + }) +} + +func (s *webhookSyncService) handleCustomerUpsert(data request.OrgSyncEventData) error { + name := strings.TrimSpace(data.Name) + if name == "" { + name = strings.TrimSpace(data.UserName) + } + email := strings.TrimSpace(strings.ToLower(data.Email)) + if email == "" { + email = strings.TrimSpace(strings.ToLower(data.UserEmail)) + } + phone := strings.TrimSpace(data.Phone) + crmPersonID := strings.TrimSpace(data.CRMPersonID) + if crmPersonID == "" { + crmPersonID = strings.TrimSpace(data.ID) + } + crmCompanyID := strings.TrimSpace(data.CRMCompanyID) + if crmCompanyID == "" { + crmCompanyID = strings.TrimSpace(data.CompanyID) + } + companyName := strings.TrimSpace(data.CompanyName) + + if name == "" && email == "" && phone == "" && crmPersonID == "" { + return errorsx.InvalidParam("at least one customer identifier is required") + } + if name == "" { + if email != "" { + name = email + } else if phone != "" { + name = phone + } else { + name = "Customer " + crmPersonID + } + } + + now := time.Now() + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + var companyID int64 = 0 + if crmCompanyID != "" { + company := repositories.CompanyRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("code", crmCompanyID)) + if company != nil { + companyID = company.ID + } + } + if companyID == 0 && companyName != "" { + company := repositories.CompanyRepository.GetByName(ctx.Tx, companyName) + if company != nil { + companyID = company.ID + } else { + newComp := &models.Company{ + Name: companyName, + Code: crmCompanyID, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "crm-sync", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "crm-sync", + }, + } + if err := repositories.CompanyRepository.Create(ctx.Tx, newComp); err == nil { + companyID = newComp.ID + } + } + } + + var customer *models.Customer + if crmPersonID != "" { + identity := repositories.CustomerIdentityRepository.FindOne(ctx.Tx, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceTwentyCRM). + Eq("external_id", crmPersonID)) + if identity != nil { + customer = repositories.CustomerRepository.Get(ctx.Tx, identity.CustomerID) + } + } + if customer == nil && email != "" { + customer = repositories.CustomerRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("primary_email", email)) + } + if customer == nil && phone != "" { + customer = repositories.CustomerRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("primary_mobile", phone)) + } + + if customer == nil { + customer = &models.Customer{ + Name: name, + PrimaryEmail: email, + PrimaryMobile: phone, + CompanyID: companyID, + Status: enums.StatusOk, + Remark: data.JobTitle, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "crm-sync", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "crm-sync", + }, + } + if err := repositories.CustomerRepository.Create(ctx.Tx, customer); err != nil { + return err + } + } else { + updates := map[string]any{ + "name": name, + "status": enums.StatusOk, + "update_user_id": 0, + "update_user_name": "crm-sync", + "updated_at": now, + } + if email != "" { + updates["primary_email"] = email + } + if phone != "" { + updates["primary_mobile"] = phone + } + if companyID > 0 { + updates["company_id"] = companyID + } + if data.JobTitle != "" { + updates["remark"] = data.JobTitle + } + if err := repositories.CustomerRepository.Updates(ctx.Tx, customer.ID, updates); err != nil { + return err + } + } + + if crmPersonID != "" { + identity := repositories.CustomerIdentityRepository.FindOne(ctx.Tx, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceTwentyCRM). + Eq("external_id", crmPersonID)) + if identity == nil { + _ = repositories.CustomerIdentityRepository.Create(ctx.Tx, &models.CustomerIdentity{ + CustomerID: customer.ID, + ExternalSource: enums.ExternalSourceTwentyCRM, + ExternalID: crmPersonID, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "crm-sync", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "crm-sync", + }, + }) + } + } + + return nil + }) +} diff --git a/internal/services/webhook_sync_service_test.go b/internal/services/webhook_sync_service_test.go index d7f30267..bcd3a9d2 100644 --- a/internal/services/webhook_sync_service_test.go +++ b/internal/services/webhook_sync_service_test.go @@ -1,12 +1,20 @@ package services import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "testing" + "time" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" - "testing" - "time" + + "github.com/mlogclub/simple/sqls" ) func TestWebhookDOSOrgSync_OrgEvents(t *testing.T) { @@ -14,17 +22,17 @@ func TestWebhookDOSOrgSync_OrgEvents(t *testing.T) { svc := newWebhookSyncService() // 1. Test org.created - err := svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + err := svc.HandleOrgSync(request.OrgSyncWebhookRequest{ Event: "org.created", Timestamp: time.Now().Format(time.RFC3339), - Data: request.DOSOrgSyncEventData{ + Data: request.OrgSyncEventData{ OrgID: "org_tingee_001", OrgName: "Tingee Corporation", Plan: "pro", }, }) if err != nil { - t.Fatalf("HandleDOSOrgSync org.created failed: %v", err) + t.Fatalf("HandleOrgSync org.created failed: %v", err) } org := repositories.OrganizationRepository.GetByCode(db, "org_tingee_001") @@ -33,17 +41,17 @@ func TestWebhookDOSOrgSync_OrgEvents(t *testing.T) { } // 2. Test org.updated - err = svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{ Event: "org.updated", Timestamp: time.Now().Format(time.RFC3339), - Data: request.DOSOrgSyncEventData{ + Data: request.OrgSyncEventData{ OrgID: "org_tingee_001", OrgName: "Tingee Global Corp", Plan: "enterprise", }, }) if err != nil { - t.Fatalf("HandleDOSOrgSync org.updated failed: %v", err) + t.Fatalf("HandleOrgSync org.updated failed: %v", err) } org = repositories.OrganizationRepository.GetByCode(db, "org_tingee_001") @@ -52,15 +60,15 @@ func TestWebhookDOSOrgSync_OrgEvents(t *testing.T) { } // 3. Test org.deleted - err = svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{ Event: "org.deleted", Timestamp: time.Now().Format(time.RFC3339), - Data: request.DOSOrgSyncEventData{ + Data: request.OrgSyncEventData{ OrgID: "org_tingee_001", }, }) if err != nil { - t.Fatalf("HandleDOSOrgSync org.deleted failed: %v", err) + t.Fatalf("HandleOrgSync org.deleted failed: %v", err) } org = repositories.OrganizationRepository.GetByCode(db, "org_tingee_001") @@ -74,10 +82,10 @@ func TestWebhookDOSOrgSync_MemberEvents(t *testing.T) { svc := newWebhookSyncService() // 1. Test org.member_added - err := svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + err := svc.HandleOrgSync(request.OrgSyncWebhookRequest{ Event: "org.member_added", Timestamp: time.Now().Format(time.RFC3339), - Data: request.DOSOrgSyncEventData{ + Data: request.OrgSyncEventData{ OrgID: "org_tingee_002", OrgName: "Tingee R&D", UserID: "usr_dos_001", @@ -87,7 +95,7 @@ func TestWebhookDOSOrgSync_MemberEvents(t *testing.T) { }, }) if err != nil { - t.Fatalf("HandleDOSOrgSync org.member_added failed: %v", err) + t.Fatalf("HandleOrgSync org.member_added failed: %v", err) } org := repositories.OrganizationRepository.GetByCode(db, "org_tingee_002") @@ -110,17 +118,17 @@ func TestWebhookDOSOrgSync_MemberEvents(t *testing.T) { } // 2. Test org.member_removed - err = svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{ Event: "org.member_removed", Timestamp: time.Now().Format(time.RFC3339), - Data: request.DOSOrgSyncEventData{ + Data: request.OrgSyncEventData{ OrgID: "org_tingee_002", UserID: "usr_dos_001", UserEmail: "member@tingee.com", }, }) if err != nil { - t.Fatalf("HandleDOSOrgSync org.member_removed failed: %v", err) + t.Fatalf("HandleOrgSync org.member_removed failed: %v", err) } member = repositories.OrganizationMemberRepository.GetByOrgAndUser(db, org.ID, user.ID) @@ -176,3 +184,202 @@ func TestOrganizationService_SwitchAndList(t *testing.T) { t.Fatalf("expected active_org_id %d, got %d", org2.ID, updatedUser.ActiveOrgID) } } + +func TestOrganizationService_CreateAndManageMembers(t *testing.T) { + db := setupAuthServiceTestDB(t) + owner := createAuthTestUser(t, db, "owner_user", "secret") + memberUser := createAuthTestUser(t, db, "member_user", "secret") + email := "member_user@example.com" + _ = repositories.UserRepository.UpdateColumn(db, memberUser.ID, "email", email) + + // 1. Create Organization + created, err := OrganizationService.CreateOrganization(owner.ID, request.OrganizationCreateRequest{ + Name: "Acme Support Org", + Code: "acme-support", + }) + if err != nil { + t.Fatalf("CreateOrganization failed: %v", err) + } + if created == nil || created.Name != "Acme Support Org" || created.Role != "OWNER" { + t.Fatalf("unexpected created org: %+v", created) + } + + updatedOwner := repositories.UserRepository.Get(db, owner.ID) + if updatedOwner.ActiveOrgID != created.ID { + t.Fatalf("expected owner active_org_id = %d, got %d", created.ID, updatedOwner.ActiveOrgID) + } + + // 2. Get Members + members, err := OrganizationService.GetOrganizationMembers(owner.ID, created.ID) + if err != nil { + t.Fatalf("GetOrganizationMembers failed: %v", err) + } + if len(members) != 1 || members[0].UserID != owner.ID || members[0].Role != "OWNER" { + t.Fatalf("unexpected members: %+v", members) + } + + // 3. Add Member by email + added, err := OrganizationService.AddMember(owner.ID, created.ID, request.OrganizationAddMemberRequest{ + EmailOrUsername: "member_user@example.com", + Role: "ADMIN", + }) + if err != nil { + t.Fatalf("AddMember failed: %v", err) + } + if added == nil || added.UserID != memberUser.ID || added.Role != "ADMIN" { + t.Fatalf("unexpected added member: %+v", added) + } + + members, _ = OrganizationService.GetOrganizationMembers(owner.ID, created.ID) + if len(members) != 2 { + t.Fatalf("expected 2 members, got %d", len(members)) + } + + // 4. Update Organization + updatedOrg, err := OrganizationService.UpdateOrganization(owner.ID, created.ID, request.OrganizationUpdateRequest{ + Name: "Acme Global Support", + }) + if err != nil { + t.Fatalf("UpdateOrganization failed: %v", err) + } + if updatedOrg.Name != "Acme Global Support" { + t.Fatalf("expected name to be Acme Global Support, got %s", updatedOrg.Name) + } + + // 5. Remove Member + err = OrganizationService.RemoveMember(owner.ID, created.ID, memberUser.ID) + if err != nil { + t.Fatalf("RemoveMember failed: %v", err) + } + + members, _ = OrganizationService.GetOrganizationMembers(owner.ID, created.ID) + if len(members) != 1 { + t.Fatalf("expected 1 member after removal, got %d", len(members)) + } +} + +func TestWebhookSync_CompanyAndCustomerEvents(t *testing.T) { + db := setupAuthServiceTestDB(t) + svc := newWebhookSyncService() + + // 1. Test company.created + err := svc.HandleOrgSync(request.OrgSyncWebhookRequest{ + Event: "company.created", + Timestamp: time.Now().Format(time.RFC3339), + Data: request.OrgSyncEventData{ + CRMCompanyID: "comp_crm_001", + Name: "MetaDOS LLC", + DomainName: "metados.com", + Address: "Ho Chi Minh City, Vietnam", + Tier: "enterprise", + AccountOwnerEmail: "sales@crove.com", + }, + }) + if err != nil { + t.Fatalf("HandleOrgSync company.created failed: %v", err) + } + + comp := repositories.CompanyRepository.GetByName(db, "MetaDOS LLC") + if comp == nil || comp.Code != "comp_crm_001" || comp.Status != enums.StatusOk { + t.Fatalf("unexpected created company: %+v", comp) + } + + // 2. Test customer.created + err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{ + Event: "customer.created", + Timestamp: time.Now().Format(time.RFC3339), + Data: request.OrgSyncEventData{ + CRMPersonID: "pers_crm_001", + CRMCompanyID: "comp_crm_001", + CompanyName: "MetaDOS LLC", + Name: "Nguyen Van A", + Email: "customer_a@metados.com", + Phone: "+84901234567", + JobTitle: "CTO", + }, + }) + if err != nil { + t.Fatalf("HandleOrgSync customer.created failed: %v", err) + } + + cust := repositories.CustomerRepository.FindOne(db, sqls.NewCnd().Eq("primary_email", "customer_a@metados.com")) + if cust == nil || cust.Name != "Nguyen Van A" || cust.PrimaryMobile != "+84901234567" || cust.CompanyID != comp.ID { + t.Fatalf("unexpected created customer: %+v", cust) + } + + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceTwentyCRM). + Eq("external_id", "pers_crm_001")) + if identity == nil || identity.CustomerID != cust.ID { + t.Fatalf("unexpected customer identity: %+v", identity) + } + + // 3. Test customer.updated + err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{ + Event: "customer.updated", + Timestamp: time.Now().Format(time.RFC3339), + Data: request.OrgSyncEventData{ + CRMPersonID: "pers_crm_001", + Name: "Nguyen Van A (Updated)", + Email: "customer_a@metados.com", + JobTitle: "VP of Engineering", + }, + }) + if err != nil { + t.Fatalf("HandleOrgSync customer.updated failed: %v", err) + } + + cust = repositories.CustomerRepository.Get(db, cust.ID) + if cust == nil || cust.Name != "Nguyen Van A (Updated)" || cust.Remark != "VP of Engineering" { + t.Fatalf("unexpected updated customer: %+v", cust) + } +} + +func TestWebhookSignatureVerification_TimestampFormat(t *testing.T) { + svc := newWebhookSyncService() + secret := "test-secret-key-12345" + + // Mock config + cfg := &config.Config{ + Webhook: config.WebhookConfig{ + OrgSyncSecret: secret, + }, + } + config.SetCurrent(cfg) + + payload := []byte(`{"event":"customer.created","data":{"name":"John Doe"}}`) + nowMs := time.Now().UnixMilli() + tsStr := fmt.Sprintf("%d", nowMs) + + // Compute HMAC-SHA256 of timestamp.payload + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(tsStr + "." + string(payload))) + sigHex := hex.EncodeToString(mac.Sum(nil)) + + header := fmt.Sprintf("t=%s,v1=%s", tsStr, sigHex) + + if !svc.VerifySignature(payload, header) { + t.Fatalf("expected signature %q to be verified successfully", header) + } + + // Test expired timestamp (> 5 minutes ago) + oldMs := time.Now().Add(-10 * time.Minute).UnixMilli() + oldTsStr := fmt.Sprintf("%d", oldMs) + macOld := hmac.New(sha256.New, []byte(secret)) + macOld.Write([]byte(oldTsStr + "." + string(payload))) + oldSigHex := hex.EncodeToString(macOld.Sum(nil)) + oldHeader := fmt.Sprintf("t=%s,v1=%s", oldTsStr, oldSigHex) + + if svc.VerifySignature(payload, oldHeader) { + t.Fatalf("expected expired signature %q to fail verification", oldHeader) + } + + // Test sha256= format + macRaw := hmac.New(sha256.New, []byte(secret)) + macRaw.Write(payload) + rawSigHex := hex.EncodeToString(macRaw.Sum(nil)) + + if !svc.VerifySignature(payload, "sha256="+rawSigHex) { + t.Fatalf("expected sha256= signature to be verified successfully") + } +} diff --git a/scripts/cloudflare-email-worker/package.json b/scripts/cloudflare-email-worker/package.json new file mode 100644 index 00000000..b1d37c80 --- /dev/null +++ b/scripts/cloudflare-email-worker/package.json @@ -0,0 +1,17 @@ +{ + "name": "crove-email-inbound-worker", + "version": "1.0.0", + "description": "Cloudflare Email Routing Worker forwarding inbound emails to Crove Desk webhook", + "main": "src/index.ts", + "scripts": { + "deploy": "wrangler deploy" + }, + "dependencies": { + "postal-mime": "^2.1.8" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241022.0", + "typescript": "^6.0.3", + "wrangler": "^3.84.1" + } +} diff --git a/scripts/cloudflare-email-worker/pnpm-lock.yaml b/scripts/cloudflare-email-worker/pnpm-lock.yaml new file mode 100644 index 00000000..7ab00b22 --- /dev/null +++ b/scripts/cloudflare-email-worker/pnpm-lock.yaml @@ -0,0 +1,984 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + postal-mime: + specifier: ^2.1.8 + version: 2.7.6 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20241022.0 + version: 4.20260702.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + wrangler: + specifier: ^3.84.1 + version: 3.114.17(@cloudflare/workers-types@4.20260702.1) + +packages: + + '@cloudflare/kv-asset-handler@0.3.4': + resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} + engines: {node: '>=16.13'} + + '@cloudflare/unenv-preset@2.0.2': + resolution: {integrity: sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==} + peerDependencies: + unenv: 2.0.0-rc.14 + workerd: ^1.20250124.0 + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20250718.0': + resolution: {integrity: sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20250718.0': + resolution: {integrity: sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20250718.0': + resolution: {integrity: sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20250718.0': + resolution: {integrity: sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20250718.0': + resolution: {integrity: sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260702.1': + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild-plugins/node-globals-polyfill@0.2.3': + resolution: {integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==} + peerDependencies: + esbuild: '*' + + '@esbuild-plugins/node-modules-polyfill@0.2.2': + resolution: {integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==} + peerDependencies: + esbuild: '*' + + '@esbuild/android-arm64@0.17.19': + resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.17.19': + resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.17.19': + resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.17.19': + resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.17.19': + resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.17.19': + resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.17.19': + resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.17.19': + resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.17.19': + resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.17.19': + resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.17.19': + resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.17.19': + resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.17.19': + resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.17.19': + resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.17.19': + resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.17.19': + resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.17.19': + resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.17.19': + resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.17.19': + resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.17.19': + resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.17.19': + resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.17.19': + resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + acorn-walk@8.3.2: + resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} + engines: {node: '>=0.4.0'} + + acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + + as-table@1.0.55: + resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + data-uri-to-buffer@2.0.2: + resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + esbuild@0.17.19: + resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} + engines: {node: '>=12'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + estree-walker@0.6.1: + resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} + + exit-hook@2.2.1: + resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} + engines: {node: '>=6'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-source@2.0.12: + resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + miniflare@3.20250718.3: + resolution: {integrity: sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==} + engines: {node: '>=16.13'} + hasBin: true + + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + postal-mime@2.7.6: + resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==} + + printable-characters@1.0.42: + resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} + + rollup-plugin-inject@3.0.2: + resolution: {integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==} + deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject. + + rollup-plugin-node-polyfills@0.2.1: + resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==} + + rollup-pluginutils@2.8.2: + resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + + stacktracey@2.2.0: + resolution: {integrity: sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==} + + stoppable@1.1.0: + resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} + engines: {node: '>=4', npm: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + + unenv@2.0.0-rc.14: + resolution: {integrity: sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==} + + workerd@1.20250718.0: + resolution: {integrity: sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==} + engines: {node: '>=16'} + hasBin: true + + wrangler@3.114.17: + resolution: {integrity: sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==} + engines: {node: '>=16.17.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20250408.0 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch@3.3.4: + resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==} + + zod@3.22.3: + resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} + +snapshots: + + '@cloudflare/kv-asset-handler@0.3.4': + dependencies: + mime: 3.0.0 + + '@cloudflare/unenv-preset@2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0)': + dependencies: + unenv: 2.0.0-rc.14 + optionalDependencies: + workerd: 1.20250718.0 + + '@cloudflare/workerd-darwin-64@1.20250718.0': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20250718.0': + optional: true + + '@cloudflare/workerd-linux-64@1.20250718.0': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20250718.0': + optional: true + + '@cloudflare/workerd-windows-64@1.20250718.0': + optional: true + + '@cloudflare/workers-types@4.20260702.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19)': + dependencies: + esbuild: 0.17.19 + + '@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.17.19)': + dependencies: + esbuild: 0.17.19 + escape-string-regexp: 4.0.0 + rollup-plugin-node-polyfills: 0.2.1 + + '@esbuild/android-arm64@0.17.19': + optional: true + + '@esbuild/android-arm@0.17.19': + optional: true + + '@esbuild/android-x64@0.17.19': + optional: true + + '@esbuild/darwin-arm64@0.17.19': + optional: true + + '@esbuild/darwin-x64@0.17.19': + optional: true + + '@esbuild/freebsd-arm64@0.17.19': + optional: true + + '@esbuild/freebsd-x64@0.17.19': + optional: true + + '@esbuild/linux-arm64@0.17.19': + optional: true + + '@esbuild/linux-arm@0.17.19': + optional: true + + '@esbuild/linux-ia32@0.17.19': + optional: true + + '@esbuild/linux-loong64@0.17.19': + optional: true + + '@esbuild/linux-mips64el@0.17.19': + optional: true + + '@esbuild/linux-ppc64@0.17.19': + optional: true + + '@esbuild/linux-riscv64@0.17.19': + optional: true + + '@esbuild/linux-s390x@0.17.19': + optional: true + + '@esbuild/linux-x64@0.17.19': + optional: true + + '@esbuild/netbsd-x64@0.17.19': + optional: true + + '@esbuild/openbsd-x64@0.17.19': + optional: true + + '@esbuild/sunos-x64@0.17.19': + optional: true + + '@esbuild/win32-arm64@0.17.19': + optional: true + + '@esbuild/win32-ia32@0.17.19': + optional: true + + '@esbuild/win32-x64@0.17.19': + optional: true + + '@fastify/busboy@2.1.1': {} + + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + acorn-walk@8.3.2: {} + + acorn@8.14.0: {} + + as-table@1.0.55: + dependencies: + printable-characters: 1.0.42 + + blake3-wasm@2.1.5: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + optional: true + + color-name@1.1.4: + optional: true + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + optional: true + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + + cookie@0.7.2: {} + + data-uri-to-buffer@2.0.2: {} + + defu@6.1.7: {} + + detect-libc@2.1.2: + optional: true + + esbuild@0.17.19: + optionalDependencies: + '@esbuild/android-arm': 0.17.19 + '@esbuild/android-arm64': 0.17.19 + '@esbuild/android-x64': 0.17.19 + '@esbuild/darwin-arm64': 0.17.19 + '@esbuild/darwin-x64': 0.17.19 + '@esbuild/freebsd-arm64': 0.17.19 + '@esbuild/freebsd-x64': 0.17.19 + '@esbuild/linux-arm': 0.17.19 + '@esbuild/linux-arm64': 0.17.19 + '@esbuild/linux-ia32': 0.17.19 + '@esbuild/linux-loong64': 0.17.19 + '@esbuild/linux-mips64el': 0.17.19 + '@esbuild/linux-ppc64': 0.17.19 + '@esbuild/linux-riscv64': 0.17.19 + '@esbuild/linux-s390x': 0.17.19 + '@esbuild/linux-x64': 0.17.19 + '@esbuild/netbsd-x64': 0.17.19 + '@esbuild/openbsd-x64': 0.17.19 + '@esbuild/sunos-x64': 0.17.19 + '@esbuild/win32-arm64': 0.17.19 + '@esbuild/win32-ia32': 0.17.19 + '@esbuild/win32-x64': 0.17.19 + + escape-string-regexp@4.0.0: {} + + estree-walker@0.6.1: {} + + exit-hook@2.2.1: {} + + exsolve@1.1.1: {} + + fsevents@2.3.3: + optional: true + + get-source@2.0.12: + dependencies: + data-uri-to-buffer: 2.0.2 + source-map: 0.6.1 + + glob-to-regexp@0.4.1: {} + + is-arrayish@0.3.4: + optional: true + + magic-string@0.25.9: + dependencies: + sourcemap-codec: 1.4.8 + + mime@3.0.0: {} + + miniflare@3.20250718.3: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + acorn: 8.14.0 + acorn-walk: 8.3.2 + exit-hook: 2.2.1 + glob-to-regexp: 0.4.1 + stoppable: 1.1.0 + undici: 5.29.0 + workerd: 1.20250718.0 + ws: 8.18.0 + youch: 3.3.4 + zod: 3.22.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + mustache@4.2.0: {} + + ohash@2.0.12: {} + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + postal-mime@2.7.6: {} + + printable-characters@1.0.42: {} + + rollup-plugin-inject@3.0.2: + dependencies: + estree-walker: 0.6.1 + magic-string: 0.25.9 + rollup-pluginutils: 2.8.2 + + rollup-plugin-node-polyfills@0.2.1: + dependencies: + rollup-plugin-inject: 3.0.2 + + rollup-pluginutils@2.8.2: + dependencies: + estree-walker: 0.6.1 + + semver@7.8.5: + optional: true + + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + optional: true + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + optional: true + + source-map@0.6.1: {} + + sourcemap-codec@1.4.8: {} + + stacktracey@2.2.0: + dependencies: + as-table: 1.0.55 + get-source: 2.0.12 + + stoppable@1.1.0: {} + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + ufo@1.6.4: {} + + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + + unenv@2.0.0-rc.14: + dependencies: + defu: 6.1.7 + exsolve: 1.1.1 + ohash: 2.0.12 + pathe: 2.0.3 + ufo: 1.6.4 + + workerd@1.20250718.0: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20250718.0 + '@cloudflare/workerd-darwin-arm64': 1.20250718.0 + '@cloudflare/workerd-linux-64': 1.20250718.0 + '@cloudflare/workerd-linux-arm64': 1.20250718.0 + '@cloudflare/workerd-windows-64': 1.20250718.0 + + wrangler@3.114.17(@cloudflare/workers-types@4.20260702.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.3.4 + '@cloudflare/unenv-preset': 2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0) + '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) + '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) + blake3-wasm: 2.1.5 + esbuild: 0.17.19 + miniflare: 3.20250718.3 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.14 + workerd: 1.20250718.0 + optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 + fsevents: 2.3.3 + sharp: 0.33.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.18.0: {} + + youch@3.3.4: + dependencies: + cookie: 0.7.2 + mustache: 4.2.0 + stacktracey: 2.2.0 + + zod@3.22.3: {} diff --git a/scripts/cloudflare-email-worker/src/index.ts b/scripts/cloudflare-email-worker/src/index.ts new file mode 100644 index 00000000..c93e5c2a --- /dev/null +++ b/scripts/cloudflare-email-worker/src/index.ts @@ -0,0 +1,58 @@ +import PostalMime from 'postal-mime'; + +export interface Env { + DESK_WEBHOOK_URL: string; + DESK_WEBHOOK_SECRET: string; +} + +export default { + async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext): Promise { + try { + const rawEmail = await new Response(message.raw).arrayBuffer(); + const parser = new PostalMime(); + const parsed = await parser.parse(rawEmail); + + const fromEmail = message.from || parsed.from?.address || ''; + const fromName = parsed.from?.name || ''; + const toEmail = message.to || (parsed.to && parsed.to[0]?.address) || 'help@crove.com'; + const toName = (parsed.to && parsed.to[0]?.name) || ''; + const subject = message.headers.get('subject') || parsed.subject || ''; + const messageId = message.headers.get('message-id') || parsed.messageId || ''; + const inReplyTo = message.headers.get('in-reply-to') || parsed.inReplyTo || ''; + const references = message.headers.get('references') || (Array.isArray(parsed.references) ? parsed.references.join(' ') : parsed.references) || ''; + + const payload = { + from: fromEmail, + from_name: fromName, + to: toEmail, + to_name: toName, + subject: subject, + text: parsed.text || '', + html: parsed.html || '', + message_id: messageId, + in_reply_to: inReplyTo, + references: references, + }; + + const webhookUrl = env.DESK_WEBHOOK_URL || 'https://desk.crove.com/api/third/email/webhook'; + const webhookSecret = env.DESK_WEBHOOK_SECRET || ''; + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Secret': webhookSecret, + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + console.error(`Failed to forward email to webhook: ${response.status} ${response.statusText}`); + } else { + console.log(`Successfully forwarded email from ${fromEmail} to Crove Desk`); + } + } catch (error) { + console.error('Error processing inbound email in worker:', error); + } + }, +}; diff --git a/scripts/cloudflare-email-worker/wrangler.toml b/scripts/cloudflare-email-worker/wrangler.toml new file mode 100644 index 00000000..bf7aae36 --- /dev/null +++ b/scripts/cloudflare-email-worker/wrangler.toml @@ -0,0 +1,8 @@ +name = "crove-email-inbound-worker" +main = "src/index.ts" +compatibility_date = "2024-11-05" +account_id = "5f2a58925e790423dfafa0e6bee46b28" + +[vars] +DESK_WEBHOOK_URL = "https://desk.crove.com/api/third/email/webhook" +DESK_WEBHOOK_SECRET = "crove_email_secret_token_123" diff --git a/scripts/publish_frill_backlog.ps1 b/scripts/publish_frill_backlog.ps1 new file mode 100644 index 00000000..94906d23 --- /dev/null +++ b/scripts/publish_frill_backlog.ps1 @@ -0,0 +1,174 @@ +$headers = @{ + "Authorization" = "Bearer 34bb877b-4ae2-4950-87e7-7282e198ab73" + "Content-Type" = "application/json" +} + +$authorIdx = "follower_3k2noqx3" # JOY + +# Statuses: +# status_xz33599z : Under consideration +# status_p49jn3p1 : Planned +# status_242mo97z : In Development +# status_p47lj9oz : Shipped + +# Topics: +# topic_63pxlq1v : Integrations 🔗 +# topic_5d9eyzp3 : Improvement 👍 + +$backlog = @( + @{ + name = "Telegram Bot Channel Integration" + description = "Native bidirectional integration with Telegram Bot API. Supports automated zero-config webhook binding, customer conversation routing, AI Agent auto-reply, and human agent outbox delivery." + status_idx = "status_p47lj9oz" # Shipped + topic_idxs = @("topic_63pxlq1v") + }, + @{ + name = "Zalo Official Account (OA) Channel Gateway" + description = "Native channel adapter for Zalo Official Account (OA). Enables Vietnamese businesses to receive customer support inquiries and dispatch AI/agent replies via Zalo CS messaging API." + status_idx = "status_p49jn3p1" # Planned + topic_idxs = @("topic_63pxlq1v") + }, + @{ + name = "Inbound Email-to-Ticket & SMTP/IMAP Gateway" + description = "Convert inbound customer support emails into threaded conversation tickets automatically. Allows agents and AI to reply directly via email." + status_idx = "status_p49jn3p1" # Planned + topic_idxs = @("topic_63pxlq1v", "topic_5d9eyzp3") + }, + @{ + name = "WhatsApp Business API & Cloud Gateway" + description = "Connect WhatsApp Business Cloud API to Crove Desk. Support template messages, interactive buttons, and real-time chat sync for international customer support." + status_idx = "status_xz33599z" # Under consideration + topic_idxs = @("topic_63pxlq1v") + }, + @{ + name = "Live Chat Web Widget SDK with Custom Theming & JWT Verification" + description = "Embeddable lightweight web chat widget with customizable theme colors, position, and secure customer JWT token verification." + status_idx = "status_p47lj9oz" # Shipped + topic_idxs = @("topic_5d9eyzp3") + }, + @{ + name = "OpenAI-Compatible AI Engine & Auto-Bootstrap" + description = "Zero-config LLM and vector embedding integration supporting OpenAI, DOS.AI, DeepSeek, and OpenAI-compatible gateways via environment variables." + status_idx = "status_p47lj9oz" # Shipped + topic_idxs = @("topic_5d9eyzp3") + }, + @{ + name = "Smart Answerability Gate & Confidence Scoring for RAG" + description = "Evaluates retrieval confidence and document relevancy before AI generates a response, preventing hallucinations on unsupported customer questions." + status_idx = "status_p47lj9oz" # Shipped + topic_idxs = @("topic_5d9eyzp3") + }, + @{ + name = "Automated Human Handoff on Low AI Confidence" + description = "Seamlessly escalates customer conversations to online human support agents with context transfer when the AI Answerability Gate confidence is below threshold." + status_idx = "status_p49jn3p1" # Planned + topic_idxs = @("topic_5d9eyzp3") + }, + @{ + name = "Visual AI Workflow Canvas & Node-based Orchestration" + description = "Legacy node-based drag-and-drop workflow designer (Flowgram) for deterministic multi-step support flows. (Kept under consideration in favor of dynamic AI-native agentic loops)." + status_idx = "status_xz33599z" # Under consideration + topic_idxs = @("topic_5d9eyzp3") + }, + @{ + name = "Automated Conversation Summarization & Sentiment Analysis" + description = "AI automatically generates resolution summaries and tags customer sentiment (Positive, Neutral, Frustrated) upon ticket closure." + status_idx = "status_xz33599z" # Under consideration + topic_idxs = @("topic_5d9eyzp3") + }, + @{ + name = "2-Tier Hybrid Sync: Relational Mirror with Twenty CRM & DOS.Me" + description = "Real-time bidirectional synchronization of Company and Customer profiles between Twenty CRM, DOS.Me, and Crove Desk via webhook events." + status_idx = "status_p47lj9oz" # Shipped + topic_idxs = @("topic_63pxlq1v", "topic_4d2x1y1v") + }, + @{ + name = "MCP Tool Calling: Live Deal & Subscription Status Lookup from CRM" + description = "Equips Crove Desk AI Agents with Model Context Protocol (MCP) tools to query live CRM deals, subscription tiers, and customer records on demand." + status_idx = "status_p49jn3p1" # Planned + topic_idxs = @("topic_63pxlq1v", "topic_4d2x1y1v") + }, + @{ + name = "Auto-Create CRM Deals & Follow-up Tasks from Support Inquiries" + description = "AI Agent identifies sales opportunities during customer support conversations and automatically creates Deals and follow-up Tasks in Twenty CRM." + status_idx = "status_xz33599z" # Under consideration + topic_idxs = @("topic_63pxlq1v", "topic_4d2x1y1v") + }, + @{ + name = "Multi-Tenant Workspace Management with Just-In-Time SSO" + description = "Isolated multi-organization workspace switching, member role management, and JIT user provisioning via DOS.Me OIDC single sign-on." + status_idx = "status_p47lj9oz" # Shipped + topic_idxs = @("topic_5d9eyzp3") + }, + @{ + name = "Configurable SLA Policies & Priority Escalation Rules" + description = "Define First Response Time and Resolution Time SLA targets based on customer tier, ticket priority, and business hours with automated alerts." + status_idx = "status_xz33599z" # Under consideration + topic_idxs = @("topic_5d9eyzp3") + }, + @{ + name = "Granular Role-Based Access Control (RBAC) for Support Agents" + description = "Customizable permission matrices for Tier 1 agents, senior support specialists, and support administrators across channels and knowledge bases." + status_idx = "status_p49jn3p1" # Planned + topic_idxs = @("topic_5d9eyzp3") + }, + @{ + name = "Multi-language Knowledge Base & Vector FAQ Indexing" + description = "Publish help documentation and categorized FAQs with multilingual support (EN, VI, ZH) and automatic Qdrant vector embedding indexing." + status_idx = "status_p47lj9oz" # Shipped + topic_idxs = @("topic_5d9eyzp3") + }, + @{ + name = "Public Customer Community Forum & Peer Discussion Board" + description = "Community discussion space allowing customers to post questions, share tips, vote on best answers, with agent moderation." + status_idx = "status_xz33599z" # Under consideration + topic_idxs = @("topic_5d9eyzp3") + }, + @{ + name = "Custom Domain & White-Label Support Portal" + description = "CNAME custom domain mapping and custom branding (colors, logos, favicons) for customer-facing Help Centers." + status_idx = "status_xz33599z" # Under consideration + topic_idxs = @("topic_5d9eyzp3") + }, + @{ + name = "Omnichannel CSAT & Customer Satisfaction Surveys" + description = "Trigger automated CSAT star ratings and feedback prompts across Web Widget, Telegram, and Zalo OA when tickets are resolved." + status_idx = "status_xz33599z" # Under consideration + topic_idxs = @("topic_5d9eyzp3") + } +) + +foreach ($item in $backlog) { + Write-Host "Creating idea: $($item.name) [Status: $($item.status_idx)]..." + $body = @{ + name = $item.name + description = $item.description + status_idx = $item.status_idx + topic_idxs = $item.topic_idxs + author_idx = $authorIdx + } | ConvertTo-Json -Depth 5 -Compress + + $success = $false + for ($attempt = 1; $attempt -le 4; $attempt++) { + try { + $resp = Invoke-RestMethod -Uri "https://api.frill.co/v1/ideas" -Method Post -Headers $headers -Body $body + Write-Host " -> Success! Idx: $($resp.data.idx) Slug: $($resp.data.slug)" + $success = $true + break + } catch { + Write-Host " -> Attempt $attempt failed: $_" + if ($_.Exception.Response) { + $stream = $_.Exception.Response.GetResponseStream() + $reader = New-Object System.IO.StreamReader($stream) + $errBody = $reader.ReadToEnd() + Write-Host " -> Response: $errBody" + if ($errBody -match "rate limit") { + Write-Host " -> Sleeping 60s for rate limit reset..." + Start-Sleep -Seconds 60 + } + } + Start-Sleep -Seconds 3 + } + } + Start-Sleep -Seconds 2 +} diff --git a/web/app/(dashboard)/dashboard.css b/web/app/(dashboard)/dashboard.css index 17a7c851..9c5dd51c 100644 --- a/web/app/(dashboard)/dashboard.css +++ b/web/app/(dashboard)/dashboard.css @@ -359,10 +359,14 @@ * { @apply border-border outline-ring/50; } + html { + font-family: var(--font-inter), var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } body { @apply bg-background text-foreground; - } - html { - @apply font-sans; + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; } } diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index ea2595e5..e86a3e74 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -73,6 +73,18 @@ type ZaloOAChannelConfig = { webhookSecret?: string } +type EmailChannelConfig = { + emailAddress?: string + senderName?: string + provider?: string + apiKey?: string + smtpHost?: string + smtpPort?: number + smtpUser?: string + smtpPassword?: string + webhookSecret?: string +} + type SlackChannelConfig = { botToken?: string signingSecret?: string @@ -89,6 +101,21 @@ type LarkChannelConfig = { verificationToken?: string } +type SlackChannelConfig = { + botToken?: string + signingSecret?: string + appId?: string + teamId?: string + teamName?: string + defaultChannel?: string +} + +type LarkChannelConfig = { + appId?: string + appSecret?: string + verificationToken?: string +} + type DiscordChannelConfig = { guildId?: string guildName?: string @@ -110,6 +137,7 @@ function getDefaultWebChannelConfig(t: Translate): Required { function createSchema(t: Translate) { return z .object({ + channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa", "email", "slack", "lark", "discord"], t("channel.typeRequired")), channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa", "slack", "lark", "discord"], t("channel.typeRequired")), aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")), aiAgentRolloutPercent: z.coerce.number().int().min(1).max(100), @@ -122,6 +150,23 @@ function createSchema(t: Translate) { zaloOaId: z.string().trim(), zaloAccessToken: z.string().trim(), zaloSecretKey: z.string().trim(), + emailAddress: z.string().trim(), + senderName: z.string().trim(), + emailProvider: z.string().trim(), + emailApiKey: z.string().trim(), + smtpHost: z.string().trim(), + smtpPort: z.coerce.number().int().optional(), + smtpUser: z.string().trim(), + smtpPassword: z.string().trim(), + slackBotToken: z.string().trim(), + slackSigningSecret: z.string().trim(), + slackAppId: z.string().trim(), + larkAppId: z.string().trim(), + larkAppSecret: z.string().trim(), + larkVerificationToken: z.string().trim(), + slackTeamId: z.string().trim(), + slackTeamName: z.string().trim(), + slackDefaultChannel: z.string().trim(), slackBotToken: z.string().trim(), slackSigningSecret: z.string().trim(), slackAppId: z.string().trim(), @@ -151,6 +196,13 @@ function createSchema(t: Translate) { message: t("channel.wxworkAccountRequired"), }) } + if (values.channelType === "email" && !values.emailAddress.trim()) { + ctx.addIssue({ + code: "custom", + path: ["emailAddress"], + message: "Email address is required (e.g. help@crove.com)", + }) + } if (values.channelType === "telegram" && !values.botToken.trim()) { ctx.addIssue({ code: "custom", @@ -183,6 +235,7 @@ function createSchema(t: Translate) { } type EditForm = { + channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "email" | "slack" | "lark" | "discord" channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "slack" | "lark" | "discord" aiAgentId: string aiAgentRolloutPercent: number @@ -195,6 +248,23 @@ type EditForm = { zaloOaId: string zaloAccessToken: string zaloSecretKey: string + emailAddress: string + senderName: string + emailProvider: string + emailApiKey: string + smtpHost: string + smtpPort?: number + smtpUser: string + smtpPassword: string + slackBotToken: string + slackSigningSecret: string + slackAppId: string + larkAppId: string + larkAppSecret: string + larkVerificationToken: string + slackTeamId: string + slackTeamName: string + slackDefaultChannel: string slackBotToken: string slackSigningSecret: string slackAppId: string @@ -232,6 +302,23 @@ function createEmptyForm(t: Translate): EditForm { zaloOaId: "", zaloAccessToken: "", zaloSecretKey: "", + emailAddress: "help@crove.com", + senderName: "Crove Desk Support", + emailProvider: "brevo", + emailApiKey: "", + smtpHost: "", + smtpPort: 587, + smtpUser: "", + smtpPassword: "", + slackBotToken: "", + slackSigningSecret: "", + slackAppId: "", + larkAppId: "", + larkAppSecret: "", + larkVerificationToken: "", + slackTeamId: "", + slackTeamName: "", + slackDefaultChannel: "", slackBotToken: "", slackSigningSecret: "", slackAppId: "", @@ -298,6 +385,25 @@ function parseZaloOAChannelConfig(configJson: string): ZaloOAChannelConfig { } } +function parseEmailChannelConfig(configJson: string): EmailChannelConfig { + if (!configJson.trim()) return {} + try { + const parsed = JSON.parse(configJson) as EmailChannelConfig + return { + emailAddress: parsed.emailAddress?.trim() || "", + senderName: parsed.senderName?.trim() || "", + provider: parsed.provider?.trim() || "brevo", + apiKey: parsed.apiKey?.trim() || "", + smtpHost: parsed.smtpHost?.trim() || "", + smtpPort: parsed.smtpPort || 587, + smtpUser: parsed.smtpUser?.trim() || "", + smtpPassword: parsed.smtpPassword?.trim() || "", + } + } catch { + return {} + } +} + function parseSlackChannelConfig(configJson: string): SlackChannelConfig { if (!configJson.trim()) return {} try { @@ -399,6 +505,9 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const isWechatMP = item.channelType === "wechat_mp" const isTelegram = item.channelType === "telegram" const isZaloOA = item.channelType === "zalo_oa" + const isEmail = item.channelType === "email" + const isSlack = item.channelType === "slack" + const isLark = item.channelType === "lark" const isSlack = item.channelType === "slack" const isLark = item.channelType === "lark" const isDiscord = item.channelType === "discord" @@ -413,6 +522,15 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const zaloConfig = isZaloOA ? parseZaloOAChannelConfig(item.configJson) : null + const emailConfig = isEmail + ? parseEmailChannelConfig(item.configJson) + : null + const slackConfig = isSlack + ? parseSlackChannelConfig(item.configJson) + : null + const larkConfig = isLark + ? parseLarkChannelConfig(item.configJson) + : null const slackConfig = isSlack ? parseSlackChannelConfig(item.configJson) : null @@ -431,12 +549,17 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { ? "telegram" : item.channelType === "zalo_oa" ? "zalo_oa" - : item.channelType === "slack" - ? "slack" - : item.channelType === "lark" - ? "lark" - : item.channelType === "discord" - ? "discord" + : item.channelType === "email" + ? "email" + : item.channelType === "slack" + ? "slack" + : item.channelType === "lark" + ? "lark" + : item.channelType === "discord" + ? "discord" + : item.channelType === "wechat_mp" + ? "wechat_mp" + : "web", : item.channelType === "wechat_mp" ? "wechat_mp" : "web", @@ -449,12 +572,30 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { webhookSecret: telegramConfig?.webhookSecret ?? zaloConfig?.webhookSecret ?? + emailConfig?.webhookSecret ?? discordConfig?.webhookSecret ?? "", zaloAppId: zaloConfig?.appId ?? "", zaloOaId: zaloConfig?.oaId ?? "", zaloAccessToken: zaloConfig?.accessToken ?? "", zaloSecretKey: zaloConfig?.secretKey ?? "", + emailAddress: emailConfig?.emailAddress || "help@crove.com", + senderName: emailConfig?.senderName || "Crove Desk Support", + emailProvider: emailConfig?.provider || "brevo", + emailApiKey: emailConfig?.apiKey || "", + smtpHost: emailConfig?.smtpHost || "", + smtpPort: emailConfig?.smtpPort || 587, + smtpUser: emailConfig?.smtpUser || "", + smtpPassword: emailConfig?.smtpPassword || "", + slackBotToken: slackConfig?.botToken ?? "", + slackSigningSecret: slackConfig?.signingSecret ?? "", + slackAppId: slackConfig?.appId ?? "", + slackTeamId: slackConfig?.teamId ?? "", + slackTeamName: slackConfig?.teamName ?? "", + slackDefaultChannel: slackConfig?.defaultChannel ?? "", + larkAppId: larkConfig?.appId ?? "", + larkAppSecret: larkConfig?.appSecret ?? "", + larkVerificationToken: larkConfig?.verificationToken ?? "", slackBotToken: slackConfig?.botToken ?? "", slackSigningSecret: slackConfig?.signingSecret ?? "", slackAppId: slackConfig?.appId ?? "", @@ -493,20 +634,49 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin const configJson = channelType === "wxwork_kf" ? JSON.stringify({ openKfId: form.openKfId.trim() }) - : channelType === "telegram" + : channelType === "email" ? JSON.stringify({ - botToken: form.botToken.trim(), - botUsername: form.botUsername.trim(), + emailAddress: form.emailAddress.trim(), + senderName: form.senderName.trim(), + provider: form.emailProvider.trim(), + apiKey: form.emailApiKey.trim(), + smtpHost: form.smtpHost.trim(), + smtpPort: form.smtpPort || 587, + smtpUser: form.smtpUser.trim(), + smtpPassword: form.smtpPassword.trim(), webhookSecret: form.webhookSecret.trim(), }) - : channelType === "zalo_oa" + : channelType === "slack" + ? JSON.stringify({ + botToken: form.slackBotToken.trim(), + signingSecret: form.slackSigningSecret.trim(), + appId: form.slackAppId.trim(), + teamId: form.slackTeamId.trim(), + teamName: form.slackTeamName.trim(), + defaultChannel: form.slackDefaultChannel.trim(), + }) + : channelType === "lark" + ? JSON.stringify({ + appId: form.larkAppId.trim(), + appSecret: form.larkAppSecret.trim(), + verificationToken: form.larkVerificationToken.trim(), + }) + : channelType === "telegram" ? JSON.stringify({ - appId: form.zaloAppId.trim(), - oaId: form.zaloOaId.trim(), - accessToken: form.zaloAccessToken.trim(), - secretKey: form.zaloSecretKey.trim(), + botToken: form.botToken.trim(), + botUsername: form.botUsername.trim(), webhookSecret: form.webhookSecret.trim(), }) + : channelType === "zalo_oa" + ? JSON.stringify({ + appId: form.zaloAppId.trim(), + oaId: form.zaloOaId.trim(), + accessToken: form.zaloAccessToken.trim(), + secretKey: form.zaloSecretKey.trim(), + webhookSecret: form.webhookSecret.trim(), + }) + + : channelType === "discord" : channelType === "slack" ? JSON.stringify({ botToken: form.slackBotToken.trim(), @@ -624,8 +794,20 @@ function ChannelFormBody({ const aiAgentId = useWatch({ control, name: "aiAgentId" }) const openKfId = useWatch({ control, name: "openKfId" }) const userTokenSecret = useWatch({ control, name: "userTokenSecret" }) + const emailProvider = useWatch({ control, name: "emailProvider" }) + const emailAddressValue = useWatch({ control, name: "emailAddress" }) + const nameValue = useWatch({ control, name: "name" }) const previousRolloutPercent = channelDetail?.previousAiAgentRolloutPercent ?? 0 + const forwardingAddressPreview = useMemo(() => { + const raw = (emailAddressValue || "").trim().toLowerCase() + if (raw.endsWith(".crove.io") || raw.endsWith(".on.crove.email") || raw.endsWith(".crove-mail.com")) { + return raw + } + const cleanName = (nameValue || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "org" + return `help@${cleanName}.crove.io` + }, [emailAddressValue, nameValue]) + async function rollbackRolloutPercent() { if (!channelDetail || previousRolloutPercent < 1) return setRollingBackRollout(true) @@ -725,7 +907,9 @@ function ChannelFormBody({ })) const channelTypeOptions = [ { value: "web", label: t("channel.typeWeb") }, + { value: "email", label: t("channel.typeEmail") }, { value: "telegram", label: t("channel.typeTelegram") }, + { value: "zalo_oa", label: t("channel.typeZaloOa") }, { value: "slack", label: t("channel.typeSlack") }, { value: "discord", label: t("channel.typeDiscord") }, @@ -895,14 +1079,183 @@ function ChannelFormBody({
{t("channel.configTitle")}
- {channelType === "wxwork_kf" - ? t("channel.configWxworkDescription") - : channelType === "wechat_mp" - ? t("channel.configWechatDescription") - : t("channel.configWebDescription")} + {channelType === "email" + ? t("channel.configEmailDescription") + : channelType === "wxwork_kf" + ? t("channel.configWxworkDescription") + : channelType === "wechat_mp" + ? t("channel.configWechatDescription") + : t("channel.configWebDescription")}
+ {channelType === "email" ? ( +
+
+ + {t("channel.emailAddress")} * + + + + + + + + {t("channel.senderName")} + + + + + +
+ +
+ + {t("channel.emailProvider")} + + + + + + + + {t("channel.webhookSecret")} + + + + + +
+ + {emailProvider === "brevo" || emailProvider === "sendgrid" || emailProvider === "resend" || emailProvider === "postmark" || emailProvider === "mailgun" ? ( + + {t("channel.emailApiKey")} + + + + + + ) : emailProvider === "smtp" ? ( +
+
+
+ + SMTP Host + + + + + +
+ + SMTP Port + + + + + +
+
+ + SMTP Username + + + + + + + SMTP Password + + + + + +
+
+ ) : null} + +
+
{t("channel.emailAutoConnectTitle")}
+
{t("channel.emailAutoConnectDescription")}
+
+ {t("channel.forwardingAddressLabel")} +
+ + {forwardingAddressPreview} + + +
+
+
+ {t("channel.inboundWebhookUrl")}: /api/third/email/webhook +
+
+
+ ) : null} + {channelType === "zalo_oa" ? (
diff --git a/web/app/(dashboard)/dashboard/channels/page.tsx b/web/app/(dashboard)/dashboard/channels/page.tsx index 43e139b9..d7a325cd 100644 --- a/web/app/(dashboard)/dashboard/channels/page.tsx +++ b/web/app/(dashboard)/dashboard/channels/page.tsx @@ -3,6 +3,7 @@ import { Building2Icon, Gamepad2Icon, + MailIcon, MessagesSquareIcon, MessageSquareMoreIcon, SendIcon, @@ -30,6 +31,9 @@ import { useI18n } from "@/i18n/provider" import { EditDialog } from "./_components/edit" function getChannelTypeLabel(channelType: string, t: (key: string) => string) { + if (channelType === "email") { + return t("channel.typeEmail") + } if (channelType === "wechat_mp") { return t("channel.typeWechatMp") } @@ -65,6 +69,9 @@ function getStatusLabel(status: Status, t: (key: string) => string) { } function ChannelIcon({ channelType }: { channelType: string }) { + if (channelType === "email") { + return + } if (channelType === "wechat_mp") { return } @@ -98,6 +105,7 @@ export default function DashboardChannelsPage() { const channelTypeOptions = [ { value: "all", label: t("channel.allTypes") }, { value: "web", label: t("channel.typeWeb") }, + { value: "email", label: t("channel.typeEmail") }, { value: "telegram", label: t("channel.typeTelegram") }, { value: "discord", label: t("channel.typeDiscord") }, { value: "lark", label: t("channel.typeLark") }, diff --git a/web/app/(dashboard)/dashboard/conversations/_components/agent-message-editor.tsx b/web/app/(dashboard)/dashboard/conversations/_components/agent-message-editor.tsx index 78bd0784..fe0c0bb8 100644 --- a/web/app/(dashboard)/dashboard/conversations/_components/agent-message-editor.tsx +++ b/web/app/(dashboard)/dashboard/conversations/_components/agent-message-editor.tsx @@ -13,7 +13,7 @@ import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin" type AgentMessageEditorProps = { disabled?: boolean uploadingAsset?: boolean - onSend: (html: string) => Promise + onSend: (html: string, messageType?: "html" | "note") => Promise onUploadImage: (file: File) => Promise onSendAttachment: (file: File) => Promise } diff --git a/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx b/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx index 3b0d6491..2cb6ec22 100644 --- a/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx +++ b/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx @@ -255,11 +255,11 @@ export function ChatPanel() { } }; - const handleSend = async (html: string) => { + const handleSend = async (html: string, messageType?: "html" | "note") => { if (!conversation || sending || isClosedConversation) return; try { shouldStickToBottomRef.current = true; - await sendMessage(html); + await sendMessage(html, messageType); } catch (error) { toast.error(error instanceof Error ? error.message : t("conversation.sendMessageFailed")); } @@ -600,13 +600,16 @@ const MessageItem = memo( ? "[&_p]:text-emerald-800" : "[&_p]:text-muted-foreground"; const showRecallAction = canRecall && !isRecalled; + const isNote = message.messageType === "note"; const bubbleVariant = isRecalled ? "recalled" - : isAi - ? "ai" - : isAgentSide - ? "agent" - : "customer"; + : isNote + ? "note" + : isAi + ? "ai" + : isAgentSide + ? "agent" + : "customer"; return (
-
- {senderName} +
+ {isNote ? ( + + 🔒 {t("conversation.internalNoteBadge")} + + ) : null} + {senderName}
([]); const [loading, setLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false); @@ -361,7 +362,7 @@ function WorkflowRunsSection({ conversation }: { conversation: AgentConversation } } catch (error) { if (!cancelled) { - toast.error(error instanceof Error ? error.message : "加载 AI 执行记录失败"); + toast.error(error instanceof Error ? error.message : t("workflowRun.loadLogsFailed")); } } finally { if (!cancelled) { @@ -374,7 +375,7 @@ function WorkflowRunsSection({ conversation }: { conversation: AgentConversation return () => { cancelled = true; }; - }, [conversation.id]); + }, [conversation.id, t]); async function openDetail(runId: number) { setDetailOpen(true); @@ -383,7 +384,7 @@ function WorkflowRunsSection({ conversation }: { conversation: AgentConversation const data = await fetchAIWorkflowRun(runId); setActiveRun(data); } catch (error) { - toast.error(error instanceof Error ? error.message : "加载 AI 执行详情失败"); + toast.error(error instanceof Error ? error.message : t("workflowRun.loadDetailFailed")); setDetailOpen(false); } finally { setDetailLoading(false); @@ -392,9 +393,9 @@ function WorkflowRunsSection({ conversation }: { conversation: AgentConversation return (
- AI 执行记录 + {t("workflowRun.recordsTitle")} {loading ? ( -

加载执行记录中

+

{t("workflowRun.loadingLogs")}

) : runs.length > 0 ? (
{runs.map((run) => ( @@ -432,7 +433,7 @@ function WorkflowRunsSection({ conversation }: { conversation: AgentConversation ))}
) : ( -

暂无 AI 执行记录

+

{t("workflowRun.emptyLogs")}

)} void; }) { + const t = useI18n(); + return ( - AI 执行详情 + {t("workflowRun.detailTitle")} } - description={run ? `Run #${run.id}` : "Workflow 执行链路"} + description={run ? `Run #${run.id}` : t("workflowRun.description")} size="xl" allowFullscreen footer={ } > {loading ? ( -
加载执行详情中
+
{t("workflowRun.loadingDetail")}
) : run ? (
- - - - - - - - + + + + + + + +
{run.errorMessage ? (
@@ -503,18 +506,19 @@ function WorkflowRunDetailDialog({ ))} {!run.nodes || run.nodes.length === 0 ? ( -

暂无节点记录

+

{t("workflowRun.emptyNodes")}

) : null}
) : ( -
未找到执行记录
+
{t("workflowRun.emptyDetail")}
)}
); } function WorkflowNodeRunBlock({ node }: { node: AIWorkflowNodeRun }) { + const t = useI18n(); const inputValue = safeParseJSON(node.inputPreview); const outputValue = safeParseJSON(node.outputPreview); @@ -541,8 +545,8 @@ function WorkflowNodeRunBlock({ node }: { node: AIWorkflowNodeRun }) {
) : null}
- - + +
); diff --git a/web/app/(dashboard)/dashboard/wxwork-outbox/page.tsx b/web/app/(dashboard)/dashboard/wxwork-outbox/page.tsx index d777c8e6..2fc3ff22 100644 --- a/web/app/(dashboard)/dashboard/wxwork-outbox/page.tsx +++ b/web/app/(dashboard)/dashboard/wxwork-outbox/page.tsx @@ -16,20 +16,9 @@ import { retryWxWorkOutbox, type ChannelMessageOutbox, } from "@/lib/api/admin" +import { useI18n } from "@/i18n/provider" import { formatDateTime } from "@/lib/utils" -const STATUS_OPTIONS = [ - { value: "failed", label: "失败" }, - { value: "ignored", label: "已忽略" }, - { value: "all", label: "全部" }, -] as const - -function statusLabel(status: string) { - if (status === "failed") return "失败" - if (status === "ignored") return "已忽略" - return status || "-" -} - function statusVariant(status: string) { if (status === "failed") return "destructive" as const if (status === "ignored") return "outline" as const @@ -47,6 +36,7 @@ function OutboxActions({ item: ChannelMessageOutbox reload: DashboardListRenderContext["reload"] }) { + const t = useI18n() const [runningAction, setRunningAction] = useState<"retry" | "ignore" | null>(null) async function runAction(action: "retry" | "ignore") { @@ -54,14 +44,14 @@ function OutboxActions({ try { if (action === "retry") { await retryWxWorkOutbox(item.id) - toast.success("已重新加入发送队列") + toast.success(t("wxworkOutbox.retrySuccess")) } else { await ignoreWxWorkOutbox(item.id) - toast.success("已忽略该失败记录") + toast.success(t("wxworkOutbox.ignoreSuccess")) } await reload() } catch (error) { - toast.error(error instanceof Error ? error.message : "操作失败") + toast.error(error instanceof Error ? error.message : t("wxworkOutbox.actionFailed")) } finally { setRunningAction(null) } @@ -77,7 +67,7 @@ function OutboxActions({ onClick={() => void runAction("retry")} > - 重试 + {t("wxworkOutbox.retry")} {item.sendStatus === "failed" ? ( ) : null}
@@ -96,28 +86,42 @@ function OutboxActions({ } export default function DashboardWxWorkOutboxPage() { + const t = useI18n() + + const statusOptions = [ + { value: "failed", label: t("wxworkOutbox.statusFailed") }, + { value: "ignored", label: t("wxworkOutbox.statusIgnored") }, + { value: "all", label: t("wxworkOutbox.statusAll") }, + ] + + const statusLabel = (status: string) => { + if (status === "failed") return t("wxworkOutbox.statusFailed") + if (status === "ignored") return t("wxworkOutbox.statusIgnored") + return status || "-" + } + return ( filters={[ { name: "sendStatus", - label: "状态", + label: t("wxworkOutbox.columnStatus"), defaultValue: "failed", type: "segment", - options: STATUS_OPTIONS, + options: statusOptions, }, { name: "conversationId", - label: "会话 ID", - placeholder: "会话 ID", + label: t("wxworkOutbox.conversationId"), + placeholder: t("wxworkOutbox.conversationId"), defaultValue: "", valueType: "number", className: "w-full sm:w-40", }, { name: "messageId", - label: "消息 ID", - placeholder: "消息 ID", + label: t("wxworkOutbox.messageId"), + placeholder: t("wxworkOutbox.messageId"), defaultValue: "", valueType: "number", className: "w-full sm:w-40", @@ -134,18 +138,18 @@ export default function DashboardWxWorkOutboxPage() { }, { key: "message", - label: "消息", + label: t("wxworkOutbox.columnMessage"), className: "w-48", render: (item) => (
-
会话 #{item.conversationId || "-"}
-
消息 #{item.messageId || "-"}
+
{t("wxworkOutbox.conversationLine", { id: String(item.conversationId || "-") })}
+
{t("wxworkOutbox.messageLine", { id: String(item.messageId || "-") })}
), }, { key: "status", - label: "状态", + label: t("wxworkOutbox.columnStatus"), className: "w-28", render: (item) => ( @@ -155,20 +159,20 @@ export default function DashboardWxWorkOutboxPage() { }, { key: "retry", - label: "重试", + label: t("wxworkOutbox.columnRetry"), className: "w-44 text-xs", render: (item) => (
-
{item.retryCount} 次
+
{t("wxworkOutbox.retriesCount", { count: String(item.retryCount) })}
- 下次 {formatOptionalTime(item.nextRetryAt)} + {t("wxworkOutbox.nextRetry", { time: formatOptionalTime(item.nextRetryAt) })}
), }, { key: "error", - label: "失败原因", + label: t("wxworkOutbox.columnError"), className: "min-w-72 max-w-[32rem]", render: (item) => item.lastError ? ( @@ -181,13 +185,13 @@ export default function DashboardWxWorkOutboxPage() { }, { key: "updatedAt", - label: "更新时间", + label: t("wxworkOutbox.columnUpdatedAt"), className: "w-44 text-xs text-muted-foreground", render: (item) => formatOptionalTime(item.updatedAt), }, { key: "actions", - label: 操作, + label: {t("wxworkOutbox.columnActions")}, className: "w-44", render: (item, context) => ( @@ -195,11 +199,11 @@ export default function DashboardWxWorkOutboxPage() { }, ]} labels={{ - refresh: "刷新", - query: "查询", - loading: "正在加载企业微信 outbox...", - empty: "暂无失败 outbox", - loadFailed: "加载企业微信 outbox 失败", + refresh: t("wxworkOutbox.refresh"), + query: t("wxworkOutbox.query"), + loading: t("wxworkOutbox.loading"), + empty: t("wxworkOutbox.empty"), + loadFailed: t("wxworkOutbox.loadFailed"), }} /> ) diff --git a/web/app/(support)/support.css b/web/app/(support)/support.css index c110635b..35a813aa 100644 --- a/web/app/(support)/support.css +++ b/web/app/(support)/support.css @@ -86,12 +86,16 @@ @apply border-border outline-ring/50; } - body { - @apply bg-background text-foreground; + html { + font-family: var(--font-inter), var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; } - html { - @apply font-sans; + body { + @apply bg-background text-foreground; + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; } } diff --git a/web/app/(support)/support/_components/support-article-content.tsx b/web/app/(support)/support/_components/support-article-content.tsx index 7cc323da..7d5bc1db 100644 --- a/web/app/(support)/support/_components/support-article-content.tsx +++ b/web/app/(support)/support/_components/support-article-content.tsx @@ -173,7 +173,7 @@ function MermaidDiagram({ definition }: { definition: string }) { signalTextColor: "#f4f4f5", tertiaryTextColor: "#f4f4f5", } : undefined, - fontFamily: "var(--font-geist-sans), Arial, sans-serif", + fontFamily: "var(--font-inter), Arial, sans-serif", flowchart: { useMaxWidth: true }, }) const valid = await mermaid.parse(definition, { suppressErrors: true }) diff --git a/web/app/(support)/typeset.css b/web/app/(support)/typeset.css index 280c3446..28b85e8a 100644 --- a/web/app/(support)/typeset.css +++ b/web/app/(support)/typeset.css @@ -1,7 +1,7 @@ @layer components { .typeset { - --typeset-font-body: var(--font-geist-sans); - --typeset-font-heading: var(--font-geist-sans); + --typeset-font-body: var(--font-inter); + --typeset-font-heading: var(--font-inter); --typeset-font-mono: var(--font-geist-mono); --typeset-size: 1rem; --typeset-leading: 1.8; diff --git a/web/components/agent-realtime-provider.test.mjs b/web/components/agent-realtime-provider.test.mjs index c308044d..79e5d24e 100644 --- a/web/components/agent-realtime-provider.test.mjs +++ b/web/components/agent-realtime-provider.test.mjs @@ -8,15 +8,15 @@ const providerSource = await readFile( "utf8", ).catch(() => ""); const workbenchLayoutSource = await readFile( - new URL("../app/workbench/layout.tsx", import.meta.url), + new URL("../app/(dashboard)/workbench/layout.tsx", import.meta.url), "utf8", ); const dashboardConversationsPageSource = await readFile( - new URL("../app/dashboard/conversations/page.tsx", import.meta.url), + new URL("../app/(dashboard)/dashboard/conversations/page.tsx", import.meta.url), "utf8", ); const conversationWorkbenchSource = await readFile( - new URL("../app/dashboard/conversations/_components/conversation-workbench.tsx", import.meta.url), + new URL("../app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx", import.meta.url), "utf8", ); diff --git a/web/components/chat/conversation-message-bubble.tsx b/web/components/chat/conversation-message-bubble.tsx index 41525c99..3cb7bbd4 100644 --- a/web/components/chat/conversation-message-bubble.tsx +++ b/web/components/chat/conversation-message-bubble.tsx @@ -11,6 +11,7 @@ export type ConversationMessageVariant = | "ai" | "system" | "recalled" + | "note" type ConversationMessageBubbleProps = { variant: ConversationMessageVariant @@ -28,6 +29,8 @@ function getBubbleVariant(variant: ConversationMessageVariant) { return "tinted" as const case "agent": return "default" as const + case "note": + return "tinted" as const case "recalled": return "outline" as const default: @@ -39,6 +42,8 @@ function getContentClassName(variant: ConversationMessageVariant) { switch (variant) { case "agent": return "bg-emerald-600 text-white" + case "note": + return "border border-amber-500/30 bg-amber-500/10 text-amber-950 dark:text-amber-200" case "system": return "border-dashed text-muted-foreground" case "recalled": diff --git a/web/components/chat/shared-message-editor.tsx b/web/components/chat/shared-message-editor.tsx index 25a4bd08..c4dd9438 100644 --- a/web/components/chat/shared-message-editor.tsx +++ b/web/components/chat/shared-message-editor.tsx @@ -33,7 +33,7 @@ import { setEditorImageUploadingByTitle, type UploadedEditorImage, } from "@/lib/im-editor-image" -import { generateUUID } from "@/lib/utils" +import { cn, generateUUID } from "@/lib/utils" import { useI18n } from "@/i18n/provider" export type UploadedMessageEditorImage = UploadedEditorImage & { @@ -60,7 +60,7 @@ type SharedMessageEditorProps = { items: MessageEditorQuickReply[] onOpenChange: (open: boolean) => void } - onSend: (html: string) => Promise + onSend: (html: string, messageType?: "html" | "note") => Promise onUploadImage: (file: File) => Promise onSendAttachment: (file: File) => Promise } @@ -76,6 +76,7 @@ export function SharedMessageEditor({ onSendAttachment, }: SharedMessageEditorProps) { const t = useI18n() + const [editorMode, setEditorMode] = useState<"reply" | "note">("reply") const [localUploading, setLocalUploading] = useState(false) const imageInputRef = useRef(null) const attachmentInputRef = useRef(null) @@ -85,11 +86,18 @@ export function SharedMessageEditor({ const shouldRestoreFocusRef = useRef(false) const objectUrlsRef = useRef>(new Set()) const uploadedImagesRef = useRef(new Map()) - const placeholderRef = useRef(t("conversation.editorPlaceholder")) + const placeholderRef = useRef( + editorMode === "note" + ? t("conversation.notePlaceholder") + : t("conversation.editorPlaceholder") + ) const isCustomer = variant === "customer" const isUploading = uploadingAsset || (manageLocalUploading && localUploading) - placeholderRef.current = t("conversation.editorPlaceholder") + placeholderRef.current = + editorMode === "note" + ? t("conversation.notePlaceholder") + : t("conversation.editorPlaceholder") useEffect(() => { const objectUrls = objectUrlsRef.current @@ -182,7 +190,7 @@ export function SharedMessageEditor({ if (!isMeaningfulHTML(html)) { return } - await onSendRef.current(html) + await onSendRef.current(html, editorMode === "note" ? "note" : "html") editor.commands.clearContent(true) revokeEditorObjectUrls(objectUrlsRef.current) uploadedImagesRef.current.clear() @@ -415,9 +423,18 @@ export function SharedMessageEditor({ size="sm" onClick={() => void handleSend()} disabled={disabled || isUploading} + className={ + editorMode === "note" + ? "bg-amber-600 hover:bg-amber-700 text-white" + : undefined + } > - {isUploading ? t("conversation.uploading") : t("conversation.send")} + {isUploading + ? t("conversation.uploading") + : editorMode === "note" + ? t("conversation.addNote") + : t("conversation.send")} )}
@@ -437,7 +454,49 @@ export function SharedMessageEditor({ return (
-
+
+
+ + +
{editorContent}
diff --git a/web/components/language-toggle.tsx b/web/components/language-toggle.tsx new file mode 100644 index 00000000..f4757bfb --- /dev/null +++ b/web/components/language-toggle.tsx @@ -0,0 +1,59 @@ +"use client" + +import { GlobeIcon } from "lucide-react" +import { useAppLocale, useI18n } from "@/i18n/provider" +import { type AppLocale } from "@/i18n/config" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +const languageOptions: Array<{ + value: AppLocale + labelKey: string + flag: string +}> = [ + { value: "en-US", labelKey: "language.enUS", flag: "🇺🇸" }, + { value: "vi-VN", labelKey: "language.viVN", flag: "🇻🇳" }, + { value: "zh-CN", labelKey: "language.zhCN", flag: "🇨🇳" }, +] + +export function LanguageToggle({ + variant = "outline", + size = "sm", +}: { + variant?: "outline" | "ghost" + size?: "sm" | "icon" | "icon-sm" +}) { + const t = useI18n() + const { locale, setLocale } = useAppLocale() + + return ( + + } + aria-label={t("language.selectLanguage")} + title={t("language.selectLanguage")} + > + + + + setLocale(value as AppLocale)} + > + {languageOptions.map((option) => ( + + {option.flag} + {t(option.labelKey)} + + ))} + + + + ) +} diff --git a/web/components/legal-document-page.tsx b/web/components/legal-document-page.tsx index 7787c008..80d6072f 100644 --- a/web/components/legal-document-page.tsx +++ b/web/components/legal-document-page.tsx @@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { useAppLocale, useI18n } from "@/i18n/provider" import { fetchPublicConfig, type PublicConfig } from "@/lib/api/config" import enUSMessages from "@/messages/en-US.json" +import viVNMessages from "@/messages/vi-VN.json" import zhCNMessages from "@/messages/zh-CN.json" type LegalPageType = "terms" | "privacy" @@ -26,9 +27,10 @@ type LegalDocument = { sections: LegalSection[] } -const messages = { +const messages: Record }> = { "zh-CN": zhCNMessages, "en-US": enUSMessages, + "vi-VN": viVNMessages as unknown as { legal: Record }, } export function LegalDocumentPage({ type }: { type: LegalPageType }) { diff --git a/web/components/login-form.tsx b/web/components/login-form.tsx index 499bb365..17521a6d 100644 --- a/web/components/login-form.tsx +++ b/web/components/login-form.tsx @@ -196,6 +196,18 @@ export function LoginForm({
+
+ {publicConfig?.companyName + {publicConfig?.companyName ? (publicConfig.companyName.toLowerCase().includes("desk") ? publicConfig.companyName : `${publicConfig.companyName} Desk`) : t("app.brand")} +

{t("auth.welcome")}

{t("auth.loginDescription", { brand: publicConfig.companyName || t("app.brand") })} diff --git a/web/components/nav-user.tsx b/web/components/nav-user.tsx index f908705d..0f040a92 100644 --- a/web/components/nav-user.tsx +++ b/web/components/nav-user.tsx @@ -6,6 +6,7 @@ import { useState } from "react" import { useAuth } from "@/components/auth-provider" import { useI18n } from "@/i18n/provider" import { ChangePasswordDialog } from "@/components/change-password-dialog" +import { LanguageToggle } from "@/components/language-toggle" import { useNotifications } from "@/components/notification-provider" import { PaletteToggle } from "@/components/palette-toggle" import { ThemeToggle } from "@/components/theme-toggle" @@ -103,6 +104,7 @@ export function NavUser({ {t("nav.preferences")}

+
diff --git a/web/components/option-combobox.tsx b/web/components/option-combobox.tsx index 89fe4010..a47f226b 100644 --- a/web/components/option-combobox.tsx +++ b/web/components/option-combobox.tsx @@ -81,7 +81,7 @@ export function OptionCombobox(props: OptionComboboxProps) { ? placeholder : selectedOptions.length === 1 ? selectedOptions[0].label - : `已选择 ${selectedOptions.length} 项` + : t("common.selectedCount", { count: selectedOptions.length }) const optionGroups = Array.from( options.reduce((groups, option) => { const group = option.group ?? "" diff --git a/web/components/organization-dialogs.tsx b/web/components/organization-dialogs.tsx new file mode 100644 index 00000000..aa5b3382 --- /dev/null +++ b/web/components/organization-dialogs.tsx @@ -0,0 +1,340 @@ +"use client" + +import { Building2Icon, Loader2Icon, PlusIcon, Trash2Icon, UserPlusIcon, UsersIcon } from "lucide-react" +import { useEffect, useState } from "react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { useI18n } from "@/i18n/provider" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { + addOrganizationMember, + createOrganization, + getOrganizationMembers, + removeOrganizationMember, + updateOrganization, + type OrganizationItem, + type OrganizationMemberItem, +} from "@/lib/api/organization" + +export function CreateOrganizationDialog({ + open, + onOpenChange, + onCreated, +}: { + open: boolean + onOpenChange: (open: boolean) => void + onCreated: (org: OrganizationItem) => void +}) { + const t = useI18n() + const [name, setName] = useState("") + const [code, setCode] = useState("") + const [isPending, setIsPending] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) { + toast.error(t("organization.nameRequired")) + return + } + setIsPending(true) + try { + const org = await createOrganization({ + name: name.trim(), + code: code.trim() || undefined, + }) + toast.success(t("organization.created", { name: org.name })) + setName("") + setCode("") + onOpenChange(false) + onCreated(org) + } catch (err) { + toast.error(err instanceof Error ? err.message : t("organization.createFailed")) + } finally { + setIsPending(false) + } + } + + return ( + + + + + + + {t("organization.createTitle")} + + + {t("organization.createDescription")} + + + + + + {t("organization.name")} + setName(e.target.value)} + placeholder={t("organization.namePlaceholder")} + required + autoFocus + /> + + + {t("organization.code")} + setCode(e.target.value)} + placeholder={t("organization.codePlaceholder")} + /> + + + + + + + + + + + ) +} + +export function ManageOrganizationDialog({ + open, + onOpenChange, + organization, + onUpdated, +}: { + open: boolean + onOpenChange: (open: boolean) => void + organization: OrganizationItem | null + onUpdated?: () => void +}) { + const t = useI18n() + const [members, setMembers] = useState([]) + const [loadingMembers, setLoadingMembers] = useState(false) + const [emailOrUsername, setEmailOrUsername] = useState("") + const [role, setRole] = useState("MEMBER") + const [isAdding, setIsAdding] = useState(false) + const [orgName, setOrgName] = useState("") + const [isUpdatingName, setIsUpdatingName] = useState(false) + + useEffect(() => { + if (open && organization) { + setOrgName(organization.name) + setLoadingMembers(true) + getOrganizationMembers() + .then((res) => { + setMembers(res || []) + }) + .catch(() => { + setMembers([]) + }) + .finally(() => { + setLoadingMembers(false) + }) + } + }, [open, organization]) + + const handleUpdateName = async (e: React.FormEvent) => { + e.preventDefault() + if (!orgName.trim() || orgName.trim() === organization?.name) return + setIsUpdatingName(true) + try { + await updateOrganization({ name: orgName.trim() }) + toast.success(t("organization.updated")) + onUpdated?.() + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to update name") + } finally { + setIsUpdatingName(false) + } + } + + const handleAddMember = async (e: React.FormEvent) => { + e.preventDefault() + if (!emailOrUsername.trim()) return + setIsAdding(true) + try { + const added = await addOrganizationMember({ + emailOrUsername: emailOrUsername.trim(), + role, + }) + setMembers((prev) => { + const filtered = prev.filter((m) => m.userId !== added.userId) + return [...filtered, added] + }) + setEmailOrUsername("") + toast.success(t("organization.memberAdded")) + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to add member") + } finally { + setIsAdding(false) + } + } + + const handleRemoveMember = async (userId: number, username: string) => { + if (!confirm(t("organization.removeConfirm"))) return + try { + await removeOrganizationMember(userId) + setMembers((prev) => prev.filter((m) => m.userId !== userId)) + toast.success(t("organization.memberRemoved")) + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to remove member") + } + } + + if (!organization) return null + + const isOwnerOrAdmin = organization.role === "OWNER" || organization.role === "ADMIN" + + return ( + + + + + + {organization.name} — {t("organization.manageTitle")} + + + {t("organization.general")} + + + + {isOwnerOrAdmin ? ( +
+ + {t("organization.name")} +
+ setOrgName(e.target.value)} + placeholder={t("organization.namePlaceholder")} + required + /> + +
+
+
+ ) : null} + +
+
+

+ + {t("organization.members")} ({members.length}) +

+
+ + {isOwnerOrAdmin ? ( +
+ setEmailOrUsername(e.target.value)} + placeholder={t("organization.emailOrUsernamePlaceholder")} + className="flex-1 text-xs" + required + /> + + +
+ ) : null} + + {loadingMembers ? ( +
+ +
+ ) : ( +
+ {members.map((m) => ( +
+
+ {m.nickname || m.username} + {m.email || `@${m.username}`} +
+
+ + {m.role} + + {isOwnerOrAdmin && m.role !== "OWNER" ? ( + + ) : null} +
+
+ ))} + {members.length === 0 ? ( +
{t("common.emptyData")}
+ ) : null} +
+ )} +
+ + + + +
+
+ ) +} diff --git a/web/components/site-header.tsx b/web/components/site-header.tsx index 8000af20..a20242e6 100644 --- a/web/components/site-header.tsx +++ b/web/components/site-header.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from "react" import { usePathname } from "next/navigation" +import { LanguageToggle } from "@/components/language-toggle" import { PaletteToggle } from "@/components/palette-toggle" import { RealtimeConnectionStatus } from "@/components/realtime-connection-status" import { ThemeToggle } from "@/components/theme-toggle" @@ -77,6 +78,7 @@ export function SiteHeader() {
+
diff --git a/web/components/tag-selector.tsx b/web/components/tag-selector.tsx index 1843d71e..d0607b99 100644 --- a/web/components/tag-selector.tsx +++ b/web/components/tag-selector.tsx @@ -269,7 +269,7 @@ export function TagSelector(props: TagSelectorProps) { variant="ghost" size="icon-sm" className="size-5 shrink-0" - aria-label={collapsed ? "展开标签" : "折叠标签"} + aria-label={collapsed ? t("common.expand") : t("common.collapse")} onMouseDown={(event) => event.preventDefault()} onClick={(event) => { event.preventDefault() diff --git a/web/components/ui/sidebar.tsx b/web/components/ui/sidebar.tsx index 475071d2..5680de77 100644 --- a/web/components/ui/sidebar.tsx +++ b/web/components/ui/sidebar.tsx @@ -475,7 +475,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-primary data-active:font-medium data-active:text-sidebar-primary-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", + "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-[13.5px] ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-primary data-active:font-medium data-active:text-sidebar-primary-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", { variants: { variant: { @@ -484,7 +484,7 @@ const sidebarMenuButtonVariants = cva( "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", }, size: { - default: "h-8 text-sm", + default: "h-8 text-[13.5px]", sm: "h-7 text-xs", lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", }, @@ -683,7 +683,7 @@ function SidebarMenuSubButton({ props: mergeProps<"a">( { className: cn( - "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-primary data-active:text-sidebar-primary-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-[13px] data-[size=sm]:text-xs data-active:bg-sidebar-primary data-active:text-sidebar-primary-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", className ), }, diff --git a/web/components/workspace-switcher.test.mjs b/web/components/workspace-switcher.test.mjs index 7fedc82a..0221b214 100644 --- a/web/components/workspace-switcher.test.mjs +++ b/web/components/workspace-switcher.test.mjs @@ -26,7 +26,6 @@ it("does not auto-open the rail menu from focus or hover events", async () => { it("centers the dashboard switcher logo and shows a collapsed switch indicator", async () => { assert.match(source, /variant === "sidebar" &&[\s\S]*group-data-\[collapsible=icon\]:p-0!/); assert.match(source, /variant === "sidebar" &&[\s\S]*group-data-\[collapsible=icon\]:justify-center/); - assert.match(appSidebarSource, /className="relative data-\[slot=sidebar-menu-button\]:p-1\.5! group-data-\[collapsible=icon\]:justify-center group-data-\[collapsible=icon\]:p-0!"/); assert.match(source, /const switchIndicatorClassName =\s*"absolute bottom-0\.5 right-0\.5 size-2\.5/); assert.match(source, /variant === "rail" \? \([\s\S]*/); assert.match(source, /className=\{cn\(switchIndicatorClassName, "hidden group-data-\[collapsible=icon\]:block"\)\}/); diff --git a/web/components/workspace-switcher.tsx b/web/components/workspace-switcher.tsx index 441e87a9..9ed0f353 100644 --- a/web/components/workspace-switcher.tsx +++ b/web/components/workspace-switcher.tsx @@ -1,11 +1,21 @@ "use client" -import { Building2Icon, CheckIcon, ChevronsUpDownIcon, LayoutDashboardIcon, WrenchIcon } from "lucide-react" +import { + Building2Icon, + CheckIcon, + ChevronsUpDownIcon, + LayoutDashboardIcon, + PlusIcon, + SettingsIcon, + WrenchIcon, +} from "lucide-react" import Link from "next/link" import { useEffect, useState, type ReactElement } from "react" import { toast } from "sonner" +import { CreateOrganizationDialog, ManageOrganizationDialog } from "@/components/organization-dialogs" import { useI18n } from "@/i18n/provider" +import { fetchPublicConfig, type PublicConfig } from "@/lib/api/config" import { listMyOrganizations, switchOrganization, type OrganizationItem } from "@/lib/api/organization" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -60,17 +70,29 @@ export function WorkspaceSwitcher({ const [orgs, setOrgs] = useState([]) const [activeOrgId, setActiveOrgId] = useState(null) const [switching, setSwitching] = useState(false) + const [publicConfig, setPublicConfig] = useState(null) + const [createDialogOpen, setCreateDialogOpen] = useState(false) + const [manageDialogOpen, setManageDialogOpen] = useState(false) - useEffect(() => { - let mounted = true + const loadOrgs = () => { listMyOrganizations() .then((res) => { - if (mounted && res?.organizations) { + if (res?.organizations) { setOrgs(res.organizations) setActiveOrgId(res.currentOrganizationId || res.organizations[0]?.id || null) } }) .catch(() => {}) + } + + useEffect(() => { + let mounted = true + loadOrgs() + fetchPublicConfig() + .then((cfg) => { + if (mounted) setPublicConfig(cfg) + }) + .catch(() => {}) return () => { mounted = false } @@ -80,6 +102,9 @@ export function WorkspaceSwitcher({ workspaceOptions.find((item) => item.key === currentWorkspace) ?? workspaceOptions[0] const currentOrg = orgs.find((o) => o.id === activeOrgId) || orgs[0] + const brandName = publicConfig?.companyName || t("app.brand") + const brandLogo = publicConfig?.companyLogoUrl || "/images/logo.svg" + const handleSwitchOrg = async (orgId: number) => { if (orgId === activeOrgId || switching) return setSwitching(true) @@ -87,7 +112,7 @@ export function WorkspaceSwitcher({ await switchOrganization(orgId) setActiveOrgId(orgId) toast.success("Switched organization successfully") - window.location.reload() + window.location.href = "/dashboard" } catch { toast.error("Failed to switch organization") } finally { @@ -113,28 +138,28 @@ export function WorkspaceSwitcher({ variant === "rail" ? ( <> {t("app.brand")} - {currentOrg?.name || t("app.brand")} - {t(currentOption.labelKey)} + {currentOrg?.name || brandName} - {t(currentOption.labelKey)} ) : ( <> {t("app.brand")}
- {currentOrg?.name || t("app.brand")} + {currentOrg?.name || brandName} {t(currentOption.labelKey)} {currentOrg?.role ? `• ${currentOrg.role}` : ""} @@ -149,67 +174,123 @@ export function WorkspaceSwitcher({ ) return ( - - - } - > - {triggerContent} - - - {orgs.length > 0 ? ( - <> - - - - Organizations / Workspaces - - {orgs.map((org) => { - const isActive = org.id === activeOrgId - return ( - handleSwitchOrg(org.id)} - > -
- {org.name} - - {org.role || "Member"} {org.plan ? `• ${org.plan}` : ""} - -
- {isActive ? : null} -
- ) - })} -
- - - ) : null} + <> + + + } + > + {triggerContent} + + + {orgs.length > 0 ? ( + <> + + + + + Organizations / Workspaces + + {currentOrg ? ( + + ) : null} + + {orgs.map((org) => { + const isActive = org.id === activeOrgId + return ( + handleSwitchOrg(org.id)} + > +
+ {org.name} + + {org.role || "Member"} {org.plan ? `• ${org.plan}` : ""} + +
+ {isActive ? : null} +
+ ) + })} + setCreateDialogOpen(true)} + > + + Create Organization + +
+ + + ) : ( + <> + + setCreateDialogOpen(true)} + > + + Create Organization + + + + + )} + + + {t("workspace.switchWorkspace")} + {workspaceOptions.map((item) => ( + } + className="cursor-pointer gap-2" + > + + {t(item.labelKey)} + {item.key === currentWorkspace ? ( + + ) : null} + + ))} + +
+
+ + { + loadOrgs() + setActiveOrgId(newOrg.id) + window.location.href = "/dashboard" + }} + /> - - {t("workspace.switchWorkspace")} - {workspaceOptions.map((item) => ( - } - className="cursor-pointer gap-2" - > - - {t(item.labelKey)} - {item.key === currentWorkspace ? ( - - ) : null} - - ))} - -
-
+ { + loadOrgs() + }} + /> + ) } diff --git a/web/i18n/config.test.mjs b/web/i18n/config.test.mjs index 2eb6e9a8..ccecc065 100644 --- a/web/i18n/config.test.mjs +++ b/web/i18n/config.test.mjs @@ -25,21 +25,24 @@ async function loadConfig() { test("normalizes supported locale aliases", async () => { const { DEFAULT_LOCALE, normalizeLocale } = await loadConfig() - assert.equal(DEFAULT_LOCALE, "zh-CN") + assert.equal(DEFAULT_LOCALE, "en-US") assert.equal(normalizeLocale("zh-CN"), "zh-CN") assert.equal(normalizeLocale("zh_CN"), "zh-CN") assert.equal(normalizeLocale("zh"), "zh-CN") assert.equal(normalizeLocale("en-US"), "en-US") assert.equal(normalizeLocale("en_US"), "en-US") assert.equal(normalizeLocale("en"), "en-US") + assert.equal(normalizeLocale("vi-VN"), "vi-VN") + assert.equal(normalizeLocale("vi_VN"), "vi-VN") + assert.equal(normalizeLocale("vi"), "vi-VN") assert.equal(normalizeLocale("fr-FR"), DEFAULT_LOCALE) }) test("reads the configured locale without browser language detection", async () => { const { configureLocale, readStoredLocale } = await loadConfig() - assert.equal(readStoredLocale(), "zh-CN") - - configureLocale("en-US") assert.equal(readStoredLocale(), "en-US") + + configureLocale("zh-CN") + assert.equal(readStoredLocale(), "zh-CN") }) diff --git a/web/i18n/config.ts b/web/i18n/config.ts index 260b33bf..81d0f328 100644 --- a/web/i18n/config.ts +++ b/web/i18n/config.ts @@ -1,7 +1,7 @@ -export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const +export const SUPPORTED_LOCALES = ["zh-CN", "en-US", "vi-VN"] as const export type AppLocale = (typeof SUPPORTED_LOCALES)[number] -export const DEFAULT_LOCALE: AppLocale = "zh-CN" +export const DEFAULT_LOCALE: AppLocale = "en-US" const LOCALE_ALIASES: Record = { zh: "zh-CN", @@ -11,6 +11,10 @@ const LOCALE_ALIASES: Record = { en: "en-US", "en-us": "en-US", en_us: "en-US", + vi: "vi-VN", + "vi-vn": "vi-VN", + vi_vn: "vi-VN", + vietnamese: "vi-VN", } export function normalizeLocale(value: string | null | undefined): AppLocale { diff --git a/web/i18n/messages.ts b/web/i18n/messages.ts index 5177c6f0..58cdf0bc 100644 --- a/web/i18n/messages.ts +++ b/web/i18n/messages.ts @@ -1,10 +1,12 @@ import { DEFAULT_LOCALE, type AppLocale, readStoredLocale } from "@/i18n/config" import enUSMessages from "@/messages/en-US.json" +import viVNMessages from "@/messages/vi-VN.json" import zhCNMessages from "@/messages/zh-CN.json" const messages = { "zh-CN": zhCNMessages, "en-US": enUSMessages, + "vi-VN": viVNMessages, } satisfies Record export function translateMessage( diff --git a/web/i18n/provider.tsx b/web/i18n/provider.tsx index 527bec89..73dbe3d4 100644 --- a/web/i18n/provider.tsx +++ b/web/i18n/provider.tsx @@ -15,7 +15,7 @@ import { configureLocale, } from "@/i18n/config" import { translateMessage } from "@/i18n/messages" -import { fetchPublicConfig } from "@/lib/api/config" +import { fetchPublicConfig, type PublicConfig } from "@/lib/api/config" type LocaleContextValue = { locale: AppLocale @@ -29,23 +29,69 @@ const LocaleContext = createContext({ t: (key) => key, }) +function applyBranding(locale: AppLocale, config?: PublicConfig | null) { + if (typeof document === "undefined") return + + const rawTitle = translateMessage(locale, "app.metadataTitle") + const brandName = config?.companyName?.trim() + if (brandName) { + if (brandName.toLowerCase().includes("desk") || brandName.toLowerCase().includes("crove")) { + document.title = `${brandName} - ${rawTitle}` + } else { + document.title = `${brandName} Desk - ${rawTitle}` + } + } else { + document.title = rawTitle + } + + const faviconUrl = config?.companyFaviconUrl || config?.companyLogoUrl || "/favicon.svg" + let link = document.querySelector("link[rel~='icon']") + if (!link) { + link = document.createElement("link") + link.rel = "icon" + document.head.appendChild(link) + } + link.href = faviconUrl +} + export function AppI18nProvider({ children }: { children: ReactNode }) { const [locale, setLocaleState] = useState(DEFAULT_LOCALE) + const [publicConfig, setPublicConfig] = useState(null) const [isLocaleReady, setIsLocaleReady] = useState(false) + const handleSetLocale = (newLocale: AppLocale) => { + const next = configureLocale(newLocale) + setLocaleState(next) + try { + window.localStorage.setItem("app_locale", next) + } catch (_) {} + document.documentElement.lang = next + applyBranding(next, publicConfig) + } + useEffect(() => { let cancelled = false + let savedLocale: string | null = null + try { + savedLocale = window.localStorage.getItem("app_locale") + } catch (_) {} fetchPublicConfig() - .then((config) => configureLocale(config.language)) - .catch(() => configureLocale(DEFAULT_LOCALE)) - .then((configuredLocale) => { - if (cancelled) { - return - } - setLocaleState(configuredLocale) - document.documentElement.lang = configuredLocale - document.title = translateMessage(configuredLocale, "app.metadataTitle") + .then((cfg) => { + if (cancelled) return + setPublicConfig(cfg) + const targetLocale = savedLocale ? configureLocale(savedLocale) : configureLocale(cfg.language) + setLocaleState(targetLocale) + document.documentElement.lang = targetLocale + applyBranding(targetLocale, cfg) + setIsLocaleReady(true) + }) + .catch(() => { + if (cancelled) return + const targetLocale = savedLocale ? configureLocale(savedLocale) : configureLocale(DEFAULT_LOCALE) + setLocaleState(targetLocale) + document.documentElement.lang = targetLocale + applyBranding(targetLocale, null) setIsLocaleReady(true) }) @@ -55,14 +101,14 @@ export function AppI18nProvider({ children }: { children: ReactNode }) { }, []) useEffect(() => { - document.title = translateMessage(locale, "app.metadataTitle") - }, [locale]) + applyBranding(locale, publicConfig) + }, [locale, publicConfig]) const value = useMemo( () => ({ locale, t: (key, values) => translateMessage(locale, key, values), - setLocale: () => {}, + setLocale: handleSetLocale, }), [locale] ) diff --git a/web/lib/api/config.ts b/web/lib/api/config.ts index 088677fb..8eb370e3 100644 --- a/web/lib/api/config.ts +++ b/web/lib/api/config.ts @@ -4,6 +4,7 @@ export type PublicConfig = { language: string companyName?: string companyLogoUrl?: string + companyFaviconUrl?: string passwordLoginEnabled?: boolean wxworkEnabled: boolean oidcEnabled: boolean diff --git a/web/lib/api/organization.ts b/web/lib/api/organization.ts index 285b8f74..025c2a2c 100644 --- a/web/lib/api/organization.ts +++ b/web/lib/api/organization.ts @@ -12,6 +12,18 @@ export type OrganizationItem = { createdAt: string } +export type OrganizationMemberItem = { + id: number + userId: number + username: string + nickname: string + email: string + avatar: string + role: string + status: number + createdAt: string +} + export type UserOrganizationListResponse = { currentOrganizationId: number organizations: OrganizationItem[] @@ -23,9 +35,43 @@ export async function listMyOrganizations() { }) } +export async function createOrganization(data: { name: string; code?: string; logo?: string }) { + return request("/api/dashboard/organization/create", { + method: "POST", + body: JSON.stringify(data), + }) +} + export async function switchOrganization(organizationId: number) { return request("/api/dashboard/organization/switch", { method: "POST", body: JSON.stringify({ organizationId }), }) } + +export async function getOrganizationMembers() { + return request("/api/dashboard/organization/members", { + method: "GET", + }) +} + +export async function addOrganizationMember(data: { emailOrUsername: string; role?: string }) { + return request("/api/dashboard/organization/add_member", { + method: "POST", + body: JSON.stringify(data), + }) +} + +export async function removeOrganizationMember(userId: number) { + return request<{ success: boolean }>("/api/dashboard/organization/remove_member", { + method: "POST", + body: JSON.stringify({ userId }), + }) +} + +export async function updateOrganization(data: { name: string; logo?: string }) { + return request("/api/dashboard/organization/update", { + method: "POST", + body: JSON.stringify(data), + }) +} diff --git a/web/lib/generated/enums.ts b/web/lib/generated/enums.ts index 0b404a59..fa8a008c 100644 --- a/web/lib/generated/enums.ts +++ b/web/lib/generated/enums.ts @@ -77,15 +77,19 @@ export enum ExternalSource { Guest = "guest", WxWorkKF = "wxwork_kf", User = "user", + TwentyCRM = "twenty_crm", Telegram = "telegram", ZaloOA = "zalo_oa", + Email = "email", } export const ExternalSourceLabels: Record = { [ExternalSource.Guest]: "访客", [ExternalSource.WxWorkKF]: "企业微信客服", [ExternalSource.User]: "用户", + [ExternalSource.TwentyCRM]: "Twenty CRM", [ExternalSource.Telegram]: "Telegram", [ExternalSource.ZaloOA]: "Zalo OA", + [ExternalSource.Email]: "Email", } export enum Gender { @@ -180,12 +184,14 @@ export enum IMMessageType { Image = "image", Attachment = "attachment", HTML = "html", + Note = "note", } export const IMMessageTypeLabels: Record = { [IMMessageType.Text]: "文本", [IMMessageType.Image]: "图片", [IMMessageType.Attachment]: "附件", [IMMessageType.HTML]: "富文本", + [IMMessageType.Note]: "便签", } export enum IMParticipantType { @@ -206,12 +212,14 @@ export enum IMSenderType { Customer = "customer", AI = "ai", System = "system", + Note = "note", } export const IMSenderTypeLabels: Record = { [IMSenderType.Agent]: "客服", [IMSenderType.Customer]: "客户", [IMSenderType.AI]: "AI", [IMSenderType.System]: "系统", + [IMSenderType.Note]: "便签", } export enum KnowledgeAnswerMode { diff --git a/web/lib/notification-i18n.test.mjs b/web/lib/notification-i18n.test.mjs index 305f23ec..c85396b9 100644 --- a/web/lib/notification-i18n.test.mjs +++ b/web/lib/notification-i18n.test.mjs @@ -18,7 +18,26 @@ async function loadModule() { module: { exports: {} }, require: (id) => { if (id === "@/i18n/config") { - return { DEFAULT_LOCALE: "zh-CN" } + return { + DEFAULT_LOCALE: "zh-CN", + normalizeLocale: (loc) => (loc === "en-US" || loc === "en" ? "en-US" : "zh-CN"), + } + } + if (id === "@/i18n/messages") { + return { + translateMessage: (loc, key, values) => { + if (key === "notification.fallbackTitle") return "Notification" + if (key === "notification.ticketAssignedTitle") return "Ticket assigned" + if (key === "notification.ticketAssignedLine") return `Ticket ${values?.ticketNo} has been assigned to you.` + if (key === "notification.assignmentReason") return `Assignment reason: ${values?.reason}` + if (key === "notification.conversationTransferredTitle") return "Conversation transferred" + if (key === "notification.conversationAutoAssignedTitle") return "Conversation auto-assigned" + if (key === "notification.conversationAssignedTitle") return "Conversation assigned" + if (key === "notification.conversationAssignedLine") return `Conversation #${values?.conversationId} was assigned to you` + if (key === "notification.transferReason") return `Transfer reason: ${values?.reason}` + return key + }, + } } throw new Error(`unexpected import ${id}`) }, diff --git a/web/lib/stores/agent-conversations.ts b/web/lib/stores/agent-conversations.ts index df1c9797..172fb640 100644 --- a/web/lib/stores/agent-conversations.ts +++ b/web/lib/stores/agent-conversations.ts @@ -90,7 +90,7 @@ type AgentConversationsStore = { loadOlderMessages: () => Promise syncLatestMessages: (conversationId: number) => Promise markSelectedConversationRead: () => Promise - sendMessage: (html: string) => Promise + sendMessage: (html: string, messageType?: "html" | "note") => Promise uploadImage: (file: File) => Promise sendAttachment: (file: File) => Promise recallMessage: (messageId: number) => Promise @@ -470,7 +470,7 @@ export const useAgentConversationsStore = create((set, } }, - sendMessage: async (html) => { + sendMessage: async (html, messageType = "html") => { const trimmedContent = html.trim() const { selectedConversationId, sending } = get() if (!selectedConversationId || !trimmedContent || sending) { @@ -481,7 +481,7 @@ export const useAgentConversationsStore = create((set, try { const message = await sendAgentMessage({ conversationId: selectedConversationId, - messageType: "html", + messageType: messageType, content: trimmedContent, clientMsgId: `agent_${generateUUID()}`, }) @@ -497,8 +497,9 @@ export const useAgentConversationsStore = create((set, conversationId: selectedConversationId, agentUnreadCount: 0, customerUnreadCount: - (current.conversations.find((item) => item.id === selectedConversationId) - ?.customerUnreadCount ?? 0) + 1, + messageType === "note" + ? (current.conversations.find((item) => item.id === selectedConversationId)?.customerUnreadCount ?? 0) + : (current.conversations.find((item) => item.id === selectedConversationId)?.customerUnreadCount ?? 0) + 1, agentLastReadMessageId: message.id, } ), diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 7f309dd7..17ae9002 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -405,6 +405,11 @@ "recalling": "Recalling...", "recall": "Recall", "loadQuickRepliesFailed": "Could not load quick replies.", + "replyMode": "Public Reply", + "noteMode": "Internal Note", + "notePlaceholder": "Write an internal team note (not visible to customer)...", + "addNote": "Add Note", + "internalNoteBadge": "Internal Note", "tagRemoved": "Conversation tag removed.", "tagAdded": "Conversation tag added.", "tagUpdateFailed": "Could not update conversation tags.", @@ -610,10 +615,27 @@ "channel": { "allTypes": "All types", "typeWeb": "Web", + "typeEmail": "Email Support", "typeTelegram": "Telegram Bot", "typeZaloOa": "Zalo Official Account", "typeWechatMp": "WeChat Official Account", "typeWxworkKf": "WeCom Customer Service", + "emailAddress": "Support Email Address", + "senderName": "Sender Display Name", + "emailProvider": "Email Delivery Service", + "emailProviderDefault": "System Default (.env / config.yaml)", + "emailProviderSmtp": "Custom SMTP Server", + "emailProviderBrevo": "Brevo API", + "emailProviderSendGrid": "SendGrid API", + "emailProviderResend": "Resend API", + "emailProviderPostmark": "Postmark API", + "emailProviderMailgun": "Mailgun API", + "emailApiKey": "API Key", + "emailAutoConnectTitle": "Automatic Inbound Email Ingestion", + "emailAutoConnectDescription": "Forward emails sent to your support address to the Inbound Webhook endpoint to automatically convert incoming emails into tickets and trigger AI agent responses.", + "configEmailDescription": "Configure inbound email webhook ingestion and outbound delivery across SMTP, Brevo, SendGrid, Resend, Postmark, or Mailgun.", + "forwardingAddressLabel": "Dedicated Forwarding Address (for auto-forwarding from Gmail / Outlook):", + "inboundWebhookUrl": "Inbound Webhook Endpoint", "botToken": "Telegram Bot Token", "botTokenRequired": "Telegram Bot Token is required", "botUsername": "Bot Username", diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json new file mode 100644 index 00000000..38dec354 --- /dev/null +++ b/web/messages/vi-VN.json @@ -0,0 +1,2978 @@ +{ + "app": { + "brand": "Crove Desk", + "metadataTitle": "Crove Desk - Hệ thống CSKH & Trợ lý AI", + "metadataDescription": "Crove Desk - Nền tảng chăm sóc khách hàng và hỗ trợ thông minh AI" + }, + "common": { + "language": "Ngôn ngữ", + "notSignedIn": "Chưa đăng nhập", + "guest": "Khách", + "loading": "Đang tải...", + "loadingData": "Đang tải dữ liệu...", + "emptyData": "Không có dữ liệu", + "searchKeyword": "Tìm theo từ khóa", + "emptyOptions": "Không có tùy chọn", + "close": "Đóng", + "fullscreen": "Toàn màn hình", + "exitFullscreen": "Thoát toàn màn hình", + "selectedCount": "Đã chọn {count} mục", + "expand": "Mở rộng", + "collapse": "Thu gọn", + "cancel": "Hủy", + "confirm": "Xác nhận", + "save": "Lưu", + "delete": "Xóa", + "edit": "Chỉnh sửa", + "create": "Tạo mới", + "refresh": "Làm mới", + "actions": "Thao tác", + "status": "Trạng thái", + "name": "Tên", + "description": "Mô tả" + }, + "language": { + "enUS": "English", + "zhCN": "Tiếng Trung (中文)", + "viVN": "Tiếng Việt", + "selectLanguage": "Chọn ngôn ngữ giao diện" + }, + "organization": { + "createTitle": "Create Organization", + "createDescription": "Create a new organization / workspace for your team.", + "name": "Organization Name", + "namePlaceholder": "e.g. Acme Corp", + "code": "Slug / Code (Optional)", + "codePlaceholder": "e.g. acme-corp", + "create": "Create Organization", + "creating": "Creating...", + "created": "Organization \"{name}\" created successfully", + "createFailed": "Failed to create organization", + "nameRequired": "Organization name is required", + "manageTitle": "Manage Organization", + "general": "General Settings", + "members": "Team Members", + "addMember": "Add Member", + "emailOrUsername": "User Email or Username", + "emailOrUsernamePlaceholder": "e.g. user@company.com or username", + "role": "Role", + "owner": "Owner", + "admin": "Admin", + "member": "Member", + "add": "Add", + "adding": "Adding...", + "remove": "Remove", + "removeConfirm": "Are you sure you want to remove this member?", + "memberAdded": "Member added successfully", + "memberRemoved": "Member removed", + "updated": "Organization settings updated", + "save": "Save Changes", + "saving": "Saving..." + }, + "pagination": { + "pageSummary": "Page {page} of {totalPages}", + "total": "{total} records", + "pageSize": "{pageSize} per page", + "previous": "Previous", + "next": "Next" + }, + "confirm": { + "title": "Confirm Action", + "description": "This action will continue after you confirm.", + "confirm": "Confirm", + "cancel": "Cancel" + }, + "realtime": { + "connecting": "Realtime channel connecting", + "connected": "Realtime channel connected", + "disconnected": "Realtime channel disconnected", + "compactConnecting": "Realtime: connecting", + "compactConnected": "Realtime: online", + "compactDisconnected": "Realtime: disconnected" + }, + "notification": { + "new": "New notification", + "view": "View", + "openFailed": "Could not open the notification.", + "connectFailed": "Could not connect to the notification service.", + "loadFailed": "Could not load notifications.", + "all": "All", + "unread": "Unread", + "read": "Read", + "refresh": "Refresh", + "markAllRead": "Mark All as Read", + "markAllReadSuccess": "All notifications marked as read.", + "markAllReadFailed": "Could not mark all notifications as read.", + "fallbackTitle": "Notification", + "loading": "Loading notifications", + "empty": "No notifications", + "ticketAssignedTitle": "Ticket assigned", + "ticketAssignedLine": "Ticket {ticketNo} has been assigned to you.", + "assignmentReason": "Assignment reason: {reason}", + "conversationTransferredTitle": "Conversation transferred", + "conversationAutoAssignedTitle": "Conversation auto-assigned", + "conversationAssignedTitle": "Conversation assigned", + "conversationAssignedLine": "Conversation #{conversationId} has been assigned to you.", + "transferReason": "Transfer reason: {reason}" + }, + "json": { + "invalid": "Invalid JSON.", + "valid": "Valid", + "invalidLabel": "Invalid", + "readyHint": "Enter valid JSON to test the tool call.", + "empty": "No data" + }, + "api": { + "requestFailed": "Request failed.", + "authExpired": "Your session has expired. Please sign in again.", + "customerSessionNotReady": "The support chat is not ready yet." + }, + "supportHelpWorkbench": { + "title": "Help Center", + "createPage": "New page", + "createChildPage": "New child page", + "createDescription": "The page appears in the help navigation and can also contain content.", + "createAndEdit": "Create and edit", + "creating": "Creating...", + "created": "Page created", + "pageTitle": "Page title", + "searchPlaceholder": "Search pages", + "filterAll": "All", + "filterDraft": "Draft", + "filterPublished": "Published", + "filterHidden": "Hidden", + "sortDisabledDuringSearch": "Sorting is unavailable in search results", + "loading": "Loading pages...", + "empty": "No help pages", + "selectPrompt": "Select a page on the left or create a new page", + "pageSettings": "Page settings", + "parentPage": "Parent page", + "selectParentPage": "Select a parent page", + "rootDirectory": "Root", + "slug": "Slug", + "slugFormatHint": "Use letters, numbers, and hyphens (-) only.", + "slugChangeWarning": "Changing the slug breaks the previous URL. Update any external references.", + "status": "Status", + "selectStatus": "Select a status", + "statusDraft": "Draft", + "statusPublished": "Published", + "statusHidden": "Hidden", + "summary": "Page summary", + "summaryPlaceholder": "Used in help navigation, recommendations, and search results", + "contentPlaceholder": "Write the page content", + "save": "Save", + "saving": "Saving...", + "saved": "Page saved", + "settingsSaved": "Page settings saved", + "savedState": "All changes saved", + "unsaved": "Unsaved changes", + "unsavedLeaveConfirm": "This page has unsaved changes. Leave anyway?", + "unsavedLeaveTitle": "Discard unsaved changes?", + "discardChanges": "Discard changes", + "continueEditing": "Continue editing", + "publishedSaveWarning": "Saving a published page updates the public content immediately", + "saveBeforeStatusChange": "Save your changes before changing the publication status", + "publishedAction": "Publish", + "publishedConfirmTitle": "Publish “{title}”?", + "publishedConfirmDescription": "The page will become publicly available immediately.", + "publishedSuccess": "Page published", + "draftAction": "Withdraw", + "draftConfirmTitle": "Withdraw “{title}”?", + "draftConfirmDescription": "The page will no longer be available from the public help center.", + "draftSuccess": "Page withdrawn to draft", + "withdrawAction": "Withdraw", + "hiddenAction": "Hide", + "hiddenConfirmTitle": "Hide “{title}”?", + "hiddenConfirmDescription": "The page will no longer be publicly available, but its content will remain in the admin.", + "hiddenSuccess": "Page hidden", + "openPublicPage": "Open public page", + "collapse": "Collapse child pages", + "expand": "Expand child pages", + "dragPage": "Drag page “{title}” to reorder", + "dragDisabled": "Sorting is unavailable in the current state", + "sameLevelOnly": "Pages can only be reordered under the same parent", + "sortSaved": "Page order updated", + "pageActions": "Page actions: {title}", + "deletePage": "Delete page", + "deleteConfirm": "Delete “{title}”? Pages with children cannot be deleted.", + "deleteConfirmTitle": "Delete “{title}”?", + "deleted": "Page deleted", + "cancel": "Cancel" + }, + "editor": { + "placeholder": "Enter content...", + "undo": "Undo", + "redo": "Redo", + "bold": "Bold", + "underline": "Underline", + "italic": "Italic", + "strike": "Strikethrough", + "heading1": "Heading 1", + "heading2": "Heading 2", + "inlineCode": "Inline Code", + "codeBlock": "Code Block", + "bulletList": "Bulleted List", + "orderedList": "Numbered List", + "quote": "Quote", + "link": "Link", + "image": "Image", + "linkText": "link text", + "promptLinkUrl": "Enter the link URL", + "fullscreen": "Fullscreen", + "exitFullscreen": "Exit fullscreen", + "previewOnly": "Preview Only", + "modeLabelMarkdown": "Markdown", + "modeLabelRichText": "Rich Text", + "modeSwitchTitle": "Switch to {mode}?", + "modeSwitchDescription": "Rich Text content will be converted to Markdown. Complex styles, embedded content, or some structure may not be preserved.", + "modeSwitchAction": "Convert and switch", + "modeSwitchCancel": "Keep current mode", + "modeSwitchLabel": "Editor mode" + }, + "locale": { + "zh-CN": "简体中文", + "en-US": "English" + }, + "auth": { + "checkingSession": "Checking your session", + "syncingProfile": "Syncing your admin profile and permissions", + "loginSuccess": "Signed in. Taking you to the dashboard.", + "loginFailed": "Sign-in failed", + "loadingOptions": "Đang tải cấu hình...", + "optionsLoadFailed": "Không thể tải cấu hình đăng nhập", + "welcome": "Chào mừng trở lại", + "loginDescription": "Đăng nhập vào hệ sinh thái {brand}", + "username": "Tên đăng nhập / Email", + "usernamePlaceholder": "Username or email", + "password": "Mật khẩu", + "passwordPlaceholder": "Enter your password", + "forgotPassword": "Forgot your password?", + "signingIn": "Đang đăng nhập...", + "signIn": "Đăng nhập", + "continueWith": "Or continue with", + "wxworkSignIn": "Sign in with WeCom", + "oidcSignIn": "Sign in with DOS ID", + "noAccount": "Don't have an account?", + "signUp": "Sign up", + "termsPrefix": "By clicking continue, you agree to our", + "termsOfService": "Terms of Service", + "termsJoiner": "and", + "privacyPolicy": "Privacy Policy", + "imageAlt": "Image", + "wxworkMissingTicket": "The WeCom sign-in ticket is missing.", + "wxworkFailed": "WeCom sign-in failed.", + "wxworkSigningIn": "Signing in with WeCom", + "oidcMissingTicket": "The DOS ID sign-in ticket is missing.", + "oidcFailed": "DOS ID sign-in failed.", + "oidcSigningIn": "Signing in with DOS ID", + "checkingTicket": "Verifying your sign-in ticket and opening the dashboard.", + "loginWithSSO": "Đăng nhập bằng DOS ID" + }, + "legal": { + "terms": { + "title": "Terms of Service", + "description": "These terms describe the basic rules for accessing and using this system. This is general product text and may be replaced by formal legal terms from the service provider.", + "updatedAt": "Last updated: 2026-05-30", + "relatedLabel": "Also read", + "relatedLink": "Privacy Policy", + "sections": [ + { + "title": "1. Service Description", + "body": "This system helps support teams manage conversations, tickets, knowledge bases, AI capabilities, and related operational data. You may access and use it only within your authorized business scope." + }, + { + "title": "2. Accounts and Access", + "body": "You are responsible for protecting your account, password, tokens, and third-party sign-in credentials. Activity caused by shared, leaked, or misused credentials may be treated as activity by the account holder." + }, + { + "title": "3. Acceptable Use", + "body": "You must not use the system for unlawful activity, rights infringement, service disruption, privilege bypassing, malicious code, unrelated data, or unauthorized sensitive information." + }, + { + "title": "4. Data and Content Responsibility", + "body": "Content you create, upload, sync, or process in the system should be lawful, accurate, and aligned with internal management requirements. Necessary activity records may be retained for security, audit, operations, and compliance." + }, + { + "title": "5. Service Changes and Availability", + "body": "System features may change due to product iteration, maintenance, security fixes, or third-party service changes. The service provider aims to keep the service stable but does not promise that any feature will remain unchanged or uninterrupted." + }, + { + "title": "6. Disclaimer", + "body": "AI-generated content, automatic assignment, summaries, and suggestions are assistive only. Final business judgment, response content, and handling outcomes should be confirmed by authorized personnel." + }, + { + "title": "7. Contact and Updates", + "body": "If you have questions about these terms, contact your system administrator or the service provider through published contact channels. The latest version displayed on this page applies after updates." + } + ] + }, + "privacy": { + "title": "Privacy Policy", + "description": "This policy explains the types of information this system may process when providing support administration capabilities, why it is used, and how it is protected. This is general product text and may be replaced by formal legal text from the service provider.", + "updatedAt": "Last updated: 2026-05-30", + "relatedLabel": "Also read", + "relatedLink": "Terms of Service", + "sections": [ + { + "title": "1. Information We Process", + "body": "The system may process account profiles, roles and permissions, sign-in records, activity logs, support conversations, ticket content, customer contact information, knowledge base content, and operational diagnostics." + }, + { + "title": "2. Purposes of Use", + "body": "This information is used for authentication, access control, support collaboration, issue follow-up, quality review, security audit, operations, feature improvement, and applicable compliance requirements." + }, + { + "title": "3. Storage and Protection", + "body": "The system uses access control, audit logging, transport protection, and appropriate data isolation measures to protect information. You should also avoid entering sensitive personal information unless there is a clear business need." + }, + { + "title": "4. Third-Party Services and Integrations", + "body": "When WeCom, OIDC, messaging channels, AI models, or other external integrations are enabled, relevant information may be sent to those services according to configuration. Processing is also governed by integration provider rules and organizational settings." + }, + { + "title": "5. User and Administrator Rights", + "body": "Authorized administrators may query, correct, export, or delete relevant business data according to organizational rules. Other users can contact their system administrator for personal-information requests." + }, + { + "title": "6. Cookies and Local Storage", + "body": "The system may use browser local storage to keep session state, language, theme, and interface preferences in order to maintain access, support security, and improve the user experience." + }, + { + "title": "7. Contact and Updates", + "body": "If you have questions about privacy protection, contact your system administrator or the service provider through published contact channels. The latest version displayed on this page applies after updates." + } + ] + } + }, + "account": { + "passwordRequired": "Enter a new password.", + "confirmPasswordRequired": "Confirm your new password.", + "passwordMismatch": "The passwords do not match.", + "passwordChanged": "Your password has been changed. Please sign in again.", + "changePasswordFailed": "Could not change your password.", + "changePassword": "Change Password", + "changePasswordDescription": "Update the password for your current account. You will need to sign in again after submitting.", + "cancel": "Cancel", + "submitting": "Submitting...", + "confirmChange": "Change Password", + "newPassword": "New Password", + "newPasswordPlaceholder": "Enter a new password", + "confirmPassword": "Confirm Password", + "confirmPasswordPlaceholder": "Re-enter your new password" + }, + "conversation": { + "syncConversationDataFailed": "Could not sync conversation data.", + "syncMessagesFailed": "Could not sync messages.", + "syncConversationListFailed": "Could not sync the conversation list.", + "realtimeConnectFailed": "Could not connect to the realtime service.", + "newMessage": "New Message", + "filterActive": "Active", + "filterPending": "Waiting", + "filterAiServing": "AI Handling", + "filterClosed": "Closed", + "filterPlaceholder": "Filter status", + "loadListFailed": "Could not load conversations.", + "loading": "Loading...", + "empty": "No conversations yet", + "customerFallback": "Customer #{id}", + "noTime": "No time yet", + "noLatestMessage": "No recent messages", + "teamOnDuty": "Team on duty: {name}", + "customerAvatar": "C", + "customerOnline": "Customer online", + "customerOffline": "Customer offline", + "channelNumber": "Channel #{id}", + "linkedCustomer": "Customer linked", + "workbenchTitle": "Conversation Workspace", + "openMenuSelectConversation": "Open the menu to choose a conversation", + "selectConversationFromSidebar": "Select a conversation on the left to start handling messages", + "conversationInfo": "Conversation Info", + "createTicket": "Create Ticket", + "transferConversation": "Transfer Conversation", + "closeConversation": "Close Conversation", + "expandConversationInfo": "Expand conversation info", + "collapseConversationInfo": "Collapse conversation info", + "closeConversationList": "Close conversation list", + "markReadFailed": "Could not mark the conversation as read.", + "emptyMessages": "No messages yet", + "loadOlder": "Load earlier messages", + "noConversationMobile": "Tap the menu in the top left to choose a conversation", + "selectConversationToChat": "Select a conversation on the left to start chatting", + "closedNotice": "This conversation is closed.", + "aiServingNotice": "AI is handling this conversation. Transfer it to a human agent before replying.", + "claim": "Claim", + "sendAttachmentFailed": "Could not send the attachment.", + "customerSender": "Customer", + "agentSender": "Agent", + "messageRecalledHtml": "This message was recalled.", + "messageRecalled": "Recalled", + "customerRead": "Read by customer", + "customerUnread": "Unread by customer", + "recallFailed": "Could not recall the message.", + "recalling": "Recalling...", + "recall": "Recall", + "loadQuickRepliesFailed": "Không thể tải câu trả lời nhanh.", + "replyMode": "Phản hồi khách", + "noteMode": "Ghi chú nội bộ", + "notePlaceholder": "Nhập ghi chú nội bộ (chỉ nhân viên trong đội ngũ nhìn thấy)...", + "addNote": "Thêm ghi chú", + "internalNoteBadge": "Ghi chú nội bộ", + "tagRemoved": "Conversation tag removed.", + "tagAdded": "Conversation tag added.", + "tagUpdateFailed": "Could not update conversation tags.", + "editTags": "Edit conversation tags", + "edit": "Edit", + "searchTags": "Search tags", + "loadingTags": "Loading tags...", + "emptyTags": "No tags available", + "tagGroup": "Tags", + "unlinkedCustomerTitle": "No CRM Customer Linked", + "unlinkedCustomerDescription": "This conversation is not linked to a customer profile yet. Link one to manage company and contact details here.", + "linkOrCreateCustomer": "Link or Create Customer", + "missingCustomerTitle": "Customer deleted or unavailable", + "missingCustomerDescription": "The customer profile linked to this conversation is no longer available. Link an existing customer again, or create a new one and attach it to this conversation.", + "relinkOrCreateCustomer": "Relink or Create Customer", + "conversationOwner": "Conversation Ownership", + "conversationId": "Conversation ID", + "channelId": "Channel ID", + "customerId": "Customer ID", + "selectConversationForInfo": "Select a conversation to view its details", + "selectSidebarConversationForInfo": "Select a conversation on the left to view its details", + "loadTagsFailed": "Could not load tags.", + "conversationTags": "Conversation Tags", + "noConversationTags": "No tags set for this conversation", + "loadCustomerFailed": "Could not load customer details.", + "loadingCustomer": "Loading customer details...", + "unnamedCustomer": "Unnamed customer", + "customerProfileEmpty": "A customer profile is linked, but its basic details are still empty. Click Edit to complete the profile.", + "lastActive": "Last Active", + "remark": "Notes", + "createdAt": "Created", + "updatedAt": "Updated", + "noContacts": "No contact methods yet", + "primaryContactBadge": "Primary", + "verifiedContactBadge": "Verified", + "companyUnavailable": "Company details could not be loaded, or the company was deleted.", + "saved": "Saved.", + "saveFailed": "Could not save.", + "loadTicketsFailed": "Could not load linked tickets.", + "relatedTickets": "Linked Tickets", + "loadingTickets": "Loading tickets...", + "noRelatedTickets": "No tickets linked to this conversation", + "companyNameRequired": "Enter a company name.", + "editCompany": "Edit Company", + "companyName": "Company Name", + "companyCode": "Company Code", + "cancel": "Cancel", + "saving": "Saving...", + "save": "Save", + "genderMale": "Male", + "genderFemale": "Female", + "contactMobile": "Mobile", + "contactEmail": "Email", + "contactOther": "Other", + "loadHistoryFailed": "Could not load earlier messages.", + "sendMessageFailed": "Could not send the message.", + "editorPlaceholder": "Type a message. Press Enter to send, Shift + Enter for a new line.", + "imageUploading": "Image is uploading", + "sendImage": "Send image", + "attachmentUploading": "Attachment is uploading", + "sendAttachment": "Send attachment", + "searchQuickReplies": "Search quick replies", + "emptyQuickReplies": "No quick replies yet", + "enterToSend": "Enter to send", + "send": "Send", + "uploading": "Uploading...", + "claimRequiresSignIn": "Your session has expired. Please sign in again.", + "claimReason": "Claim conversation", + "claimSuccess": "Conversation claimed.", + "claimFailed": "Could not claim the conversation.", + "claimTitle": "Claim Conversation", + "claimConfirmPrefix": "Claim \"", + "claimConfirmSuffix": "\"? It will move to your conversations.", + "claimCurrent": "Claim the current conversation?", + "customerFallbackPrefix": "Customer #", + "claiming": "Claiming...", + "confirmClaim": "Claim" + }, + "supportChat": { + "title": "Support", + "agentLabel": "Support", + "customerSelf": "You", + "customerFallback": "G", + "newMessage": "New message", + "justNow": "Just now", + "todayAt": "Today {time}", + "loadingOlder": "Loading...", + "loadOlder": "Load earlier messages", + "emptyPrompt": "Describe your issue and we will help as soon as possible.", + "agentRead": "Seen by support", + "agentUnread": "Not seen by support", + "imageSummary": "[Image]", + "attachmentSummary": "[Attachment]", + "messageSummary": "[Message]", + "closedTitle": "Conversation ended", + "closedDescription": "This support conversation has been closed. Thanks for chatting with us.", + "connecting": "Connecting", + "connected": "Online support", + "disconnected": "Disconnected", + "retry": "Reconnect", + "moreActions": "More actions", + "minimize": "Minimize window", + "maximize": "Maximize", + "maximizeWindow": "Maximize chat window", + "restoreWindow": "Restore window", + "closeWindow": "Close window", + "closeChatWindow": "Close chat window", + "closeDialogTitle": "End this conversation?", + "closeDialogDescription": "After ending the conversation, support agents will no longer be able to view this message history. Start a new chat if you need more help.", + "continueConversation": "Continue Chat", + "closing": "Ending...", + "confirmClose": "End Conversation", + "initFailed": "Could not start the chat.", + "loadMessagesFailed": "Could not load messages.", + "syncMessagesFailed": "Could not sync messages.", + "loadHistoryFailed": "Could not load earlier messages.", + "sendMessageFailed": "Could not send the message.", + "uploadImageFailed": "Could not upload the image.", + "sendAttachmentFailed": "Could not send the attachment.", + "closeConversationFailed": "Could not close the conversation.", + "refreshFailed": "Could not refresh the chat." + }, + "dashboardHome": { + "rangeToday": "Today", + "range7d": "Last 7 days", + "range30d": "Last 30 days", + "loadFailed": "Could not load the dashboard overview.", + "title": "Dashboard", + "description": "Track support conversations, tickets, agent capacity, and AI operations.", + "updatedAt": ", updated at {time}", + "refresh": "Refresh", + "conversationTrend": "Conversation Trend", + "conversationTrendDescription": "Track new and closed conversations to spot rising support pressure early.", + "enabledAiAgents": "Active AI Agents", + "enabledChannels": "Active Channels", + "todayKnowledgeRetrieves": "Knowledge Searches Today", + "todayKnowledgeRetrieveFailRate": "Search Failure Rate Today", + "todayAgentRunFailCount": "Agent Run Failures Today", + "todayAiHandoffCount": "AI-to-Human Handoffs Today", + "empty": "No dashboard overview data yet.", + "summaryTodayNewConversations": "New Conversations Today", + "summaryTodayNewConversationsDescription": "New customer inquiries created today.", + "summaryProcessingConversations": "Currently Active", + "summaryProcessingConversationsDescription": "Conversations currently handled by AI or human agents.", + "summaryPendingDispatchConversations": "Waiting for Assignment", + "summaryPendingDispatchConversationsDescription": "Conversations still waiting in the assignment queue.", + "summaryOnlineAgents": "Online Agents", + "summaryOnlineAgentsDescription": "Agents with an active heartbeat in the last 15 minutes.", + "summaryAiServiceRate": "AI Coverage", + "summaryAiServiceRateDescription": "Share of active conversations where AI is helping.", + "chartNew": "New", + "chartClosed": "Closed", + "chartCount": "Count", + "statusDistribution": "Status Distribution", + "statusDistributionDescription": "Current count by conversation status.", + "statusAiServing": "AI Handling", + "statusPending": "Queued", + "statusActive": "Active", + "statusClosed": "Closed", + "teamLoad": "Team Load", + "teamLoadDescription": "Online {online}, busy {busy}, offline {offline}", + "noTeamData": "No team load data yet.", + "scheduled": "On Schedule", + "notScheduled": "No Current Schedule", + "teamStats": "Total {total}, online {online}, busy {busy}, offline {offline}", + "load": "Load {processing}/{capacity}", + "waiting": "Waiting", + "processing": "Active", + "capacity": "Capacity", + "busyAgents": "Busy Agents", + "riskAlerts": "Risk Alerts", + "riskAlertsDescription": "Prioritize issues that can affect support efficiency or AI reliability.", + "noRiskTitle": "No high-priority risks right now", + "noRiskDescription": "The dashboard will keep monitoring conversation backlogs, schedules, and AI configuration issues." + }, + "placeholder": { + "nextSteps": "Recommended Next Steps", + "status": "Status", + "statusDescription": "This module currently has a page scaffold. It is ready for real APIs, form dialogs, and list queries.", + "nextPhase": "Start Next Phase" + }, + "help": { + "title": "Help Center Scaffold", + "description": "The help center will collect product guidance, permission rules, channel integration workflows, and FAQs.", + "step1": "Collect project development standards and admin usage guides.", + "step2": "Add third-party integration flows and configuration checklists.", + "step3": "Connect Markdown or a documentation center later." + }, + "settings": { + "title": "System Settings Scaffold", + "description": "System settings will manage authentication, uploads, basic site information, and runtime policies.", + "step1": "Add APIs for reading and saving system settings.", + "step2": "Split settings into security, storage, and sign-in policy sections.", + "step3": "Add masked display and confirmation for sensitive settings." + }, + "status": { + "all": "All statuses", + "ok": "Enabled", + "disabled": "Disabled", + "deleted": "Deleted" + }, + "channel": { + "allTypes": "Tất cả loại kênh", + "typeWeb": "Web Chat Widget", + "typeEmail": "Kênh Email Hỗ trợ", + "typeTelegram": "Telegram Bot", + "typeZaloOa": "Zalo Official Account", + "typeDiscord": "Discord", + "discordGuildId": "ID server (Guild)", + "discordGuildName": "Tên server", + "discordBotToken": "Bot Token", + "discordSetupTitle": "Thiết lập Discord Bot", + "discordSetupDescription": "Kết nối Discord server của công ty với Crove Desk. Tin nhắn kênh và DM sẽ tạo Ticket và nhận phản hồi từ AI Agent.", + "typeWechatMp": "WeChat Official Account", + "typeWxworkKf": "WeCom Customer Service", + "emailAddress": "Địa chỉ Email Hỗ trợ", + "senderName": "Tên Người gửi Hiển thị", + "emailProvider": "Dịch vụ Gửi Email", + "emailProviderDefault": "Mặc định hệ thống (.env / config.yaml)", + "emailProviderSmtp": "Máy chủ SMTP tùy chỉnh", + "emailProviderBrevo": "Brevo API", + "emailProviderSendGrid": "SendGrid API", + "emailProviderResend": "Resend API", + "emailProviderPostmark": "Postmark API", + "emailProviderMailgun": "Mailgun API", + "emailApiKey": "API Key", + "emailAutoConnectTitle": "Tự động Nhận & Xử lý Email Khách hàng", + "emailAutoConnectDescription": "Forward hoặc cấu hình webhook email gửi đến hộp thư hỗ trợ về Crove Desk để tự động tạo Ticket và kích hoạt AI Agent phản hồi.", + "configEmailDescription": "Cấu hình tiếp nhận email qua Inbound Webhook và gửi phản hồi qua SMTP, Brevo, SendGrid, Resend, Postmark hoặc Mailgun.", + "forwardingAddressLabel": "Địa chỉ Chuyển tiếp Tự động (dùng cấu hình Auto-Forwarding trên Gmail / Outlook):", + "inboundWebhookUrl": "Endpoint Nhận Inbound Webhook", + "botToken": "Telegram Bot Token", + "botTokenRequired": "Telegram Bot Token is required", + "botUsername": "Bot Username", + "webhookSecret": "Webhook Secret Token", + "telegramAutoConnectTitle": "Automatic Telegram Bot Connection", + "telegramAutoConnectDescription": "Enter your Bot Token from @BotFather. Crove Desk automatically connects to Telegram and syncs inbound/outbound customer conversations seamlessly.", + "zaloAccessToken": "Zalo OA Access Token", + "zaloOaId": "OA ID", + "zaloAppId": "App ID", + "zaloAutoConnectTitle": "Zalo Official Account Connection", + "zaloAutoConnectDescription": "Connect your Zalo Official Account using the Access Token to automatically receive customer messages and dispatch replies.", + "loadFailed": "Could not load channels.", + "created": "Channel created: {name}", + "updated": "Channel updated: {name}", + "saveFailed": "Could not save the channel.", + "statusEnabled": "Enabled: {name}", + "statusDisabled": "Disabled: {name}", + "statusUpdateFailed": "Could not update the channel status.", + "deleted": "Channel deleted: {name}", + "deleteFailed": "Could not delete the channel.", + "refresh": "Refresh", + "new": "New Channel", + "filterName": "Filter by channel name", + "filterChannelId": "Filter by channelId", + "searchType": "Search channel types", + "emptyType": "No channel types found", + "searchStatus": "Search statuses", + "emptyStatus": "No statuses found", + "query": "Search", + "columnChannel": "Channel", + "columnType": "Type", + "columnAgent": "AI Agent", + "columnStatus": "Status", + "columnActions": "Actions", + "loading": "Loading channels...", + "empty": "No channels yet", + "toggleStatus": "Toggle status for {name}", + "edit": "Edit", + "moreActions": "More actions for {name}", + "delete": "Delete", + "typeRequired": "Select a channel type.", + "agentRequired": "Select an AI Agent.", + "searchAiAgent": "Search AI Agents", + "emptyAiAgent": "No AI Agents found", + "nameRequired": "Enter a channel name.", + "wxworkAccountRequired": "Select a WeCom customer service account.", + "positionRight": "Bottom right", + "positionLeft": "Bottom left", + "defaultTitleWeb": "Support", + "defaultTitleWechat": "WeChat Support", + "defaultSubtitle": "How can we help?", + "loadWxworkAccountsFailed": "Could not load WeCom customer service accounts.", + "resetSecretConfirm": "Resetting this secret will invalidate old userTokens after they expire. Continue?", + "resetSecretSuccess": "User JWT Secret reset.", + "resetSecretFailed": "Could not reset the User JWT Secret.", + "copySecretSuccess": "User JWT Secret copied.", + "copyFailed": "Copy failed.", + "editTitle": "Edit Channel", + "createTitle": "New Channel", + "cancel": "Cancel", + "saving": "Saving...", + "save": "Save", + "loadingDetail": "Loading...", + "name": "Channel Name", + "channelType": "Channel Type", + "selectChannelType": "Select a channel type", + "searchChannelType": "Search channel types", + "emptyChannelType": "No channel types found", + "configTitle": "Channel Settings", + "configWxworkDescription": "Configure the WeCom customer service account used to match callbacks and send outbound messages.", + "configWechatDescription": "Configure the display settings for the WeChat menu chat page.", + "configWebDescription": "Configure how the web support widget appears on your site.", + "wxworkAccount": "WeCom Customer Service Account", + "loadingWxworkAccount": "Loading service accounts", + "selectWxworkAccount": "Select a service account", + "searchWxworkAccount": "Search service accounts", + "emptyWxworkAccount": "No WeCom customer service accounts found", + "widgetTitle": "Widget Title", + "widgetSubtitle": "Widget Subtitle", + "themeColor": "Theme Color", + "mountPosition": "Widget Position", + "selectMountPosition": "Select a position", + "searchMountPosition": "Search positions", + "emptyMountPosition": "No positions found", + "widgetWidth": "Widget Width", + "userJwtSecret": "User JWT Secret", + "userJwtSecretDescription": "Your business system uses this secret to sign userTokens. After resetting it, update your business system configuration as well.", + "secretAfterSave": "Save the channel to generate a User JWT Secret.", + "copy": "Copy", + "reset": "Reset", + "remark": "Notes", + "webAccessInfo": "Web Integration", + "webAccessReady": "Copy the link or embed code to connect this web channel.", + "webAccessPending": "Save the channel to generate an integration link.", + "newChannelPending": "This is a new channel. A channelId will be generated after you save it.", + "directAccessUrl": "Direct Chat Link", + "copyLink": "Copy link", + "openLink": "Open link", + "copiedAccessLink": "Integration link copied.", + "embeddedSnippet": "Embed Code", + "copiedSnippet": "Embed code copied.", + "copyCode": "Copy Code", + "accessGuide": "Integration Guide", + "webGuide1": "1. Make sure this channel is enabled.", + "webGuide2": "2. Paste the embed code before the closing body tag on your website.", + "webGuide3": "3. Publish the site and refresh the page. The support entry will appear with this channel's settings.", + "webGuide4": "4. For standalone pages or QR codes, use the direct chat link.", + "openTestPage": "Open Test Page", + "wechatAccessInfo": "WeChat Official Account Integration", + "wechatAccessReady": "Add this link to a WeChat custom menu so users can open the support chat directly.", + "wechatAccessPending": "Save the channel to generate the WeChat menu link.", + "wechatMenuUrl": "WeChat Menu Link", + "copiedWechatMenuUrl": "WeChat menu link copied.", + "wechatGuide2": "2. Set your WeChat custom menu URL to the link above.", + "wechatGuide3": "3. Users will open the support chat directly after tapping the menu item." + }, + "upload": { + "imagePlaceholder": "Upload image", + "chooseImage": "Choose an image file.", + "imageTooLarge": "Image size must be under {maxSize} MB.", + "imageUploaded": "Image uploaded.", + "imageUploadFailed": "Could not upload the image.", + "replaceImage": "Replace image", + "uploadedImage": "Uploaded image", + "uploading": "Uploading...", + "deleteImage": "Delete image" + }, + "lightbox": { + "providerError": "useImageLightbox must be used within ImageLightboxProvider", + "loadFailed": "Could not load the image.", + "openInNewTab": "Open in new tab", + "previewImage": "Preview image", + "imagePreview": "Image preview", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "rotateLeft": "Rotate left", + "rotateRight": "Rotate right", + "reset": "Reset zoom, position, and rotation", + "previous": "View previous image", + "next": "View next image", + "position": "{current} / {total}", + "close": "Close", + "help": "Use the mouse wheel or pinch gesture to zoom. Drag to pan the image. Use the toolbar to rotate left or right.", + "galleryHelp": "Use the left and right arrow keys to switch images. Use the mouse wheel or pinch gesture to zoom, and drag to pan." + }, + "companyPicker": { + "placeholder": "Select a company", + "loadFailed": "Could not load companies.", + "fallback": "Company #{id}", + "created": "Company created: {name}", + "createFailed": "Could not create the company.", + "searchPlaceholder": "Search company name", + "loading": "Loading...", + "empty": "No matching companies found", + "results": "Search Results", + "none": "No company", + "actions": "Actions", + "create": "Create company \"{name}\"" + }, + "company": { + "loadFailed": "Could not load companies.", + "updated": "Company updated: {name}", + "created": "Company created: {name}", + "saveFailed": "Could not save the company.", + "enabled": "Enabled: {name}", + "disabled": "Disabled: {name}", + "statusUpdateFailed": "Could not update the status.", + "deleted": "Company deleted: {name}", + "deleteFailed": "Could not delete the company.", + "refresh": "Refresh", + "new": "New Company", + "filterName": "Filter by company name", + "filterCode": "Filter by company code", + "query": "Search", + "columnName": "Company Name", + "columnCode": "Company Code", + "columnCustomerCount": "Customers", + "columnStatus": "Status", + "columnRemark": "Notes", + "columnActions": "Actions", + "unknownStatus": "Unknown", + "loading": "Loading companies...", + "empty": "No companies yet", + "edit": "Edit", + "moreActions": "More actions for {name}", + "processing": "Working...", + "enable": "Enable", + "disable": "Disable", + "delete": "Delete", + "editTitle": "Edit Company", + "createTitle": "New Company", + "cancel": "Cancel", + "saving": "Saving...", + "save": "Save", + "create": "Create", + "loadingDetail": "Loading...", + "nameRequired": "Enter a company name.", + "namePlaceholder": "Enter company name", + "optional": "Optional", + "remarkPlaceholder": "Optional" + }, + "customerForm": { + "nameRequired": "Enter a customer name.", + "genderRequired": "Select a gender.", + "companyRequired": "Select a company.", + "sectionCustomer": "Customer Details", + "name": "Customer Name", + "namePlaceholder": "Enter customer name", + "gender": "Gender", + "company": "Company", + "remark": "Notes", + "optional": "Optional", + "sectionContacts": "Contact Methods", + "type": "Type", + "contact": "Contact", + "primary": "Primary", + "actions": "Actions", + "emailPlaceholder": "Email", + "contactPlaceholder": "Phone / account", + "primaryContact": "Primary contact", + "setPrimary": "Set as primary contact", + "deleteContact": "Delete this contact method", + "addContact": "Add Contact Method", + "loading": "Loading...", + "genderUnknown": "Unknown", + "genderMale": "Male", + "genderFemale": "Female", + "contactMobile": "Mobile", + "contactEmail": "Email", + "contactOther": "Other", + "editTitle": "Edit Customer", + "createTitle": "New Customer", + "cancel": "Cancel", + "saving": "Saving...", + "save": "Save", + "create": "Create" + }, + "customer": { + "allGenders": "All genders", + "allCompanies": "All companies", + "select": "Select", + "loadFailed": "Could not load customers.", + "updated": "Customer updated: {name}", + "created": "Customer created: {name}", + "saveFailed": "Could not save the customer.", + "enabled": "Enabled: {name}", + "disabled": "Disabled: {name}", + "statusUpdateFailed": "Could not update the status.", + "deleted": "Customer deleted: {name}", + "deleteFailed": "Could not delete the customer.", + "new": "New", + "refresh": "Refresh", + "keywordPlaceholder": "Name, phone, email, company, or contact", + "searchCompany": "Search companies", + "query": "Search", + "columnName": "Customer Name", + "columnGender": "Gender", + "columnCompany": "Company", + "columnMobile": "Mobile", + "columnEmail": "Email", + "columnStatus": "Status", + "columnActions": "Actions", + "loading": "Loading customers...", + "empty": "No customers yet", + "edit": "Edit", + "moreActions": "More actions for {name}", + "processing": "Working...", + "enable": "Enable", + "disable": "Disable", + "delete": "Delete" + }, + "customerLink": { + "keywordRequired": "Enter a keyword, such as a name, phone, email, company, or contact.", + "noMatch": "No matching customers found. You can fill out a new customer below.", + "searchFailed": "Search failed.", + "selected": "Customer selected: {name}", + "linked": "Customer linked.", + "linkFailed": "Could not link the customer.", + "createdAndLinkedConversation": "Customer created and linked to this conversation.", + "createdAndLinkedTicket": "Customer created and linked to this ticket.", + "created": "Customer created.", + "saveFailed": "Could not save the customer.", + "descriptionPrefix": "Search existing customers first. ", + "descriptionLinkConversation": "Select a customer to link it to this conversation. ", + "descriptionLinkTicket": "Select a customer to link it to this ticket. ", + "descriptionNoContext": "Without a linked context, this will only create or select a customer. ", + "descriptionCreatePrefix": "If there are no results, fill out a new customer below", + "descriptionCreateConversation": "; it will be linked to the conversation after saving.", + "descriptionCreateTicket": "; it will be linked to the ticket after saving.", + "descriptionCreateNoContext": ".", + "title": "Link or Create Customer", + "close": "Close", + "submitting": "Submitting...", + "createAndLinkConversation": "Create and Link Conversation", + "createAndLinkTicket": "Create and Link Ticket", + "createCustomer": "Create Customer", + "searchPlaceholder": "Name / phone / email / company / contact", + "searching": "Searching...", + "search": "Search", + "fallbackName": "Customer #{id}", + "processing": "Working...", + "link": "Link", + "select": "Select", + "collapseCreate": "Hide new customer form", + "showCreate": "Not found? Fill out a new customer" + }, + "conversationAction": { + "closeReasonRequired": "Enter a close reason.", + "conversationMissing": "Conversation not found.", + "closed": "Conversation closed: #{id}", + "closeFailed": "Could not close the conversation.", + "closeTitle": "Close Conversation", + "closeReason": "Close Reason", + "closeReasonPlaceholder": "Enter the reason. It will be added to the activity log.", + "cancel": "Cancel", + "closing": "Closing...", + "confirmClose": "Close Conversation", + "targetAgentRequired": "Select a target agent.", + "agentFallback": "Agent #{id}", + "loadAgentsFailed": "Could not load agents.", + "assigned": "Conversation assigned: #{id}", + "transferred": "Conversation transferred: #{id}", + "assignFailed": "Could not assign the conversation.", + "transferFailed": "Could not transfer the conversation.", + "assignTitle": "Assign Conversation", + "transferTitle": "Transfer Conversation", + "targetAgent": "Target Agent", + "loading": "Loading...", + "selectTargetAgent": "Select target agent", + "searchAgent": "Search agents", + "emptyAgents": "No agents available", + "assignNote": "Assignment Note", + "transferReason": "Transfer Reason", + "assignPlaceholder": "Add a note for follow-up tracking.", + "transferPlaceholder": "Enter the transfer reason for follow-up tracking.", + "assigning": "Assigning...", + "transferring": "Transferring...", + "confirmAssign": "Assign", + "confirmTransfer": "Transfer" + }, + "conversationMonitor": { + "allStatuses": "All statuses", + "unknown": "Unknown", + "serviceAi": "AI handling", + "serviceHuman": "Human handling", + "serviceAiFirst": "AI first", + "serviceUndefined": "Undefined", + "allTags": "All tags", + "allAssignees": "All assignees", + "allTeams": "All teams", + "loadListFailed": "Could not load conversations.", + "loadFiltersFailed": "Could not load filters.", + "realtimeConnectFailed": "Could not connect to the realtime service.", + "loadDetailFailed": "Could not load conversation details.", + "loadMoreMessagesFailed": "Could not load more messages.", + "markedRead": "Marked as read: {name}", + "markReadFailed": "Could not mark as read.", + "dispatchTriggered": "Automatic assignment started: {name}", + "dispatchFailed": "Automatic assignment failed.", + "refresh": "Refresh", + "keywordPlaceholder": "Filter by subject or summary", + "selectTag": "Select tag", + "searchTagPath": "Search tag path", + "emptyTags": "No matching tags", + "selectAssignee": "Select assignee", + "searchAssignee": "Search assignees", + "emptyAssignees": "No matching assignees", + "selectTeam": "Select team", + "searchTeam": "Search teams", + "emptyTeams": "No matching teams", + "query": "Search", + "columnConversation": "Conversation", + "columnStatus": "Status", + "columnServiceMode": "Service Mode", + "columnAssignee": "Current Agent", + "columnUnread": "Unread", + "columnLastActive": "Last Active", + "columnActions": "Actions", + "customerFallback": "Customer #{id}", + "channelIdValue": "Channel ID: {id}", + "noLatestSummary": "No recent message summary", + "unreadSummary": "Agent {agent} / Customer {customer}", + "view": "View", + "moreActions": "More actions for {name}", + "processing": "Working...", + "assign": "Assign Conversation", + "retryDispatch": "Retry Assignment", + "markRead": "Mark as Read", + "transfer": "Transfer Conversation", + "close": "Close Conversation", + "loadingRows": "Loading conversations...", + "emptyRows": "No matching conversations", + "detailFallbackTitle": "Conversation Details", + "lastActivePrefix": "Last active: {time}", + "noConversationInfo": "No conversation details yet", + "loadingDetail": "Loading conversation details...", + "currentAssignee": "Current Agent", + "agentUnread": "Agent Unread", + "customerUnread": "Customer Unread", + "closedAt": "Closed At", + "participants": "Participants", + "participantCount": "{count} people", + "participantIdentity": "ID: {value}", + "participantJoinedAt": "Joined: {time}", + "emptyParticipants": "No participant details yet", + "loadingOlder": "Loading earlier messages...", + "loadOlderHint": "Scroll up to load earlier messages", + "messageRecalled": "Recalled", + "messageRecalledBody": "This message was recalled.", + "readStatus": "Agent {agent} / Customer {customer}", + "read": "Read", + "unread": "Unread", + "emptyMessages": "No message history yet", + "emptyDetail": "No conversation details to show", + "imageFallback": "[Image]", + "messageImageAlt": "Message image", + "senderAgent": "Agent", + "senderCustomer": "Customer", + "senderSystem": "System" + }, + "user": { + "loadFailed": "Could not load users.", + "loadRoleAssignFailed": "Could not load role assignment data.", + "created": "User created: {username}", + "createFailed": "Could not create the user.", + "updated": "Updated {username}", + "fallbackUser": "user", + "updateFailed": "Could not update the user.", + "rolesUpdated": "Roles updated for {username}", + "saveRolesFailed": "Could not save role assignments.", + "passwordReset": "Password reset for {username}", + "resetPasswordFailed": "Could not reset the password.", + "statusUpdated": "{username} {status}", + "statusUpdateFailed": "Could not update the status.", + "addUser": "Add User", + "filterUsername": "Filter by username", + "query": "Search", + "columnUser": "User", + "columnRoles": "Roles", + "columnStatus": "Status", + "columnLastLogin": "Last Login", + "columnContact": "Contact", + "columnActions": "Actions", + "unassigned": "Unassigned", + "enabled": "Enabled", + "disabled": "Disabled", + "system": "System", + "edit": "Edit", + "moreActions": "More actions for {username}", + "processing": "Working...", + "assignRoles": "Assign Roles", + "resetPassword": "Reset Password", + "loadingRows": "Loading users...", + "emptyRows": "No matching users", + "nicknameRequired": "Enter a nickname.", + "usernameRequired": "Enter a username.", + "avatarInvalid": "Invalid avatar.", + "mobileInvalid": "Enter a valid phone number.", + "emailInvalid": "Enter a valid email address.", + "editTitle": "Edit User", + "currentUser": "Current user: {username}", + "loading": "Loading...", + "nickname": "Nickname", + "nicknamePlaceholder": "Enter nickname", + "avatar": "Avatar", + "avatarPlaceholder": "Upload avatar", + "mobile": "Mobile", + "mobilePlaceholder": "Enter mobile number", + "email": "Email", + "emailPlaceholder": "Enter email", + "saving": "Saving...", + "saveEdit": "Save Changes", + "cancel": "Cancel", + "createTitle": "Add User", + "createDescription": "The system will generate an initial password after submission. It will only be shown once.", + "username": "Username", + "usernamePlaceholder": "Login name, required", + "nicknameCreatePlaceholder": "Optional, defaults to username", + "avatarCreatePlaceholder": "Upload avatar", + "optional": "Optional", + "remark": "Notes", + "rolesOptional": "Roles (optional)", + "searchRoles": "Search roles", + "loadingRoles": "Loading roles...", + "disabledBadge": "Disabled", + "emptyRoles": "No roles yet", + "creating": "Creating...", + "createUser": "Create User", + "copied": "Password copied.", + "copyFailed": "Could not copy. Copy it manually.", + "createdTitle": "User Created", + "initialPasswordDescription": "The initial password for {username} has been generated. It will only be shown once, so copy it now and share it securely.", + "initialPassword": "Initial Password", + "copying": "Copying...", + "copyPassword": "Copy Password", + "close": "Close", + "confirmResetTitle": "Reset Password?", + "confirmResetDescription": "A new random password will be generated for {username}, and the user's current sessions will be invalidated.", + "resetting": "Resetting...", + "confirmReset": "Reset Password", + "resetSuccessTitle": "Password Reset", + "resetSuccessDescription": "A new password has been generated for {username}. Copy it now and share it securely.", + "newPassword": "New Password", + "assignedRoles": "Assigned Roles", + "unassignedRoles": "No roles assigned", + "roleList": "Role List", + "searchRoleNameOrCode": "Search role name or code", + "loadingRoleList": "Loading roles...", + "noMatchedRoles": "No matching roles", + "changes": "Changes", + "addedRoles": "Added Roles", + "removedRoles": "Removed Roles", + "noneAdded": "None added", + "noneRemoved": "None removed", + "confirmAssign": "Assign Roles" + }, + "aiAgent": { + "allStatuses": "Tất cả trạng thái", + "enabled": "Đang hoạt động", + "disabled": "Đã tắt", + "loadFailed": "Không thể tải danh sách AI agent.", + "updated": "Đã cập nhật AI agent: {name}", + "created": "Đã tạo AI agent: {name}", + "saveFailed": "Không thể lưu AI agent.", + "statusChanged": "{name} {status}", + "statusUpdateFailed": "Could not update the AI agent status.", + "deleted": "Đã xóa AI agent: {name}", + "deleteFailed": "Could not delete the AI agent.", + "sortUpdated": "AI agent order updated.", + "sortUpdateFailed": "Could not update the order.", + "refresh": "Làm mới", + "new": "Tạo AI Agent", + "filterName": "Lọc theo tên", + "searchStatus": "Search status", + "emptyStatus": "No matching status", + "query": "Tìm kiếm", + "columnAiConfig": "Cấu hình AI", + "columnServiceMode": "Chế độ phục vụ", + "columnKnowledge": "Cơ sở tri thức", + "columnSkills": "Kỹ năng", + "columnCapabilities": "Năng lực", + "columnStatus": "Trạng thái", + "columnActions": "Thao tác", + "dragSort": "Drag to reorder {name}", + "notConfigured": "Not configured", + "ragOnly": "RAG only", + "noMcpServer": "No MCP server linked", + "toggleStatus": "Toggle status for {name}", + "edit": "Edit", + "moreActions": "More actions for {name}", + "processing": "Working...", + "stop": "Disable", + "workflow": "Workflow", + "configure": "Configure", + "delete": "Delete", + "loadingRows": "Loading AI agents...", + "emptyRows": "No AI agents yet", + "serviceAiOnly": "Chỉ dùng AI", + "serviceHumanOnly": "Chỉ dùng Người thật", + "serviceAiFirst": "Ưu tiên AI trước", + "handoffWaitPool": "Send to waiting queue", + "handoffDefaultTeamPool": "Send to default team queue", + "handoffAiHoldAndNotify": "AI handles fallback and notifies agents", + "fallbackNoAnswer": "Thông báo không có câu trả lời", + "fallbackSuggestRetry": "Đề xuất khách hỏi cụ thể hơn", + "nameRequired": "Enter a name.", + "aiConfigRequired": "Select an AI config.", + "serviceModeRequired": "Select a service mode.", + "replyTimeoutInvalid": "Reply timeout must be an integer greater than or equal to 0.", + "handoffModeRequired": "Select a human handoff mode.", + "fallbackModeRequired": "Select a fallback strategy.", + "loadDetailFailed": "Could not load AI agent details.", + "loadAiConfigFailed": "Could not load AI configs.", + "loadTeamsFailed": "Could not load support teams.", + "loadKnowledgeFailed": "Could not load knowledge bases.", + "loadSkillsFailed": "Could not load skills.", + "loadMCPToolsFailed": "Could not load MCP Tools.", + "builtinTools": "Built-in tools", + "ungrouped": "Ungrouped", + "notSelected": "Not selected", + "sectionSetup": "Định danh & Vận hành", + "sectionPersona": "Vai trò & Phản hồi", + "sectionCapability": "Tri thức & Kỹ năng", + "sectionService": "Điều phối & Dự phòng", + "basicDescription": "Cấu hình tên hệ thống, chế độ phục vụ và mô tả nhiệm vụ của Agent.", + "sectionModel": "Mô hình & Phản hồi", + "modelDescription": "Chọn mô hình suy luận AI và thời gian chờ phản hồi.", + "replyTimeout": "Thời gian chờ phản hồi", + "seconds": "giây", + "sectionRole": "Lời nhắc hệ thống (System Prompt)", + "personaDescription": "Định nghĩa vai trò, nhiệm vụ và giới hạn phản hồi của Agent (hỗ trợ Markdown).", + "sectionGreeting": "Lời chào mở đầu", + "greetingDescription": "Tin nhắn chào mừng tự động gửi khi khách hàng bắt đầu phiên chat.", + "sectionKnowledge": "Cơ sở tri thức", + "knowledgeDescription": "Phạm vi tài liệu tri thức Agent có thể truy xuất khi trả lời.", + "sectionSkill": "Kỹ năng (Skills)", + "skillDescription": "Các năng lực và luồng nghiệp vụ tiêu chuẩn Agent có thể kích hoạt.", + "sectionWorkflow": "Quy trình (Workflow)", + "workflowDescription": "Liên kết các phiên bản quy trình đã xuất bản từ Workflow Studio.", + "selectWorkflow": "Chọn quy trình", + "emptyWorkflow": "Chưa có quy trình nào xuất bản", + "sectionMcp": "Công cụ MCP (MCP Tools)", + "mcpDescription": "Chọn các công cụ MCP Tools mà Agent được phép gọi.", + "sectionHandoff": "Chuyển giao cho người thật", + "handoffDescription": "Cấu hình phương thức chuyển tiếp và đội ngũ tiếp nhận khi cần người hỗ trợ.", + "sectionFallback": "Dự phòng khi thiếu dữ liệu", + "fallbackDescription": "Cấu hình chiến lược xử lý và nội dung phản hồi khi AI không đủ căn cứ trả lời.", + "publishAgent": "Xuất bản Agent", + "saveConfig": "Lưu cấu hình", + "publishedNote": "Phiên bản đã xuất bản đang hoạt động; xuất bản lại để áp dụng cấu hình mới", + "unpublishedNote": "Hệ thống sẽ tự động lưu cấu hình trước khi xuất bản", + "readOnly": "Chỉ đọc", + "writeAction": "Ghi dữ liệu (Cần xác nhận)", + "systemDefined": "Hệ thống định nghĩa", + "activeRevision": "Đang áp dụng", + "rollback": "Khôi phục", + "versionHistory": "Lịch sử phiên bản", + "notCreated": "Chưa khởi tạo", + "published": "Đã xuất bản", + "unpublished": "Chưa xuất bản", + "noRevisions": "Chưa có phiên bản Agent nào được xuất bản.", + "createTitle": "Tạo mới AI Agent", + "editTitle": "Chỉnh sửa AI Agent", + "cancel": "Cancel", + "saving": "Saving...", + "save": "Save", + "loading": "Loading...", + "namePrompt": "Please enter an agent name", + "savedActiveNote": "Configuration saved. Currently published revision remains active.", + "savedNote": "AI Agent configuration saved.", + "createdNote": "AI Agent created successfully.", + "publishedSuccess": "AI Agent saved and published successfully.", + "publishFailed": "Failed to save and publish AI Agent.", + "rollbackSuccess": "Rolled back to the selected AI Agent revision.", + "rollbackFailed": "Failed to rollback AI Agent revision.", + "fixedVersion": "Fixed Version #{version}", + "publishStatus": "Publish Status", + "configuredWorkflows": "{count} Workflows configured", + "directAutonomous": "Autonomous reasoning without fixed workflows", + "channelRolloutTitle": "AI Rollout Percentage (%)", + "channelRolloutRestored": "Restored previous channel rollout percentage", + "channelRolloutRestoreFailed": "Failed to restore channel rollout percentage", + "channelRolloutRestoreBtn": "Restore {percent}%", + "agentNotPublishedWarning": "This Agent is not yet published. AI will not auto-reply. Please publish a revision in Agent settings first.", + "agentNotPublishedShort": "Unpublished", + "sectionBasic": "Thông tin cơ bản", + "name": "Tên Agent", + "aiConfig": "Cấu hình AI", + "selectAiConfig": "Chọn cấu hình AI", + "searchAiConfig": "Tìm cấu hình AI", + "emptyAiConfig": "Không có cấu hình AI nào", + "selectServiceMode": "Select service mode", + "searchServiceMode": "Search service modes", + "emptyServiceMode": "No service modes found", + "description": "Mô tả nhiệm vụ", + "welcomeMessage": "Nội dung lời chào", + "systemPrompt": "System Prompt", + "sectionStrategy": "Service Strategy", + "handoffMode": "Phương thức chuyển tiếp", + "selectHandoffMode": "Chọn phương thức chuyển tiếp", + "searchHandoffMode": "Search handoff modes", + "emptyHandoffMode": "No handoff modes found", + "replyTimeoutSeconds": "Thời gian phản hồi (giây)", + "replyTimeoutHelp": "Timeout for async AI replies. Enter 0 to use the system default of 180 seconds.", + "teams": "Đội ngũ tiếp nhận", + "currentHandoffMode": "Current handoff mode: {mode}", + "handoffNeedsTeam": ". This mode requires at least one support team.", + "handoffOnlyWhenNeeded": ". Applies only when human handoff is needed.", + "selectTeam": "Chọn đội ngũ", + "searchTeam": "Search support teams", + "emptyTeam": "Chưa có đội ngũ", + "noTeams": "No support teams configured", + "removeTeam": "Remove team {name}", + "sectionCapabilities": "Capabilities", + "knowledgeTab": "Knowledge", + "skillsTab": "Skills", + "mcpToolsTab": "MCP Tools", + "knowledgeHint": "Select at least one knowledge base. You can adjust priority order.", + "selectKnowledge": "Chọn cơ sở tri thức", + "searchKnowledge": "Tìm cơ sở tri thức", + "emptyKnowledge": "Không có cơ sở tri thức khả dụng", + "add": "Add", + "knowledgeRequired": "Select at least one knowledge base.", + "fallbackMode": "Chiến lược dự phòng", + "selectFallbackMode": "Chọn chiến lược dự phòng", + "searchFallbackMode": "Search fallback strategies", + "emptyFallbackMode": "No fallback strategies found", + "fallbackMessage": "Nội dung phản hồi dự phòng", + "fallbackMessageHint": "When the knowledge base has no strong match, the agent will use this strategy and message.", + "skillsHint": "Use skills for fixed business workflows and multi-step tasks.", + "selectSkill": "Chọn kỹ năng", + "searchSkill": "Search skills", + "emptySkill": "Không có kỹ năng khả dụng", + "noSkillsHint": "Without skills, routing only uses knowledge bases or human handoff.", + "removeSkill": "Remove skill {name}", + "mcpToolsHint": "Select the MCP Tools this Agent is allowed to call.", + "selectToolGroup": "Select tool group", + "searchToolGroup": "Search tool groups", + "emptyToolGroup": "No tool groups available", + "selectMCPTool": "Chọn MCP Tool", + "searchMCPTool": "Search MCP Tools", + "emptyMCPTool": "Không có MCP Tool khả dụng", + "noMCPToolsHint": "Without MCP Tools, the Agent will not call external MCP Tools.", + "removeMCPTool": "Remove MCP Tool {name}" + }, + "aiConfig": { + "allStatuses": "All statuses", + "allProviders": "All providers", + "allTypes": "All types", + "providerOpenAI": "OpenAI", + "modelTypeLlm": "Large language model", + "modelTypeEmbedding": "Embedding model", + "modelTypeRerank": "Reranker", + "enabled": "Enabled", + "disabled": "Disabled", + "deletedStatus": "Deleted", + "loadFailed": "Could not load AI configs.", + "updated": "AI config updated: {name}", + "created": "AI config created: {name}", + "saveFailed": "Could not save the AI config.", + "statusChanged": "{name} {status}", + "statusUpdateFailed": "Could not update the status.", + "activeDeleteBlocked": "Enabled AI configs cannot be deleted.", + "deleted": "AI config deleted: {name}", + "deleteFailed": "Could not delete the AI config.", + "sortUpdated": "AI config order updated.", + "sortUpdateFailed": "Could not update the order.", + "refresh": "Refresh", + "new": "New", + "filterName": "Filter by config name", + "searchModelType": "Search model types", + "emptyModelType": "No matching model types", + "searchProvider": "Search providers", + "emptyProvider": "No matching providers", + "searchStatus": "Search status", + "emptyStatus": "No matching status", + "query": "Search", + "columnConfig": "Config", + "columnProvider": "Provider", + "columnModel": "Model", + "columnAccess": "Access", + "columnLimits": "Limits", + "columnStatus": "Status", + "columnActions": "Actions", + "dragSort": "Drag to reorder {name}", + "dimension": "{count} dims", + "apiKey": "Key: {key}", + "contextTokens": "Context {count}", + "outputTokens": "Output {count}", + "timeoutRetry": "Timeout {timeout}ms / retries {retries}", + "toggleStatus": "Toggle status for {name}", + "edit": "Edit", + "moreActions": "More actions for {name}", + "deleteDisabledActive": "Cannot delete while enabled", + "deleting": "Deleting...", + "delete": "Delete", + "loadingRows": "Loading AI configs...", + "emptyRows": "No AI configs yet", + "confirmDeleteTitle": "Delete AI Config?", + "confirmDeleteDescription": "Delete \"{name}\"? This action cannot be undone.", + "deleteIrreversible": "This action cannot be undone.", + "cancel": "Cancel", + "confirmDelete": "Delete", + "nameRequired": "Enter a config name.", + "providerRequired": "Select a provider.", + "baseUrlRequired": "Enter a base URL.", + "modelTypeRequired": "Select a model type.", + "modelNameRequired": "Enter a model name.", + "dimensionInvalid": "Embedding dimension must be an integer greater than or equal to 0.", + "maxContextInvalid": "Max context tokens must be an integer greater than or equal to 0.", + "maxOutputInvalid": "Max output tokens must be an integer greater than or equal to 0.", + "timeoutInvalid": "Timeout must be an integer greater than or equal to 0.", + "retryInvalid": "Max retries must be an integer greater than or equal to 0.", + "rpmInvalid": "RPM limit must be an integer greater than or equal to 0.", + "tpmInvalid": "TPM limit must be an integer greater than or equal to 0.", + "editTitle": "Edit AI Config", + "createTitle": "New AI Config", + "saving": "Saving...", + "save": "Save", + "create": "Create", + "loading": "Loading...", + "name": "Config Name", + "namePlaceholder": "Example: OpenAI primary answer model", + "provider": "Provider", + "selectProvider": "Select provider", + "modelType": "Model Type", + "selectModelType": "Select model type", + "baseUrl": "Base URL", + "baseUrlPlaceholder": "Example: https://api.openai.com/v1", + "apiKeyPlaceholder": "Enter API Key", + "modelName": "Model Name", + "modelNamePlaceholder": "Example: gpt-4o-mini", + "dimensionLabel": "Embedding Dimension", + "maxContextTokens": "Max Context Tokens", + "maxOutputTokens": "Max Output Tokens", + "timeoutMs": "Timeout (ms)", + "maxRetryCount": "Max Retries", + "rpmLimit": "RPM Limit", + "tpmLimit": "TPM Limit", + "remark": "Notes", + "remarkPlaceholder": "Record usage, cost, or limit notes" + }, + "supportFaqCategory": { + "allStatuses": "All statuses", + "enabled": "Enabled", + "disabled": "Disabled", + "name": "Category name", + "searchName": "Search category names", + "noDescription": "No description", + "status": "Status", + "confirmDeleteTitle": "Delete FAQ Category?", + "confirmDeleteDescription": "Delete \"{name}\"? Categories used by questions cannot be deleted.", + "delete": "Delete", + "sortUpdated": "FAQ category order updated.", + "sortUpdateFailed": "Could not update the order.", + "dragSort": "Drag to reorder {name}", + "create": "Create", + "save": "Save", + "saving": "Saving...", + "cancel": "Cancel", + "loadingDetail": "Loading details...", + "required": "This field is required.", + "invalidNumber": "Enter a valid number.", + "minValue": "Must be at least {min}", + "maxValue": "Must be no more than {max}", + "namePlaceholder": "Enter a category name", + "slugPlaceholder": "Example: account-security", + "slugPatternMessage": "Use letters, numbers, and hyphens (-) only.", + "description": "Description", + "descriptionPlaceholder": "Describe the questions covered by this category", + "remark": "Notes", + "remarkPlaceholder": "Visible to dashboard users only", + "createTitle": "New FAQ Category", + "editTitle": "Edit FAQ Category", + "refresh": "Refresh", + "new": "New category", + "query": "Search", + "loading": "Loading categories...", + "empty": "No FAQ categories yet", + "actions": "Actions", + "edit": "Edit", + "processing": "Processing...", + "moreActions": "More actions for {name}", + "loadFailed": "Could not load categories.", + "saveFailed": "Could not save the category.", + "deleteFailed": "Could not delete the category.", + "created": "Category created: {name}", + "updated": "Category updated: {name}", + "deleted": "Category deleted: {name}" + }, + "supportQuestionAdmin": { + "allCategories": "Tất cả danh mục", + "allStatuses": "Tất cả trạng thái", + "statusPending": "Chờ kiểm duyệt", + "statusNormal": "Bình thường", + "statusResolved": "Đã giải quyết", + "statusClosed": "Đã đóng", + "statusHidden": "Đã ẩn", + "statusUpdated": "Đã cập nhật trạng thái câu hỏi.", + "answerPublished": "Đã đăng câu trả lời thành công.", + "answersCount": "{count} câu trả lời", + "votesAndViews": "{votes} lượt thích · {views} lượt xem", + "moderateNormal": "Khôi phục bình thường", + "moderateClose": "Đóng câu hỏi", + "moderateHide": "Ẩn câu hỏi", + "setBest": "Chọn làm câu trả lời tốt nhất", + "bestAnswer": "Câu trả lời hay nhất", + "replyPlaceholder": "Nhập câu trả lời của nhân viên hỗ trợ...", + "publishReply": "Đăng câu trả lời", + "empty": "Không có câu hỏi nào phù hợp.", + "loading": "Đang tải danh sách câu hỏi...", + "title": "Xử lý câu hỏi", + "userFallback": "Người dùng", + "uncategorized": "Chưa phân loại" + }, + "aiWorkflow": { + "filterName": "Tên quy trình", + "searchName": "Tìm theo tên quy trình", + "columnWorkflow": "Quy trình", + "noDescription": "Chưa có mô tả nghiệp vụ", + "columnStatus": "Trạng thái", + "published": "Đã xuất bản", + "draft": "Bản nháp", + "currentVersion": "Phiên bản hiện tại", + "publishedVersion": "Đã xuất bản #{version}", + "versionHistory": "Lịch sử phiên bản", + "usage": "Mức độ sử dụng", + "editTitle": "Chỉnh sửa quy trình", + "createTitle": "Tạo mới quy trình", + "refresh": "Làm mới", + "create": "Tạo quy trình", + "query": "Tìm kiếm", + "loading": "Đang tải danh sách quy trình...", + "empty": "Chưa có quy trình nào", + "actions": "Thao tác", + "edit": "Chỉnh sửa", + "delete": "Xóa", + "processing": "Đang xử lý...", + "moreActions": "Thao tác khác cho {name}", + "loadFailed": "Không thể tải danh sách quy trình.", + "saveFailed": "Không thể lưu quy trình.", + "deleteFailed": "Không thể xóa quy trình.", + "created": "Đã tạo quy trình: {name}", + "updated": "Đã cập nhật quy trình: {name}", + "deleted": "Đã xóa quy trình: {name}", + "nameRequired": "Vui lòng nhập tên quy trình", + "nameSaved": "Đã lưu tên quy trình", + "descriptionSaved": "Đã lưu mô tả", + "savedSuccess": "Lưu thành công", + "saveFirst": "Vui lòng lưu trước", + "publishedVersionSuccess": "Đã xuất bản v{version}", + "publishFailed": "Xuất bản thất bại", + "nameLabel": "Tên quy trình", + "loadingNodeCapabilities": "Đang tải danh mục node...", + "nodeStart": "Bắt đầu", + "nodeEnd": "Kết thúc", + "usageTitle": "Mức độ sử dụng", + "loadingUsage": "Đang tải dữ liệu sử dụng...", + "fixedBoundVersion": "Liên kết cố định v{version}", + "enabledStatus": "Bật", + "disabledStatus": "Tắt", + "notUsedByAnyAgent": "Chưa được Agent nào sử dụng", + "versionsTitle": "Lịch sử phiên bản", + "loadingVersions": "Đang tải lịch sử phiên bản...", + "publisher": "Người xuất bản: {name}", + "restoring": "Đang khôi phục...", + "restoreToDraft": "Khôi phục thành bản nháp", + "restoredDraftSuccess": "Đã khôi phục v{version} thành bản nháp", + "restoreFailed": "Khôi phục phiên bản thất bại", + "noVersionsYet": "Chưa có phiên bản nào được xuất bản", + "editorTitle": "Trình thiết kế quy trình FlowGram", + "loadDetailFailed": "Không thể tải chi tiết lịch sử node", + "executedCount": "Đã thực thi {count}", + "errorStatus": "Lỗi", + "nodeTraceTitle": "Lịch sử node", + "nodeTraceHint": "Nhấn để xem dữ liệu đầu vào, đầu ra và lỗi", + "noNodeRuns": "Chưa có bản ghi thực thi node nào.", + "inputTitle": "Đầu vào", + "outputTitle": "Đầu ra", + "noneValue": "Không có" + }, + "mcp": { + "disabled": "disabled", + "loadServersFailed": "Could not load MCP servers.", + "connectSuccess": "MCP connection succeeded.", + "connectFailed": "MCP connection failed.", + "toolsLoaded": "Loaded {count} tools.", + "loadToolsFailed": "Could not load tools.", + "selectToolFirst": "Select a tool first.", + "argumentsInvalid": "Arguments JSON is invalid.", + "argumentsMustBeJson": "arguments must be valid JSON.", + "toolReturnedError": "Tool returned an error result.", + "toolCallSuccess": "Tool call succeeded.", + "toolCallFailed": "Tool call failed.", + "testConnection": "Test Connection", + "listTools": "List Tools", + "serverConfig": "Server Config", + "selectServer": "Select an MCP server", + "searchServer": "Search serverCode", + "emptyServer": "No MCP servers available", + "connected": "Connected", + "emptyToolsHint": "No tool results yet. Click List Tools above.", + "toolName": "Tool Name", + "description": "Description", + "actions": "Actions", + "view": "View", + "toolDetail": "Tool Details", + "detailDescription": "Review tool details here and run a real test call against the selected MCP server.", + "noDescription": "No description", + "testTool": "Test Tool", + "returnedError": "Returned Error", + "callSuccess": "Call Succeeded", + "close": "Close" + }, + "agentProfile": { + "allStatuses": "All statuses", + "all": "All", + "statusIdle": "Idle", + "statusBusy": "Busy", + "enabled": "Enabled", + "disabled": "Disabled", + "loadFailed": "Could not load agent profiles.", + "updated": "Agent profile updated: {name}", + "created": "Agent profile created: {name}", + "saveFailed": "Could not save the agent profile.", + "deleted": "Agent profile deleted: {name}", + "deleteFailed": "Could not delete the agent profile.", + "refresh": "Refresh", + "expandTeams": "Expand team list", + "collapseTeams": "Collapse team list", + "new": "New", + "filterDisplayName": "Filter by display name", + "filterAgentCode": "Filter by agent code", + "searchStatus": "Search status", + "emptyStatus": "No matching status", + "query": "Search", + "columnAgent": "Agent", + "columnRules": "Service Rules", + "columnDispatch": "Assignment", + "columnRecent": "Recent Activity", + "columnActions": "Actions", + "userFallback": "User #{id}", + "agentCode": "Code: {code}", + "capacityPriority": "Max chats {capacity} / priority {priority}", + "autoAssign": "Auto-assign", + "noAutoAssign": "No auto-assign", + "offlineReceive": "Receives offline messages", + "noOfflineReceive": "No offline messages", + "onlineAt": "Online: {time}", + "statusAt": "Status: {time}", + "edit": "Edit", + "moreActions": "More actions for {name}", + "deleting": "Deleting...", + "delete": "Delete", + "loadingRows": "Loading agent profiles...", + "emptyTeamRows": "No matching agent profiles in this team", + "emptyRows": "No matching agent profiles", + "userRequired": "Select a linked user.", + "teamRequired": "Select a support team.", + "agentCodeRequired": "Enter an agent code.", + "displayNameRequired": "Enter a display name.", + "statusRequired": "Select an agent status.", + "maxConcurrentInvalid": "Max concurrent chats must be an integer greater than or equal to 0.", + "priorityInvalid": "Priority must be an integer.", + "loadOptionsFailed": "Could not load options.", + "loadDetailFailed": "Could not load agent profile details.", + "editTitle": "Edit Agent Profile", + "createTitle": "New Agent Profile", + "cancel": "Cancel", + "saving": "Saving...", + "save": "Save", + "loading": "Loading...", + "linkedUser": "Linked User", + "selectUser": "Select user", + "searchUser": "Search users...", + "emptyUser": "No matching users", + "displayName": "Display Name", + "displayNamePlaceholder": "Enter display name", + "agentCodeLabel": "Agent Code", + "agentCodePlaceholder": "Example: A1001", + "avatar": "Avatar", + "avatarUpload": "Upload avatar", + "serviceStatus": "Agent Status", + "maxConcurrent": "Max Concurrent Chats", + "priority": "Priority", + "autoAssignEnabled": "Include in auto-assignment", + "receiveOfflineMessage": "Receive offline messages", + "remark": "Notes", + "remarkPlaceholder": "Enter notes", + "teamTitle": "Support Teams", + "searchTeams": "Search teams", + "loadTeamsFailed": "Could not load support teams.", + "teamUpdated": "Support team updated: {name}", + "teamCreated": "Support team created: {name}", + "teamSaveFailed": "Could not save the support team.", + "teamDeleted": "Support team deleted: {name}", + "teamDeleteFailed": "Could not delete the support team.", + "noTeams": "No matching support teams", + "teamNameRequired": "Enter a team name.", + "leaderInvalid": "Team lead is invalid.", + "teamStatusRequired": "Select a status.", + "loadUsersFailed": "Could not load user options.", + "loadTeamDetailFailed": "Could not load support team details.", + "teamEditTitle": "Edit", + "teamCreateTitle": "New", + "teamName": "Team Name", + "teamNamePlaceholder": "Enter team name", + "leader": "Team Lead", + "noLeader": "No lead", + "selectLeader": "Select team lead", + "status": "Status", + "selectStatus": "Select status", + "description": "Responsibilities", + "descriptionPlaceholder": "Example: Handles presales questions and lead qualification" + }, + "agentTeamSchedule": { + "loadFailed": "Could not load support team schedules.", + "loadCalendarFailed": "Could not load the schedule calendar.", + "loadTeamsFailed": "Could not load support team options.", + "historyReadonly": "Past schedules cannot be changed.", + "updated": "Support team schedule updated.", + "created": "Support team schedule created.", + "saveFailed": "Could not save the support team schedule.", + "deleted": "Support team schedule deleted.", + "deleteFailed": "Could not delete the support team schedule.", + "updateFailed": "Could not update the support team schedule.", + "month": "Month", + "week": "Week", + "list": "List", + "prevMonth": "Previous month", + "thisMonth": "This month", + "nextMonth": "Next month", + "prevWeek": "Previous week", + "thisWeek": "This week", + "nextWeek": "Next week", + "today": "Today", + "allTeams": "All teams", + "filterTeam": "Filter by team", + "searchTeam": "Search teams", + "emptyTeam": "No matching teams", + "query": "Search", + "refresh": "Refresh", + "batch": "Batch schedule", + "new": "New", + "team": "Team", + "timeRange": "Time Range", + "actions": "Actions", + "teamFallback": "Team #{id}", + "teamId": "Team ID: {id}", + "edit": "Edit", + "moreActions": "More actions for {name}", + "deleting": "Deleting...", + "delete": "Delete", + "emptyRows": "No matching support team schedules", + "teamRequired": "Select a support team.", + "startRequired": "Start time is required.", + "endRequired": "End time is required.", + "endAfterStart": "End time must be later than start time.", + "singleDayOnly": "A single schedule cannot cross days.", + "loadOptionsFailed": "Could not load options.", + "loadDetailFailed": "Could not load schedule details.", + "editTitle": "Edit Support Team Schedule", + "createTitle": "New Support Team Schedule", + "loading": "Loading...", + "startTime": "Start Time", + "endTime": "End Time", + "remark": "Notes", + "remarkPlaceholder": "Enter notes", + "cancel": "Cancel", + "saving": "Saving...", + "save": "Save", + "weekdayMon": "Mon", + "weekdayTue": "Tue", + "weekdayWed": "Wed", + "weekdayThu": "Thu", + "weekdayFri": "Fri", + "weekdaySat": "Sat", + "weekdaySun": "Sun", + "weekdayShortMon": "Mon", + "weekdayShortTue": "Tue", + "weekdayShortWed": "Wed", + "weekdayShortThu": "Thu", + "weekdayShortFri": "Fri", + "weekdayShortSat": "Sat", + "weekdayShortSun": "Sun", + "monthTitle": "{month}/{year}", + "dropInsideCalendar": "Drop onto a calendar day.", + "cannotMoveHere": "Cannot move here.", + "resizeInvalid": "Cannot cross days or be shorter than 15 minutes.", + "noTeamsCalendar": "No support teams are available for the calendar.", + "moreItems": "{count} more", + "batchDescription": "Select teams, a date range, and working hours. Preview before generating schedules.", + "addTeam": "Add team", + "loadingTeams": "Loading teams...", + "noTeams": "No teams", + "allTeamsSelected": "All teams selected", + "removeTeam": "Remove {name}", + "noSelectedTeams": "No teams selected", + "startDate": "Start Date", + "endDate": "End Date", + "weekday": "Weekday", + "previewFailed": "Could not preview batch schedules.", + "previewFirst": "Preview the batch schedule first.", + "conflictCannotSubmit": "Schedules with conflicts cannot be submitted.", + "batchCreated": "{count} support team schedules created.", + "batchRefreshFailed": "Schedules were generated, but refresh failed: {message}", + "batchRefreshFailedFallback": "Schedules were generated, but refresh failed.", + "generateFailed": "Could not generate batch schedules.", + "selectAtLeastOneTeam": "Select at least one support team.", + "selectDateRange": "Select a date range.", + "startBeforeToday": "Start date cannot be earlier than today.", + "endBeforeStart": "End date cannot be earlier than start date.", + "selectAtLeastOneWeekday": "Select at least one weekday.", + "selectStartEndTime": "Select start and end times.", + "previewSummaryConflict": "{total} schedules, with conflicts. Go back and adjust them.", + "previewSummaryReady": "{total} schedules. Generate after confirming there are no conflicts.", + "hasConflict": "Has conflicts", + "noConflict": "No conflicts", + "date": "Date", + "time": "Time", + "status": "Status", + "conflictFallback": "Schedule conflict", + "canGenerate": "Ready", + "emptyPreview": "No schedules to generate", + "backToEdit": "Back to edit", + "preview": "Preview", + "generate": "Generate schedules" + }, + "ticket": { + "allAssignees": "All assignees", + "allTags": "All tags", + "hours": "{hours} hours", + "manual": "Manual", + "conversation": "Conversation", + "manualCreated": "Created manually", + "conversationGenerated": "Created from conversation", + "agentFallback": "Agent #{id}", + "customerFallback": "Customer #{id}", + "userFallback": "User #{id}", + "unassigned": "Unassigned", + "noCustomer": "No customer", + "loadFailed": "Could not load tickets.", + "loadFiltersFailed": "Could not load filters.", + "created": "Ticket created.", + "createFailed": "Could not create the ticket.", + "quickAll": "All Tickets", + "quickPending": "Pending", + "quickInProgress": "In Progress", + "quickDone": "Resolved", + "quickUnassigned": "Unassigned", + "quickMine": "My Tickets", + "quickStale": "Stale", + "refresh": "Refresh", + "newTicket": "New Ticket", + "searchPlaceholder": "Search ticket number, title, or description", + "staleThreshold": "Stale after", + "reset": "Reset", + "query": "Search", + "columnTicket": "Ticket", + "columnStatus": "Status", + "columnAssignee": "Assignee", + "columnUpdated": "Last Updated", + "columnActions": "Actions", + "loadingRows": "Loading tickets...", + "emptyRows": "No tickets yet", + "detail": "Details", + "currentStatus": "Current Status", + "statusPending": "Pending", + "statusInProgress": "In Progress", + "statusDone": "Resolved", + "titleRequired": "Enter a ticket title.", + "descriptionRequired": "Enter a problem description.", + "selectedTags": "{count} tags selected", + "selectTags": "Select ticket tags", + "searchTags": "Search tags", + "emptyTags": "No tags available", + "tags": "Tags", + "noAssignee": "No assignee", + "editTitle": "Edit Ticket", + "createTitle": "New Ticket", + "dialogDescription": "Enter the basic ticket details.", + "cancel": "Cancel", + "saving": "Saving...", + "save": "Save", + "create": "Create", + "loading": "Loading...", + "title": "Title", + "titlePlaceholder": "Enter a ticket title", + "description": "Description", + "assignee": "Assignee", + "selectAssignee": "Select assignee", + "ticketTags": "Ticket Tags", + "detailTitle": "Ticket Details", + "creator": "Created by {name}", + "loadDetailFailed": "Could not load ticket details.", + "statusUpdated": "Ticket status updated.", + "statusUpdateFailed": "Could not update ticket status.", + "statusChangeConfirmTitle": "Confirm Status Change", + "statusChangeConfirmDescription": "Change ticket status from \"{from}\" to \"{to}\"?", + "statusChangeConfirm": "Confirm Change", + "updating": "Updating...", + "progressRequired": "Enter an update.", + "progressRecorded": "Update added.", + "progressCreateFailed": "Could not add the update.", + "selectTicket": "Select a ticket.", + "updated": "Ticket updated.", + "updateFailed": "Could not update the ticket.", + "noLinkedCustomer": "This ticket is not linked to a customer.", + "saved": "Saved.", + "saveFailed": "Could not save.", + "noDescription": "No description", + "assign": "Assign", + "edit": "Edit", + "linkOrCreate": "Link or create", + "customerInfo": "Customer Info", + "customer": "Customer", + "contact": "Contact", + "ticketInfo": "Ticket Info", + "source": "Source", + "channel": "Channel", + "conversationId": "Conversation ID", + "progress": "Updates", + "addProgress": "Add Update", + "noProgress": "No updates yet", + "chooseTicket": "Select a ticket", + "progressPlaceholder": "Write this update", + "submitting": "Submitting...", + "submit": "Submit", + "assignTitle": "Assign Ticket", + "assigneeRequired": "Select an assignee.", + "loadAssigneesFailed": "Could not load assignees.", + "assigneeUpdated": "Assignee updated.", + "assignFailed": "Could not assign the ticket.", + "selectHandler": "Select assignee", + "assignReason": "Note", + "assignReasonPlaceholder": "Add an assignment note", + "confirmAssign": "Assign", + "conversationToTicket": "Create Ticket from Conversation", + "conversationToTicketDescription": "Create a ticket from the current conversation context.", + "conversationMissing": "Conversation not found.", + "createSuccess": "Ticket created." + }, + "skillDefinition": { + "allStatus": "All statuses", + "statusOk": "Enabled", + "statusDisabled": "Disabled", + "statusDeleted": "Deleted", + "whitelistCount": "{count} whitelisted", + "exampleCount": "{count} examples", + "noDescription": "No description", + "toggleStatus": "Toggle status for {name}", + "debug": "Debug", + "edit": "Edit", + "moreActions": "More actions for {name}", + "restoring": "Restoring...", + "restore": "Restore", + "deleting": "Deleting...", + "processing": "Working...", + "delete": "Delete", + "loadFailed": "Could not load skills.", + "updated": "Skill updated: {name}", + "created": "Skill created: {name}", + "saveFailed": "Could not save the skill.", + "enabled": "Enabled: {name}", + "disabled": "Disabled: {name}", + "statusUpdateFailed": "Could not update status.", + "deleted": "Skill deleted: {name}", + "deleteFailed": "Could not delete the skill.", + "restored": "Skill restored: {name}", + "restoreFailed": "Could not restore the skill.", + "refresh": "Refresh", + "new": "New", + "filterName": "Filter by name", + "searchStatus": "Search statuses", + "emptyStatus": "No matching statuses", + "query": "Search", + "status": "Status", + "updatedAt": "Last Updated", + "actions": "Actions", + "loadingRows": "Loading skills...", + "emptyRows": "No matching skills", + "nameRequired": "Enter a skill name.", + "instructionRequired": "Enter skill instructions.", + "editTitle": "Edit", + "createTitle": "New", + "cancel": "Cancel", + "saving": "Saving...", + "save": "Save", + "create": "Create", + "loading": "Loading...", + "name": "Name", + "namePlaceholder": "Example: Refund Handling", + "description": "Description", + "descriptionPlaceholder": "Describe what this skill does, its boundaries, and when to use it.", + "instruction": "Instructions", + "instructionPlaceholder": "Enter the skill documentation, including goals, steps, tool rules, and boundaries.", + "examples": "Example Phrases", + "examplesPlaceholder": "One typical user phrase per line, for example:\nI need a refund\nCan you check my order?", + "toolWhitelist": "Tool Allowlist", + "selectTool": "Select tools this skill may use", + "searchTool": "Search tool code or name", + "emptyTool": "No tools can be added", + "add": "Add", + "inheritAgentTools": "Leave this empty to inherit the agent's available tools.", + "remark": "Notes", + "remarkPlaceholder": "Add internal notes or maintenance details", + "agentRequired": "Select an AI agent.", + "messageRequired": "Enter a user message.", + "emptyData": "No data", + "traceData": "Trace Data", + "resumeTraceData": "Resume Trace Data", + "confirm": "Confirm", + "reject": "Cancel", + "debugFailed": "Skill debug failed.", + "resumeMessageRequired": "Enter a resume message.", + "resumeFailed": "Could not resume debugging.", + "debugTitle": "Debug Skill · {name}", + "debugDescription": "Force this skill and inspect routing, tools, graph execution, HITL, and the final reply.", + "close": "Close", + "debugging": "Debugging...", + "startDebug": "Start Debug", + "debugInput": "Debug Input", + "selectAgent": "Select AI agent", + "searchAgent": "Search AI agents", + "emptyAgent": "No AI agents found", + "conversationPlaceholder": "Optional. Enter an existing conversation ID to reuse context.", + "matchedAgent": "Matched Agent", + "noAgentSelected": "None selected", + "userMessage": "User Message", + "userMessagePlaceholder": "Enter a user message to debug this skill's capability calls and reply inside the Agent Loop.", + "debugSummary": "Debug Summary", + "interrupted": "Interrupted", + "notInterrupted": "Not interrupted", + "skillName": "Skill Name", + "none": "None", + "errorMessage": "Error Message", + "toolView": "Tool View", + "skillToolWhitelist": "Skill Tool Allowlist", + "invokedTools": "Tools Called This Run", + "resumeDebug": "Resume Debug", + "currentCheckpoint": "Current Checkpoint", + "resumeMessage": "Resume Message", + "resumePlaceholder": "Enter confirm, cancel, or another resume message", + "resuming": "Resuming...", + "resumeDebugAction": "Resume Debug", + "resumeResult": "Resume Result", + "stillWaiting": "Still waiting for confirmation", + "resumeCompleted": "Resume completed", + "resumeReply": "Resume Reply" + }, + "knowledge": { + "document": "Documents", + "faq": "FAQ", + "retrieveLogs": "Retrieval Logs", + "expandList": "Expand knowledge base list", + "collapseList": "Collapse knowledge base list", + "resizeList": "Resize knowledge base list", + "refreshDocuments": "Refresh documents", + "listLayout": "List layout", + "gridLayout": "Grid layout", + "openDebugPanel": "Open debug panel", + "newDocument": "New document", + "refreshFAQ": "Refresh FAQ", + "importFAQ": "Import FAQ", + "newFAQ": "New FAQ", + "ragDebug": "RAG Debug", + "title": "Knowledge Base", + "columnBase": "Knowledge base", + "columnType": "Type", + "columnContent": "Items", + "columnStatus": "Status", + "columnUpdatedAt": "Last updated", + "columnActions": "Actions", + "sortHandle": "Drag to reorder", + "processing": "Processing...", + "noDescription": "No description", + "refreshBases": "Refresh knowledge bases", + "backToList": "Back to knowledge bases", + "baseNotFound": "This knowledge base was not found or has been deleted.", + "loadBaseFailed": "Could not load the knowledge base.", + "allStatus": "All statuses", + "status": "Status", + "statusOk": "Enabled", + "statusDisabled": "Disabled", + "statusDeleted": "Deleted", + "typeDocument": "Document Knowledge Base", + "typeFAQ": "FAQ Knowledge Base", + "chunkFixed": "Fixed length", + "chunkStructured": "Structured chunks", + "chunkFAQ": "Q&A chunks", + "chunkSemantic": "Semantic chunks", + "answerStrict": "Strict mode", + "answerAssist": "Assist mode", + "loadBasesFailed": "Could not load knowledge bases.", + "baseUpdated": "Knowledge base updated: {name}", + "baseCreated": "Knowledge base created: {name}", + "baseSaveFailed": "Could not save the knowledge base.", + "baseDeleted": "Knowledge base deleted: {name}", + "baseDeleteFailed": "Could not delete the knowledge base.", + "loadDirectoriesFailed": "Could not load directories.", + "directoryCreated": "Directory created: {name}", + "directoryUpdated": "Directory updated: {name}", + "directoryDeleted": "Directory deleted: {name}", + "directorySaveFailed": "Could not save the directory.", + "directoryDeleteFailed": "Could not delete the directory.", + "directoryMoved": "Directory moved.", + "directoryMoveFailed": "Could not move directory.", + "directorySortUpdated": "Directory order updated.", + "directorySortUpdateFailed": "Could not update directory order.", + "directoryNameRequired": "Directory name is required.", + "rebuildStarted": "Knowledge base reindex started: {name}", + "rebuildFailed": "Could not rebuild the knowledge base index.", + "sortUpdated": "Knowledge base order updated.", + "sortUpdateFailed": "Could not update knowledge base order.", + "moreActions": "More actions for {name}", + "view": "View", + "edit": "Edit", + "rebuilding": "Rebuilding...", + "rebuildIndex": "Rebuild Index", + "deleting": "Deleting...", + "delete": "Delete", + "move": "Move", + "moving": "Moving...", + "selectedCount": "{count} selected", + "clearSelection": "Clear selection", + "selectCurrentPage": "Select current page", + "selectItem": "Select {name}", + "batchMove": "Move selected", + "batchMoveDescription": "Move {count} selected items to a directory.", + "batchMoved": "{count} items moved.", + "batchMoveFailed": "Could not move selected items.", + "batchDelete": "Delete selected", + "batchDeleteConfirm": "Delete {count} selected items? This cannot be undone.", + "batchDeleted": "{count} items deleted.", + "batchDeleteFailed": "Could not delete selected items.", + "targetDirectory": "Target directory", + "directory": "Directory", + "resizeDirectoryPanel": "Resize directory panel", + "allContent": "All content", + "rootContent": "Root content", + "rootDirectory": "Root directory", + "parentDirectory": "Parent directory", + "createDirectory": "New directory", + "createSubDirectory": "New subdirectory", + "editDirectory": "Edit directory", + "expandDirectory": "Expand directory", + "collapseDirectory": "Collapse directory", + "selectDirectory": "Select directory", + "searchDirectory": "Search directories", + "emptyDirectory": "No matching directories", + "directoryName": "Directory name", + "directoryNamePlaceholder": "Enter directory name", + "searchBase": "Search knowledge bases", + "query": "Search", + "emptyBases": "No matching knowledge bases", + "nameRequired": "Name is required.", + "nameMax": "Name can be up to 100 characters.", + "descriptionMax": "Description can be up to 500 characters.", + "typeRequired": "Select a knowledge base type.", + "topKRequired": "Enter a Top K value.", + "scoreRequired": "Enter a score threshold.", + "rerankRequired": "Enter a rerank limit.", + "chunkProviderRequired": "Select a chunking strategy.", + "targetTokensRequired": "Enter target token count.", + "maxTokensRequired": "Enter maximum token count.", + "overlapTokensRequired": "Enter overlap token count.", + "answerModeRequired": "Select an answer mode.", + "remarkMax": "Notes can be up to 500 characters.", + "editBaseTitle": "Edit Knowledge Base", + "createBaseTitle": "New Knowledge Base", + "cancel": "Cancel", + "saving": "Saving...", + "save": "Save", + "create": "Create", + "loading": "Loading...", + "knowledgeType": "Knowledge Base Type", + "selectKnowledgeType": "Select knowledge base type", + "searchKnowledgeType": "Search knowledge base types", + "emptyKnowledgeType": "No matching knowledge base types", + "name": "Name", + "namePlaceholder": "Knowledge base name", + "description": "Description", + "descriptionPlaceholder": "Knowledge base description", + "chunkProvider": "Chunking Strategy", + "selectChunkProvider": "Select chunking strategy", + "searchChunkProvider": "Search chunking strategies", + "emptyChunkProvider": "No matching chunking strategies", + "targetToken": "Target Tokens", + "maxToken": "Max Tokens", + "overlapToken": "Overlap Tokens", + "defaultTopK": "Default Top K", + "defaultScoreThreshold": "Default Score Threshold", + "defaultRerankLimit": "Default Rerank Limit", + "answerMode": "Answer Mode", + "selectAnswerMode": "Select answer mode", + "searchAnswerMode": "Search answer modes", + "emptyAnswerMode": "No matching answer modes", + "remark": "Notes", + "remarkPlaceholder": "Notes", + "allIndexStatus": "All index statuses", + "searchStatus": "Search statuses", + "emptyStatus": "No matching statuses", + "indexPending": "Pending", + "indexIndexed": "Indexed", + "indexFailed": "Index failed", + "loadDocumentsFailed": "Could not load documents.", + "documentUpdated": "Document updated: {title}", + "documentCreated": "Document created: {title}", + "documentSaveFailed": "Could not save the document.", + "documentDeleted": "Document deleted: {title}", + "documentDeleteFailed": "Could not delete the document.", + "documentIndexRebuilt": "Index rebuilt: {title}", + "documentIndexRebuildFailed": "Could not rebuild the index.", + "selectKnowledgeBaseForDocuments": "Select a knowledge base to view documents.", + "searchDocumentTitle": "Search document titles", + "indexedAt": "Indexed {time}", + "indexTime": "Indexed at {time}", + "running": "Running...", + "emptyDocuments": "No matching documents", + "documentTitleRequired": "Title is required.", + "documentTitleMax": "Title can be up to 255 characters.", + "contentTypeRequired": "Select a content type.", + "contentRequired": "Content is required.", + "editDocumentTitle": "Edit Document", + "createDocumentTitle": "New Document", + "viewDocumentTitle": "View Document", + "documentTitle": "Title", + "documentTitlePlaceholder": "Document title", + "content": "Content", + "contentPlaceholder": "Enter document content", + "loadFAQFailed": "Could not load FAQs.", + "loadingFAQ": "Loading FAQs...", + "faqSaved": "FAQ saved.", + "faqSaveFailed": "Could not save the FAQ.", + "faqDeleted": "FAQ deleted.", + "faqDeleteFailed": "Could not delete the FAQ.", + "faqIndexRebuilt": "FAQ index rebuilt.", + "faqIndexRebuildFailed": "Could not rebuild the FAQ index.", + "selectFAQBase": "Select an FAQ knowledge base to view FAQs.", + "searchFAQ": "Search FAQs by question", + "question": "Question", + "indexStatus": "Index Status", + "indexedAtLabel": "Indexed At", + "similarQuestions": "Similar Questions", + "updatedAt": "Updated", + "createUser": "Created By", + "updateUser": "Updated By", + "actions": "Actions", + "emptyFAQ": "This knowledge base has no FAQs yet", + "faqQuestionRequired": "Question is required.", + "faqQuestionMax": "Question can be up to 500 characters.", + "faqAnswerRequired": "Answer is required.", + "editFAQTitle": "Edit FAQ", + "createFAQTitle": "New FAQ", + "viewFAQTitle": "View FAQ", + "standardQuestion": "Primary Question", + "questionPlaceholder": "Enter the primary question", + "answer": "Answer", + "answerPlaceholder": "Enter the FAQ answer", + "similarQuestionsPlaceholder": "One similar question per line", + "importRowFailed": "Row {row}: {message}", + "importFailed": "Import failed.", + "importFAQTitle": "Import FAQ", + "importFAQDescription": "Upload an Excel file to import FAQs in bulk. Required columns: primary question and answer. Put one similar question per line in the same cell.", + "downloadTemplate": "Download Template", + "downloadTemplateFailed": "Could not download the template.", + "importing": "Importing...", + "startImport": "Start Import", + "importFile": "Import File", + "chooseFile": "Choose File", + "importFileDescription": "Only .xlsx files are supported.", + "currentFile": "Current file: {name}", + "importHint": "Import Notes", + "importXlsxHint": "The template columns are primary question, answer, similar questions, and remark. Put one similar question per line inside the cell.", + "importXlsxOnly": "Only .xlsx files are supported.", + "importMode": "Import Mode", + "selectImportMode": "Select import mode", + "searchImportMode": "Search import mode", + "emptyImportMode": "No import modes found", + "importModeAppend": "Append Import", + "importModeOverwrite": "Overwrite Import", + "importModeAppendDescription": "Only creates primary questions that do not already exist. Existing primary questions are skipped.", + "importModeOverwriteDescription": "Matches by primary question and updates existing FAQs. Missing primary questions are created.", + "importResult": "Import Result", + "importTotal": "Total", + "importCreated": "Created", + "importUpdated": "Updated", + "importSkipped": "Skipped", + "importFailedCount": "Failed", + "importFAQResultToast": "Created {created}, updated {updated}, skipped {skipped}, failed {failed}", + "exportFAQ": "Export FAQ", + "exportFAQSuccess": "FAQ export download started.", + "exportFAQFailed": "Could not export FAQs.", + "none": "None", + "allChannels": "All channels", + "channelIM": "Support chat", + "channelAgentAssist": "Agent assist", + "channelAPI": "API", + "channelDebug": "Debug", + "allScenes": "All scenes", + "sceneFirstResponse": "First response", + "sceneAssist": "Assisted reply", + "sceneQA": "Q&A", + "allAnswerStatus": "All answer statuses", + "answerNormal": "Normal", + "answerNoAnswer": "No answer", + "answerFallback": "Fallback reply", + "answerBlocked": "Blocked", + "allChunkProviders": "All chunking strategies", + "allRerank": "All rerank", + "rerankEnabled": "Rerank enabled", + "rerankDisabled": "Rerank disabled", + "loadRetrieveLogsFailed": "Could not load retrieval logs.", + "selectBaseForLogs": "Select a knowledge base to view retrieval logs.", + "loadingRetrieveLogs": "Loading retrieval logs...", + "emptyRetrieveLogs": "This knowledge base has no retrieval logs yet", + "filterQuestion": "Filter by question keyword", + "selectChannel": "Select channel", + "selectScene": "Select scene", + "answerStatus": "Answer Status", + "chunkStrategy": "Chunking Strategy", + "filter": "Filter", + "time": "Time", + "hitCount": "Hits", + "citations": "Citations", + "duration": "Duration", + "yes": "Yes", + "no": "No", + "loadRetrieveLogDetailFailed": "Could not load retrieval log details.", + "noCitations": "No citation sources", + "unrecordedSection": "Section not recorded", + "retrieveLogDetail": "Retrieval Log Details", + "retrieveLogNotFound": "Retrieval log not found", + "loadingDetail": "Loading details...", + "emptyDetail": "No details", + "requestInfo": "Request Info", + "createdAt": "Created At", + "channelScene": "Channel / Scene", + "originalQuestion": "Original Question", + "rewriteQuestion": "Rewritten Question", + "answerContent": "Answer", + "retrieveStrategy": "Retrieval Strategy", + "resultSummary": "Result Summary", + "contextChunks": "Context Chunks", + "retrieveMs": "Retrieval Time", + "generateMs": "Generation Time", + "totalMs": "Total Time", + "model": "Model", + "sessionId": "Session ID", + "citationSources": "Citation Sources", + "hitDetails": "Hit Details", + "itemsCount": "{count} items", + "usedInContext": "Used in context", + "notUsedInContext": "Not used in context", + "section": "Section: {section}", + "selectKnowledgeBaseFirst": "Select a knowledge base first.", + "debugQuestionRequired": "Enter a debug question.", + "searchCompleted": "Search complete. {count} hits found.", + "searchFailed": "Search failed.", + "answerCompleted": "Answer generated. Status: {status}", + "answerFailed": "Answer generation failed.", + "debugQuestionPlaceholder": "Enter a question to test retrieval and answer quality.", + "topKPlaceholder": "Number of hits", + "scoreThreshold": "Similarity Threshold", + "scoreThresholdPlaceholder": "Minimum score", + "rerankLimitLabel": "Rerank Limit", + "rerankLimitPlaceholder": "Items to rerank", + "searching": "Searching...", + "debugSearch": "Debug Search", + "generating": "Generating...", + "debugAnswer": "Debug Answer", + "answerResult": "Answer Result", + "searchHits": "Search Hits", + "hitsSummary": "{count} hits · {latency}ms" + }, + "scaffold": { + "moduleBoard": "Module Delivery Board", + "moduleBoardDescription": "A first-phase admin scaffold. Replace it with real API-backed modules later.", + "searchModule": "Search modules", + "filter": "Filter", + "moduleList": "Modules", + "milestones": "Milestones", + "phaseOneModules": "Phase One Modules", + "phaseOneDescription": "Covers accounts, permissions, knowledge base, channels, and skill entry points.", + "module": "Module", + "owner": "Owner", + "status": "Status", + "progress": "Progress", + "updatedAt": "Updated", + "done": "Done", + "nextMilestone": "Next Milestone", + "nextMilestoneDescription": "The UI scaffold is ready. Next step: connect real APIs and business forms.", + "milestoneLogin": "Connect sign-in, users, roles, and permissions APIs.", + "milestoneForms": "Add form dialogs, paginated queries, and error handling.", + "milestoneKnowledge": "Wire up real knowledge base and channel configuration.", + "adminUsers": "Admin Users", + "newAdminsThisWeek": "New admins this week", + "accountSystemReady": "Account foundation is ready for real APIs", + "permissions": "Permissions", + "initialized": "Initialized", + "permissionsPending": "Menu and API permissions are pending", + "permissionModelReady": "Ready to connect the backend permission model", + "knowledgeTasks": "Knowledge Tasks", + "running": "Running", + "ragReserved": "RAG integration placeholder", + "docIndexSupport": "Supports document import and indexing flow", + "channelAccess": "Channel Access", + "pendingConfig": "Pending setup", + "channelEntrances": "WeCom, DingTalk, and IM entry points", + "waitingRealConfig": "Waiting for real connection settings", + "recentActivity": "Recent Development Activity", + "activityDescription": "Demo data showing conversation growth and knowledge base readiness.", + "activeSessions": "Active Sessions", + "indexedDocs": "Knowledge Docs", + "last14Days": "Last 14 days", + "last7Days": "Last 7 days", + "selectTimeRange": "Select time range", + "businessModules": "Business Modules", + "open": "Open", + "share": "Share", + "delete": "Delete", + "more": "More" + }, + "widgetDemo": { + "defaultName": "Demo User", + "missingUserId": "Enter userId.", + "missingName": "Enter the user's name.", + "missingSecret": "Enter the JWT secret.", + "invalidTtl": "TTL must be greater than 0.", + "missingChannel": "Enter channelId.", + "mountedJwt": "Widget mounted in JWT user mode.", + "mountedGuest": "Widget mounted in guest mode.", + "mounted": "Widget mounted.", + "mountFailed": "Could not mount the widget.", + "linkFailed": "Could not generate the support link.", + "unmounted": "Widget unmounted.", + "title": "Widget Mount Test", + "authMode": "Auth Mode", + "guest": "Guest", + "jwtUser": "JWT User", + "ttlMinutes": "TTL (minutes)", + "mount": "Mount", + "unmount": "Unmount", + "snippetTitle": "Embed Code", + "jwtNotice": "This page is for local simulation only. In production, your backend should issue the userToken.", + "copiedSnippet": "Embed code copied", + "copySnippet": "Copy embed code", + "copied": "Copied", + "copyCode": "Copy code", + "directChat": "Direct Customer Chat", + "directChatPlaceholder": "Copy or open in a new window to generate the latest link", + "copy": "Copy", + "openNewWindow": "Open in New Window", + "currentToken": "Current userToken" + }, + "quickReply": { + "loadFailed": "Could not load quick replies.", + "created": "Quick reply created: {title}", + "updated": "Quick reply updated: {title}", + "saveFailed": "Could not save the quick reply.", + "enabled": "Enabled: {title}", + "disabled": "Disabled: {title}", + "statusUpdateFailed": "Could not update the status.", + "deleted": "Quick reply deleted: {title}", + "deleteFailed": "Could not delete the quick reply.", + "refresh": "Refresh", + "new": "New Quick Reply", + "filterTitle": "Filter by title", + "filterGroup": "Filter by group", + "query": "Search", + "columnQuickReply": "Quick Reply", + "columnGroup": "Group", + "columnStatus": "Status", + "columnSort": "Sort", + "columnCreator": "Created By", + "columnActions": "Actions", + "edit": "Edit", + "moreActions": "More actions for {title}", + "processing": "Working...", + "enable": "Enable", + "disable": "Disable", + "deleting": "Deleting...", + "delete": "Delete", + "loading": "Loading quick replies...", + "empty": "No matching quick replies", + "editTitle": "Edit Quick Reply", + "createTitle": "New Quick Reply", + "cancel": "Cancel", + "saving": "Saving...", + "save": "Save", + "create": "Create", + "loadingDetail": "Loading...", + "groupName": "Group Name", + "groupNameRequired": "Enter a group name.", + "groupNamePlaceholder": "For example: Presales, Support, Follow-up", + "title": "Title", + "titleRequired": "Enter a title.", + "titlePlaceholder": "Enter a quick reply title", + "content": "Reply Content", + "contentRequired": "Enter reply content.", + "contentPlaceholder": "Enter the reply content", + "statusRequired": "Select a status.", + "sortRequired": "Enter a sort value.", + "sortInvalid": "Sort value must be an integer greater than or equal to 0.", + "sortPlaceholder": "Higher numbers appear first" + }, + "tag": { + "loadFailed": "Could not load tags.", + "updated": "Tag updated: {name}", + "created": "Tag created: {name}", + "saveFailed": "Could not save the tag.", + "enabled": "Enabled: {name}", + "disabled": "Disabled: {name}", + "statusUpdateFailed": "Could not update the status.", + "deleted": "Tag deleted: {name}", + "deleteFailed": "Could not delete the tag.", + "sameParentOnly": "Tags can only be reordered under the same parent tag.", + "notFound": "Tag not found.", + "sortUpdated": "Tag order updated.", + "sortUpdateFailed": "Could not update tag order.", + "refresh": "Refresh", + "expandAll": "Expand All", + "collapseAll": "Collapse All", + "new": "New Tag", + "filterName": "Filter by name", + "searchStatus": "Search status", + "emptyStatus": "No statuses found", + "query": "Search", + "columnName": "Tag Name", + "columnStatus": "Status", + "columnRemark": "Notes", + "columnCreatedAt": "Created At", + "columnActions": "Actions", + "dragSort": "Drag to reorder {name}", + "toggleStatus": "Toggle status for {name}", + "edit": "Edit", + "moreActions": "More actions for {name}", + "deleting": "Deleting...", + "delete": "Delete", + "loading": "Loading tags...", + "empty": "No matching tags", + "editTitle": "Edit Tag", + "createTitle": "New Tag", + "cancel": "Cancel", + "saving": "Saving...", + "save": "Save", + "create": "Create", + "loadingDetail": "Loading...", + "parent": "Parent Tag", + "rootParent": "None (top-level tag)", + "name": "Tag Name", + "nameRequired": "Enter a tag name.", + "namePlaceholder": "Enter tag name", + "remark": "Notes", + "remarkPlaceholder": "Optional notes", + "searchParent": "Search parent tags", + "emptyParent": "No parent tags found" + }, + "permission": { + "loadFailed": "Could not load permissions.", + "refresh": "Refresh", + "sync": "Sync Permissions", + "syncing": "Syncing...", + "syncTitle": "Sync Built-in Permissions", + "syncDescription": "Sync built-in permissions from code and add missing permissions to built-in roles. Existing role permissions will not be removed.", + "syncSuccess": "Permissions synced: {created} created, {updated} updated, {rolePermissionsAdded} role permissions added", + "syncFailed": "Could not sync permissions.", + "filterKeyword": "Filter by permission name or code", + "filterGroup": "Filter by group", + "query": "Search", + "columnPermission": "Permission", + "columnCode": "Code", + "columnGroup": "Group", + "columnApi": "API", + "columnStatus": "Status", + "loading": "Loading permissions...", + "empty": "No matching permissions" + }, + "role": { + "dragSort": "Drag to reorder {name}", + "system": "System", + "processing": "Working...", + "assignPermissions": "Assign Permissions", + "loadFailed": "Could not load roles.", + "created": "Role created: {name}", + "createFailed": "Could not create the role.", + "loadAssignFailed": "Could not load permission assignment data.", + "sortUpdated": "Role order updated.", + "sortUpdateFailed": "Could not update role order.", + "permissionsUpdated": "Permissions updated for {name}.", + "savePermissionsFailed": "Could not save permission assignments.", + "add": "Add Role", + "refresh": "Refresh", + "dragHint": "Drag rows to reorder roles.", + "pageSummary": "Page {page} / {limit} per page", + "columnRole": "Role", + "columnCode": "Code", + "columnStatus": "Status", + "columnSort": "Sort", + "columnActions": "Actions", + "loading": "Loading roles...", + "empty": "No roles yet", + "assignTitle": "Assign Permissions", + "currentRole": "Current role: {name} {code}", + "permissionList": "Permissions", + "searchPermission": "Search by permission name, code, group, or API", + "loadingPermissions": "Loading permissions...", + "permissionCount": "{count} items", + "noPermissions": "No matching permissions", + "saving": "Saving...", + "savePermissions": "Save Permissions", + "cancel": "Cancel", + "nameRequired": "Enter a role name.", + "codeRequired": "Enter a role code.", + "codeInvalid": "Role code must start with a letter and can only include letters, numbers, colons, underscores, and hyphens.", + "createTitle": "Add Role", + "createDescription": "After creating a role, you can assign permissions and adjust its order in the list.", + "name": "Role Name", + "namePlaceholder": "For example: Support Manager", + "code": "Role Code", + "codePlaceholder": "For example: support_manager", + "remark": "Notes", + "optional": "Optional", + "creating": "Creating...", + "create": "Create Role" + }, + "workflowRun": { + "recordsTitle": "Lịch sử thực thi AI", + "loadingLogs": "Đang tải lịch sử thực thi...", + "emptyLogs": "Chưa có lịch sử thực thi AI", + "detailTitle": "Chi tiết thực thi AI", + "description": "Chuỗi thực thi Workflow", + "loadingDetail": "Đang tải chi tiết thực thi...", + "emptyDetail": "Không tìm thấy bản ghi thực thi", + "loadLogsFailed": "Không thể tải lịch sử thực thi AI", + "loadDetailFailed": "Không thể tải chi tiết thực thi AI", + "labelConversation": "Hội thoại", + "labelMessage": "Tin nhắn", + "labelAgent": "Agent", + "labelStatus": "Trạng thái", + "labelStartedAt": "Bắt đầu", + "labelEndedAt": "Kết thúc", + "labelInterruptNode": "Node gián đoạn", + "labelWorkflow": "Workflow", + "emptyNodes": "Chưa có bản ghi node nào", + "input": "Đầu vào", + "output": "Đầu ra", + "viewDetails": "Chi tiết thực thi" + }, + "agentRun": { + "conversation": "Cuộc hội thoại", + "agent": "AI Agent", + "engine": "Engine", + "status": "Trạng thái", + "startedAt": "Bắt đầu", + "duration": "Thời lượng", + "tokens": "Token Vào/Ra", + "error": "Lỗi", + "refresh": "Làm mới", + "query": "Tìm kiếm", + "loading": "Đang tải phiên chạy...", + "empty": "Chưa có phiên chạy nào", + "loadFailed": "Không thể tải danh sách phiên chạy", + "loadDetailFailed": "Không thể tải chi tiết phiên chạy", + "detailTitle": "Chi tiết phiên chạy Agent", + "detailDescription": "Xem toàn bộ luồng kiểm toán phiên chạy", + "loadingDetail": "Đang tải chi tiết phiên chạy...", + "close": "Đóng", + "revision": "Phiên bản", + "trace": "Dấu vết luồng (Trace)", + "steps": "Các bước thực thi", + "emptySteps": "Chưa có bước thực thi nào", + "toolCalls": "Lượt gọi Tool", + "emptyToolCalls": "Chưa có lượt gọi Tool nào", + "input": "Dữ liệu đầu vào", + "output": "Dữ liệu đầu ra", + "arguments": "Tham số truyền vào", + "result": "Kết quả trả về", + "notFound": "Không tìm thấy phiên chạy Agent", + "metricCompletionRate": "Tỷ lệ hoàn thành", + "metricResolutionRate": "Tỷ lệ giải quyết", + "metricUnsupportedRate": "Tỷ lệ thiếu căn cứ", + "metricToolSuccessRate": "Tỷ lệ gọi Tool thành công", + "metricAvgSteps": "Số bước trung bình", + "metricP95Latency": "Độ trễ P95", + "metricToken": "Lượng Token", + "metricHandoffRate": "Tỷ lệ chuyển giao người thật", + "metricFallbackRate": "Tỷ lệ dự phòng tri thức", + "metricInterruptRecoveryRate": "Tỷ lệ khôi phục gián đoạn", + "qualityTitle": "Đánh giá chất lượng phiên chạy", + "qualityDescription": "Tỷ lệ giải quyết và thiếu căn cứ chỉ tính trên các phiên đã đánh giá.", + "selectResolution": "Chọn tình trạng giải quyết", + "selectEvidence": "Chọn căn cứ thông tin", + "qualityCommentPlaceholder": "Ghi chú đánh giá...", + "saveQuality": "Lưu đánh giá", + "qualitySaved": "Đã lưu kết quả đánh giá", + "workflowAudit": "Kiểm toán Node Workflow", + "viewWorkflowAudit": "Xem kiểm toán Node" + }, + "wxworkOutbox": { + "statusFailed": "Thất bại", + "statusIgnored": "Đã bỏ qua", + "statusAll": "Tất cả", + "retrySuccess": "Đã đưa lại tin nhắn vào hàng đợi gửi", + "ignoreSuccess": "Đã bỏ qua bản ghi lỗi", + "actionFailed": "Thao tác thất bại", + "retry": "Thử lại", + "ignore": "Bỏ qua", + "columnStatus": "Trạng thái", + "conversationId": "Mã hội thoại", + "messageId": "Mã tin nhắn", + "columnMessage": "Tin nhắn", + "conversationLine": "Hội thoại #{id}", + "messageLine": "Tin nhắn #{id}", + "columnRetry": "Lần thử lại", + "retriesCount": "{count} lần", + "nextRetry": "Lần tới: {time}", + "columnError": "Nguyên nhân lỗi", + "columnUpdatedAt": "Thời gian cập nhật", + "columnActions": "Thao tác", + "refresh": "Làm mới", + "query": "Tìm kiếm", + "loading": "Đang tải hàng đợi hộp thư đi...", + "empty": "Không có tin nhắn lỗi trong hàng đợi", + "loadFailed": "Không thể tải hàng đợi hộp thư đi" + }, + "supportPublic": { + "brand": "AgentDesk Support", + "nav": { + "home": "Home", + "help": "Help", + "questions": "FAQ", + "login": "Log In" + }, + "home": { + "badge": "Support Center", + "title": "Docs, community answers, and live support in one place.", + "description": "Capture recurring questions like a community and organize guidance like a docs site. Search first, then contact support when needed.", + "searchPlaceholder": "Search help pages or questions", + "helpTitle": "Help Center", + "helpDescription": "Official guides, deployment notes, and troubleshooting.", + "questionsTitle": "FAQ Community", + "questionsDescription": "Browse questions, then log in to ask or answer.", + "chatTitle": "Live Support", + "chatDescription": "Open a support conversation when self-service is not enough.", + "quickPanelTitle": "Quick Links", + "quickPanelDescription": "Continue by task", + "unsolvedQuestions": "View unsolved questions", + "browseDocs": "Browse help docs", + "askQuestion": "Ask a question", + "recommendedPages": "Recommended Pages", + "hotQuestions": "Popular Questions" + }, + "help": { + "title": "Help Center", + "description": "Read official guides, deployment notes, and troubleshooting docs.", + "searchPlaceholder": "Search help docs", + "openPage": "Open help page", + "childPages": "Pages in this section", + "navigation": "Documentation", + "toc": "On this page", + "noToc": "No sections", + "updatedAt": "Updated {date}", + "feedbackTitle": "Was this page helpful?", + "feedbackDescription": "Your feedback helps us improve the documentation.", + "copyCode": "Copy code", + "mermaidDiagram": "Documentation diagram", + "mermaidPreview": "Enlarge diagram", + "mermaidLoading": "Rendering diagram...", + "mermaidError": "The diagram syntax is invalid and cannot be displayed.", + "previous": "Previous", + "next": "Next" + }, + "questions": { + "title": "FAQ Community", + "description": "Signed-in users can ask, answer, vote, and accept best answers.", + "categoryNavigation": "Question categories", + "moreCategories": "More categories", + "more": "More", + "categoriesFailed": "Could not load categories. Click to retry.", + "searchPlaceholder": "Search question titles", + "detailTitle": "Question Detail", + "answers": "Answers", + "votes": "Votes", + "views": "Views", + "askedBy": "Asked by {name}", + "updatedAt": "Updated {date}" + }, + "ask": { + "title": "Ask a Question", + "description": "Sign in to submit a question. Community members and support agents can answer.", + "formTitle": "Publish a question", + "category": "Category", + "categoryPlaceholder": "Select category", + "categorySearch": "Search categories", + "categoryEmpty": "No categories", + "categoriesFailed": "Could not load categories. Click to retry.", + "categoryRequired": "Select a category", + "questionTitle": "Question title", + "questionTitlePlaceholder": "Describe the question in one sentence", + "titleRequired": "Enter a question title", + "content": "Details", + "contentPlaceholder": "Describe the issue, environment, and what you have already tried", + "contentRequired": "Add the question details", + "tags": "Tags", + "tagsPlaceholder": "Separate tags with commas" + }, + "answer": { + "title": "Submit Answer", + "placeholder": "Write your answer", + "best": "Best Answer" + }, + "login": { + "title": "Log In to Support", + "description": "Log in to ask, answer, vote, and accept best answers.", + "name": "Name", + "namePlaceholder": "Enter your name", + "email": "Email", + "emailPlaceholder": "Enter your email", + "password": "Password", + "passwordPlaceholder": "At least 8 characters", + "loginAction": "Log In", + "registerAction": "Register and Log In", + "switchToRegister": "No account? Register", + "switchToLogin": "Already have an account? Log in" + }, + "account": { + "loading": "Loading sign-in status", + "openMenu": "Open account menu", + "signOut": "Sign out", + "signingOut": "Signing out..." + }, + "actions": { + "search": "Search", + "viewAll": "View all", + "helpful": "Helpful", + "notHelpful": "Not helpful", + "openStandalone": "Open separately", + "askQuestion": "Ask Question", + "publishQuestion": "Publish Question", + "publishAnswer": "Publish Answer", + "publishing": "Publishing...", + "processing": "Processing...", + "accept": "Accept", + "retry": "Retry", + "loadMore": "Load more", + "viewDiscussion": "View discussion", + "answerQuestion": "Answer" + }, + "status": { + "all": "All", + "normal": "Unsolved", + "resolved": "Resolved", + "closed": "Closed" + }, + "common": { + "allCategories": "All categories", + "uncategorized": "Uncategorized", + "user": "User" + }, + "empty": { + "noPages": "No published pages yet", + "noQuestions": "No community questions yet", + "noPagesMatched": "No pages found", + "pageNotFound": "This page does not exist, is not published, or has been withdrawn", + "pagesFailed": "Could not load the documentation. Try again later.", + "noQuestionsMatched": "No questions found", + "questionsFailed": "Could not load questions. Try again later.", + "noAnswers": "No answers yet", + "selectPage": "Select a page on the left" + }, + "loading": { + "categories": "Loading categories…", + "navigation": "Loading documentation...", + "page": "Loading page...", + "question": "Loading question...", + "questions": "Loading questions...", + "session": "Checking sign-in status..." + }, + "toast": { + "feedbackSaved": "Thanks for the feedback", + "codeCopied": "Code copied", + "answerCreated": "Answer published", + "questionCreated": "Question published", + "loggedIn": "Logged in" + }, + "a11y": { + "openNavigation": "Open documentation navigation", + "closeNavigation": "Close documentation navigation", + "collapse": "Collapse child pages", + "expand": "Expand child pages" + } + }, + "nav": { + "overview": "Tổng quan", + "receptionCenter": "Support Desk", + "conversations": "Cuộc hội thoại", + "tickets": "Quản lý Ticket", + "conversationMonitor": "Giám sát hội thoại", + "customers": "Khách hàng", + "companies": "Doanh nghiệp", + "agentConfig": "Support Setup", + "tags": "Thẻ phân loại", + "quickReplies": "Câu trả lời nhanh", + "agents": "AI Agents", + "agentTeamSchedules": "Team Schedules", + "channels": "Kênh liên lạc", + "wxworkOutbox": "WeCom Outbox", + "aiCapabilities": "Năng lực AI", + "knowledge": "Knowledge Base", + "support": "Support Center", + "supportCenter": "Cổng Help Center", + "supportHelp": "Help Center", + "supportFaq": "FAQ Community", + "supportFaqCategories": "FAQ Categories", + "aiConfigs": "Model Settings", + "aiAgents": "Agents", + "aiWorkflows": "AI Workflows", + "workflowRuns": "Workflow Audit", + "workflows": "Workflows", + "agentRuns": "Agent Audit", + "skillDefinition": "Skills", + "mcp": "MCP tools", + "system": "Hệ thống", + "users": "Người dùng", + "roles": "Vai trò & Quyền", + "permissions": "Danh sách quyền", + "dashboardHome": "Dashboard", + "notifications": "Thông báo", + "preferences": "Tùy chọn hiển thị", + "changePassword": "Đổi mật khẩu", + "signOut": "Đăng xuất", + "supportDesk": "Trung tâm Hỗ trợ", + "helpCenter": "Trang Hướng dẫn (Docs)", + "faqCommunity": "Cộng đồng FAQ", + "faqCategories": "Danh mục FAQ", + "supportSetup": "Cài đặt Hỗ trợ", + "humanAgents": "Nhân viên hỗ trợ", + "teamSchedules": "Lịch trực ca", + "wecomOutbox": "Hộp thư đi", + "knowledgeBase": "Cơ sở tri thức", + "modelSettings": "Cấu hình AI", + "skills": "Kỹ năng (Skills)", + "mcpTools": "Công cụ MCP", + "workflowStudio": "Workflow Studio", + "agentAudit": "Nhật ký AI", + "linkToWebsite": "Trang chủ Crove", + "docs": "Tài liệu" + }, + "workspace": { + "dashboard": "Admin Dashboard", + "workbench": "Support Workbench", + "switchWorkspace": "Switch workspace" + }, + "theme": { + "toggle": "Change theme", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "palette": { + "toggle": "Change accent color", + "plain": "Default", + "green": "Service Green", + "gray": "Neutral Gray", + "blue": "Clear Blue" + } +} \ No newline at end of file diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 2e4f7a50..783a9ac0 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -405,6 +405,11 @@ "recalling": "撤回中...", "recall": "撤回", "loadQuickRepliesFailed": "加载快捷回复失败", + "replyMode": "公开回复", + "noteMode": "内部便签", + "notePlaceholder": "输入内部便签(仅客服团队可见)...", + "addNote": "添加便签", + "internalNoteBadge": "内部便签", "tagRemoved": "已移除会话标签", "tagAdded": "已添加会话标签", "tagUpdateFailed": "更新会话标签失败", @@ -610,10 +615,27 @@ "channel": { "allTypes": "全部类型", "typeWeb": "Web 站点", + "typeEmail": "邮件客服", "typeTelegram": "Telegram Bot", "typeZaloOa": "Zalo 公众号", "typeWechatMp": "微信公众号", "typeWxworkKf": "企业微信客服", + "emailAddress": "支持邮箱地址", + "senderName": "发件人显示名称", + "emailProvider": "邮件发送服务", + "emailProviderDefault": "系统默认配置 (.env / config.yaml)", + "emailProviderSmtp": "自定义 SMTP 服务器", + "emailProviderBrevo": "Brevo API", + "emailProviderSendGrid": "SendGrid API", + "emailProviderResend": "Resend API", + "emailProviderPostmark": "Postmark API", + "emailProviderMailgun": "Mailgun API", + "emailApiKey": "API Key", + "emailAutoConnectTitle": "邮件客服自动接入", + "emailAutoConnectDescription": "将发送至支持邮箱的邮件通过 Webhook 转发至 Inbound Webhook 接口,自动创建工单并触发 AI Agent 回复。", + "configEmailDescription": "配置邮件 Inbound Webhook 接入与 SMTP / Brevo / SendGrid / Resend / Postmark / Mailgun 邮件发送。", + "forwardingAddressLabel": "自动转发专用地址(用于 Gmail / Outlook 邮件自动转发):", + "inboundWebhookUrl": "Inbound Webhook 回调地址", "botToken": "Telegram Bot Token", "botTokenRequired": "请输入 Telegram Bot Token", "botUsername": "Bot 用户名", diff --git a/web/next.config.ts b/web/next.config.mjs similarity index 78% rename from web/next.config.ts rename to web/next.config.mjs index b5c5a510..99f63e8d 100644 --- a/web/next.config.ts +++ b/web/next.config.mjs @@ -1,24 +1,24 @@ -import type { NextConfig } from "next" -import { PHASE_DEVELOPMENT_SERVER } from "next/constants" +import { PHASE_DEVELOPMENT_SERVER } from "next/constants.js"; const backendBaseUrl = process.env.NEXT_API_BASE_URL?.trim() || process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || - "http://127.0.0.1:8083" -const productionBasePath = "" + "http://127.0.0.1:8083"; +const productionBasePath = ""; -export default function nextConfig(phase: string): NextConfig { - const config: NextConfig = { +/** @type {(phase: string) => import('next').NextConfig} */ +export default function nextConfig(phase) { + const config = { output: "export", basePath: productionBasePath, assetPrefix: `${productionBasePath}/`, trailingSlash: false, devIndicators: false, reactStrictMode: false, - } + }; if (phase !== PHASE_DEVELOPMENT_SERVER) { - return config + return config; } return { @@ -45,7 +45,7 @@ export default function nextConfig(phase: string): NextConfig { source: "/storage/:path*", destination: `${backendBaseUrl}/storage/:path*`, }, - ] + ]; }, - } + }; } diff --git a/web/package.json b/web/package.json index 4f197c8f..1612c226 100644 --- a/web/package.json +++ b/web/package.json @@ -60,7 +60,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/markdown-it": "^14.1.2", - "@types/node": "^20", + "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", "@types/turndown": "^5.0.6", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a47e97fb..49685b9e 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -112,7 +112,7 @@ importers: version: 11.17.0 next: specifier: 16.2.11 - version: 16.2.11(@babel/core@7.29.6(supports-color@7.2.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.98.0) + version: 16.2.11(@babel/core@7.29.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.98.0) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -130,7 +130,7 @@ importers: version: 7.71.2(react@19.2.3) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.14)(react@19.2.3)(supports-color@7.2.0) + version: 10.1.0(@types/react@19.2.14)(react@19.2.3) react-resizable-panels: specifier: ^4.7.6 version: 4.7.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -148,10 +148,10 @@ importers: version: 4.0.0 remark-gfm: specifier: ^4.0.1 - version: 4.0.1(supports-color@7.2.0) + version: 4.0.1 shadcn: specifier: ^4.18.0 - version: 4.18.0(supports-color@7.2.0)(typescript@6.0.3) + version: 4.18.0(typescript@6.0.3) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -181,8 +181,8 @@ importers: specifier: ^14.1.2 version: 14.1.2 '@types/node': - specifier: ^20 - version: 20.19.37 + specifier: ^22 + version: 22.20.1 '@types/react': specifier: ^19 version: 19.2.14 @@ -194,10 +194,10 @@ importers: version: 5.0.6 eslint: specifier: ^9 - version: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) + version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 16.2.11 - version: 16.2.11(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + version: 16.2.11(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) sass: specifier: ^1.98.0 version: 1.98.0 @@ -1675,8 +1675,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@20.19.37': - resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -4750,20 +4750,20 @@ snapshots: '@babel/compat-data@7.29.0': {} - '@babel/core@7.29.6(supports-color@7.2.0)': + '@babel/core@7.29.6': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.8 '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0(supports-color@7.2.0) + '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -4798,41 +4798,41 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@7.2.0) + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0) - '@babel/traverse': 7.29.0(supports-color@7.2.0) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 semver: 6.3.1 transitivePeerDependencies: - supports-color '@babel/helper-globals@7.28.0': {} - '@babel/helper-member-expression-to-functions@7.28.5(supports-color@7.2.0)': + '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.29.0(supports-color@7.2.0) + '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.28.6(supports-color@7.2.0)': + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.29.0(supports-color@7.2.0) + '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@7.2.0) - '@babel/helper-module-imports': 7.28.6(supports-color@7.2.0) + '@babel/core': 7.29.6 + '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0(supports-color@7.2.0) + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -4842,18 +4842,18 @@ snapshots: '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@7.2.0) - '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0) + '@babel/core': 7.29.6 + '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0(supports-color@7.2.0) + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.27.1(supports-color@7.2.0)': + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.29.0(supports-color@7.2.0) + '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -4881,43 +4881,43 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@7.2.0) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@7.2.0) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@7.2.0) + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0)) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.6) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@7.2.0) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0)) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.6) transitivePeerDependencies: - supports-color @@ -4935,7 +4935,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - '@babel/traverse@7.29.0(supports-color@7.2.0)': + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 @@ -4943,7 +4943,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -5304,17 +5304,17 @@ snapshots: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) + eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2(supports-color@7.2.0)': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -5327,10 +5327,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5(supports-color@7.2.0)': + '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -5632,7 +5632,7 @@ snapshots: '@mixmark-io/domino@2.2.0': {} - '@modelcontextprotocol/sdk@1.27.1(supports-color@7.2.0)(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.15(hono@4.12.34) ajv: 8.18.0 @@ -5642,8 +5642,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.0.6 - express: 5.2.1(supports-color@7.2.0) - express-rate-limit: 8.3.1(express@5.2.1(supports-color@7.2.0)) + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) hono: 4.12.34 jose: 6.2.3 json-schema-typed: 8.0.2 @@ -6361,7 +6361,7 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@20.19.37': + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -6386,15 +6386,15 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.57.0 - '@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.57.0 - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) + eslint: 9.39.4(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@6.0.3) @@ -6402,23 +6402,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.57.0 '@typescript-eslint/types': 8.57.0 - '@typescript-eslint/typescript-estree': 8.57.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.57.0 - debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.57.0(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.57.0(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@6.0.3) '@typescript-eslint/types': 8.57.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -6432,13 +6432,13 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.57.0 - '@typescript-eslint/typescript-estree': 8.57.0(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -6446,13 +6446,13 @@ snapshots: '@typescript-eslint/types@8.57.0': {} - '@typescript-eslint/typescript-estree@8.57.0(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.57.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.57.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/project-service': 8.57.0(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@6.0.3) '@typescript-eslint/types': 8.57.0 '@typescript-eslint/visitor-keys': 8.57.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 @@ -6461,13 +6461,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.57.0 '@typescript-eslint/types': 8.57.0 - '@typescript-eslint/typescript-estree': 8.57.0(supports-color@7.2.0)(typescript@6.0.3) - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3) + eslint: 9.39.4(jiti@2.6.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -6689,11 +6689,11 @@ snapshots: baseline-browser-mapping@2.11.21: {} - body-parser@2.3.0(supports-color@7.2.0): + body-parser@2.3.0: dependencies: bytes: 3.1.2 content-type: 2.1.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -7092,17 +7092,13 @@ snapshots: dayjs@1.11.23: {} - debug@3.2.7(supports-color@7.2.0): + debug@3.2.7: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 7.2.0 - debug@4.4.3(supports-color@7.2.0): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 7.2.0 decimal.js-light@2.5.1: {} @@ -7318,18 +7314,18 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.2.11(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): + eslint-config-next@16.2.11(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: '@next/eslint-plugin-next': 16.2.11 - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) - eslint-import-resolver-node: 0.3.9(supports-color@7.2.0) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0)) - eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) globals: 16.4.0 - typescript-eslint: 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + typescript-eslint: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -7338,52 +7334,52 @@ snapshots: - eslint-plugin-import-x - supports-color - eslint-import-resolver-node@0.3.9(supports-color@7.2.0): + eslint-import-resolver-node@0.3.9: dependencies: - debug: 3.2.7(supports-color@7.2.0) + debug: 3.2.7 is-core-module: 2.16.1 resolve: 1.22.11 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) get-tsconfig: 4.13.6 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: - debug: 3.2.7(supports-color@7.2.0) + debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) - eslint-import-resolver-node: 0.3.9(supports-color@7.2.0) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) + '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7(supports-color@7.2.0) + debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) - eslint-import-resolver-node: 0.3.9(supports-color@7.2.0) - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -7395,13 +7391,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -7411,7 +7407,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) + eslint: 9.39.4(jiti@2.6.1) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -7420,18 +7416,18 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): dependencies: - '@babel/core': 7.29.6(supports-color@7.2.0) + '@babel/core': 7.29.6 '@babel/parser': 7.29.0 - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) + eslint: 9.39.4(jiti@2.6.1) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0)): + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -7439,7 +7435,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.3.1 - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) + eslint: 9.39.4(jiti@2.6.1) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -7464,14 +7460,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0): + eslint@9.39.4(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2(supports-color@7.2.0) + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5(supports-color@7.2.0) + '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -7481,7 +7477,7 @@ snapshots: ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -7564,25 +7560,25 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - express-rate-limit@8.3.1(express@5.2.1(supports-color@7.2.0)): + express-rate-limit@8.3.1(express@5.2.1): dependencies: - express: 5.2.1(supports-color@7.2.0) + express: 5.2.1 ip-address: 10.3.1 - express@5.2.1(supports-color@7.2.0): + express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.3.0(supports-color@7.2.0) + body-parser: 2.3.0 content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1(supports-color@7.2.0) + finalhandler: 2.1.1 fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -7593,9 +7589,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.16.0 range-parser: 1.2.1 - router: 2.2.0(supports-color@7.2.0) - send: 1.2.1(supports-color@7.2.0) - serve-static: 2.2.1(supports-color@7.2.0) + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 statuses: 2.0.2 type-is: 2.0.1 vary: 1.1.2 @@ -7654,9 +7650,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1(supports-color@7.2.0): + finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -7800,7 +7796,7 @@ snapshots: dependencies: '@types/hast': 3.0.5 - hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.5 @@ -7809,9 +7805,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -8296,14 +8292,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3(supports-color@7.2.0): + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2(supports-color@7.2.0) + micromark: 4.0.2 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -8321,67 +8317,67 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): + mdast-util-gfm-footnote@2.1.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): + mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0(supports-color@7.2.0): + mdast-util-gfm-table@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): + mdast-util-gfm-task-list-item@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0(supports-color@7.2.0): + mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) - mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0): + mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 @@ -8389,7 +8385,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -8398,13 +8394,13 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0): + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -8653,10 +8649,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2(supports-color@7.2.0): + micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -8715,7 +8711,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.2.11(@babel/core@7.29.6(supports-color@7.2.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.98.0): + next@16.2.11(@babel/core@7.29.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.98.0): dependencies: '@next/env': 16.2.11 '@swc/helpers': 0.5.15 @@ -8724,7 +8720,7 @@ snapshots: postcss: 8.5.23 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.29.6(supports-color@7.2.0))(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.29.6)(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.2.11 '@next/swc-darwin-x64': 16.2.11 @@ -9091,17 +9087,17 @@ snapshots: react-is@18.3.1: {} - react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.3)(supports-color@7.2.0): + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.3): dependencies: '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/react': 19.2.14 devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) + hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 react: 19.2.3 - remark-parse: 11.0.0(supports-color@7.2.0) + remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 unist-util-visit: 5.1.0 @@ -9226,21 +9222,21 @@ snapshots: mdast-util-newline-to-break: 2.0.0 unified: 11.0.5 - remark-gfm@4.0.1(supports-color@7.2.0): + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0(supports-color@7.2.0) + mdast-util-gfm: 3.1.0 micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0(supports-color@7.2.0) + remark-parse: 11.0.0 remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-parse@11.0.0(supports-color@7.2.0): + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -9301,9 +9297,9 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 - router@2.2.0(supports-color@7.2.0): + router@2.2.0: dependencies: - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -9357,9 +9353,9 @@ snapshots: semver@7.8.5: optional: true - send@1.2.1(supports-color@7.2.0): + send@1.2.1: dependencies: - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -9373,12 +9369,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.2.1(supports-color@7.2.0): + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1(supports-color@7.2.0) + send: 1.2.1 transitivePeerDependencies: - supports-color @@ -9406,14 +9402,14 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.18.0(supports-color@7.2.0)(typescript@6.0.3): + shadcn@4.18.0(typescript@6.0.3): dependencies: - '@babel/core': 7.29.6(supports-color@7.2.0) + '@babel/core': 7.29.6 '@babel/parser': 7.29.0 - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.6) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.6) '@dotenvx/dotenvx': 1.55.0 - '@modelcontextprotocol/sdk': 1.27.1(supports-color@7.2.0)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.7 commander: 14.0.3 @@ -9658,12 +9654,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.6(supports-color@7.2.0))(react@19.2.3): + styled-jsx@5.1.6(@babel/core@7.29.6)(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 optionalDependencies: - '@babel/core': 7.29.6(supports-color@7.2.0) + '@babel/core': 7.29.6 stylis@4.4.0: {} @@ -9788,13 +9784,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): + typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.57.0(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0) + '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + eslint: 9.39.4(jiti@2.6.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index 7150c25b..47214672 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -1,3 +1,8 @@ +allowBuilds: + '@parcel/watcher': set this to true or false + sharp: set this to true or false + unrs-resolver: set this to true or false + ignoredBuiltDependencies: - sharp - unrs-resolver diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 00000000..a45aee63 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/web/public/images/logo.svg b/web/public/images/logo.svg index 8350f616..60e7f996 100644 --- a/web/public/images/logo.svg +++ b/web/public/images/logo.svg @@ -1,41 +1,15 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - diff --git a/web/scripts/build-sdk.mjs b/web/scripts/build-sdk.mjs index b1c07128..062c80a1 100644 --- a/web/scripts/build-sdk.mjs +++ b/web/scripts/build-sdk.mjs @@ -12,6 +12,7 @@ const target = path.join(targetDir, "agent-desk-sdk.min.js"); await mkdir(targetDir, { recursive: true }); const sourceCode = await readFile(source, "utf8"); + const compiled = ts.transpileModule(sourceCode, { compilerOptions: { target: ts.ScriptTarget.ES2017, @@ -21,6 +22,7 @@ const compiled = ts.transpileModule(sourceCode, { }, fileName: source, }); + const compiledCode = compiled.outputText.replace(/\nexport\s*\{\};?\s*$/, ""); const result = await minify(compiledCode, { compress: { diff --git a/web/scripts/generate-vi-messages.mjs b/web/scripts/generate-vi-messages.mjs new file mode 100644 index 00000000..782e4056 --- /dev/null +++ b/web/scripts/generate-vi-messages.mjs @@ -0,0 +1,402 @@ +import fs from "node:fs" +import path from "node:path" + +const enPath = path.resolve("web/messages/en-US.json") +const enData = JSON.parse(fs.readFileSync(enPath, "utf-8")) + +// Deep clone +const viData = JSON.parse(JSON.stringify(enData)) + +// Common & Core Overrides +viData.app = { + brand: "Crove Desk", + metadataTitle: "Crove Desk - Hệ thống CSKH & Trợ lý AI", + metadataDescription: "Crove Desk - Nền tảng chăm sóc khách hàng và hỗ trợ thông minh AI", +} + +viData.common = { + language: "Ngôn ngữ", + notSignedIn: "Chưa đăng nhập", + guest: "Khách", + loading: "Đang tải...", + loadingData: "Đang tải dữ liệu...", + emptyData: "Không có dữ liệu", + searchKeyword: "Tìm theo từ khóa", + emptyOptions: "Không có tùy chọn", + close: "Đóng", + fullscreen: "Toàn màn hình", + exitFullscreen: "Thoát toàn màn hình", + selectedCount: "Đã chọn {count} mục", + expand: "Mở rộng", + collapse: "Thu gọn", + cancel: "Hủy", + confirm: "Xác nhận", + save: "Lưu", + delete: "Xóa", + edit: "Chỉnh sửa", + create: "Tạo mới", + refresh: "Làm mới", + actions: "Thao tác", + status: "Trạng thái", + name: "Tên", + description: "Mô tả", +} + +viData.nav = { + ...viData.nav, + preferences: "Tùy chọn hiển thị", + notifications: "Thông báo", + changePassword: "Đổi mật khẩu", + signOut: "Đăng xuất", + supportDesk: "Trung tâm Hỗ trợ", + overview: "Tổng quan", + conversations: "Cuộc hội thoại", + tickets: "Quản lý Ticket", + conversationMonitor: "Giám sát hội thoại", + customers: "Khách hàng", + companies: "Doanh nghiệp", + supportCenter: "Cổng Help Center", + helpCenter: "Trang Hướng dẫn (Docs)", + faqCommunity: "Cộng đồng FAQ", + faqCategories: "Danh mục FAQ", + supportSetup: "Cài đặt Hỗ trợ", + tags: "Thẻ phân loại", + quickReplies: "Câu trả lời nhanh", + humanAgents: "Nhân viên hỗ trợ", + teamSchedules: "Lịch trực ca", + channels: "Kênh liên lạc", + wecomOutbox: "Hộp thư đi", + aiCapabilities: "Năng lực AI", + knowledgeBase: "Cơ sở tri thức", + modelSettings: "Cấu hình AI", + agents: "AI Agents", + skills: "Kỹ năng (Skills)", + mcpTools: "Công cụ MCP", + workflowStudio: "Workflow Studio", + agentAudit: "Nhật ký AI", + system: "Hệ thống", + users: "Người dùng", + roles: "Vai trò & Quyền", + permissions: "Danh sách quyền", + linkToWebsite: "Trang chủ Crove", + docs: "Tài liệu", +} + +viData.auth = { + ...viData.auth, + welcome: "Chào mừng trở lại", + loginDescription: "Đăng nhập vào hệ sinh thái {brand}", + signIn: "Đăng nhập", + signingIn: "Đang đăng nhập...", + username: "Tên đăng nhập / Email", + password: "Mật khẩu", + optionsLoadFailed: "Không thể tải cấu hình đăng nhập", + loadingOptions: "Đang tải cấu hình...", + loginWithSSO: "Đăng nhập bằng DOS ID", +} + +viData.aiAgent = { + ...viData.aiAgent, + allStatuses: "Tất cả trạng thái", + enabled: "Đang hoạt động", + disabled: "Đã tắt", + loadFailed: "Không thể tải danh sách AI agent.", + updated: "Đã cập nhật AI agent: {name}", + created: "Đã tạo AI agent: {name}", + saveFailed: "Không thể lưu AI agent.", + statusChanged: "{name} {status}", + deleted: "Đã xóa AI agent: {name}", + refresh: "Làm mới", + new: "Tạo AI Agent", + createTitle: "Tạo mới AI Agent", + editTitle: "Chỉnh sửa AI Agent", + filterName: "Lọc theo tên", + query: "Tìm kiếm", + columnAiConfig: "Cấu hình AI", + columnServiceMode: "Chế độ phục vụ", + columnKnowledge: "Cơ sở tri thức", + columnSkills: "Kỹ năng", + columnCapabilities: "Năng lực", + columnStatus: "Trạng thái", + columnActions: "Thao tác", + serviceAiOnly: "Chỉ dùng AI", + serviceHumanOnly: "Chỉ dùng Người thật", + serviceAiFirst: "Ưu tiên AI trước", + sectionSetup: "Định danh & Vận hành", + sectionPersona: "Vai trò & Phản hồi", + sectionCapability: "Tri thức & Kỹ năng", + sectionService: "Điều phối & Dự phòng", + sectionBasic: "Thông tin cơ bản", + basicDescription: "Cấu hình tên hệ thống, chế độ phục vụ và mô tả nhiệm vụ của Agent.", + name: "Tên Agent", + description: "Mô tả nhiệm vụ", + sectionModel: "Mô hình & Phản hồi", + modelDescription: "Chọn mô hình suy luận AI và thời gian chờ phản hồi.", + aiConfig: "Cấu hình AI", + selectAiConfig: "Chọn cấu hình AI", + searchAiConfig: "Tìm cấu hình AI", + emptyAiConfig: "Không có cấu hình AI nào", + replyTimeout: "Thời gian chờ phản hồi", + replyTimeoutSeconds: "Thời gian phản hồi (giây)", + seconds: "giây", + sectionRole: "Lời nhắc hệ thống (System Prompt)", + systemPrompt: "System Prompt", + personaDescription: "Định nghĩa vai trò, nhiệm vụ và giới hạn phản hồi của Agent (hỗ trợ Markdown).", + sectionGreeting: "Lời chào mở đầu", + greetingDescription: "Tin nhắn chào mừng tự động gửi khi khách hàng bắt đầu phiên chat.", + welcomeMessage: "Nội dung lời chào", + sectionKnowledge: "Cơ sở tri thức", + knowledgeDescription: "Phạm vi tài liệu tri thức Agent có thể truy xuất khi trả lời.", + selectKnowledge: "Chọn cơ sở tri thức", + searchKnowledge: "Tìm cơ sở tri thức", + emptyKnowledge: "Không có cơ sở tri thức khả dụng", + sectionSkill: "Kỹ năng (Skills)", + skillDescription: "Các năng lực và luồng nghiệp vụ tiêu chuẩn Agent có thể kích hoạt.", + selectSkill: "Chọn kỹ năng", + emptySkill: "Không có kỹ năng khả dụng", + sectionWorkflow: "Quy trình (Workflow)", + workflowDescription: "Liên kết các phiên bản quy trình đã xuất bản từ Workflow Studio.", + selectWorkflow: "Chọn quy trình", + emptyWorkflow: "Chưa có quy trình nào xuất bản", + sectionMcp: "Công cụ MCP (MCP Tools)", + mcpDescription: "Chọn các công cụ MCP Tools mà Agent được phép gọi.", + selectMCPTool: "Chọn MCP Tool", + emptyMCPTool: "Không có MCP Tool khả dụng", + sectionHandoff: "Chuyển giao cho người thật", + handoffDescription: "Cấu hình phương thức chuyển tiếp và đội ngũ tiếp nhận khi cần người hỗ trợ.", + handoffMode: "Phương thức chuyển tiếp", + selectHandoffMode: "Chọn phương thức chuyển tiếp", + teams: "Đội ngũ tiếp nhận", + selectTeam: "Chọn đội ngũ", + emptyTeam: "Chưa có đội ngũ", + sectionFallback: "Dự phòng khi thiếu dữ liệu", + fallbackDescription: "Cấu hình chiến lược xử lý và nội dung phản hồi khi AI không đủ căn cứ trả lời.", + fallbackMode: "Chiến lược dự phòng", + selectFallbackMode: "Chọn chiến lược dự phòng", + fallbackMessage: "Nội dung phản hồi dự phòng", + fallbackNoAnswer: "Thông báo không có câu trả lời", + fallbackSuggestRetry: "Đề xuất khách hỏi cụ thể hơn", + publishAgent: "Xuất bản Agent", + saveConfig: "Lưu cấu hình", + publishedNote: "Phiên bản đã xuất bản đang hoạt động; xuất bản lại để áp dụng cấu hình mới", + unpublishedNote: "Hệ thống sẽ tự động lưu cấu hình trước khi xuất bản", + readOnly: "Chỉ đọc", + writeAction: "Ghi dữ liệu (Cần xác nhận)", + systemDefined: "Hệ thống định nghĩa", + activeRevision: "Đang áp dụng", + rollback: "Khôi phục", + versionHistory: "Lịch sử phiên bản", + notCreated: "Chưa khởi tạo", + published: "Đã xuất bản", + unpublished: "Chưa xuất bản", + noRevisions: "Chưa có phiên bản Agent nào được xuất bản.", +} + +viData.supportQuestionAdmin = { + allCategories: "Tất cả danh mục", + allStatuses: "Tất cả trạng thái", + statusPending: "Chờ kiểm duyệt", + statusNormal: "Bình thường", + statusResolved: "Đã giải quyết", + statusClosed: "Đã đóng", + statusHidden: "Đã ẩn", + statusUpdated: "Đã cập nhật trạng thái câu hỏi.", + answerPublished: "Đã đăng câu trả lời thành công.", + answersCount: "{count} câu trả lời", + votesAndViews: "{votes} lượt thích · {views} lượt xem", + moderateNormal: "Khôi phục bình thường", + moderateClose: "Đóng câu hỏi", + moderateHide: "Ẩn câu hỏi", + setBest: "Chọn làm câu trả lời tốt nhất", + bestAnswer: "Câu trả lời hay nhất", + replyPlaceholder: "Nhập câu trả lời của nhân viên hỗ trợ...", + publishReply: "Đăng câu trả lời", + empty: "Không có câu hỏi nào phù hợp.", + loading: "Đang tải danh sách câu hỏi...", + title: "Xử lý câu hỏi", + userFallback: "Người dùng", + uncategorized: "Chưa phân loại", +} + +viData.aiWorkflow = { + ...viData.aiWorkflow, + filterName: "Tên quy trình", + searchName: "Tìm theo tên quy trình", + columnWorkflow: "Quy trình", + noDescription: "Chưa có mô tả nghiệp vụ", + columnStatus: "Trạng thái", + published: "Đã xuất bản", + draft: "Bản nháp", + currentVersion: "Phiên bản hiện tại", + publishedVersion: "Đã xuất bản #{version}", + versionHistory: "Lịch sử phiên bản", + usage: "Mức độ sử dụng", + editTitle: "Chỉnh sửa quy trình", + createTitle: "Tạo mới quy trình", + refresh: "Làm mới", + create: "Tạo quy trình", + query: "Tìm kiếm", + loading: "Đang tải danh sách quy trình...", + empty: "Chưa có quy trình nào", + actions: "Thao tác", + edit: "Chỉnh sửa", + delete: "Xóa", + processing: "Đang xử lý...", + moreActions: "Thao tác khác cho {name}", + loadFailed: "Không thể tải danh sách quy trình.", + saveFailed: "Không thể lưu quy trình.", + deleteFailed: "Không thể xóa quy trình.", + created: "Đã tạo quy trình: {name}", + updated: "Đã cập nhật quy trình: {name}", + deleted: "Đã xóa quy trình: {name}", + nameRequired: "Vui lòng nhập tên quy trình", + nameSaved: "Đã lưu tên quy trình", + descriptionSaved: "Đã lưu mô tả", + savedSuccess: "Lưu thành công", + saveFirst: "Vui lòng lưu trước", + publishedVersionSuccess: "Đã xuất bản v{version}", + publishFailed: "Xuất bản thất bại", + nameLabel: "Tên quy trình", + loadingNodeCapabilities: "Đang tải danh mục node...", + nodeStart: "Bắt đầu", + nodeEnd: "Kết thúc", + usageTitle: "Mức độ sử dụng", + loadingUsage: "Đang tải dữ liệu sử dụng...", + fixedBoundVersion: "Liên kết cố định v{version}", + enabledStatus: "Bật", + disabledStatus: "Tắt", + notUsedByAnyAgent: "Chưa được Agent nào sử dụng", + versionsTitle: "Lịch sử phiên bản", + loadingVersions: "Đang tải lịch sử phiên bản...", + publisher: "Người xuất bản: {name}", + restoring: "Đang khôi phục...", + restoreToDraft: "Khôi phục thành bản nháp", + restoredDraftSuccess: "Đã khôi phục v{version} thành bản nháp", + restoreFailed: "Khôi phục phiên bản thất bại", + noVersionsYet: "Chưa có phiên bản nào được xuất bản", + editorTitle: "Trình thiết kế quy trình FlowGram", + loadDetailFailed: "Không thể tải chi tiết lịch sử node", + executedCount: "Đã thực thi {count}", + errorStatus: "Lỗi", + nodeTraceTitle: "Lịch sử node", + nodeTraceHint: "Nhấn để xem dữ liệu đầu vào, đầu ra và lỗi", + noNodeRuns: "Chưa có bản ghi thực thi node nào.", + inputTitle: "Đầu vào", + outputTitle: "Đầu ra", + noneValue: "Không có" +} + +viData.agentRun = { + ...viData.agentRun, + conversation: "Cuộc hội thoại", + agent: "AI Agent", + engine: "Engine", + status: "Trạng thái", + startedAt: "Bắt đầu", + duration: "Thời lượng", + tokens: "Token Vào/Ra", + error: "Lỗi", + refresh: "Làm mới", + query: "Tìm kiếm", + loading: "Đang tải phiên chạy...", + empty: "Chưa có phiên chạy nào", + loadFailed: "Không thể tải danh sách phiên chạy", + loadDetailFailed: "Không thể tải chi tiết phiên chạy", + detailTitle: "Chi tiết phiên chạy Agent", + detailDescription: "Xem toàn bộ luồng kiểm toán phiên chạy", + loadingDetail: "Đang tải chi tiết phiên chạy...", + close: "Đóng", + revision: "Phiên bản", + trace: "Dấu vết luồng (Trace)", + steps: "Các bước thực thi", + emptySteps: "Chưa có bước thực thi nào", + toolCalls: "Lượt gọi Tool", + emptyToolCalls: "Chưa có lượt gọi Tool nào", + input: "Dữ liệu đầu vào", + output: "Dữ liệu đầu ra", + arguments: "Tham số truyền vào", + result: "Kết quả trả về", + notFound: "Không tìm thấy phiên chạy Agent", + metricCompletionRate: "Tỷ lệ hoàn thành", + metricResolutionRate: "Tỷ lệ giải quyết", + metricUnsupportedRate: "Tỷ lệ thiếu căn cứ", + metricToolSuccessRate: "Tỷ lệ gọi Tool thành công", + metricAvgSteps: "Số bước trung bình", + metricP95Latency: "Độ trễ P95", + metricToken: "Lượng Token", + metricHandoffRate: "Tỷ lệ chuyển giao người thật", + metricFallbackRate: "Tỷ lệ dự phòng tri thức", + metricInterruptRecoveryRate: "Tỷ lệ khôi phục gián đoạn", + qualityTitle: "Đánh giá chất lượng phiên chạy", + qualityDescription: "Tỷ lệ giải quyết và thiếu căn cứ chỉ tính trên các phiên đã đánh giá.", + selectResolution: "Chọn tình trạng giải quyết", + selectEvidence: "Chọn căn cứ thông tin", + qualityCommentPlaceholder: "Ghi chú đánh giá...", + saveQuality: "Lưu đánh giá", + qualitySaved: "Đã lưu kết quả đánh giá", + workflowAudit: "Kiểm toán Node Workflow", + viewWorkflowAudit: "Xem kiểm toán Node", +} + +viData.wxworkOutbox = { + statusFailed: "Thất bại", + statusIgnored: "Đã bỏ qua", + statusAll: "Tất cả", + retrySuccess: "Đã đưa lại tin nhắn vào hàng đợi gửi", + ignoreSuccess: "Đã bỏ qua bản ghi lỗi", + actionFailed: "Thao tác thất bại", + retry: "Thử lại", + ignore: "Bỏ qua", + columnStatus: "Trạng thái", + conversationId: "Mã hội thoại", + messageId: "Mã tin nhắn", + columnMessage: "Tin nhắn", + conversationLine: "Hội thoại #{id}", + messageLine: "Tin nhắn #{id}", + columnRetry: "Lần thử lại", + retriesCount: "{count} lần", + nextRetry: "Lần tới: {time}", + columnError: "Nguyên nhân lỗi", + columnUpdatedAt: "Thời gian cập nhật", + columnActions: "Thao tác", + refresh: "Làm mới", + query: "Tìm kiếm", + loading: "Đang tải hàng đợi hộp thư đi...", + empty: "Không có tin nhắn lỗi trong hàng đợi", + loadFailed: "Không thể tải hàng đợi hộp thư đi", +} + +viData.workflowRun = { + recordsTitle: "Lịch sử thực thi AI", + loadingLogs: "Đang tải lịch sử thực thi...", + emptyLogs: "Chưa có lịch sử thực thi AI", + detailTitle: "Chi tiết thực thi AI", + description: "Chuỗi thực thi Workflow", + loadingDetail: "Đang tải chi tiết thực thi...", + emptyDetail: "Không tìm thấy bản ghi thực thi", + loadLogsFailed: "Không thể tải lịch sử thực thi AI", + loadDetailFailed: "Không thể tải chi tiết thực thi AI", + labelConversation: "Hội thoại", + labelMessage: "Tin nhắn", + labelAgent: "Agent", + labelStatus: "Trạng thái", + labelStartedAt: "Bắt đầu", + labelEndedAt: "Kết thúc", + labelInterruptNode: "Node gián đoạn", + labelWorkflow: "Workflow", + emptyNodes: "Chưa có bản ghi node nào", + input: "Đầu vào", + output: "Đầu ra", + viewDetails: "Chi tiết thực thi", +} + +viData.language = { + enUS: "English", + zhCN: "Tiếng Trung (中文)", + viVN: "Tiếng Việt", + selectLanguage: "Chọn ngôn ngữ giao diện", +} + +const viPath = path.resolve("web/messages/vi-VN.json") +fs.writeFileSync(viPath, JSON.stringify(viData, null, 2), "utf-8") +console.log("Successfully generated web/messages/vi-VN.json")