chore: import clean Agent Desk snapshot (1/7)
149
.codex/skills/git-commit/SKILL.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
name: git-commit
|
||||
description: Review local git changes, write an appropriate commit message, create commits, and push the current branch to every remote. Use when Codex needs to handle repository submission work such as checking `git status`, summarizing diffs, committing staged or unstaged changes, committing and pushing git submodules before the parent repository, then committing the parent repository with the updated submodule pointer and pushing the active branch to all remotes.
|
||||
---
|
||||
|
||||
# Git Commit
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill for repository submission work. Inspect changes first, derive a commit message from the actual diff, commit and push submodules first when they are involved, then commit the parent repository with the updated submodule pointer and push the current branch to every remote.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read repository state before changing anything.
|
||||
2. Detect submodules and handle them before touching the parent repository commit.
|
||||
3. For each affected submodule, commit and push the submodule repository first.
|
||||
4. Return to the parent repository and commit the parent repository with the updated submodule pointer plus any related parent-repository changes.
|
||||
5. Push the current parent-repository branch to every remote after the parent commit succeeds.
|
||||
|
||||
## Inspect Repository State
|
||||
|
||||
Run these checks up front:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git branch --show-current
|
||||
git remote -v
|
||||
git submodule status
|
||||
```
|
||||
|
||||
Inspect the actual diff before writing a commit message:
|
||||
|
||||
```bash
|
||||
git diff --stat
|
||||
git diff --submodule=log
|
||||
```
|
||||
|
||||
If the worktree is clean, report that there is nothing to commit. Do not create an empty commit unless the user explicitly asks for one.
|
||||
|
||||
## Handle Submodules First
|
||||
|
||||
Treat submodules as independent repositories, but always finish them before the parent repository.
|
||||
|
||||
- If `git status --short` in the parent repo shows a changed submodule entry, inspect whether the submodule itself has uncommitted work.
|
||||
- If the parent repository shows a changed submodule pointer, enter the submodule even when its worktree is clean.
|
||||
- For each dirty submodule, enter the submodule and run the same inspection flow there first.
|
||||
- Write a commit message for the submodule based on its own diff, not the parent repository diff.
|
||||
- Commit and push the submodule before committing the parent repository.
|
||||
- If the submodule worktree is clean but the parent pointer changed, verify that the submodule `HEAD` commit exists on the submodule remotes and push it if needed before committing the parent repository.
|
||||
- Return to the parent repository and verify that the parent diff now contains the updated submodule pointer, and include that pointer update in the parent commit.
|
||||
|
||||
Use commands like:
|
||||
|
||||
```bash
|
||||
git -C <submodule-path> status --short
|
||||
git -C <submodule-path> diff --stat
|
||||
git -C <submodule-path> branch --show-current
|
||||
git -C <submodule-path> remote -v
|
||||
git -C <submodule-path> rev-parse HEAD
|
||||
git -C <submodule-path> ls-remote --heads <remote>
|
||||
git -C <submodule-path> add -A
|
||||
git -C <submodule-path> commit -m "<message>"
|
||||
git -C <submodule-path> push <remote> <branch>
|
||||
```
|
||||
|
||||
If a submodule has multiple remotes, push its current branch to every remote exactly like the parent repository.
|
||||
|
||||
Before the parent repository commit, explicitly validate the submodule remote state:
|
||||
|
||||
- Read the submodule `HEAD` commit with `git -C <submodule-path> rev-parse HEAD`.
|
||||
- Read the submodule current branch with `git -C <submodule-path> branch --show-current`.
|
||||
- Compare against each submodule remote branch with `git -C <submodule-path> rev-list <remote>/<branch>..HEAD`.
|
||||
- If the branch has outgoing commits, push that submodule branch to every remote before touching the parent repository commit.
|
||||
- If the submodule is in detached `HEAD`, stop and report it unless the user explicitly asks to push a detached commit reference.
|
||||
|
||||
After all affected submodules are pushed, re-check the parent repository diff and make sure the parent commit includes:
|
||||
|
||||
- The submodule pointer update for each affected submodule.
|
||||
- Any related parent-repository files that should travel with that submodule change.
|
||||
- No unrelated user changes unless the user explicitly asks for a broader commit.
|
||||
|
||||
## Write Commit Messages
|
||||
|
||||
Base the message on the diff, not on filenames alone.
|
||||
|
||||
- Prefer a short imperative subject.
|
||||
- Keep the subject scoped to the dominant change.
|
||||
- Add a body when the diff contains multiple related updates that need context.
|
||||
- If the user asks for a comment or annotation, treat that as the commit message request and still ground it in the diff.
|
||||
|
||||
Good patterns:
|
||||
|
||||
- `docs: fix design spec links`
|
||||
- `feat(ticket): add workbench redesign docs`
|
||||
- `refactor(api): simplify auth middleware wiring`
|
||||
|
||||
## Commit Safely
|
||||
|
||||
Before committing, confirm the change set is understood.
|
||||
|
||||
- Use `git add` only for the files intended for this commit.
|
||||
- Do not revert unrelated user changes.
|
||||
- Do not amend an existing commit unless the user explicitly asks.
|
||||
- If there are unrelated dirty files and the target commit should stay focused, stage only the intended files instead of using a broad `git add -A`.
|
||||
- When submodules are involved, the parent repository commit must include the updated submodule pointer after the submodule push succeeds.
|
||||
|
||||
Typical commands:
|
||||
|
||||
```bash
|
||||
git add <paths...>
|
||||
git commit -m "<subject>" -m "<optional body>"
|
||||
```
|
||||
|
||||
## Push Every Remote
|
||||
|
||||
After local commits succeed, push the current branch to every push remote.
|
||||
|
||||
- Get the active branch with `git branch --show-current`.
|
||||
- Enumerate remotes with `git remote -v`.
|
||||
- Deduplicate remote names.
|
||||
- Push the same branch to every remote.
|
||||
- Prefer parallel pushes when remotes are independent, so one slow remote does not block the others.
|
||||
|
||||
Typical pattern:
|
||||
|
||||
```bash
|
||||
git push <remote> <current-branch>
|
||||
```
|
||||
|
||||
If one remote succeeds and another fails, report the partial result clearly and include the failing remote plus the error.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- If authentication or network access fails on a remote, keep the successful pushes and report which remotes still need retry.
|
||||
- If a submodule push fails, or if the referenced submodule commit is not confirmed on the submodule remotes, do not commit the parent repository submodule pointer unless the user explicitly asks to proceed with that inconsistent state.
|
||||
- If the parent repository has no changes after submodule processing, report that explicitly.
|
||||
- If a command hangs during remote access, retry with a non-interactive or bounded-timeout variant to surface a concrete error.
|
||||
- Do not push the parent repository before the parent commit that contains the updated submodule pointer is created.
|
||||
|
||||
## Completion Checklist
|
||||
|
||||
- Submodule worktrees inspected
|
||||
- Dirty submodules committed and pushed first
|
||||
- Changed submodule pointers validated against submodule remotes
|
||||
- Parent repository diff re-checked after submodule updates
|
||||
- Parent repository commit includes the updated submodule pointer
|
||||
- Parent repository committed with a diff-based message
|
||||
- Current branch pushed to every remote
|
||||
- Final status and any failed remotes reported back to the user
|
||||
4
.codex/skills/git-commit/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Git Commit"
|
||||
short_description: "Commit changes, handle submodules, push all remotes."
|
||||
default_prompt: "Use $git-commit to inspect local git changes, commit dirty submodules first, commit the parent repo, and push the current branch to every remote."
|
||||
225
.codex/skills/release-version/SKILL.md
Normal file
@@ -0,0 +1,225 @@
|
||||
---
|
||||
name: release-version
|
||||
description: Use when preparing or publishing a new semantic version release, updating bilingual changelogs, creating Git tags, or publishing GitHub/Gitee release pages for this repository.
|
||||
---
|
||||
|
||||
# Release Version
|
||||
|
||||
## Overview
|
||||
|
||||
Create releases with a strict `vx.y.z` tag, produce bilingual changelog entries from actual Git history, publish both the `docs` submodule update and repository tag, and create the GitHub and Gitee Release page entries in one controlled workflow.
|
||||
|
||||
Run the workflow from the repository root. Read [references/changelog-style.md](references/changelog-style.md) before drafting the human-facing update notes.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Validate the requested version.
|
||||
2. Inspect the repository and determine the comparison range.
|
||||
3. Draft bilingual changelog entries from the actual diff.
|
||||
4. Commit and push the `docs` submodule.
|
||||
5. Commit the parent repository update if the submodule pointer changed.
|
||||
6. Create and push the annotated tag.
|
||||
7. Create GitHub and Gitee Release page entries for the tag.
|
||||
8. Verify both remote tags and both Release pages.
|
||||
|
||||
Do not skip the repository inspection step. Release notes must come from the real diff between tags, not from guesswork.
|
||||
|
||||
## Validate The Version
|
||||
|
||||
- Accept only tags that match `^v\d+\.\d+\.\d+$`.
|
||||
- Reject date-style tags such as `v20260414`.
|
||||
- Confirm the target tag does not already exist locally or on any configured remote.
|
||||
- Prefer the latest reachable semver tag as the previous release tag.
|
||||
- If no earlier semver tag exists, fall back to the latest reachable tag of any format and state that fallback in the changelog drafting notes.
|
||||
|
||||
Use the helper script first:
|
||||
|
||||
```bash
|
||||
python3 .codex/skills/release-version/scripts/collect_release_context.py \
|
||||
--repo . \
|
||||
--tag v1.2.3
|
||||
```
|
||||
|
||||
If the caller already specifies the previous tag, pass it explicitly:
|
||||
|
||||
```bash
|
||||
python3 .codex/skills/release-version/scripts/collect_release_context.py \
|
||||
--repo . \
|
||||
--tag v1.2.3 \
|
||||
--previous-tag v1.2.2
|
||||
```
|
||||
|
||||
If the repository-local helper is unavailable, fall back to `~/.codex/skills/release-version/scripts/collect_release_context.py`.
|
||||
|
||||
## Inspect The Repository
|
||||
|
||||
- Check `git status --short` in the parent repo.
|
||||
- Check `git -C docs status --short` in the `docs` submodule.
|
||||
- Read the JSON output of `collect_release_context.py`.
|
||||
- Use the commit list, changed files, and insertions/deletions to decide what is user-visible.
|
||||
- Prioritize behavior changes, new features, fixes, migrations, API changes, configuration changes, and documentation changes that matter to adopters.
|
||||
- Ignore pure formatting churn unless it changes usage.
|
||||
|
||||
If the working tree contains unrelated changes that would be risky to include in the release, stop and ask the user before proceeding.
|
||||
|
||||
## Draft The Changelog
|
||||
|
||||
Update these files:
|
||||
|
||||
- `docs/zh/docs/changelog.md`
|
||||
- `docs/en/docs/changelog.md`
|
||||
|
||||
Prepend a new entry using this exact structure:
|
||||
|
||||
```md
|
||||
## ${tag} (${yyyy-MM-dd})
|
||||
|
||||
### 更新内容
|
||||
|
||||
${content}
|
||||
|
||||
### 发布地址
|
||||
|
||||
- Github: <https://github.com/huabeitech/agent-desk/releases/tag/${tag}>
|
||||
- Gitee: <https://gitee.com/huabeitech/agent-desk/releases/tag/${tag}>
|
||||
```
|
||||
|
||||
For the English file, keep the same links and heading level, but translate the section heading and content naturally:
|
||||
|
||||
```md
|
||||
## ${tag} (${yyyy-MM-dd})
|
||||
|
||||
### Updates
|
||||
|
||||
${content}
|
||||
|
||||
### Release Links
|
||||
|
||||
- Github: <https://github.com/huabeitech/agent-desk/releases/tag/${tag}>
|
||||
- Gitee: <https://gitee.com/huabeitech/agent-desk/releases/tag/${tag}>
|
||||
```
|
||||
|
||||
Changelog writing rules:
|
||||
|
||||
- Write concise, user-facing summaries instead of raw commit subjects.
|
||||
- Keep Chinese and English entries semantically aligned.
|
||||
- Prefer 3-6 bullets unless the release is extremely small.
|
||||
- Group related changes into a single bullet when that reads better.
|
||||
- Mention compatibility-sensitive changes explicitly.
|
||||
- If the comparison baseline is a non-semver fallback tag, note that in your private reasoning, not in the public changelog unless the user asks for it.
|
||||
|
||||
## Commit And Push The Docs Submodule
|
||||
|
||||
After editing the changelog files:
|
||||
|
||||
1. Run `git -C docs status --short`.
|
||||
2. Review the diff with `git -C docs diff -- zh/docs/changelog.md en/docs/changelog.md`.
|
||||
3. Commit inside the `docs` submodule with a focused message such as `docs: update changelog for v1.2.3`.
|
||||
4. Push the `docs` submodule commit to its remote branch.
|
||||
|
||||
Branch rule:
|
||||
|
||||
- If `docs` is on a local branch, push that branch.
|
||||
- If `docs` is detached, push `HEAD` to `origin/main` unless the repository clearly uses another default branch.
|
||||
|
||||
## Commit The Parent Repository
|
||||
|
||||
If the `docs` submodule pointer changed in the parent repository, commit it before tagging. Otherwise the release tag will not reference the new changelog revision.
|
||||
|
||||
Recommended flow:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git add docs
|
||||
git commit -m "chore: update docs submodule for v1.2.3"
|
||||
```
|
||||
|
||||
Only include unrelated parent-repo changes if the user explicitly wants them in the release commit.
|
||||
|
||||
## Create And Push The Tag
|
||||
|
||||
Create an annotated tag after the repository state is ready:
|
||||
|
||||
```bash
|
||||
git tag -a v1.2.3 -m "Release v1.2.3"
|
||||
```
|
||||
|
||||
Push the commit branch first if needed, then push the tag to every configured remote that should publish releases:
|
||||
|
||||
```bash
|
||||
git push github HEAD
|
||||
git push origin HEAD
|
||||
git push github v1.2.3
|
||||
git push origin v1.2.3
|
||||
```
|
||||
|
||||
Adjust the branch name if `HEAD` is not tracking the intended release branch.
|
||||
|
||||
## Create GitHub And Gitee Releases
|
||||
|
||||
Pushing tags is not enough. The release is incomplete until both Release pages exist:
|
||||
|
||||
- GitHub: `https://github.com/huabeitech/agent-desk/releases/tag/${tag}`
|
||||
- Gitee: `https://gitee.com/huabeitech/agent-desk/releases/tag/${tag}`
|
||||
|
||||
Use the same concise release notes derived from the changelog. Prefer a bilingual body with Chinese first and English second.
|
||||
|
||||
Required credentials:
|
||||
|
||||
- GitHub: `GITHUB_TOKEN` or `GH_TOKEN` with access to `huabeitech/agent-desk` and permission to create releases. For a fine-grained PAT, use an organization-allowed lifetime and grant the repository at least `Contents: Read and write` plus `Metadata: Read`.
|
||||
- Gitee: `GITEE_ACCESS_TOKEN` or `GITEE_TOKEN` with release write access to `huabeitech/agent-desk`.
|
||||
|
||||
Never print tokens in command output or final responses. If the user pastes a token into the conversation, use it only for the requested release operation and recommend rotation after use.
|
||||
|
||||
Build the release body from the new changelog entry, for example:
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/agent-desk-release
|
||||
awk 'BEGIN{p=0} /^## v1\.2\.3 /{p=1; next} /^## v[0-9]/{if(p) exit} p{print}' \
|
||||
docs/zh/docs/changelog.md | sed '/^### 发布地址/,$d' > /tmp/agent-desk-release/v1.2.3-zh.md
|
||||
awk 'BEGIN{p=0} /^## v1\.2\.3 /{p=1; next} /^## v[0-9]/{if(p) exit} p{print}' \
|
||||
docs/en/docs/changelog.md | sed '/^### Release Links/,$d' > /tmp/agent-desk-release/v1.2.3-en.md
|
||||
{
|
||||
printf '## 更新内容\n\n'
|
||||
sed '1,/^### 更新内容$/d' /tmp/agent-desk-release/v1.2.3-zh.md
|
||||
printf '\n## Updates\n\n'
|
||||
sed '1,/^### Updates$/d' /tmp/agent-desk-release/v1.2.3-en.md
|
||||
} > /tmp/agent-desk-release/v1.2.3-release-body.md
|
||||
```
|
||||
|
||||
Create the GitHub Release:
|
||||
|
||||
```bash
|
||||
token="${GITHUB_TOKEN:-$GH_TOKEN}"
|
||||
curl -sS -o /tmp/github_release_v1.2.3.json -w '%{http_code}' \
|
||||
-X POST https://api.github.com/repos/huabeitech/agent-desk/releases \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @<(jq -n --rawfile body /tmp/agent-desk-release/v1.2.3-release-body.md \
|
||||
'{tag_name:"v1.2.3", target_commitish:"main", name:"v1.2.3", body:$body, draft:false, prerelease:false}')
|
||||
```
|
||||
|
||||
Create the Gitee Release:
|
||||
|
||||
```bash
|
||||
token="${GITEE_ACCESS_TOKEN:-$GITEE_TOKEN}"
|
||||
curl -sS -o /tmp/gitee_release_v1.2.3.json -w '%{http_code}' \
|
||||
-X POST https://gitee.com/api/v5/repos/huabeitech/agent-desk/releases \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @<(jq -n --rawfile body /tmp/agent-desk-release/v1.2.3-release-body.md --arg token "${token}" \
|
||||
'{access_token:$token, tag_name:"v1.2.3", target_commitish:"main", name:"v1.2.3", body:$body, prerelease:false}')
|
||||
```
|
||||
|
||||
If creation returns `422`/already exists, fetch the existing release and verify it references the target tag before treating it as complete. If GitHub returns `Resource not accessible by personal access token`, inspect the API message and ask for a token that satisfies the organization policy and repository permissions.
|
||||
|
||||
## Final Verification
|
||||
|
||||
- Confirm `git rev-parse v1.2.3^{tag}` succeeds.
|
||||
- Confirm `git ls-remote --tags github v1.2.3` and `git ls-remote --tags origin v1.2.3` show the new tag.
|
||||
- Confirm `curl -sS -o /tmp/github_release_verify.json -w '%{http_code}' https://api.github.com/repos/huabeitech/agent-desk/releases/tags/v1.2.3` returns `200`.
|
||||
- Confirm `curl -sS -o /tmp/gitee_release_verify.json -w '%{http_code}' https://gitee.com/api/v5/repos/huabeitech/agent-desk/releases/tags/v1.2.3` returns `200`.
|
||||
- Confirm the `docs` submodule remote contains the changelog commit.
|
||||
- Confirm both parent and `docs` working trees are clean.
|
||||
- Summarize the previous tag used for comparison, the files updated, the commit hashes created, the remotes pushed, and the GitHub/Gitee Release URLs.
|
||||
4
.codex/skills/release-version/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Release Version"
|
||||
short_description: "Create semver tags and changelog drafts"
|
||||
default_prompt: "Use $release-version to create a new vx.y.z release, update changelogs, and push docs plus tags."
|
||||
61
.codex/skills/release-version/references/changelog-style.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Changelog Style
|
||||
|
||||
Use this guide when drafting `docs/zh/changelog.md` and `docs/en/changelog.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
Turn Git history into short release notes that explain what changed for adopters.
|
||||
|
||||
## Keep
|
||||
|
||||
- New user-facing features.
|
||||
- Bug fixes with clear impact.
|
||||
- Breaking or compatibility-sensitive changes.
|
||||
- API, configuration, deployment, model, workflow, or schema changes that affect usage.
|
||||
- Important documentation updates when they unlock new workflows.
|
||||
|
||||
## Drop Or Compress
|
||||
|
||||
- Pure refactors with no visible effect.
|
||||
- Formatting-only changes.
|
||||
- Internal rename churn.
|
||||
- Mechanical dependency updates unless they fix a real issue.
|
||||
|
||||
## Chinese Style
|
||||
|
||||
- Use concise bullets.
|
||||
- Prefer product or workflow language over commit jargon.
|
||||
- Start with the effect, then mention the area if needed.
|
||||
- Keep terms consistent across bullets.
|
||||
|
||||
Example:
|
||||
|
||||
```md
|
||||
- 优化会话列表查询与筛选逻辑,减少后台定位问题时的人工排查成本。
|
||||
- 修复消息发送链路中的异常处理,避免部分失败场景下页面状态不同步。
|
||||
```
|
||||
|
||||
## English Style
|
||||
|
||||
- Mirror the Chinese meaning instead of translating word by word.
|
||||
- Use direct release-note phrasing.
|
||||
- Prefer active wording and concrete impact.
|
||||
|
||||
Example:
|
||||
|
||||
```md
|
||||
- Improved conversation list querying and filtering so operators can locate problem cases faster.
|
||||
- Fixed error handling in the message send flow to prevent UI state from drifting after partial failures.
|
||||
```
|
||||
|
||||
## Grouping Heuristics
|
||||
|
||||
- Merge multiple commits into one bullet when they deliver one outcome.
|
||||
- Separate bullets when the audience or impact differs.
|
||||
- Keep both language versions aligned in bullet count when possible.
|
||||
|
||||
## Before Finalizing
|
||||
|
||||
- Re-check that every bullet is supported by the diff.
|
||||
- Remove statements that depend on assumptions you cannot verify from code, tests, or commits.
|
||||
- Keep the notes short enough to scan in under a minute.
|
||||
221
.codex/skills/release-version/scripts/collect_release_context.py
Executable file
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect release context between a target tag and its comparison baseline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SEMVER_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
||||
|
||||
|
||||
def run_git(
|
||||
repo: Path,
|
||||
args: list[str],
|
||||
allow_failure: bool = False,
|
||||
timeout_seconds: int = 15,
|
||||
) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
if allow_failure:
|
||||
return ""
|
||||
raise RuntimeError(f"git command timed out: git {' '.join(args)}")
|
||||
if result.returncode != 0 and not allow_failure:
|
||||
raise RuntimeError(result.stderr.strip() or "git command failed")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def parse_semver(tag: str) -> tuple[int, int, int] | None:
|
||||
match = SEMVER_TAG_RE.match(tag)
|
||||
if not match:
|
||||
return None
|
||||
return tuple(int(part) for part in match.groups())
|
||||
|
||||
|
||||
def list_reachable_tags(repo: Path) -> list[str]:
|
||||
output = run_git(repo, ["tag", "--merged", "HEAD"])
|
||||
return [line.strip() for line in output.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def choose_previous_tag(repo: Path, tags: list[str], target_tag: str | None) -> tuple[str | None, str | None]:
|
||||
semver_tags: list[tuple[tuple[int, int, int], str]] = []
|
||||
other_tags: list[str] = []
|
||||
target_semver = parse_semver(target_tag) if target_tag else None
|
||||
|
||||
for tag in tags:
|
||||
if target_tag and tag == target_tag:
|
||||
continue
|
||||
parsed = parse_semver(tag)
|
||||
if parsed is None:
|
||||
other_tags.append(tag)
|
||||
continue
|
||||
if target_semver is not None and parsed >= target_semver:
|
||||
continue
|
||||
semver_tags.append((parsed, tag))
|
||||
|
||||
if semver_tags:
|
||||
semver_tags.sort(key=lambda item: item[0], reverse=True)
|
||||
return semver_tags[0][1], "semver"
|
||||
|
||||
if not other_tags:
|
||||
return None, None
|
||||
|
||||
candidates: list[tuple[int, str]] = []
|
||||
for tag in other_tags:
|
||||
ts = run_git(repo, ["log", "-1", "--format=%ct", tag], allow_failure=True)
|
||||
try:
|
||||
candidates.append((int(ts), tag))
|
||||
except ValueError:
|
||||
continue
|
||||
if not candidates:
|
||||
return other_tags[-1], "fallback"
|
||||
candidates.sort(reverse=True)
|
||||
return candidates[0][1], "fallback"
|
||||
|
||||
|
||||
def ensure_tag_absent(repo: Path, tag: str) -> None:
|
||||
local = run_git(repo, ["tag", "--list", tag])
|
||||
if local:
|
||||
raise ValueError(f"target tag already exists locally: {tag}")
|
||||
|
||||
remotes_output = run_git(repo, ["remote"])
|
||||
for remote in [line.strip() for line in remotes_output.splitlines() if line.strip()]:
|
||||
remote_hit = run_git(
|
||||
repo,
|
||||
["ls-remote", "--tags", remote, tag],
|
||||
allow_failure=True,
|
||||
timeout_seconds=8,
|
||||
)
|
||||
if remote_hit:
|
||||
raise ValueError(f"target tag already exists on remote {remote}: {tag}")
|
||||
|
||||
|
||||
def ensure_tag_exists(repo: Path, tag: str) -> None:
|
||||
hit = run_git(repo, ["rev-parse", "--verify", "--quiet", tag], allow_failure=True)
|
||||
if not hit:
|
||||
raise ValueError(f"previous tag does not exist locally: {tag}")
|
||||
|
||||
|
||||
def get_commit_list(repo: Path, rev_range: str) -> list[dict[str, str]]:
|
||||
output = run_git(repo, ["log", "--reverse", "--date=short", "--pretty=format:%H%x09%ad%x09%s", rev_range])
|
||||
commits: list[dict[str, str]] = []
|
||||
for line in output.splitlines():
|
||||
commit_hash, date_str, subject = line.split("\t", 2)
|
||||
commits.append(
|
||||
{
|
||||
"hash": commit_hash,
|
||||
"short_hash": commit_hash[:7],
|
||||
"date": date_str,
|
||||
"subject": subject,
|
||||
}
|
||||
)
|
||||
return commits
|
||||
|
||||
|
||||
def get_changed_files(repo: Path, rev_range: str) -> list[str]:
|
||||
output = run_git(repo, ["diff", "--name-only", rev_range])
|
||||
return [line.strip() for line in output.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def get_numstat(repo: Path, rev_range: str) -> dict[str, int]:
|
||||
output = run_git(repo, ["diff", "--numstat", rev_range])
|
||||
changed_files = 0
|
||||
insertions = 0
|
||||
deletions = 0
|
||||
for line in output.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
added, removed = parts[0], parts[1]
|
||||
changed_files += 1
|
||||
if added.isdigit():
|
||||
insertions += int(added)
|
||||
if removed.isdigit():
|
||||
deletions += int(removed)
|
||||
return {
|
||||
"changed_files": changed_files,
|
||||
"insertions": insertions,
|
||||
"deletions": deletions,
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo", default=".", help="Repository root. Defaults to current directory.")
|
||||
parser.add_argument("--tag", help="Target release tag to validate, for example v1.2.3.")
|
||||
parser.add_argument("--previous-tag", help="Explicit comparison baseline.")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
repo_path = Path(args.repo).expanduser().resolve()
|
||||
|
||||
if not (repo_path / ".git").exists():
|
||||
print(json.dumps({"error": f"not a git repository: {repo_path}"}))
|
||||
return 1
|
||||
|
||||
if args.tag and parse_semver(args.tag) is None:
|
||||
print(json.dumps({"error": f"invalid tag format: {args.tag}", "expected": "vx.y.z"}))
|
||||
return 1
|
||||
|
||||
try:
|
||||
if args.tag:
|
||||
ensure_tag_absent(repo_path, args.tag)
|
||||
if args.previous_tag:
|
||||
ensure_tag_exists(repo_path, args.previous_tag)
|
||||
except ValueError as exc:
|
||||
print(json.dumps({"error": str(exc)}))
|
||||
return 1
|
||||
|
||||
tags = list_reachable_tags(repo_path)
|
||||
previous_tag = args.previous_tag
|
||||
previous_tag_source = "explicit" if previous_tag else None
|
||||
|
||||
if previous_tag is None:
|
||||
previous_tag, previous_tag_source = choose_previous_tag(repo_path, tags, args.tag)
|
||||
|
||||
rev_range = "HEAD"
|
||||
if previous_tag:
|
||||
rev_range = f"{previous_tag}..HEAD"
|
||||
|
||||
try:
|
||||
commits = get_commit_list(repo_path, rev_range)
|
||||
changed_files = get_changed_files(repo_path, rev_range)
|
||||
stats = get_numstat(repo_path, rev_range)
|
||||
head_commit = run_git(repo_path, ["rev-parse", "HEAD"])
|
||||
except RuntimeError as exc:
|
||||
print(json.dumps({"error": str(exc)}))
|
||||
return 1
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"repo": str(repo_path),
|
||||
"target_tag": args.tag,
|
||||
"previous_tag": previous_tag,
|
||||
"previous_tag_source": previous_tag_source,
|
||||
"rev_range": rev_range,
|
||||
"head_commit": head_commit,
|
||||
"commit_count": len(commits),
|
||||
"commits": commits,
|
||||
"changed_files": changed_files,
|
||||
}
|
||||
payload.update(stats)
|
||||
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
31
.dockerignore
Normal file
@@ -0,0 +1,31 @@
|
||||
.git
|
||||
.gitmodules
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
.worktrees
|
||||
.superpowers
|
||||
|
||||
Dockerfile
|
||||
docker-data
|
||||
dist
|
||||
data
|
||||
qdrant
|
||||
include
|
||||
lib
|
||||
*.log
|
||||
__debug_bin*
|
||||
|
||||
config/config.yaml
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
web/node_modules
|
||||
web/.next
|
||||
web/out
|
||||
web/coverage
|
||||
web/tsconfig.tsbuildinfo
|
||||
web/next-env.d.ts
|
||||
web/.env*
|
||||
!web/.env.example
|
||||
120
.env.example
Normal file
@@ -0,0 +1,120 @@
|
||||
# ==============================================================================
|
||||
# AgentDesk Environment Variables Configuration
|
||||
# Copy this file to .env and adjust the configuration values as needed.
|
||||
# ==============================================================================
|
||||
|
||||
# Server Configuration
|
||||
PORT=8083
|
||||
COMPANY_NAME=
|
||||
COMPANY_LOGO_URL=
|
||||
# AGENT_DESK_SERVER_CORS_ALLOWEDORIGINS="http://localhost:3000,http://127.0.0.1:8083"
|
||||
|
||||
# Client IP resolution.
|
||||
# Gin defaults to trusting every proxy, which makes X-Forwarded-For authoritative:
|
||||
# any caller could then choose the IP recorded in the login credential log and in
|
||||
# user.last_login_ip, and any IP-keyed rate limit or lockout would be bypassable.
|
||||
# Unset, trustedProxies falls back to the loopback / RFC1918 / IPv6 unique-local /
|
||||
# link-local ranges, which already covers a cloudflared sidecar on a compose
|
||||
# network. Set trustedPlatform when an edge overwrites the header instead of
|
||||
# appending to it - Cloudflare does, and CF-Connecting-IP is not client-forgeable.
|
||||
# TRUSTED_PROXIES="10.0.0.0/8,172.16.0.0/12"
|
||||
TRUSTED_PLATFORM=cloudflare
|
||||
|
||||
# Rate limiting for the public, unauthenticated endpoints (login, support
|
||||
# registration, widget session exchange, message uploads, doc feedback). Enabled
|
||||
# by default; the counters are per process. Channel webhooks, websockets and the
|
||||
# authenticated dashboard are deliberately exempt.
|
||||
# RATE_LIMIT_ENABLED=true
|
||||
# RATE_LIMIT_WINDOW_SECONDS=60
|
||||
|
||||
# Database Configuration
|
||||
# Driver options: sqlite, mysql, postgres
|
||||
DB_TYPE=sqlite
|
||||
DATABASE_URL=file:./data/app.db?_busy_timeout=5000
|
||||
# MySQL example:
|
||||
# DATABASE_URL="cs_ai_agent:cs_ai_agent_password@tcp(127.0.0.1:3306)/cs_ai_agent?charset=utf8mb4&parseTime=True&multiStatements=true&loc=Local"
|
||||
# PostgreSQL example:
|
||||
# DATABASE_URL="postgres://postgres:password@127.0.0.1:5432/cs_ai_agent?sslmode=disable"
|
||||
|
||||
# Auth & Security
|
||||
PASSWORD_LOGIN_ENABLED=true
|
||||
# AUTH_TOKEN_TTL_HOURS=12
|
||||
# CUSTOMER_SESSION_SECRET=replace-with-a-random-secret-at-least-32-chars
|
||||
|
||||
# Widget visitor identity. Shared, deployment-wide secret a host platform uses to
|
||||
# sign the external visitor ID it passes to the widget:
|
||||
# signature = hex(HMAC-SHA256(secret, channelId + "\n" + externalId + "\n" + issuedAt))
|
||||
# with issuedAt in unix seconds, sent as externalIdSignature +
|
||||
# externalIdSignedAt (or X-External-Id-Signature + X-External-Id-Signed-At).
|
||||
# Unset (the default) disables it: unsigned visitors are anonymous guests, which
|
||||
# is the existing behaviour.
|
||||
# IDENTITY_HMAC_SECRET=replace-with-a-random-secret-at-least-32-chars
|
||||
# How long a signed identity stays acceptable, in minutes. Bounds replay of a
|
||||
# leaked signature. Defaults to 1440 (24 hours).
|
||||
# IDENTITY_SIGNATURE_MAX_AGE_MINUTES=1440
|
||||
# How many not-yet-closed conversations one visitor may have at a time. Each open
|
||||
# conversation is real agent work, and in human-only mode opening one dispatches
|
||||
# an agent immediately, so this caps how much a single caller can queue. Unset or
|
||||
# 0 means 3; use -1 for no cap.
|
||||
# CONVERSATION_CUSTOMER_MAX_OPEN=3
|
||||
# How long a chat still counts as being watched, in minutes. An email arriving
|
||||
# during a live chat is appended to that chat instead of starting an email
|
||||
# thread. Unset or 0 means 30.
|
||||
# CONVERSATION_EMAIL_CHAT_LIVE_MINUTES=30
|
||||
|
||||
# Storage (local or oss)
|
||||
STORAGE_DEFAULT=local
|
||||
STORAGE_LOCAL_ROOT=data/storage
|
||||
STORAGE_LOCAL_BASE_URL=/storage
|
||||
|
||||
# Vector Database (Qdrant)
|
||||
VECTOR_DB_TYPE=qdrant
|
||||
QDRANT_HOST=127.0.0.1
|
||||
QDRANT_GRPC_PORT=6334
|
||||
# QDRANT_API_KEY=
|
||||
|
||||
# Single Sign-On (OIDC / OAuth 2.0)
|
||||
# OIDC_ENABLED=true
|
||||
# OIDC_ISSUER=https://auth.example.com
|
||||
# OIDC_CLIENT_ID=your-client-id
|
||||
# OIDC_CLIENT_SECRET=your-client-secret
|
||||
# OIDC_REDIRECT_URL=http://localhost:8083/api/auth/oidc_callback
|
||||
|
||||
# Webhook & Organization Sync
|
||||
# ORG_SYNC_SECRET=your-webhook-hmac-secret
|
||||
|
||||
# Email Channel & Delivery (inbound webhook ingestion + outbound sending)
|
||||
# System-level defaults, used when a channel does not carry its own provider settings.
|
||||
# Provider options: smtp, brevo, sendgrid, resend, postmark, mailgun
|
||||
# EMAIL_PROVIDER=brevo
|
||||
# EMAIL_FROM=support@example.com
|
||||
# EMAIL_FROM_NAME="Support Team"
|
||||
# EMAIL_API_KEY=your-provider-api-key
|
||||
# Inbound Webhook Authentication Secret (Header: X-Webhook-Secret)
|
||||
# EMAIL_INBOUND_SECRET=your-inbound-webhook-secret
|
||||
#
|
||||
# Custom SMTP server (when EMAIL_PROVIDER=smtp):
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=your-smtp-username
|
||||
# SMTP_PASSWORD=your-smtp-password
|
||||
# SMTP_USE_TLS=true
|
||||
# Accept a self-signed certificate and skip opportunistic STARTTLS, so a channel can be
|
||||
# bound to a local test SMTP server (Mailpit, MailHog). Weakens transport
|
||||
# security: never enable it for a real mailbox.
|
||||
# SMTP_ALLOW_INSECURE=false
|
||||
|
||||
# Discord Bot Channel
|
||||
# Deployment-wide bot token, used when a Discord channel does not carry its own.
|
||||
# DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||
# DISCORD_CLIENT_ID=your-discord-application-id
|
||||
# DISCORD_CLIENT_SECRET=your-discord-client-secret
|
||||
# DISCORD_PUBLIC_KEY=your-discord-application-public-key
|
||||
|
||||
# Slack deployment-wide app credentials. When a Slack channel leaves its bot
|
||||
# token empty the deployment-wide one is used, so one shared app can serve
|
||||
# every Slack workspace the desk supports.
|
||||
# SLACK_CLIENT_ID=your-slack-client-id
|
||||
# SLACK_CLIENT_SECRET=your-slack-client-secret
|
||||
# SLACK_BOT_TOKEN=xoxb-your-slack-bot-token
|
||||
# SLACK_SIGNING_SECRET=your-slack-signing-secret
|
||||
62
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
- "screenshots/**"
|
||||
- ".gitignore"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
backend-test:
|
||||
name: Backend Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
|
||||
- name: Run Go Tests
|
||||
run: |
|
||||
go test -v -tags dev ./internal/services/... ./internal/repositories/... ./internal/pkg/... ./internal/oidcclient/... ./internal/migration/...
|
||||
|
||||
frontend-typecheck:
|
||||
name: Frontend Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.30.2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd web && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Typecheck
|
||||
run: |
|
||||
cd web && pnpm typecheck
|
||||
56
.github/workflows/deploy-beta.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Build & Deploy Beta
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
- "screenshots/**"
|
||||
- ".gitignore"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-beta
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & Push Beta Image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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 Beta Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: app
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
65
.github/workflows/deploy-prod.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Build & Deploy Production
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-prod
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & Push Release Image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Production Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: app
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
.DS_Store
|
||||
.worktrees
|
||||
.idea
|
||||
.pnpm-store
|
||||
|
||||
data/
|
||||
config/config.yaml
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/include/
|
||||
/lib/
|
||||
|
||||
dist/
|
||||
__debug_bin*
|
||||
node_modules/
|
||||
.next/
|
||||
.task/
|
||||
*.tsbuildinfo
|
||||
|
||||
.superpowers
|
||||
|
||||
agent-desk
|
||||
|
||||
test-reports
|
||||
web/public/flowgram-editor/
|
||||
# Python bytecode from scripts/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
4
.gitmodules
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[submodule "qdrant"]
|
||||
path = qdrant
|
||||
url = git@github.com:huabeitech/agent-desk-qdrant.git
|
||||
|
||||
40
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "server",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/cmd/server/main.go",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"buildFlags": "-tags=dev,lancedb",
|
||||
"env": {
|
||||
"CGO_ENABLED": "1",
|
||||
"CGO_CFLAGS": "-I${workspaceFolder}/include",
|
||||
"CGO_LDFLAGS": "${workspaceFolder}/lib/darwin_arm64/liblancedb_go.a -framework Security -framework CoreFoundation"
|
||||
},
|
||||
"args": [
|
||||
"-config",
|
||||
"${workspaceFolder}/config/config.yaml"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm dev",
|
||||
"cwd": "${workspaceFolder}/web"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "server + web",
|
||||
"configurations": [
|
||||
"server",
|
||||
"web"
|
||||
],
|
||||
"stopAll": true
|
||||
}
|
||||
]
|
||||
}
|
||||
30
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "web:dev",
|
||||
"type": "shell",
|
||||
"command": "pnpm dev",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/web"
|
||||
},
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"group": "web"
|
||||
},
|
||||
"problemMatcher": {
|
||||
"owner": "custom",
|
||||
"pattern": {
|
||||
"regexp": "."
|
||||
},
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": ".",
|
||||
"endsPattern": "Ready in|Local:"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
275
AGENTS.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file defines the mandatory working agreement for AI agents in this repository. It is intentionally based on the current codebase rather than historical conventions.
|
||||
|
||||
## 1. Scope and Priorities
|
||||
|
||||
- These rules apply to the repository root and every subdirectory.
|
||||
- Explicit user instructions take precedence over this file. Mention any deliberate deviation in the final summary.
|
||||
- Inspect the relevant implementation before editing. Reuse current helpers, component APIs, generated-code workflows, and neighboring patterns instead of relying on memory.
|
||||
- Keep changes narrowly scoped. Preserve unrelated and user-owned worktree changes, including staged changes.
|
||||
- A review, investigation, or diagnosis request is read-only unless the user also asks for implementation.
|
||||
|
||||
## 2. Current Architecture
|
||||
|
||||
The repository contains three application areas:
|
||||
|
||||
- Go server: `cmd/server` and `internal/*`
|
||||
- Next.js application: `web/*`
|
||||
- Embedded workflow editor: `flowgram-editor/*`, built into `web/public/flowgram-editor`
|
||||
|
||||
The main stack is:
|
||||
|
||||
- Go 1.26, Gin, GORM, `github.com/mlogclub/simple`
|
||||
- SQLite and MySQL
|
||||
- Next.js 16 App Router, React 19, TypeScript, Tailwind CSS, shadcn/Base UI
|
||||
- `pnpm` for both frontend projects
|
||||
- Optional LanceDB builds through CGO; Qdrant is also supported by the application
|
||||
|
||||
Important entry points:
|
||||
|
||||
- Server assembly and middleware: `internal/bootstrap/server.go`
|
||||
- Explicit API routes: `internal/bootstrap/routes.go`
|
||||
- Model registration: `internal/models/models.go`
|
||||
- Schema/data migration startup: `internal/bootstrap/migration.go`
|
||||
- CRUD generator: `cmd/generator/generator.go`
|
||||
- Frontend enum generator: `cmd/enums/generator.go`
|
||||
- Frontend API client: `web/lib/api/client.ts`
|
||||
- Frontend i18n: `web/i18n/*` and `web/messages/*`
|
||||
- Dashboard shared components: `web/components/dashboard/*`
|
||||
- Project commands: `Taskfile.yml`
|
||||
|
||||
## 3. General Change Rules
|
||||
|
||||
- Read the actual type, function, or component signature before using it.
|
||||
- Prefer the highest-level existing abstraction that fits the requirement. Do not duplicate query state, pagination, auth refresh, localization, or dashboard CRUD behavior.
|
||||
- Do not edit generated artifacts by hand. Change their source and run the corresponding generator/build command.
|
||||
- Do not add a second implementation style when the repository already has a shared path for the same concern.
|
||||
- Use `log/slog` for new Go logging and structured key-value fields for relevant context.
|
||||
- New Go code uses `any`, not `interface{}`. Existing generated or legacy code does not need unrelated cleanup.
|
||||
- Secrets, tokens, credentials, and private customer data must never be committed or printed in logs/tests.
|
||||
|
||||
## 4. Go Backend
|
||||
|
||||
### 4.1 Layer Ownership
|
||||
|
||||
The normal dependency and data flow is:
|
||||
|
||||
`models -> repositories -> services -> handlers -> builders/response DTOs`
|
||||
|
||||
- `internal/models`: entity fields, GORM mappings, associations, and schema metadata only.
|
||||
- `internal/repositories`: GORM/SQL access, conditions, ordering, pagination, locks, and persistence details.
|
||||
- `internal/services`: business validation, state changes, authorization-independent domain rules, aggregation, transactions, and event orchestration.
|
||||
- `internal/handlers/{api,dashboard,third}`: HTTP parameter parsing, authentication/permission checks, service calls, and response writing.
|
||||
- `internal/builders`: pure model/aggregate-to-response mapping. Builders must not query the database.
|
||||
- `internal/pkg/dto/request` and `internal/pkg/dto/response`: external request/response contracts.
|
||||
|
||||
Mandatory boundaries:
|
||||
|
||||
- Handlers must not call repositories or issue GORM queries directly.
|
||||
- Models and repositories must not contain HTTP, permission, or cross-resource workflow logic.
|
||||
- GORM models must not be returned directly from an API. Map them to response DTOs/builders.
|
||||
- A service may return models internally, but public response shape remains owned by builders/DTOs.
|
||||
- Put reusable SQL in repositories. A genuinely one-off aggregate query may stay near its domain service only when extraction would make ownership less clear.
|
||||
|
||||
### 4.2 Database and Transactions
|
||||
|
||||
- Repository methods that participate in transactions accept `db *gorm.DB`; call them with `sqls.DB()` outside a transaction and `ctx.Tx` inside one.
|
||||
- Services own transaction boundaries through `sqls.WithTransaction`.
|
||||
- Use a transaction for atomic multi-write workflows and consistency-sensitive read-modify-write operations.
|
||||
- Do not add a transaction around a single independent SQL write.
|
||||
- Every database operation inside a transaction must use the same `ctx.Tx`; never escape to `sqls.DB()` mid-transaction.
|
||||
- Use `sqls.Cnd`/`sqls.NewCnd()` and repository methods for ordinary filtering and pagination.
|
||||
- Preserve SQLite and MySQL compatibility. Avoid dialect-specific SQL unless both dialects are explicitly implemented and tested.
|
||||
- Use portable column types and `int64` primary/foreign identifiers. Keep time handling compatible with MySQL `parseTime=True`.
|
||||
|
||||
### 4.3 Models, Generation, and Migrations
|
||||
|
||||
- Register persistent models in `internal/models/models.go` so startup `AutoMigrate(models.Models...)` includes them.
|
||||
- New tables, columns, indexes, and compatible constraints are normally applied by GORM `AutoMigrate` in `internal/bootstrap/migration.go`.
|
||||
- Use `internal/migration/*` only for versioned, idempotent data migration/backfill/repair work. Its version must increase monotonically.
|
||||
- When a model uses the standard generated repository/service surface, register it in `cmd/generator/generator.go` and run `task generator`.
|
||||
- Treat generator output as mechanical infrastructure. Put business-specific methods in handwritten files and do not manually patch generated CRUD output.
|
||||
- Backend/frontend shared enums are defined in `internal/pkg/enums`, annotated using the existing enum pattern, and generated with `task enums` into `web/lib/generated/enums.ts`.
|
||||
- Never create a handwritten frontend duplicate of a generated backend enum.
|
||||
|
||||
### 4.4 HTTP APIs
|
||||
|
||||
- All routes are explicit in `internal/bootstrap/routes.go`; handler names do not create endpoints.
|
||||
- Public/product APIs live under `/api/*`, authenticated management APIs under `/api/dashboard/*`, callbacks under `/api/third/*`, and WebSockets under `/api/ws/*`.
|
||||
- Add a resource-specific `register...Routes` function or extend the existing one, then mount it from `addRouter` under the correct group.
|
||||
- Follow the existing resource contract: detail commonly uses `GET /:id`, list uses `/list`, writes use explicit POST actions such as `/create`, `/update`, `/delete`, and domain actions retain their established snake_case path names.
|
||||
- Do not introduce `/api/v1`, automatic routing assumptions, or unnecessary deeply nested resource paths.
|
||||
- Handler names mirror the registered method and path, for example `XxxGetBy`, `XxxAnyList`, and `XxxPostCreate`.
|
||||
- Parse JSON/form/query/path values with `internal/pkg/httpx/params` and `internal/pkg/httpx` helpers.
|
||||
- Dashboard permission checks use `services.AuthService.RequirePermission` or the established permission helper before domain work.
|
||||
- Write responses through `httpx.WriteJSON`; preserve the shared `JsonResult` contract.
|
||||
- Paginated responses use `web.PageResult` with `data.results` and `data.page`.
|
||||
- Convert not-found, validation, permission, and persistence failures into stable application errors. Never expose raw SQL errors to clients.
|
||||
|
||||
### 4.5 Backend Internationalization
|
||||
|
||||
- Any new or changed user-visible backend error must support every backend locale, currently `zh-CN` and `en-US`.
|
||||
- Add matching keys to both `internal/pkg/i18nx/locales/zh-CN.yml` and `internal/pkg/i18nx/locales/en-US.yml` in the same change.
|
||||
- Services should return localized application errors through the `errorsx.*I18n` helpers.
|
||||
- Handlers that need an immediate localized response use `httpx.JsonErrorMsg(ctx, key, args...)`; other request-context translation uses `i18nx.T`.
|
||||
- Do not hard-code Chinese or English error sentences in handlers/services when the text can reach a user.
|
||||
- Keep format arguments equivalent across locales and cover new reusable/error-format behavior with focused tests.
|
||||
|
||||
## 5. Frontend
|
||||
|
||||
### 5.1 Component and Data Boundaries
|
||||
|
||||
- Application routes live under `web/app`; reusable business components live under `web/components`; route-private components live in the route's `_components` directory.
|
||||
- Reuse `web/components/ui/*` primitives. Do not edit these shadcn base components for a feature-specific requirement.
|
||||
- Use the current Base UI/shadcn component API as implemented in the repository; do not assume APIs such as Radix `asChild` exist.
|
||||
- Use `@/*` imports for code within `web`.
|
||||
- Client components must declare `"use client"` when they use state, effects, browser APIs, or client-only hooks.
|
||||
- Keep resource APIs in `web/lib/api/*` and route all normal requests through `web/lib/api/client.ts`.
|
||||
- Pages, business components, and stores must not implement their own `JsonResult` parsing, auth header handling, token refresh, or login-expiry cleanup.
|
||||
- Direct `fetch` is reserved for unsupported transports such as third-party calls, binary transfers, SSE, and WebSocket handshakes; explain the exception in code.
|
||||
- Prefer `OptionCombobox` for standard dropdowns rather than adding shadcn Select-based business controls.
|
||||
- Format displayed timestamps with `formatDateTime` from `web/lib/utils.ts` unless the product explicitly requires a different representation.
|
||||
|
||||
### 5.2 Dashboard Pages
|
||||
|
||||
Before building a dashboard page, inspect `web/components/dashboard/crud`, `web/components/dashboard/list`, and `web/components/dashboard-page.tsx`.
|
||||
|
||||
Use this order of preference:
|
||||
|
||||
1. `DashboardCrudPage` for standard create/read/update/delete resources.
|
||||
2. `DashboardListPage` for read-only or custom-content paginated resources.
|
||||
3. `useDashboardPagedList` when layout is bespoke but list query/filter/pagination lifecycle is standard.
|
||||
4. `DashboardPage`, `DashboardToolbar`, `DashboardTableShell`, and related low-level primitives only for interactions that cannot fit the higher-level components.
|
||||
|
||||
Rules:
|
||||
|
||||
- Do not copy a resource page to recreate standard filters, query/reset/refresh actions, pagination, loading/empty states, confirmations, row actions, or dialogs.
|
||||
- Configure `DashboardCrudPage` through its filters, columns, labels, service callbacks, row actions, sorting, and form/dialog extension points before adding page-local infrastructure.
|
||||
- Use its schema-driven `DashboardCrudFormDialog` when supported. A genuinely custom form may live in `_components` and should normally use `react-hook-form`, `zod`, and `Field`.
|
||||
- Configure `DashboardListPage` with columns or `renderContent`, and use `renderToolbarActions` for resource-specific actions.
|
||||
- Business API knowledge stays in the page/service module; generic dashboard components must not import a resource-specific API.
|
||||
- A change to `web/components/dashboard/*` must be generic, backward-compatible, and useful beyond one page. Otherwise keep it local to the feature.
|
||||
|
||||
### 5.3 Frontend Internationalization
|
||||
|
||||
- Every frontend feature and modification must work in all `SUPPORTED_LOCALES`, currently `zh-CN` and `en-US`.
|
||||
- Add every new key to both `web/messages/zh-CN.json` and `web/messages/en-US.json` in the same change, preserving matching structure.
|
||||
- React pages/components use `useI18n()`; locale-aware formatting/mapping may also use `useAppLocale()`.
|
||||
- Non-React code uses `translateCurrentMessage()` or `translateMessage()` from `web/i18n/messages.ts`.
|
||||
- Do not hard-code user-visible copy in JSX/TSX, toast messages, dialogs, confirmations, placeholders, validation, empty/loading/error states, tooltips, accessibility labels, or client-side fallback errors.
|
||||
- Product names, protocol literals, user content, and raw business data do not need translation unless the UI already provides a display-name mapping.
|
||||
- Dashboard labels supplied to shared CRUD/list components must come from translation keys.
|
||||
- Centralize localized display names for backend identifiers/enums in a reusable `web/lib/*-i18n.ts` helper instead of duplicating locale switches across pages.
|
||||
- Preserve the same placeholders in every locale and interpolate with `t(key, values)`; do not assemble sentences by concatenating translated fragments.
|
||||
- Locale configuration belongs to `AppI18nProvider` and `web/i18n/config.ts`; features must not introduce separate locale detection or state.
|
||||
|
||||
### 5.4 Commercial-Grade UI and Interaction
|
||||
|
||||
This is a commercial product, not a prototype or component demo. UI work is complete only when it is visually coherent, interaction-complete, responsive, localized, and credible with real production data.
|
||||
|
||||
#### Visual Quality
|
||||
|
||||
- Follow the existing design language, spacing scale, typography, radius, color tokens, and component variants. A new feature must look native to the product rather than like a pasted template.
|
||||
- In support platform UI, if rounded corners are needed, use `rounded-md` consistently.
|
||||
- Support platform components should not add `shadow`.
|
||||
- Establish a clear hierarchy: page title/primary action, filters or context, main content, and secondary information. Do not make every region a card or every action visually prominent.
|
||||
- Prefer restrained, purposeful styling. Avoid decorative gradients, oversized hero text, excessive shadows, glass effects, emoji icons, random accent colors, and ornamental copy unless the product context explicitly calls for them.
|
||||
- Use Lucide icons consistently. Choose icons by meaning, keep icon size and stroke weight aligned with neighboring controls, and never use icons as decoration without communicative value.
|
||||
- Keep spacing and alignment deliberate at every breakpoint. Labels, inputs, table columns, action groups, dialog footers, and empty states must align cleanly without ad hoc offsets.
|
||||
- Design for realistic content, not ideal sample text. Verify long names, multiline content, large counts, missing optional fields, and mixed Chinese/English values. Use wrapping, truncation, tooltips, or scroll containers intentionally.
|
||||
- Preserve information density appropriate to an operations dashboard. Do not waste large areas on decoration, but do not compress controls until scanning and clicking become difficult.
|
||||
|
||||
#### Interaction Completeness
|
||||
|
||||
- Every asynchronous action must have an immediate and unambiguous state: pending/loading, success, failure, and retry or recovery when appropriate.
|
||||
- Prevent duplicate submissions. Disable or lock the initiating control while a mutation is pending and show action-specific progress text or a spinner without causing layout shift.
|
||||
- Keep feedback close to the action. Use inline validation for field problems, contextual error states for failed content, and toast notifications for completed background or page-level actions.
|
||||
- Never silently discard user input. Warn before closing, navigating away, resetting, or switching context when there are meaningful unsaved changes.
|
||||
- Destructive, irreversible, security-sensitive, or broad-impact operations require confirmation that clearly names the object and consequence. Do not use a generic “Are you sure?” message.
|
||||
- Confirmation is not a substitute for good defaults: routine reversible actions should remain efficient and should not be interrupted by unnecessary modal prompts.
|
||||
- After create/update/delete operations, keep list state coherent: refresh affected data, preserve useful filters/page position when possible, close dialogs only on success, and prevent stale selections or detail panels.
|
||||
- Buttons and menu items must use precise verbs describing the result. Avoid vague labels such as “OK”, “Submit”, or “Process” when a specific action name is available.
|
||||
- Preserve keyboard behavior and focus flow: Enter submits only where expected, Escape closes dismissible overlays, focus moves into dialogs and returns to the trigger, and destructive actions are not the accidental default.
|
||||
- Interactive rows, icons, badges, and text links must look interactive only when they are interactive. Do not rely on hover-only discoverability for essential actions.
|
||||
|
||||
#### Forms and Dialogs
|
||||
|
||||
- Use the smallest suitable interaction container: inline editing for simple local changes, a dialog for focused tasks, and a full page/workbench for complex or multi-step workflows.
|
||||
- Dialogs need a clear title, concise context when necessary, stable body layout, and a consistent footer with secondary action before primary action. Long content must scroll inside the dialog without pushing actions off-screen.
|
||||
- Forms must have visible labels, appropriate controls, useful defaults, required/optional semantics, and examples or help text only where they reduce ambiguity.
|
||||
- Validate at the right time: do not show errors before the user has interacted, clear stale errors after correction, and map backend validation failures to the relevant field when possible.
|
||||
- Preserve entered values after failed submission. Do not reset or close a form until the server confirms success.
|
||||
- Dependent fields must clearly reflect disabled/loading/empty states. When one field invalidates another, update it predictably and explain the dependency when it is not obvious.
|
||||
- Configuration and high-impact forms should not expose a permanently editable surface when a deliberate edit mode improves safety. Saving sensitive configuration requires explicit user intent and appropriate confirmation.
|
||||
|
||||
#### Lists, Tables, and Operational Screens
|
||||
|
||||
- Use the shared Dashboard CRUD/list system and maintain consistent toolbar, filter, pagination, loading, empty, and action placement across resources.
|
||||
- Filters must distinguish draft values from applied query state. Query, reset, refresh, pagination, and URL/state behavior should be predictable and must not unexpectedly erase one another.
|
||||
- Tables must remain scannable: align comparable values, keep action columns stable, use badges sparingly for status, avoid dense multiline cells when a detail view is more appropriate, and provide horizontal overflow on narrow screens.
|
||||
- Empty states must distinguish “no data exists” from “no results match the current filters” and offer the most relevant next action when the user can resolve the state.
|
||||
- Loading states should preserve layout. Prefer skeletons for content whose shape is known and compact spinners for localized actions; avoid replacing an entire stable page with a centered spinner.
|
||||
- Error states must explain what failed in user terms and provide retry when retry is meaningful. Never leave a blank table or empty panel after a request failure.
|
||||
- Bulk actions must show selection count, affected scope, eligibility, and partial-failure results. Clear selection when it is no longer valid.
|
||||
|
||||
#### Responsive and Accessibility Requirements
|
||||
|
||||
- Every changed screen must work at desktop and narrow/mobile widths. Do not treat horizontal clipping, overlapping controls, wrapped action chaos, or off-screen dialog buttons as acceptable.
|
||||
- Responsive behavior must preserve task priority: primary actions remain reachable, secondary actions may move into menus, filters may stack or collapse, and tables may scroll without hiding row identity/actions.
|
||||
- Use semantic controls and accessible names. Icon-only buttons require localized accessible labels and tooltips where the meaning is not universally obvious.
|
||||
- Maintain visible focus states, logical tab order, sufficient target sizes, and adequate text/background contrast. Do not encode status or errors using color alone.
|
||||
- Respect reduced-motion preferences. Animations should explain state or continuity, remain subtle, and never delay work.
|
||||
|
||||
#### Product Copy and Data Credibility
|
||||
|
||||
- Copy must sound like a finished product: concise, specific, consistent, and action-oriented. Do not expose implementation jargon, placeholder prose, “TODO”, mock labels, debug wording, or developer instructions to users.
|
||||
- Do not ship fake statistics, sample records, disabled-looking placeholder buttons, decorative charts without meaning, or interactions that only log to the console.
|
||||
- Distinguish unavailable features from empty data. If a capability is not implemented, do not render a control that pretends it is functional.
|
||||
- User-visible names, statuses, permissions, dates, and errors must use the established formatting and i18n mappings rather than raw backend identifiers.
|
||||
|
||||
#### UI Verification
|
||||
|
||||
- For meaningful UI changes, inspect the finished screen in a real browser at representative desktop and narrow widths. Source review and typecheck alone are not sufficient visual validation.
|
||||
- Exercise the complete interaction, including initial load, populated state, empty/filtered state, validation failure, server failure when practical, pending/disabled behavior, success, cancel/close, and refresh.
|
||||
- Check both `zh-CN` and `en-US`; verify that translated copy does not overflow, truncate critical meaning, or break control alignment.
|
||||
- Before handoff, remove temporary data, debug UI, console output, test-only shortcuts, and visual artifacts introduced during verification.
|
||||
- When browser verification cannot be performed, state that limitation explicitly; do not describe the UI as visually verified.
|
||||
|
||||
### 5.5 Generated and Embedded Frontend Assets
|
||||
|
||||
- `web/lib/generated/enums.ts` is generated by `task enums`; do not edit it manually.
|
||||
- `web/public/sdk/agent-desk-sdk.min.js` is generated from the SDK source. When SDK source changes, run `cd web && pnpm build:sdk` and its focused SDK tests.
|
||||
- `web/public/flowgram-editor` is produced from `flowgram-editor`; edit the source project, not the generated public output.
|
||||
- When changing `flowgram-editor`, use its own `pnpm` scripts and ensure the embedding build still succeeds.
|
||||
- The Next.js application is statically exported/embedded into the Go binary. Avoid runtime-only Next.js features that conflict with the current export and embedding model.
|
||||
|
||||
## 6. Testing and Validation
|
||||
|
||||
Validation must match the changed surface. Do not claim checks that were not run.
|
||||
|
||||
- Go formatting: run `gofmt` on every changed `.go` file.
|
||||
- Go behavior: add focused tests for changed business rules, transactions, security boundaries, parsing, and reusable helpers; run the narrow package tests first, then `go test ./...` when practical.
|
||||
- Frontend TypeScript: run `cd web && pnpm typecheck` for frontend changes.
|
||||
- Frontend lint: run `cd web && pnpm lint` for broader component/page changes or when lint-sensitive code changed.
|
||||
- Frontend logic: run relevant `node --test ...` files when changing utilities, i18n mappings, generated SDK behavior, or other modules with focused tests.
|
||||
- Workflow editor: run the relevant `flowgram-editor` lint/build commands for changes in that project.
|
||||
- Generation: after model CRUD or shared enum changes, run the corresponding `task generator` or `task enums` and review generated diffs.
|
||||
- Build: use `task build` when changes affect frontend embedding, build configuration, generated public assets, or release assembly.
|
||||
- Browser verification is expected for meaningful visual or interaction changes when a runnable environment is available; state explicitly when it was not performed.
|
||||
- Documentation-only changes require at least `git diff --check` and verification that every referenced path/command exists.
|
||||
|
||||
## 7. Completion Checklist
|
||||
|
||||
Before handing off a change, confirm the applicable items:
|
||||
|
||||
- The implementation follows current layer/component ownership and does not create reverse dependencies.
|
||||
- Transactions cover exactly the operations that must be atomic, and all transactional DB calls use the same `ctx.Tx`.
|
||||
- API routes are explicitly registered and responses preserve `JsonResult`/`PageResult` contracts.
|
||||
- Models, migrations, queries, and tests remain compatible with SQLite and MySQL.
|
||||
- Backend and frontend user-visible text is complete in both Chinese and English.
|
||||
- Dashboard pages reuse the highest-level suitable component under `web/components/dashboard/*`.
|
||||
- UI changes meet the commercial-grade standard: complete states, precise feedback, safe mutations, responsive layout, accessible controls, credible copy/data, and no demo-only behavior.
|
||||
- Generated files were regenerated from their source and were not manually edited.
|
||||
- Relevant tests/typechecks/lint/build/browser checks were run, and any validation limitation is reported.
|
||||
- `git diff --check` passes and unrelated worktree changes remain untouched.
|
||||
135
Dockerfile
Normal file
@@ -0,0 +1,135 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:24-alpine AS flowgram-editor-builder
|
||||
WORKDIR /src/flowgram-editor
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.30.2 --activate
|
||||
COPY flowgram-editor/package.json flowgram-editor/pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY flowgram-editor/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:24-alpine AS web-builder
|
||||
WORKDIR /src/web
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.30.2 --activate
|
||||
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY web/ ./
|
||||
COPY --from=flowgram-editor-builder /src/web/public/flowgram-editor ./public/flowgram-editor
|
||||
|
||||
ARG NEXT_PUBLIC_OPEN_IM_CHANNEL_ID=""
|
||||
ENV NEXT_PUBLIC_OPEN_IM_CHANNEL_ID=${NEXT_PUBLIC_OPEN_IM_CHANNEL_ID}
|
||||
|
||||
RUN pnpm build:sdk && pnpm build
|
||||
|
||||
FROM golang:1.26-alpine AS server-builder
|
||||
WORKDIR /src
|
||||
|
||||
RUN apk add --no-cache git
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY . ./
|
||||
COPY --from=web-builder /src/web/out ./web/out
|
||||
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -v -trimpath -ldflags="-s -w" -o /out/agent-desk ./cmd/server
|
||||
|
||||
FROM golang:1.26-trixie AS server-builder-lancedb
|
||||
WORKDIR /src
|
||||
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH
|
||||
ARG LANCEDB_VERSION=v0.1.2
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bash binutils build-essential ca-certificates curl file git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY . ./
|
||||
COPY --from=web-builder /src/web/out ./web/out
|
||||
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
set -eux; \
|
||||
if [ "${TARGETOS}" != "linux" ]; then \
|
||||
echo "LanceDB Docker image supports TARGETOS=linux only, got ${TARGETOS}" >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
arch="${TARGETARCH:-$(go env GOARCH)}"; \
|
||||
case "$arch" in \
|
||||
amd64) ;; \
|
||||
*) echo "LanceDB Docker image currently supports linux/amd64 only, got linux/$arch" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
mkdir -p /out; \
|
||||
curl -fL --retry 3 --retry-delay 2 -o /tmp/lancedb-go-native-binaries.tar.gz \
|
||||
"https://github.com/lancedb/lancedb-go/releases/download/${LANCEDB_VERSION}/lancedb-go-native-binaries.tar.gz"; \
|
||||
tar -xzf /tmp/lancedb-go-native-binaries.tar.gz -C /src; \
|
||||
test -f "/src/include/lancedb.h"; \
|
||||
test -f "/src/lib/linux_$arch/liblancedb_go.so"; \
|
||||
ldd --version | head -n 1; \
|
||||
file "/src/lib/linux_$arch/liblancedb_go.so"; \
|
||||
CGO_ENABLED=1 GOOS="${TARGETOS}" GOARCH="$arch" \
|
||||
CGO_CFLAGS="-I/src/include" \
|
||||
CGO_LDFLAGS="-L/src/lib/linux_$arch -llancedb_go -Wl,-rpath,/usr/local/lib -lm -ldl -lpthread" \
|
||||
go build -tags lancedb -v -trimpath -ldflags="-s -w" -o /out/agent-desk ./cmd/server; \
|
||||
ldd /out/agent-desk; \
|
||||
cp "/src/lib/linux_$arch/liblancedb_go.so" /out/liblancedb_go.so
|
||||
|
||||
FROM debian:trixie-slim AS app-lancedb
|
||||
WORKDIR /app
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
ENV LD_LIBRARY_PATH=/usr/local/lib
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libgcc-s1 tzdata wget \
|
||||
&& mkdir -p /app/config /app/data/storage /app/data/lancedb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=server-builder-lancedb /out/agent-desk /app/agent-desk
|
||||
COPY --from=server-builder-lancedb /out/liblancedb_go.so /usr/local/lib/liblancedb_go.so
|
||||
COPY config/config.example.yaml /app/config/config.example.yaml
|
||||
COPY config/config.example.yaml /app/config/config.yaml
|
||||
|
||||
EXPOSE 8083
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8083/ >/dev/null || exit 1
|
||||
|
||||
CMD ["/app/agent-desk", "-config", "/app/config/config.yaml"]
|
||||
|
||||
FROM alpine:3.22 AS app
|
||||
WORKDIR /app
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata wget \
|
||||
&& mkdir -p /app/config /app/data/storage
|
||||
|
||||
COPY --from=server-builder /out/agent-desk /app/agent-desk
|
||||
COPY config/config.example.yaml /app/config/config.example.yaml
|
||||
COPY config/config.example.yaml /app/config/config.yaml
|
||||
|
||||
EXPOSE 8083
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8083/ >/dev/null || exit 1
|
||||
|
||||
CMD ["/app/agent-desk", "-config", "/app/config/config.yaml"]
|
||||
201
LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
219
Makefile
Normal file
@@ -0,0 +1,219 @@
|
||||
APP := agent-desk
|
||||
MAIN := ./cmd/server
|
||||
WEB_DIR := web
|
||||
DIST_DIR := dist
|
||||
|
||||
GO ?= go
|
||||
PNPM ?= pnpm
|
||||
GOOS ?= $(shell $(GO) env GOOS)
|
||||
GOARCH ?= $(shell $(GO) env GOARCH)
|
||||
DEV_PORT ?= 8083
|
||||
DEV_API_BASE_URL ?= http://127.0.0.1:$(DEV_PORT)
|
||||
DEV_SERVER_URL ?= $(DEV_API_BASE_URL)/api/health
|
||||
LANCEDB_VERSION ?= v0.1.2
|
||||
LANCEDB_DOWNLOAD_SCRIPT ?= https://raw.githubusercontent.com/lancedb/lancedb-go/main/scripts/download-artifacts.sh
|
||||
LANCEDB ?= 0
|
||||
|
||||
UNAME_S := $(shell uname -s)
|
||||
UNAME_M := $(shell uname -m)
|
||||
|
||||
ifeq ($(GOOS),windows)
|
||||
APP_EXT := .exe
|
||||
else
|
||||
APP_EXT :=
|
||||
endif
|
||||
|
||||
ifeq ($(LANCEDB),1)
|
||||
BUILD_NAME_SUFFIX := -lancedb
|
||||
BUILD_TAGS := -tags lancedb
|
||||
BUILD_CGO_ENABLED := 1
|
||||
else
|
||||
BUILD_NAME_SUFFIX :=
|
||||
BUILD_TAGS :=
|
||||
BUILD_CGO_ENABLED :=
|
||||
endif
|
||||
|
||||
BUILD_OUTPUT := $(DIST_DIR)/$(APP)$(BUILD_NAME_SUFFIX)$(APP_EXT)
|
||||
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
LANCEDB_ARCH := amd64
|
||||
else ifeq ($(UNAME_M),amd64)
|
||||
LANCEDB_ARCH := amd64
|
||||
else ifeq ($(UNAME_M),arm64)
|
||||
LANCEDB_ARCH := arm64
|
||||
else ifeq ($(UNAME_M),aarch64)
|
||||
LANCEDB_ARCH := arm64
|
||||
else
|
||||
LANCEDB_ARCH := unsupported
|
||||
endif
|
||||
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
LANCEDB_PLATFORM := darwin
|
||||
LANCEDB_SYSTEM_LDFLAGS := -framework Security -framework CoreFoundation
|
||||
else ifeq ($(UNAME_S),Linux)
|
||||
LANCEDB_PLATFORM := linux
|
||||
LANCEDB_SYSTEM_LDFLAGS := -lm -ldl -lpthread
|
||||
else ifneq (,$(findstring MINGW,$(UNAME_S)))
|
||||
LANCEDB_PLATFORM := windows
|
||||
LANCEDB_ARCH := amd64
|
||||
LANCEDB_SYSTEM_LDFLAGS :=
|
||||
else ifneq (,$(findstring MSYS,$(UNAME_S)))
|
||||
LANCEDB_PLATFORM := windows
|
||||
LANCEDB_ARCH := amd64
|
||||
LANCEDB_SYSTEM_LDFLAGS :=
|
||||
else ifneq (,$(findstring CYGWIN,$(UNAME_S)))
|
||||
LANCEDB_PLATFORM := windows
|
||||
LANCEDB_ARCH := amd64
|
||||
LANCEDB_SYSTEM_LDFLAGS :=
|
||||
else
|
||||
LANCEDB_PLATFORM := unsupported
|
||||
LANCEDB_SYSTEM_LDFLAGS :=
|
||||
endif
|
||||
|
||||
LANCEDB_PLATFORM_ARCH := $(LANCEDB_PLATFORM)_$(LANCEDB_ARCH)
|
||||
LANCEDB_NATIVE_LIB := $(CURDIR)/lib/$(LANCEDB_PLATFORM_ARCH)/liblancedb_go.a
|
||||
LANCEDB_CGO_CFLAGS := -I$(CURDIR)/include
|
||||
LANCEDB_CGO_LDFLAGS := $(LANCEDB_NATIVE_LIB) $(LANCEDB_SYSTEM_LDFLAGS)
|
||||
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
.PHONY: help dev build release generator enums \
|
||||
_web-build-spa _web-dev _prepare-dist _lancedb-artifacts _lancedb-check _lancedb-release-check
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " make dev Start backend and frontend development servers"
|
||||
@echo " make build Build the current system into dist/"
|
||||
@echo " make build LANCEDB=1"
|
||||
@echo " Build the current-platform LanceDB binary into dist/"
|
||||
@echo " make release Build linux/darwin/windows release binaries into dist/"
|
||||
@echo " make release LANCEDB=1"
|
||||
@echo " Build LanceDB release binaries into dist/"
|
||||
@echo " make generator Run code generation"
|
||||
@echo " make enums Generate frontend enums"
|
||||
@echo " make help Show this help"
|
||||
|
||||
dev: _lancedb-check
|
||||
@AGENT_DESK_SERVER_PORT="$(DEV_PORT)" CGO_ENABLED=1 CGO_CFLAGS="$(LANCEDB_CGO_CFLAGS)" CGO_LDFLAGS="$(LANCEDB_CGO_LDFLAGS)" \
|
||||
$(GO) run -tags "dev lancedb" $(MAIN) & \
|
||||
server_pid=$$!; \
|
||||
trap 'kill $$server_pid 2>/dev/null || true' EXIT INT TERM; \
|
||||
echo "Waiting for server at $(DEV_SERVER_URL)..."; \
|
||||
until curl -fsS "$(DEV_SERVER_URL)" >/dev/null 2>&1; do \
|
||||
if ! kill -0 $$server_pid 2>/dev/null; then \
|
||||
wait $$server_pid; \
|
||||
exit $$?; \
|
||||
fi; \
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "Server is ready; starting web dev server..."; \
|
||||
$(MAKE) _web-dev
|
||||
|
||||
ifeq ($(LANCEDB),1)
|
||||
build: _lancedb-check _prepare-dist _web-build-spa
|
||||
else
|
||||
build: _prepare-dist _web-build-spa
|
||||
endif
|
||||
@echo "Building $(BUILD_OUTPUT)..."
|
||||
ifeq ($(LANCEDB),1)
|
||||
@CGO_ENABLED=$(BUILD_CGO_ENABLED) CGO_CFLAGS="$(LANCEDB_CGO_CFLAGS)" CGO_LDFLAGS="$(LANCEDB_CGO_LDFLAGS)" \
|
||||
$(GO) build $(BUILD_TAGS) -v -o $(BUILD_OUTPUT) $(MAIN)
|
||||
else
|
||||
@$(GO) build -v -o $(BUILD_OUTPUT) $(MAIN)
|
||||
endif
|
||||
|
||||
ifeq ($(LANCEDB),1)
|
||||
release: _lancedb-release-check _prepare-dist _web-build-spa
|
||||
else
|
||||
release: _prepare-dist _web-build-spa
|
||||
endif
|
||||
@echo "Building release binaries in $(DIST_DIR)..."
|
||||
@if [ "$(LANCEDB)" = "1" ]; then \
|
||||
set -e; \
|
||||
if [ ! -f "$(CURDIR)/include/lancedb.h" ]; then \
|
||||
echo "Missing LanceDB header: $(CURDIR)/include/lancedb.h"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
build_lancedb() { \
|
||||
platform="$$1"; \
|
||||
arch="$$2"; \
|
||||
ext="$$3"; \
|
||||
system_ldflags="$$4"; \
|
||||
native_lib="$(CURDIR)/lib/$${platform}_$${arch}/liblancedb_go.a"; \
|
||||
output="$(DIST_DIR)/$(APP)-lancedb-$${platform}-$${arch}$${ext}"; \
|
||||
if [ ! -f "$$native_lib" ]; then \
|
||||
echo "Missing LanceDB native library for $${platform}/$${arch}: $$native_lib"; \
|
||||
echo "LanceDB release builds require matching native artifacts and a CGO-capable toolchain for each target platform."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "Building $$output..."; \
|
||||
CGO_ENABLED=1 CGO_CFLAGS="-I$(CURDIR)/include" CGO_LDFLAGS="$$native_lib $$system_ldflags" \
|
||||
GOOS="$$platform" GOARCH="$$arch" $(GO) build -tags lancedb -v -o "$$output" $(MAIN); \
|
||||
}; \
|
||||
build_lancedb linux amd64 "" "-lm -ldl -lpthread"; \
|
||||
build_lancedb linux arm64 "" "-lm -ldl -lpthread"; \
|
||||
build_lancedb darwin amd64 "" "-framework Security -framework CoreFoundation"; \
|
||||
build_lancedb darwin arm64 "" "-framework Security -framework CoreFoundation"; \
|
||||
build_lancedb windows amd64 ".exe" ""; \
|
||||
else \
|
||||
GOOS=linux GOARCH=amd64 $(GO) build -v -o $(DIST_DIR)/$(APP)-linux-amd64 $(MAIN); \
|
||||
GOOS=linux GOARCH=arm64 $(GO) build -v -o $(DIST_DIR)/$(APP)-linux-arm64 $(MAIN); \
|
||||
GOOS=darwin GOARCH=amd64 $(GO) build -v -o $(DIST_DIR)/$(APP)-darwin-amd64 $(MAIN); \
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build -v -o $(DIST_DIR)/$(APP)-darwin-arm64 $(MAIN); \
|
||||
GOOS=windows GOARCH=amd64 $(GO) build -v -o $(DIST_DIR)/$(APP)-windows-amd64.exe $(MAIN); \
|
||||
fi
|
||||
|
||||
generator:
|
||||
@$(GO) run ./cmd/generator/generator.go
|
||||
|
||||
enums:
|
||||
@$(GO) run ./cmd/enums/generator.go
|
||||
|
||||
_web-build-spa:
|
||||
@cd $(WEB_DIR) && $(PNPM) build:sdk && $(PNPM) build
|
||||
|
||||
_web-dev:
|
||||
@cd $(WEB_DIR) && NEXT_PUBLIC_API_BASE_URL="" NEXT_API_BASE_URL="$(DEV_API_BASE_URL)" $(PNPM) dev
|
||||
|
||||
_prepare-dist:
|
||||
@mkdir -p $(DIST_DIR)
|
||||
|
||||
_lancedb-artifacts:
|
||||
@if [ "$(LANCEDB_PLATFORM)" = "unsupported" ] || [ "$(LANCEDB_ARCH)" = "unsupported" ]; then \
|
||||
echo "Unsupported LanceDB platform: $(UNAME_S)/$(UNAME_M)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ -f "$(LANCEDB_NATIVE_LIB)" ] && [ -f "$(CURDIR)/include/lancedb.h" ]; then \
|
||||
echo "LanceDB native artifacts already exist for $(LANCEDB_PLATFORM_ARCH)."; \
|
||||
else \
|
||||
echo "Downloading LanceDB native artifacts $(LANCEDB_VERSION) for $(LANCEDB_PLATFORM_ARCH)..."; \
|
||||
curl -sSL "$(LANCEDB_DOWNLOAD_SCRIPT)" | bash -s "$(LANCEDB_VERSION)"; \
|
||||
fi
|
||||
|
||||
_lancedb-check: _lancedb-artifacts
|
||||
@if [ ! -f "$(LANCEDB_NATIVE_LIB)" ]; then \
|
||||
echo "Missing LanceDB native library: $(LANCEDB_NATIVE_LIB)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ ! -f "$(CURDIR)/include/lancedb.h" ]; then \
|
||||
echo "Missing LanceDB header: $(CURDIR)/include/lancedb.h"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
_lancedb-release-check:
|
||||
@if [ ! -f "$(CURDIR)/include/lancedb.h" ]; then \
|
||||
echo "Missing LanceDB header: $(CURDIR)/include/lancedb.h"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@missing=0; \
|
||||
for target in linux_amd64 linux_arm64 darwin_amd64 darwin_arm64 windows_amd64; do \
|
||||
native_lib="$(CURDIR)/lib/$${target}/liblancedb_go.a"; \
|
||||
if [ ! -f "$$native_lib" ]; then \
|
||||
echo "Missing LanceDB native library: $$native_lib"; \
|
||||
missing=1; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ "$$missing" = "1" ]; then \
|
||||
echo "LanceDB release builds require matching native artifacts and a CGO-capable toolchain for each target platform."; \
|
||||
exit 1; \
|
||||
fi
|
||||
513
README.md
@@ -1,3 +1,512 @@
|
||||
# agent-desk-ng
|
||||
# AgentDesk
|
||||
|
||||
Independent source snapshot of https://github.com/MonoVerseNS/agent-desk-ng. Apache-2.0; original history is not imported.
|
||||
English | [简体中文](README_ZH.md)
|
||||
|
||||
An open-source AI Agent customer support system with knowledge-based answers, human handoff, ticket workflows, and self-hosted deployment.
|
||||
|
||||
> Built for teams that need online support, knowledge-base Q&A, human collaboration, and service tracking in one system. It is not just an LLM inside a chat box; it is an AI Helpdesk foundation designed around real support operations.
|
||||
|
||||
## What's New
|
||||
|
||||
Everything below is additive; the sections that follow this one are unchanged.
|
||||
|
||||
### Russian localization
|
||||
|
||||
`ru-RU` is now a fully supported third locale, on both sides of the wire.
|
||||
|
||||
- Backend: `internal/pkg/i18nx/locales/ru-RU.yml`, complete alongside `zh-CN` and `en-US`, so every user-visible backend error is translated in all three.
|
||||
- Frontend: `web/messages/ru-RU.json`, a full catalogue rather than a partial one.
|
||||
- Permission and role display names are localized per locale instead of falling back to the backend's Chinese labels.
|
||||
- The UI font moved from Geist to Inter, which has real Cyrillic coverage. The previous font rendered Russian with mismatched glyphs.
|
||||
- Missing copy was filled in for the support portal, community, audit and help pages, admin settings, and navigation; several places were rendering raw message keys instead of text.
|
||||
- `web/i18n/message-integrity.test.mjs` now fails the build when a key used in the UI is missing from any locale, so a gap cannot come back silently.
|
||||
- A single radius scale was applied across dashboard and support surfaces.
|
||||
|
||||
To change the default locale, set `language` in `config/config.yaml`. `zh-CN`, `en-US` and `ru-RU` are supported.
|
||||
|
||||
### Visitor identity from a host platform
|
||||
|
||||
A host that embeds the widget can now assert the identity of its own signed-in user, so the visitor is treated as identified rather than anonymous. There are two paths, and both already existed conceptually:
|
||||
|
||||
- `userToken`: a JWT issued by the host, verified against a per-channel secret.
|
||||
- `externalId`: a new signed identity, verified against a deployment-wide shared secret.
|
||||
|
||||
```
|
||||
signature = hex(HMAC-SHA256(secret, channelId + "\n" + externalId + "\n" + issuedAt))
|
||||
```
|
||||
|
||||
`issuedAt` is unix seconds, sent alongside the signature as `externalIdSignedAt` (`X-External-Id-Signed-At`), and is covered by the signed bytes. Two properties are deliberate:
|
||||
|
||||
- The channel ID is part of the payload, because visitor identities are stored globally: a signature minted for one channel must not assert the same identity on another.
|
||||
- The timestamp is inside the signed payload even though it also travels as its own field, so a captured signature cannot be re-dated.
|
||||
|
||||
A signature is accepted only while it is within `identity.maxAgeMinutes` (default 24 hours). This bounds how long a leaked signature stays replayable. It does not protect the secret: HMAC is not invertible, so an external ID and its signature reveal nothing about the key, and no expiry would change that.
|
||||
|
||||
Anything that is missing, forged, expired or replayed against a different visitor is downgraded to an anonymous guest rather than rejected, so an unsigned embed keeps working exactly as before.
|
||||
|
||||
New configuration:
|
||||
|
||||
```yaml
|
||||
identity:
|
||||
secret: "" # IDENTITY_HMAC_SECRET — empty disables signed identities
|
||||
maxAgeMinutes: 1440 # IDENTITY_SIGNATURE_MAX_AGE_MINUTES
|
||||
```
|
||||
|
||||
The SDK resolves a fresh signature on every `open()` and reloads the frame when the resolved URL changes. Without that, a host page left open longer than the max age would silently degrade to a guest. It also fixes the same staleness for `userToken`, which had been frozen at the first `open()`.
|
||||
|
||||
### Several requests per visitor
|
||||
|
||||
A visitor could previously only ever hold one conversation: creating one resumed the most recent unfinished request, so a second, unrelated issue silently landed in the first thread.
|
||||
|
||||
- `POST /api/conversation/create` starts a new request and never resumes.
|
||||
- `POST /api/conversation/create_or_match` is unchanged, because reloading the page must still rejoin the live thread rather than orphan it.
|
||||
- `GET /api/conversation/list` returns the visitor's own requests, scoped from the external identity the request already carries.
|
||||
- Conversations carry an optional `subject`, so several requests are tellable apart.
|
||||
- `conversation.customerMaxOpen` caps how many requests may be open at once (default 3, `-1` removes the cap). The cap protects agents rather than the visitor: each open request is real work, and in human-only mode creating one dispatches an agent immediately. It applies only to the create path, never to the resume path.
|
||||
|
||||
The widget gained a request switcher showing the current subject, total unread, and per-request status and time. Activity in a request you are not viewing now surfaces in the switcher instead of being discarded.
|
||||
|
||||
### Support portal
|
||||
|
||||
- `/` redirects to `/support`, so the visitor-facing entry point is what loads first.
|
||||
- `/support/chat` opened directly now renders a real page shell instead of a bare widget; embedded in an iframe it still behaves as a widget.
|
||||
- Enterprise SSO (OIDC, WeCom) is no longer offered to portal visitors, and the server rejects it for a portal destination. Those transports create a staff account with a staff role and never link it to a customer, so a visitor signing in through the portal would have received an employee session with none of their history behind it.
|
||||
- `/dashboard/users` filters on account type and shows a type column. Portal visitors previously appeared in the agent list with admin actions offered on them.
|
||||
|
||||
### Email channel
|
||||
|
||||
The email channel works as a full request medium: a visitor's mail becomes a conversation, and the conversation continues as mail.
|
||||
|
||||
- **Inbound from real mail.** `POST /api/third/email/webhook` accepts Cloudflare Email Routing, Mailgun, SendGrid, Brevo, Postmark and generic JSON. A Cloudflare worker that parses MIME and forwards is included in `scripts/cloudflare-email-worker`.
|
||||
- **Inbound from a program.** `POST /api/third/email/request` files a request from an external system. This is the API bridge for headless integrations - it is the only way to open a request without a browser or a mail forwarder:
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-desk.example.com/api/third/email/request/support_mail \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-Webhook-Secret: <channel webhook secret>' \
|
||||
-d '{"email":"alice@example.com","name":"Alice","subject":"Cannot complete payment","message":"The checkout page spins forever."}'
|
||||
```
|
||||
|
||||
It answers with the conversation id, the subject, and where the request actually landed:
|
||||
|
||||
```json
|
||||
{ "conversationId": 42, "outcome": "email", "verified": true, "subject": "Cannot complete payment" }
|
||||
```
|
||||
|
||||
**Verified and self-reported modes.** Presenting the channel's `X-Webhook-Secret` marks the submitted address as verified. Omitting it still files the request, but the address is recorded as self-reported and agents can see that in the dashboard, so an unproven address is never mistaken for a proven one. A secret that is present but wrong is rejected rather than quietly downgraded - a broken integration must not look like a working one.
|
||||
- **Automatic acknowledgement.** The visitor gets a receipt by email as soon as the request is filed, then the AI Agent's answer arrives separately. The receipt text comes from the channel's welcome message, so it can be branded per channel.
|
||||
- **Where a request lands.** If the visitor still has a live chat open, the email is appended to that chat instead of starting an email thread - they are present in the chat and absent from their inbox - and they are emailed a note asking them to continue there. Once the chat has gone quiet it counts as abandoned and the email becomes its own conversation. The window is `conversation.emailChatLiveMinutes`, 30 minutes by default.
|
||||
- **Replies.** `Reply-To` points at the channel's own inbound address, so a reply comes back to AgentDesk rather than to the agent's mailbox. Threads hold through the `[#id]` token in the subject and through `In-Reply-To`.
|
||||
- **Outbound delivery** goes through an outbox with retries, over SMTP, Brevo, SendGrid, Resend, Postmark or Mailgun.
|
||||
- **Binding a channel to an SMTP server.** Each email channel can carry its own host, port, credentials and TLS settings, falling back to the deployment defaults for anything it leaves unset. Two of those settings are per channel on purpose:
|
||||
- `smtpUseTls` is tri-state. An unchecked box means "inherit", not "off", so a channel can be bound to a plaintext local test server without changing the global setting for everyone else.
|
||||
- `smtpAllowInsecure` accepts a self-signed certificate. It exists for pointing a channel at a test server such as Mailpit or MailHog and must never be enabled for a real mailbox.
|
||||
- **Verifying a binding.** `POST /api/dashboard/channel/test_email` opens a connection using the same settings resolution and the same transport rules as the real send, and reports what the server offered: whether it advertises STARTTLS, whether TLS was established, which capabilities it has, and whether the credentials were accepted. The probe sends no mail. Binding is then a checked action instead of a guess - otherwise a wrong host or password only shows up by filing a real request and watching the outbox retry five times.
|
||||
|
||||
```yaml
|
||||
email:
|
||||
provider: smtp # smtp | brevo | sendgrid | resend | postmark | mailgun
|
||||
fromAddress: support@example.com
|
||||
fromName: Support
|
||||
smtpHost: smtp.example.com
|
||||
smtpPort: 587
|
||||
smtpUser: support@example.com
|
||||
smtpPassword: ...
|
||||
smtpUseTls: true
|
||||
smtpAllowInsecure: false # test servers only
|
||||
inboundSecret: ... # fallback when a channel has no secret of its own
|
||||
```
|
||||
|
||||
To develop against a local catcher, run Mailpit or MailHog, then set the channel's SMTP host to it, port 1025, leave the credentials empty, and enable `smtpAllowInsecure`.
|
||||
|
||||
### Fixes
|
||||
|
||||
- The email channel wrote to an outbox `send_detail` column that no migration created, so outbound email failed on an unpatched database.
|
||||
- Sidebar section titles wrapped inside a fixed-height row and overlapped their neighbours. A section trigger is the one place where the label is not the button's last child, so the shared truncation never reached it.
|
||||
- Regenerated the shared frontend enums, which were missing four `ExternalSource` values and the whole `UserType` enum.
|
||||
|
||||
## Product Preview
|
||||
|
||||
Customer chat, agent workspace, knowledge base, model configuration, and AI Agent orchestration are managed in one system.
|
||||
|
||||
### Customer Chat
|
||||
|
||||

|
||||
|
||||
Customers can start a conversation from the web chat page. The AI Agent responds first with knowledge-grounded answers. When the user explicitly asks for a human, the system can start a handoff confirmation flow.
|
||||
|
||||
### Agent Workspace
|
||||
|
||||

|
||||
|
||||
The support workspace includes conversation lists, message handling, AI-to-human handoff, agent replies, conversation tags, linked customers, and ticket context for daily support work.
|
||||
|
||||
### Knowledge Base and AI Agent Configuration
|
||||
|
||||
| Knowledge Base FAQ | AI Agent Configuration |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
The knowledge base stores FAQs, documents, and retrievable content. AI Agents can be bound to model configurations, knowledge bases, Skills, and tools to create support agents for specific scenarios.
|
||||
|
||||
### Model Configuration
|
||||
|
||||

|
||||
|
||||
Model configuration supports OpenAI-compatible providers. You can configure LLMs, embedding models, rerank models, context limits, output settings, timeout, retry behavior, and enablement state.
|
||||
|
||||
## Why Use It
|
||||
|
||||
- **AI-first support**: Let AI Agents handle common questions, standard procedures, and knowledge-base answers first.
|
||||
- **Knowledge-constrained replies**: Use RAG and the Answerability Gate to decide whether retrieved knowledge is strong enough to answer, reducing unsupported responses.
|
||||
- **Natural human handoff**: Move to human agents when knowledge is insufficient, the user asks for help, or a workflow requires human confirmation.
|
||||
- **Conversation-to-ticket loop**: Online chat, support handling, ticket creation, status flow, and progress records stay in one system.
|
||||
- **Built for extension**: The backend uses Go, the frontend uses Next.js, and the runtime supports Skills, MCP, and OpenAI-compatible model access.
|
||||
- **Self-host friendly**: Supports SQLite / MySQL and Qdrant for local trials, intranet deployment, and enterprise self-hosting.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- **AI Agent support**: AI replies first, with fallback, confirmation, tool calling, and human collaboration.
|
||||
- **Online conversation system**: Visitor sessions, message send/receive, unread status, assignment, transfer, and close flows.
|
||||
- **Agent workspace**: Agents can take over conversations, reply to users, transfer teammates, link customers, and create tickets.
|
||||
- **Knowledge-base RAG**: Knowledge bases, documents, FAQs, chunking, vector retrieval, retrieval logs, and quality analysis.
|
||||
- **Answerability Gate**: Checks whether retrieved content can support an answer; otherwise returns a fallback and recommends human support.
|
||||
- **Ticket system**: Create tickets from conversations, categorize, assign, move through status flows, record progress, and close the loop.
|
||||
- **Support organization management**: Agent profiles, teams, schedules, and automatic assignment.
|
||||
- **AI extensibility**: Skills, MCP debugging, and external tool integration.
|
||||
- **Multiple entry points**: Admin dashboard, agent workspace, customer-facing web pages, and embeddable SDK.
|
||||
|
||||
## Embedding the widget
|
||||
|
||||
The widget is a single script tag plus one global config object. No build step and no framework required.
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.AgentDeskConfig = {
|
||||
baseUrl: "https://your-desk.example.com",
|
||||
channelId: "web_main",
|
||||
};
|
||||
</script>
|
||||
<script async src="https://your-desk.example.com/sdk/agent-desk-sdk.min.js"></script>
|
||||
```
|
||||
|
||||
`baseUrl` is where AgentDesk is served. `channelId` is the channel the visitor's conversation belongs to; the dashboard's channel page shows a ready-made snippet with both filled in.
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `channelId` | `string` | **Required.** Channel the conversation belongs to. |
|
||||
| `baseUrl` | `string` | Where AgentDesk is served. Inferred from the script URL when omitted. |
|
||||
| `apiBaseUrl` | `string` | Only when the API lives on a different origin. |
|
||||
| `widgetBaseUrl` | `string` | Only when the frontend is served separately from the API. |
|
||||
| `externalId` | `string` | Stable visitor ID from your platform. A browser-local ID is used when omitted. |
|
||||
| `signExternalId` | `fn` | Proves that `externalId`, described below. |
|
||||
| `externalName` | `string` | Display name, used on first contact only. |
|
||||
| `getUserToken` | `fn` | A user JWT issued by your system. |
|
||||
| `title` / `subtitle` | `string` | Widget header text, overriding the channel's. |
|
||||
| `language` | `string` | `zh-CN`, `en-US` or `ru-RU`. |
|
||||
| `themeColor` | `string` | Accent colour. |
|
||||
| `position` | `"left" \| "right"` | Which corner the launcher sits in. |
|
||||
| `width` | `string` | Launcher width. |
|
||||
|
||||
### Identifying your signed-in user
|
||||
|
||||
Anonymous visitors work with no configuration. If your platform has its own users, you have two ways to prove who they are, and both are optional.
|
||||
|
||||
**A signed visitor ID.** Sign the ID with the deployment-wide shared secret and return the pair. `issuedAt` is unix seconds and travels separately, but is covered by the signature, so a captured pair cannot be re-dated:
|
||||
|
||||
```
|
||||
signature = hex(HMAC-SHA256(secret, channelId + "\n" + externalId + "\n" + issuedAt))
|
||||
```
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.AgentDeskConfig = {
|
||||
baseUrl: "https://your-desk.example.com",
|
||||
channelId: "web_main",
|
||||
externalId: currentUser.id,
|
||||
signExternalId: async (externalId) => ({
|
||||
signature: await signForMe(externalId),
|
||||
issuedAt: Math.floor(Date.now() / 1000),
|
||||
}),
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
The secret is the deployment's `identity.secret`, never the host's own session secret. A signature is accepted for `identity.maxAgeMinutes`, 24 hours by default. Anything missing, forged, expired, or replayed against a different visitor is downgraded to an anonymous guest rather than rejected, so an unsigned embed keeps working.
|
||||
|
||||
The SDK resolves the signature on every `open()` and reloads the frame when the value changes. That matters: without it a host page left open longer than the max age would silently degrade to a guest, and a `userToken` would stay frozen at whatever it was when the page loaded.
|
||||
|
||||
**A user JWT.** If your platform already issues HS256 JWTs, hand one over instead:
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.AgentDeskConfig = {
|
||||
baseUrl: "https://your-desk.example.com",
|
||||
channelId: "web_main",
|
||||
getUserToken: async () => await fetchMyToken(),
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
The token is verified against the channel's `userTokenSecret` and must carry a non-empty user id and name. Prefer this when your platform already issues JWTs; it needs no shared secret in the browser.
|
||||
|
||||
### Controlling the widget
|
||||
|
||||
```js
|
||||
const widget = window.AgentDeskWidget
|
||||
widget.open() // resolves a fresh identity and shows the panel
|
||||
widget.close()
|
||||
widget.getChatUrl() // the chat URL, if you want to link to it instead
|
||||
widget.destroy()
|
||||
```
|
||||
|
||||
`open()` is where identity is resolved, so prefer it over mounting with an open panel. It also returns a promise that settles after the frame has been told to open.
|
||||
|
||||
The widget and the page talk over `postMessage`, so the panel can be closed, minimized or maximized from the host with the messages `agent-desk:request-close`, `agent-desk:request-minimize` and `agent-desk:request-toggle-maximize`.
|
||||
|
||||
### Allowing the origin
|
||||
|
||||
The widget runs in an iframe and calls the API cross-origin, so add your site's origin to `server.cors.allowedOrigins`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
cors:
|
||||
allowedOrigins:
|
||||
- https://your-site.example.com
|
||||
```
|
||||
|
||||
### Self-hosting the deployment name
|
||||
|
||||
The platform name and logo apply to the portal, the dashboard and the widget chrome at once. Rename a deployment from **Settings** in the dashboard, or set it in configuration:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
companyName: "Acme Support"
|
||||
companyLogoUrl: /brand/logo.svg
|
||||
```
|
||||
|
||||
What the dashboard saves wins over configuration, and clearing a field hands control back to the configuration value, so a deployment that already sets `COMPANY_NAME` in its environment keeps its branding.
|
||||
|
||||
Note that the widget's JavaScript globals (`AgentDeskConfig`, `AgentDeskWidget`) and its script path are part of the published embed contract and are deliberately not renamed. Renaming them would break every existing integration silently.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- Website live support
|
||||
- SaaS product support
|
||||
- AI + human hybrid support
|
||||
- Internal enterprise service desk
|
||||
- After-sales service, incident reporting, complaints, and operations support
|
||||
- Support teams that need knowledge-base Q&A with human collaboration
|
||||
|
||||
## Quick Start
|
||||
|
||||
The fastest way to try the full stack is Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
For the full English setup guide, see [Docker Compose Quick Start](https://agent-desk.huabei.pro/docs/getting-started/docker-compose.html).
|
||||
|
||||
To embed customer support on your website, see [Web Widget Integration](https://agent-desk.huabei.pro/docs/integration/web-widget.html).
|
||||
|
||||
To connect OpenAI-compatible model providers, see [Model Provider Configuration](https://agent-desk.huabei.pro/docs/config/model-provider.html).
|
||||
|
||||
Compose starts:
|
||||
|
||||
- `agent-desk`: application service on port `8083`
|
||||
- `mysql`: MySQL 8.4 with the `mysql-data` volume
|
||||
- `qdrant`: vector database with the `qdrant-data` volume, ports `6333` / `6334`
|
||||
|
||||
After startup, open:
|
||||
|
||||
- Admin dashboard: `http://localhost:8083/dashboard`
|
||||
- Agent workspace: `http://localhost:8083/dashboard/conversations`
|
||||
- Customer web integration demo: `http://localhost:8083/support/demo`
|
||||
- Customer chat page: `http://localhost:8083/support/chat`
|
||||
|
||||
Default administrator account:
|
||||
|
||||
- Username: `admin`
|
||||
- Password: `ChangeMe123!`
|
||||
|
||||
> Before exposing the system to the public internet or a team environment, change the default administrator password and configure independent authentication, session, and model secrets.
|
||||
|
||||
## Local Development
|
||||
|
||||
### Requirements
|
||||
|
||||
- Go `1.26+`
|
||||
- Node.js `20+`
|
||||
- `pnpm`
|
||||
- Qdrant
|
||||
|
||||
### Prepare Configuration
|
||||
|
||||
```bash
|
||||
cp config/config.example.yaml config/config.yaml
|
||||
```
|
||||
|
||||
The default configuration uses:
|
||||
|
||||
- SQLite: `data/app.db`
|
||||
- Backend: `http://127.0.0.1:8083`
|
||||
- Qdrant gRPC: `127.0.0.1:6334`
|
||||
|
||||
If Qdrant is not running locally, start it with Docker:
|
||||
|
||||
```bash
|
||||
docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant
|
||||
```
|
||||
|
||||
Install frontend dependencies:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm install
|
||||
cd ..
|
||||
```
|
||||
|
||||
Start backend and frontend development servers together:
|
||||
|
||||
```bash
|
||||
task dev
|
||||
```
|
||||
|
||||
Default development URLs:
|
||||
|
||||
- Admin dashboard: `http://localhost:3000/dashboard`
|
||||
- Agent workspace: `http://localhost:3000/dashboard/conversations`
|
||||
- Customer web integration demo: `http://localhost:3000/support/demo`
|
||||
- Customer chat page: `http://localhost:3000/support/chat`
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Backend: Golang + Gin + GORM + `github.com/mlogclub/simple`
|
||||
- Frontend: Next.js 16 + React 19 + shadcn/ui + Tailwind CSS
|
||||
- Database: SQLite / MySQL
|
||||
- Vector DB: Qdrant
|
||||
- AI: OpenAI-compatible LLM / Embedding + RAG + Skills + MCP
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
.
|
||||
├── cmd/ # server / migration / generator / testdata
|
||||
├── internal/
|
||||
│ ├── bootstrap/ # startup, routes, database, and migration initialization
|
||||
│ ├── builders/ # model / aggregate result to response DTO mapping
|
||||
│ ├── handlers/ # dashboard / api / third HTTP handlers
|
||||
│ ├── middleware/ # Gin middleware
|
||||
│ ├── migration/ # idempotent data migrations
|
||||
│ ├── models/ # GORM models
|
||||
│ ├── repositories/ # data access layer
|
||||
│ ├── services/ # business orchestration and transaction boundaries
|
||||
│ ├── ai/ # LLM / RAG / Runtime / Skills / MCP
|
||||
│ └── pkg/ # config / dto / enums / httpx / utils and shared packages
|
||||
├── web/ # Next.js frontend project
|
||||
│ ├── app/dashboard/ # admin dashboard and agent workspace
|
||||
│ ├── app/support/ # customer integration and chat pages
|
||||
│ ├── components/ # React components
|
||||
│ ├── lib/ # API client, SDK source, and utilities
|
||||
│ └── public/sdk/ # built embeddable SDK
|
||||
├── config/ # configuration files
|
||||
├── docker/ # Docker configuration
|
||||
└── docs/ # documentation site
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
task dev # start backend and frontend development servers
|
||||
task build # build the frontend SPA and current-platform Go binary into dist/
|
||||
task build:lancedb # build the current-platform LanceDB binary into dist/
|
||||
task release # build linux/darwin/windows release binaries into dist/
|
||||
task release:lancedb # build LanceDB release binaries into dist/
|
||||
task generator # run code generation
|
||||
task enums # generate frontend enums
|
||||
task --list # show available tasks
|
||||
```
|
||||
|
||||
## AI Agent Workflow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User starts a support request<br/>Web support entry / Open API] --> B[Create or match a conversation]
|
||||
B --> C[Customer sends a message]
|
||||
C --> D[Trigger AI Reply Runtime]
|
||||
D --> E[Load conversation history / AI configuration]
|
||||
E --> F[Retrieve from bound knowledge bases]
|
||||
F --> G{Are retrieved chunks enough to answer?}
|
||||
G -- No --> Z[Return knowledge fallback<br/>and recommend human support]
|
||||
G -- Yes --> H[Prepare Skills / MCP Tools]
|
||||
H --> I[Pass trusted knowledge context to the Agent]
|
||||
I --> J{Direct reply?}
|
||||
J -- Yes --> K[LLM generates a knowledge-grounded reply]
|
||||
J -- No --> N{Call Graph / MCP Tool?}
|
||||
N -- Yes --> O[Run Skill / Graph / MCP Tool]
|
||||
O --> P{Need user confirmation?}
|
||||
P -- No --> I
|
||||
P -- Yes --> Q[Ask the user to confirm]
|
||||
Q --> R{Confirmation result}
|
||||
R -- Confirm handoff --> S[Move conversation to human handoff pool]
|
||||
S --> T[Automatic or manual assignment]
|
||||
T --> U[Agent workspace takeover]
|
||||
U --> V{Need ticket tracking?}
|
||||
V -- Yes --> W[Create or link a ticket]
|
||||
V -- No --> X[Human agent continues handling]
|
||||
W --> X
|
||||
X --> Y[Resolve and close]
|
||||
R -- Confirm ticket --> AA[Create a ticket from the current conversation]
|
||||
AA --> I
|
||||
R -- Cancel --> K
|
||||
N -- No --> K
|
||||
```
|
||||
|
||||
## Support Loop
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Customer request] --> B[AI Agent handles first]
|
||||
B --> C{Can the knowledge base answer?}
|
||||
C -- Yes --> D[AI replies with trusted knowledge]
|
||||
C -- No --> E[Fallback / recommend human support]
|
||||
D --> F{Need a human?}
|
||||
E --> G[Human takeover]
|
||||
F -- No --> H[Conversation ends or data is retained]
|
||||
F -- Yes --> G
|
||||
G --> I[Agent workspace handles the case]
|
||||
I --> J{Need follow-up tracking?}
|
||||
J -- Yes --> K[Create / link a ticket]
|
||||
J -- No --> L[Resolve directly]
|
||||
K --> M[Ticket status flow and progress records]
|
||||
M --> N[Complete]
|
||||
L --> N
|
||||
```
|
||||
|
||||
## Docker Image
|
||||
|
||||
If you only need to build the application image, prepare MySQL and Qdrant yourself and mount a configuration file:
|
||||
|
||||
```bash
|
||||
docker build -t mlogclub/agent-desk .
|
||||
docker run --rm -p 8083:8083 \
|
||||
-v $(pwd)/docker/agent-desk.yaml:/app/config/config.yaml:ro \
|
||||
-v agent-desk-data:/app/data \
|
||||
mlogclub/agent-desk
|
||||
```
|
||||
|
||||
Compose uses [docker/agent-desk.yaml](docker/agent-desk.yaml) as the in-container configuration. The application reaches `mysql` and `qdrant` through Docker service names.
|
||||
|
||||
## Open-source Positioning
|
||||
|
||||
`AgentDesk` is useful as an open-source foundation for:
|
||||
|
||||
- AI customer support systems
|
||||
- AI Helpdesk / AI Support Platform projects
|
||||
- RAG answerability + human handoff implementation references
|
||||
- Enterprise AI Agent application frameworks
|
||||
|
||||
If you are looking for a customer support system centered on AI Agents rather than a simple LLM chat box, this project is designed for that purpose.
|
||||
|
||||
278
README_ZH.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# AgentDesk
|
||||
|
||||
[English](README.md) | 简体中文
|
||||
|
||||
开源的 AI Agent 客服系统,支持知识库问答、人工接管、工单闭环和私有化部署。
|
||||
|
||||
> 面向需要同时处理在线咨询、知识库问答、人工协同和服务跟踪的团队。它不是把 LLM 接进聊天框,而是一套围绕客服场景设计的 AI Helpdesk 基础系统。
|
||||
|
||||
## 产品预览
|
||||
|
||||
客户侧在线咨询、客服工作台、知识库、模型配置和 AI Agent 编排都在同一套系统中完成。
|
||||
|
||||
### 客户侧在线咨询
|
||||
|
||||

|
||||
|
||||
客户可以在 Web 聊天页中直接发起咨询。AI Agent 会先接待,基于知识库回答问题;当用户明确要求人工介入时,会触发转人工确认流程。
|
||||
|
||||
### 客服工作台
|
||||
|
||||

|
||||
|
||||
客服工作台支持会话列表、消息处理、AI 转人工、客服回复、会话标签、关联客户和工单信息查看,适合客服日常接待使用。
|
||||
|
||||
### 知识库与 AI 配置
|
||||
|
||||
| 知识库 FAQ | AI Agent 配置 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
知识库用于沉淀 FAQ、文档和可检索内容;AI Agent 可以绑定模型配置、知识库、Skills 和工具能力,形成面向具体客服场景的智能客服实例。
|
||||
|
||||
### 模型配置
|
||||
|
||||

|
||||
|
||||
模型配置支持 OpenAI-compatible 接入方式,可分别配置大语言模型、向量模型和重排模型,并管理上下文、输出、超时、重试和启用状态。
|
||||
|
||||
## 为什么选择它
|
||||
|
||||
- **AI 先接待**:让 AI Agent 优先处理常见问题、标准流程和知识库问答。
|
||||
- **知识约束回答**:通过 RAG 和 Answerability Gate 判断知识片段是否足以回答,减少超出知识库范围的乱答。
|
||||
- **自然转人工**:当知识库不足、用户明确要求或流程需要人工确认时,进入人工接管。
|
||||
- **会话到工单闭环**:在线会话、客服接待、工单创建、状态流转和处理记录在同一套系统里完成。
|
||||
- **适合二次开发**:后端使用 Go,前端使用 Next.js,支持 Skills、MCP 和 OpenAI-compatible 模型接入。
|
||||
- **可私有化部署**:支持 SQLite / MySQL 和 Qdrant,适合本地体验、内网部署和企业自托管。
|
||||
|
||||
## 核心能力
|
||||
|
||||
- **AI Agent 客服**:AI 优先回复,支持兜底、确认、工具调用和人工协同。
|
||||
- **在线会话系统**:支持访客会话、消息收发、未读状态、会话分配、转接和关闭。
|
||||
- **客服工作台**:客服可接管会话、回复用户、转接同事、关联客户和创建工单。
|
||||
- **知识库 RAG**:支持知识库、文档、FAQ、切片、向量检索、检索日志和质量分析。
|
||||
- **Answerability Gate**:判断检索内容是否足以支撑回答,不足时返回兜底提示并建议联系人工。
|
||||
- **工单系统**:支持从会话创建工单、分类、指派、状态流转、进展记录和闭环处理。
|
||||
- **客服组织管理**:支持客服档案、客服组、排班和自动分配能力。
|
||||
- **AI 扩展能力**:支持 Skills、MCP 调试和外部工具接入。
|
||||
- **多入口接入**:提供管理后台、客服工作台、客户侧 Web 页面和嵌入式 SDK。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 官网在线客服
|
||||
- SaaS 产品支持
|
||||
- AI + 人工混合接待
|
||||
- 企业内部服务台
|
||||
- 售后、报障、投诉和运营支持
|
||||
- 需要知识库问答与人工协同的客服团队
|
||||
|
||||
## 快速开始
|
||||
|
||||
推荐先用 Docker Compose 体验完整服务:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
完整英文配置与排查说明见 [Docker Compose Quick Start](https://agent-desk.huabei.pro/zh/docs/getting-started/docker-compose.html)。
|
||||
|
||||
如需在官网或产品中嵌入客服入口,见 [Web Widget Integration](https://agent-desk.huabei.pro/zh/docs/integration/web-widget.html)。
|
||||
|
||||
如需接入 OpenAI-compatible 模型供应商,见 [Model Provider Configuration](https://agent-desk.huabei.pro/zh/docs/config/model-provider.html)。
|
||||
|
||||
Compose 默认会启动:
|
||||
|
||||
- `agent-desk`:应用服务,端口 `8083`
|
||||
- `mysql`:MySQL 8.4,数据卷 `mysql-data`
|
||||
- `qdrant`:向量数据库,数据卷 `qdrant-data`,端口 `6333` / `6334`
|
||||
|
||||
启动后访问:
|
||||
|
||||
- 管理后台:`http://localhost:8083/dashboard`
|
||||
- 客服工作台:`http://localhost:8083/dashboard/conversations`
|
||||
- 客户侧 Web 接入示例:`http://localhost:8083/support/demo`
|
||||
- 客户侧聊天页:`http://localhost:8083/support/chat`
|
||||
|
||||
默认管理员账号:
|
||||
|
||||
- 用户名:`admin`
|
||||
- 密码:`ChangeMe123!`
|
||||
|
||||
> 首次用于公网或团队环境前,请务必修改默认管理员密码,并配置独立的鉴权、会话和模型密钥。
|
||||
|
||||
## 本地开发
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Go `1.26+`
|
||||
- Node.js `20+`
|
||||
- `pnpm`
|
||||
- Qdrant
|
||||
|
||||
### 准备配置
|
||||
|
||||
```bash
|
||||
cp config/config.example.yaml config/config.yaml
|
||||
```
|
||||
|
||||
默认配置使用:
|
||||
|
||||
- SQLite:`data/app.db`
|
||||
- Backend:`http://127.0.0.1:8083`
|
||||
- Qdrant gRPC:`127.0.0.1:6334`
|
||||
|
||||
如果本地还没有 Qdrant,可以用 Docker 启动:
|
||||
|
||||
```bash
|
||||
docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant
|
||||
```
|
||||
|
||||
安装前端依赖:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm install
|
||||
cd ..
|
||||
```
|
||||
|
||||
同时启动后端和前端开发服务:
|
||||
|
||||
```bash
|
||||
task dev
|
||||
```
|
||||
|
||||
开发环境默认入口:
|
||||
|
||||
- 管理后台:`http://localhost:3000/dashboard`
|
||||
- 客服工作台:`http://localhost:3000/dashboard/conversations`
|
||||
- 客户侧 Web 接入示例:`http://localhost:3000/support/demo`
|
||||
- 客户侧聊天页:`http://localhost:3000/support/chat`
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Backend:Golang + Gin + GORM + `github.com/mlogclub/simple`
|
||||
- Frontend:Next.js 16 + React 19 + shadcn/ui + Tailwind CSS
|
||||
- Database:SQLite / MySQL
|
||||
- Vector DB:Qdrant
|
||||
- AI:OpenAI-compatible LLM / Embedding + RAG + Skills + MCP
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── cmd/ # server / migration / generator / testdata
|
||||
├── internal/
|
||||
│ ├── bootstrap/ # 启动、路由、数据库和迁移初始化
|
||||
│ ├── builders/ # model / 聚合结果到 response DTO 的映射
|
||||
│ ├── handlers/ # dashboard / api / third HTTP handlers
|
||||
│ ├── middleware/ # Gin middleware
|
||||
│ ├── migration/ # 幂等数据迁移
|
||||
│ ├── models/ # GORM models
|
||||
│ ├── repositories/ # 数据访问层
|
||||
│ ├── services/ # 业务编排和事务边界
|
||||
│ ├── ai/ # LLM / RAG / Runtime / Skills / MCP
|
||||
│ └── pkg/ # config / dto / enums / httpx / utils 等基础包
|
||||
├── web/ # Next.js 前端工程
|
||||
│ ├── app/dashboard/ # 管理后台与客服工作台
|
||||
│ ├── app/support/ # 客户侧接入和聊天页面
|
||||
│ ├── components/ # React 组件
|
||||
│ ├── lib/ # API client、SDK 源码和工具函数
|
||||
│ └── public/sdk/ # 构建后的嵌入式 SDK
|
||||
├── config/ # 配置文件
|
||||
├── docker/ # Docker 配置
|
||||
└── docs/ # 项目文档
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
task dev # 同时启动后端和前端开发服务
|
||||
task build # 构建前端 SPA 和当前平台 Go 二进制
|
||||
task build:lancedb # 构建当前平台 LanceDB 二进制
|
||||
task release # 构建常用平台二进制
|
||||
task release:lancedb # 构建 LanceDB 发布二进制
|
||||
task generator # 执行代码生成
|
||||
task enums # 生成前端枚举
|
||||
task --list # 查看可用任务
|
||||
```
|
||||
|
||||
## AI Agent 工作流
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户发起咨询<br/>Web 客服入口 / Open API] --> B[创建或匹配会话]
|
||||
B --> C[客户发送消息]
|
||||
C --> D[触发 AI Reply Runtime]
|
||||
D --> E[加载会话历史 / AI 配置]
|
||||
E --> F[按绑定知识库执行检索]
|
||||
F --> G{知识片段是否足以回答?}
|
||||
G -- 否 --> Z[返回知识库兜底提示<br/>并建议联系人工客服]
|
||||
G -- 是 --> H[准备 Skills / MCP Tools]
|
||||
H --> I[将可信知识上下文交给 Agent]
|
||||
I --> J{直接回复?}
|
||||
J -- 是 --> K[LLM 基于知识生成回复并返回用户]
|
||||
J -- 否 --> N{是否调用 Graph / MCP Tool?}
|
||||
N -- 是 --> O[执行 Skill / Graph / MCP Tool]
|
||||
O --> P{需要用户确认?}
|
||||
P -- 否 --> I
|
||||
P -- 是 --> Q[向用户发起确认]
|
||||
Q --> R{用户确认结果}
|
||||
R -- 确认转人工 --> S[会话转人工并进入待接入池]
|
||||
S --> T[自动分配或人工分配]
|
||||
T --> U[客服工作台接管]
|
||||
U --> V{是否需要工单跟踪?}
|
||||
V -- 是 --> W[创建或关联工单]
|
||||
V -- 否 --> X[人工继续处理]
|
||||
W --> X
|
||||
X --> Y[问题解决并关闭]
|
||||
R -- 确认建单 --> AA[从当前会话创建工单]
|
||||
AA --> I
|
||||
R -- 取消 --> K
|
||||
N -- 否 --> K
|
||||
```
|
||||
|
||||
## 业务闭环
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[客户咨询] --> B[AI Agent 接待]
|
||||
B --> C{知识库可回答?}
|
||||
C -- 是 --> D[AI 基于可信知识回复]
|
||||
C -- 否 --> E[兜底提示 / 建议人工]
|
||||
D --> F{是否需要人工?}
|
||||
E --> G[人工接管]
|
||||
F -- 否 --> H[会话结束或沉淀数据]
|
||||
F -- 是 --> G
|
||||
G --> I[客服工作台处理]
|
||||
I --> J{是否需要跟踪?}
|
||||
J -- 是 --> K[创建 / 关联工单]
|
||||
J -- 否 --> L[直接解决]
|
||||
K --> M[工单流转与进展记录]
|
||||
M --> N[处理完成]
|
||||
L --> N
|
||||
```
|
||||
|
||||
## Docker 镜像
|
||||
|
||||
如果只需要构建应用镜像,可以自行准备 MySQL 和 Qdrant,并挂载配置文件:
|
||||
|
||||
```bash
|
||||
docker build -t mlogclub/agent-desk .
|
||||
docker run --rm -p 8083:8083 \
|
||||
-v $(pwd)/docker/agent-desk.yaml:/app/config/config.yaml:ro \
|
||||
-v agent-desk-data:/app/data \
|
||||
mlogclub/agent-desk
|
||||
```
|
||||
|
||||
Compose 使用 [docker/agent-desk.yaml](docker/agent-desk.yaml) 作为容器内配置,应用会通过 Docker 内部服务名访问 `mysql` 和 `qdrant`。
|
||||
|
||||
## 开源定位
|
||||
|
||||
`AgentDesk` 适合作为以下方向的开源基础项目:
|
||||
|
||||
- AI 客服系统
|
||||
- AI Helpdesk / AI Support Platform
|
||||
- RAG 可回答性判定 + Human Handoff 的落地样板
|
||||
- 面向企业场景的 AI Agent 应用框架
|
||||
|
||||
如果你在寻找一个以 AI Agent 为中心,而不是仅仅把 LLM 嵌进聊天框的客服系统,这个项目就是为此设计的。
|
||||
266
Taskfile.yml
Normal file
@@ -0,0 +1,266 @@
|
||||
version: '3'
|
||||
|
||||
silent: true
|
||||
|
||||
vars:
|
||||
APP: agent-desk
|
||||
MAIN: ./cmd/server
|
||||
WEB_DIR: web
|
||||
DIST_DIR: dist
|
||||
GO:
|
||||
sh: 'printf "%s" "${GO:-go}"'
|
||||
PNPM:
|
||||
sh: 'printf "%s" "${PNPM:-pnpm}"'
|
||||
DEV_PORT:
|
||||
sh: 'printf "%s" "${DEV_PORT:-8083}"'
|
||||
DEV_API_BASE_URL:
|
||||
sh: 'printf "%s" "${DEV_API_BASE_URL:-http://127.0.0.1:${DEV_PORT:-8083}}"'
|
||||
LANCEDB_VERSION:
|
||||
sh: 'printf "%s" "${LANCEDB_VERSION:-v0.1.2}"'
|
||||
LANCEDB_DOWNLOAD_SCRIPT:
|
||||
sh: 'printf "%s" "${LANCEDB_DOWNLOAD_SCRIPT:-https://raw.githubusercontent.com/lancedb/lancedb-go/main/scripts/download-artifacts.sh}"'
|
||||
HOST_OS:
|
||||
sh: uname -s
|
||||
HOST_ARCH:
|
||||
sh: uname -m
|
||||
HOST_GOOS:
|
||||
sh: '${GO:-go} env GOOS'
|
||||
HOST_LANCEDB_PLATFORM:
|
||||
sh: |
|
||||
case "$(uname -s)" in
|
||||
Darwin) printf darwin ;;
|
||||
Linux) printf linux ;;
|
||||
MINGW*|MSYS*|CYGWIN*) printf windows ;;
|
||||
*) printf unsupported ;;
|
||||
esac
|
||||
HOST_LANCEDB_ARCH:
|
||||
sh: |
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) printf amd64 ;;
|
||||
arm64|aarch64) printf arm64 ;;
|
||||
*) printf unsupported ;;
|
||||
esac
|
||||
HOST_LANCEDB_TARGET:
|
||||
sh: |
|
||||
case "$(uname -s)" in
|
||||
Darwin) platform=darwin ;;
|
||||
Linux) platform=linux ;;
|
||||
MINGW*|MSYS*|CYGWIN*) platform=windows ;;
|
||||
*) platform=unsupported ;;
|
||||
esac
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch=amd64 ;;
|
||||
arm64|aarch64) arch=arm64 ;;
|
||||
*) arch=unsupported ;;
|
||||
esac
|
||||
printf "%s_%s" "$platform" "$arch"
|
||||
HOST_LANCEDB_LIB:
|
||||
sh: |
|
||||
case "$(uname -s)" in
|
||||
Darwin) platform=darwin ;;
|
||||
Linux) platform=linux ;;
|
||||
MINGW*|MSYS*|CYGWIN*) platform=windows ;;
|
||||
*) platform=unsupported ;;
|
||||
esac
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch=amd64 ;;
|
||||
arm64|aarch64) arch=arm64 ;;
|
||||
*) arch=unsupported ;;
|
||||
esac
|
||||
printf "%s/lib/%s_%s/liblancedb_go.a" "{{.ROOT_DIR}}" "$platform" "$arch"
|
||||
LANCEDB_CGO_CFLAGS: '-I{{.ROOT_DIR}}/include'
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: Show available tasks
|
||||
cmds:
|
||||
- task --list
|
||||
|
||||
dev:
|
||||
desc: Start backend and frontend development servers
|
||||
deps:
|
||||
- task: dev:backend
|
||||
- task: dev:frontend
|
||||
|
||||
build:
|
||||
desc: Build the frontend SPA and current-platform Go binary into dist/
|
||||
deps:
|
||||
- task: build:web
|
||||
- task: build:dist
|
||||
cmds:
|
||||
- |
|
||||
ext=""
|
||||
if [ "{{.HOST_GOOS}}" = "windows" ]; then
|
||||
ext=".exe"
|
||||
fi
|
||||
output="{{.DIST_DIR}}/{{.APP}}${ext}"
|
||||
echo "Building $output..."
|
||||
{{.GO}} build -v -o "$output" {{.MAIN}}
|
||||
|
||||
build:lancedb:
|
||||
desc: Build the current-platform LanceDB binary into dist/
|
||||
deps:
|
||||
- task: lancedb:ensure-current
|
||||
- task: build:web
|
||||
- task: build:dist
|
||||
cmds:
|
||||
- |
|
||||
case "$(uname -s)" in
|
||||
Darwin) system_ldflags="-framework Security -framework CoreFoundation" ;;
|
||||
Linux) system_ldflags="-lm -ldl -lpthread" ;;
|
||||
*) system_ldflags="" ;;
|
||||
esac
|
||||
ext=""
|
||||
if [ "{{.HOST_GOOS}}" = "windows" ]; then
|
||||
ext=".exe"
|
||||
fi
|
||||
output="{{.DIST_DIR}}/{{.APP}}-lancedb${ext}"
|
||||
echo "Building $output..."
|
||||
CGO_ENABLED=1 CGO_CFLAGS="{{.LANCEDB_CGO_CFLAGS}}" CGO_LDFLAGS="{{.HOST_LANCEDB_LIB}} $system_ldflags" \
|
||||
{{.GO}} build -tags lancedb -v -o "$output" {{.MAIN}}
|
||||
|
||||
release:
|
||||
desc: Build linux/darwin/windows release binaries into dist/
|
||||
deps:
|
||||
- task: build:web
|
||||
- task: build:dist
|
||||
cmds:
|
||||
- |
|
||||
echo "Building release binaries in {{.DIST_DIR}}..."
|
||||
GOOS=linux GOARCH=amd64 {{.GO}} build -v -o "{{.DIST_DIR}}/{{.APP}}-linux-amd64" {{.MAIN}}
|
||||
GOOS=linux GOARCH=arm64 {{.GO}} build -v -o "{{.DIST_DIR}}/{{.APP}}-linux-arm64" {{.MAIN}}
|
||||
GOOS=darwin GOARCH=amd64 {{.GO}} build -v -o "{{.DIST_DIR}}/{{.APP}}-darwin-amd64" {{.MAIN}}
|
||||
GOOS=darwin GOARCH=arm64 {{.GO}} build -v -o "{{.DIST_DIR}}/{{.APP}}-darwin-arm64" {{.MAIN}}
|
||||
GOOS=windows GOARCH=amd64 {{.GO}} build -v -o "{{.DIST_DIR}}/{{.APP}}-windows-amd64.exe" {{.MAIN}}
|
||||
|
||||
release:lancedb:
|
||||
desc: Build LanceDB release binaries into dist/
|
||||
deps:
|
||||
- task: lancedb:check-release
|
||||
- task: build:web
|
||||
- task: build:dist
|
||||
cmds:
|
||||
- |
|
||||
echo "Building LanceDB release binaries in {{.DIST_DIR}}..."
|
||||
build_lancedb() {
|
||||
platform="$1"
|
||||
arch="$2"
|
||||
ext="$3"
|
||||
system_ldflags="$4"
|
||||
native_lib="{{.ROOT_DIR}}/lib/${platform}_${arch}/liblancedb_go.a"
|
||||
output="{{.DIST_DIR}}/{{.APP}}-lancedb-${platform}-${arch}${ext}"
|
||||
echo "Building $output..."
|
||||
CGO_ENABLED=1 CGO_CFLAGS="-I{{.ROOT_DIR}}/include" CGO_LDFLAGS="$native_lib $system_ldflags" \
|
||||
GOOS="$platform" GOARCH="$arch" {{.GO}} build -tags lancedb -v -o "$output" {{.MAIN}}
|
||||
}
|
||||
build_lancedb linux amd64 "" "-lm -ldl -lpthread"
|
||||
build_lancedb linux arm64 "" "-lm -ldl -lpthread"
|
||||
build_lancedb darwin amd64 "" "-framework Security -framework CoreFoundation"
|
||||
build_lancedb darwin arm64 "" "-framework Security -framework CoreFoundation"
|
||||
build_lancedb windows amd64 ".exe" ""
|
||||
|
||||
generator:
|
||||
desc: Run code generation
|
||||
cmds:
|
||||
- '{{.GO}} run ./cmd/generator/generator.go'
|
||||
|
||||
enums:
|
||||
desc: Generate frontend enums
|
||||
cmds:
|
||||
- '{{.GO}} run ./cmd/enums/generator.go'
|
||||
|
||||
dev:backend:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: lancedb:ensure-current
|
||||
- |
|
||||
echo "Starting backend on http://127.0.0.1:{{.DEV_PORT}} ..."
|
||||
case "$(uname -s)" in
|
||||
Darwin) system_ldflags="-framework Security -framework CoreFoundation" ;;
|
||||
Linux) system_ldflags="-lm -ldl -lpthread" ;;
|
||||
*) system_ldflags="" ;;
|
||||
esac
|
||||
AGENT_DESK_SERVER_PORT="{{.DEV_PORT}}" CGO_ENABLED=1 CGO_CFLAGS="{{.LANCEDB_CGO_CFLAGS}}" CGO_LDFLAGS="{{.HOST_LANCEDB_LIB}} $system_ldflags" \
|
||||
{{.GO}} run -tags "dev lancedb" {{.MAIN}}
|
||||
|
||||
dev:frontend:
|
||||
internal: true
|
||||
deps:
|
||||
- task: build:flowgram-editor
|
||||
cmds:
|
||||
- |
|
||||
echo "Starting frontend on http://localhost:3000 ..."
|
||||
cd {{.WEB_DIR}} && NEXT_PUBLIC_API_BASE_URL="" NEXT_API_BASE_URL="{{.DEV_API_BASE_URL}}" {{.PNPM}} dev
|
||||
|
||||
build:web:
|
||||
internal: true
|
||||
deps:
|
||||
- task: build:flowgram-editor
|
||||
cmds:
|
||||
- 'cd {{.WEB_DIR}} && {{.PNPM}} build:sdk && {{.PNPM}} build'
|
||||
|
||||
build:flowgram-editor:
|
||||
internal: true
|
||||
sources:
|
||||
- flowgram-editor/src/**/*
|
||||
- flowgram-editor/package.json
|
||||
- flowgram-editor/pnpm-lock.yaml
|
||||
- flowgram-editor/rsbuild.config.ts
|
||||
generates:
|
||||
- web/public/flowgram-editor/index.html
|
||||
cmds:
|
||||
- 'cd flowgram-editor && {{.PNPM}} install --frozen-lockfile'
|
||||
- 'cd flowgram-editor && {{.PNPM}} build'
|
||||
|
||||
build:dist:
|
||||
internal: true
|
||||
cmds:
|
||||
- 'mkdir -p {{.DIST_DIR}}'
|
||||
|
||||
lancedb:ensure-current:
|
||||
internal: true
|
||||
cmds:
|
||||
- |
|
||||
if [ "{{.HOST_LANCEDB_PLATFORM}}" = "unsupported" ] || [ "{{.HOST_LANCEDB_ARCH}}" = "unsupported" ]; then
|
||||
echo "Unsupported LanceDB platform: {{.HOST_OS}}/{{.HOST_ARCH}}"
|
||||
exit 1
|
||||
fi
|
||||
- |
|
||||
if [ -f "{{.HOST_LANCEDB_LIB}}" ] && [ -f "{{.ROOT_DIR}}/include/lancedb.h" ]; then
|
||||
echo "LanceDB native artifacts already exist for {{.HOST_LANCEDB_TARGET}}."
|
||||
else
|
||||
echo "Downloading LanceDB native artifacts {{.LANCEDB_VERSION}} for {{.HOST_LANCEDB_TARGET}}..."
|
||||
curl -sSL "{{.LANCEDB_DOWNLOAD_SCRIPT}}" | bash -s "{{.LANCEDB_VERSION}}"
|
||||
fi
|
||||
- |
|
||||
if [ ! -f "{{.HOST_LANCEDB_LIB}}" ]; then
|
||||
echo "Missing LanceDB native library: {{.HOST_LANCEDB_LIB}}"
|
||||
exit 1
|
||||
fi
|
||||
- |
|
||||
if [ ! -f "{{.ROOT_DIR}}/include/lancedb.h" ]; then
|
||||
echo "Missing LanceDB header: {{.ROOT_DIR}}/include/lancedb.h"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
lancedb:check-release:
|
||||
internal: true
|
||||
cmds:
|
||||
- |
|
||||
if [ ! -f "{{.ROOT_DIR}}/include/lancedb.h" ]; then
|
||||
echo "Missing LanceDB header: {{.ROOT_DIR}}/include/lancedb.h"
|
||||
exit 1
|
||||
fi
|
||||
- |
|
||||
missing=0
|
||||
for target in linux_amd64 linux_arm64 darwin_amd64 darwin_arm64 windows_amd64; do
|
||||
native_lib="{{.ROOT_DIR}}/lib/${target}/liblancedb_go.a"
|
||||
if [ ! -f "$native_lib" ]; then
|
||||
echo "Missing LanceDB native library: $native_lib"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
if [ "$missing" = "1" ]; then
|
||||
echo "LanceDB release builds require matching native artifacts and a CGO-capable toolchain for each target platform."
|
||||
exit 1
|
||||
fi
|
||||
546
cmd/enums/generator.go
Normal file
@@ -0,0 +1,546 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
const (
|
||||
enumsPkgName = "enums"
|
||||
enumsDir = "internal/pkg/enums"
|
||||
outputPath = "web/lib/generated/enums.ts"
|
||||
)
|
||||
|
||||
type enumValueType string
|
||||
|
||||
const (
|
||||
enumValueTypeInt enumValueType = "int"
|
||||
enumValueTypeString enumValueType = "string"
|
||||
)
|
||||
|
||||
type constDef struct {
|
||||
Name string
|
||||
ValueType enumValueType
|
||||
Value any
|
||||
}
|
||||
|
||||
type enumItem struct {
|
||||
Name string
|
||||
Value any
|
||||
Label string
|
||||
Order int
|
||||
}
|
||||
|
||||
type enumDef struct {
|
||||
Name string
|
||||
ValueType enumValueType
|
||||
Items []enumItem
|
||||
}
|
||||
|
||||
func main() {
|
||||
defs, err := parseEnums(enumsDir)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
content := buildTSFile(defs)
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.WriteFile(outputPath, []byte(content), 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseEnums(dir string) ([]enumDef, error) {
|
||||
pkgs, err := packages.Load(&packages.Config{
|
||||
Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax,
|
||||
Dir: ".",
|
||||
}, "./"+filepath.ToSlash(dir))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if packages.PrintErrors(pkgs) > 0 {
|
||||
return nil, fmt.Errorf("failed to load package %s", dir)
|
||||
}
|
||||
|
||||
var pkg *packages.Package
|
||||
for _, candidate := range pkgs {
|
||||
if candidate.Name == enumsPkgName {
|
||||
pkg = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if pkg == nil {
|
||||
return nil, fmt.Errorf("package %s not found in %s", enumsPkgName, dir)
|
||||
}
|
||||
|
||||
typeMap := make(map[string]enumValueType)
|
||||
constMap := make(map[string]constDef)
|
||||
orderMap := make(map[string]int)
|
||||
order := 0
|
||||
|
||||
files := sortedPackageFiles(pkg)
|
||||
for _, file := range files {
|
||||
for _, decl := range file.Decls {
|
||||
genDecl, ok := decl.(*ast.GenDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch genDecl.Tok {
|
||||
case token.TYPE:
|
||||
readTypes(genDecl, typeMap)
|
||||
case token.CONST:
|
||||
readConsts(genDecl, typeMap, constMap, orderMap, &order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var defs []enumDef
|
||||
for _, file := range files {
|
||||
for _, decl := range file.Decls {
|
||||
genDecl, ok := decl.(*ast.GenDecl)
|
||||
if !ok || genDecl.Tok != token.VAR {
|
||||
continue
|
||||
}
|
||||
items := parseLabelMaps(genDecl, typeMap, constMap, orderMap)
|
||||
defs = append(defs, items...)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(defs, func(i, j int) bool {
|
||||
return defs[i].Name < defs[j].Name
|
||||
})
|
||||
return defs, nil
|
||||
}
|
||||
|
||||
func sortedPackageFiles(pkg *packages.Package) []*ast.File {
|
||||
type fileWithPath struct {
|
||||
path string
|
||||
file *ast.File
|
||||
}
|
||||
|
||||
pairs := make([]fileWithPath, 0, len(pkg.Syntax))
|
||||
for _, file := range pkg.Syntax {
|
||||
pairs = append(pairs, fileWithPath{
|
||||
path: pkg.Fset.Position(file.Package).Filename,
|
||||
file: file,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
return pairs[i].path < pairs[j].path
|
||||
})
|
||||
|
||||
results := make([]*ast.File, 0, len(pairs))
|
||||
for _, pair := range pairs {
|
||||
results = append(results, pair.file)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func readTypes(genDecl *ast.GenDecl, typeMap map[string]enumValueType) {
|
||||
for _, spec := range genDecl.Specs {
|
||||
typeSpec, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ident, ok := typeSpec.Type.(*ast.Ident)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
valueType, ok := parseValueType(ident.Name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typeMap[typeSpec.Name.Name] = valueType
|
||||
}
|
||||
}
|
||||
|
||||
func readConsts(genDecl *ast.GenDecl, typeMap map[string]enumValueType, constMap map[string]constDef, orderMap map[string]int, order *int) {
|
||||
for _, spec := range genDecl.Specs {
|
||||
valueSpec, ok := spec.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
explicitTypeName := ""
|
||||
explicitValueType := enumValueType("")
|
||||
if valueSpec.Type != nil {
|
||||
typeName := exprName(valueSpec.Type)
|
||||
if valueType, ok := lookupValueType(typeName, typeMap); ok {
|
||||
explicitTypeName = typeName
|
||||
explicitValueType = valueType
|
||||
}
|
||||
}
|
||||
|
||||
for idx, name := range valueSpec.Names {
|
||||
valueExpr := expressionAt(valueSpec.Values, idx)
|
||||
if valueExpr == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
valueType := explicitValueType
|
||||
if valueType == "" {
|
||||
valueType = inferValueType(valueExpr)
|
||||
}
|
||||
if valueType == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
value, ok := parseLiteralValue(valueExpr, valueType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
constMap[name.Name] = constDef{
|
||||
Name: name.Name,
|
||||
ValueType: valueType,
|
||||
Value: value,
|
||||
}
|
||||
orderMap[name.Name] = *order
|
||||
*order++
|
||||
_ = explicitTypeName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseLabelMaps(genDecl *ast.GenDecl, typeMap map[string]enumValueType, constMap map[string]constDef, orderMap map[string]int) []enumDef {
|
||||
var defs []enumDef
|
||||
for _, spec := range genDecl.Specs {
|
||||
valueSpec, ok := spec.(*ast.ValueSpec)
|
||||
if !ok || len(valueSpec.Values) != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
compLit, ok := valueSpec.Values[0].(*ast.CompositeLit)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
mapType, ok := compLit.Type.(*ast.MapType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if exprName(mapType.Value) != "string" {
|
||||
continue
|
||||
}
|
||||
|
||||
keyTypeName := exprName(mapType.Key)
|
||||
mapValueType, ok := lookupValueType(keyTypeName, typeMap)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
items := make([]enumItem, 0, len(compLit.Elts))
|
||||
constNames := make([]string, 0, len(compLit.Elts))
|
||||
for _, elt := range compLit.Elts {
|
||||
kv, ok := elt.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
constName := exprName(kv.Key)
|
||||
def, ok := constMap[constName]
|
||||
if !ok || def.ValueType != mapValueType {
|
||||
continue
|
||||
}
|
||||
|
||||
label, ok := parseStringLiteral(kv.Value)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, enumItem{
|
||||
Value: def.Value,
|
||||
Label: label,
|
||||
Order: orderMap[constName],
|
||||
})
|
||||
constNames = append(constNames, constName)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
enumName := keyTypeName
|
||||
if keyTypeName == "int" || keyTypeName == "string" {
|
||||
enumName = exportedEnumName(valueSpec.Names[0].Name)
|
||||
if enumName == "" {
|
||||
enumName = commonIdentifierPrefix(constNames)
|
||||
}
|
||||
}
|
||||
if enumName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
valid := true
|
||||
for idx, constName := range constNames {
|
||||
itemName := strings.TrimPrefix(constName, enumName)
|
||||
if itemName == "" {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
items[idx].Name = itemName
|
||||
}
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].Order < items[j].Order
|
||||
})
|
||||
|
||||
defs = append(defs, enumDef{
|
||||
Name: enumName,
|
||||
ValueType: mapValueType,
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
func expressionAt(values []ast.Expr, idx int) ast.Expr {
|
||||
if idx < len(values) {
|
||||
return values[idx]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func exprName(expr ast.Expr) string {
|
||||
switch v := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return v.Name
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parseValueType(name string) (enumValueType, bool) {
|
||||
switch name {
|
||||
case "int":
|
||||
return enumValueTypeInt, true
|
||||
case "string":
|
||||
return enumValueTypeString, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func lookupValueType(name string, typeMap map[string]enumValueType) (enumValueType, bool) {
|
||||
if valueType, ok := parseValueType(name); ok {
|
||||
return valueType, true
|
||||
}
|
||||
valueType, ok := typeMap[name]
|
||||
return valueType, ok
|
||||
}
|
||||
|
||||
func inferValueType(expr ast.Expr) enumValueType {
|
||||
switch value := expr.(type) {
|
||||
case *ast.BasicLit:
|
||||
switch value.Kind {
|
||||
case token.INT:
|
||||
return enumValueTypeInt
|
||||
case token.STRING:
|
||||
return enumValueTypeString
|
||||
}
|
||||
case *ast.UnaryExpr:
|
||||
if value.Op == token.SUB {
|
||||
return inferValueType(value.X)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseLiteralValue(expr ast.Expr, valueType enumValueType) (any, bool) {
|
||||
switch value := expr.(type) {
|
||||
case *ast.BasicLit:
|
||||
switch valueType {
|
||||
case enumValueTypeInt:
|
||||
parsed, err := strconv.Atoi(value.Value)
|
||||
return parsed, err == nil
|
||||
case enumValueTypeString:
|
||||
parsed, err := strconv.Unquote(value.Value)
|
||||
return parsed, err == nil
|
||||
}
|
||||
case *ast.UnaryExpr:
|
||||
if valueType == enumValueTypeInt && value.Op == token.SUB {
|
||||
parsed, ok := parseLiteralValue(value.X, valueType)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return -parsed.(int), true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func parseStringLiteral(expr ast.Expr) (string, bool) {
|
||||
basicLit, ok := expr.(*ast.BasicLit)
|
||||
if !ok || basicLit.Kind != token.STRING {
|
||||
return "", false
|
||||
}
|
||||
value, err := strconv.Unquote(basicLit.Value)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
func commonIdentifierPrefix(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
common := splitIdentifier(names[0])
|
||||
for _, name := range names[1:] {
|
||||
tokens := splitIdentifier(name)
|
||||
limit := min(len(common), len(tokens))
|
||||
idx := 0
|
||||
for idx < limit && common[idx] == tokens[idx] {
|
||||
idx++
|
||||
}
|
||||
common = common[:idx]
|
||||
if len(common) == 0 {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.Join(common, "")
|
||||
}
|
||||
|
||||
func exportedEnumName(varName string) string {
|
||||
base := strings.TrimSuffix(varName, "LabelMap")
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
tokens := splitIdentifier(base)
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
for _, token := range tokens {
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
if isAllLower(token) && len(token) <= 3 {
|
||||
result.WriteString(strings.ToUpper(token))
|
||||
continue
|
||||
}
|
||||
result.WriteString(strings.ToUpper(token[:1]))
|
||||
result.WriteString(token[1:])
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func splitIdentifier(value string) []string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
runes := []rune(value)
|
||||
parts := make([]string, 0, 4)
|
||||
start := 0
|
||||
for idx := 1; idx < len(runes); idx++ {
|
||||
prev := runes[idx-1]
|
||||
curr := runes[idx]
|
||||
|
||||
if isBoundary(runes, idx, prev, curr) {
|
||||
parts = append(parts, string(runes[start:idx]))
|
||||
start = idx
|
||||
}
|
||||
}
|
||||
parts = append(parts, string(runes[start:]))
|
||||
return parts
|
||||
}
|
||||
|
||||
func isBoundary(runes []rune, idx int, prev rune, curr rune) bool {
|
||||
if isLower(prev) && isUpper(curr) {
|
||||
return true
|
||||
}
|
||||
if isUpper(prev) && isUpper(curr) && idx+1 < len(runes) && isLower(runes[idx+1]) {
|
||||
return true
|
||||
}
|
||||
if isDigit(prev) != isDigit(curr) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isLower(r rune) bool {
|
||||
return r >= 'a' && r <= 'z'
|
||||
}
|
||||
|
||||
func isUpper(r rune) bool {
|
||||
return r >= 'A' && r <= 'Z'
|
||||
}
|
||||
|
||||
func isDigit(r rune) bool {
|
||||
return r >= '0' && r <= '9'
|
||||
}
|
||||
|
||||
func isAllLower(value string) bool {
|
||||
for _, r := range value {
|
||||
if !isLower(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildTSFile(defs []enumDef) string {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("// Code generated by cmd/enums/generator.go. DO NOT EDIT.\n\n")
|
||||
for _, def := range defs {
|
||||
buf.WriteString(renderEnum(def))
|
||||
buf.WriteString("\n")
|
||||
buf.WriteString(renderLabels(def))
|
||||
buf.WriteString("\n\n")
|
||||
}
|
||||
return strings.TrimRight(buf.String(), "\n") + "\n"
|
||||
}
|
||||
|
||||
func renderEnum(def enumDef) string {
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("export enum %s {", def.Name))
|
||||
for _, item := range def.Items {
|
||||
lines = append(lines, fmt.Sprintf(" %s = %s,", item.Name, formatTSValue(item.Value, def.ValueType)))
|
||||
}
|
||||
lines = append(lines, "}")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func renderLabels(def enumDef) string {
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("export const %sLabels: Record<%s, string> = {", def.Name, def.Name))
|
||||
for _, item := range def.Items {
|
||||
lines = append(lines, fmt.Sprintf(" [%s.%s]: %s,", def.Name, item.Name, strconv.Quote(item.Label)))
|
||||
}
|
||||
lines = append(lines, "}")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatTSValue(value any, valueType enumValueType) string {
|
||||
switch valueType {
|
||||
case enumValueTypeString:
|
||||
return strconv.Quote(value.(string))
|
||||
case enumValueTypeInt:
|
||||
return fmt.Sprint(value.(int))
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported enum value type: %s", valueType))
|
||||
}
|
||||
}
|
||||
|
||||
func min(a int, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
60
cmd/generator/generator.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
|
||||
"github.com/mlogclub/codegen"
|
||||
)
|
||||
|
||||
func main() {
|
||||
codegen.GenerateWithOption(
|
||||
codegen.Options{
|
||||
BaseDir: "./",
|
||||
PkgName: "agent-desk",
|
||||
Version: 1,
|
||||
Repository: true,
|
||||
Service: true,
|
||||
Controller: false,
|
||||
WebIndex: false,
|
||||
WebEdit: false,
|
||||
},
|
||||
codegen.GetGenerateStruct(&models.Migration{}),
|
||||
codegen.GetGenerateStruct(&models.User{}),
|
||||
codegen.GetGenerateStruct(&models.UserIdentity{}),
|
||||
codegen.GetGenerateStruct(&models.Company{}),
|
||||
codegen.GetGenerateStruct(&models.Customer{}),
|
||||
codegen.GetGenerateStruct(&models.CustomerIdentity{}),
|
||||
codegen.GetGenerateStruct(&models.CustomerContact{}),
|
||||
codegen.GetGenerateStruct(&models.Role{}),
|
||||
codegen.GetGenerateStruct(&models.Permission{}),
|
||||
codegen.GetGenerateStruct(&models.UserRole{}),
|
||||
codegen.GetGenerateStruct(&models.RolePermission{}),
|
||||
codegen.GetGenerateStruct(&models.UserPermission{}),
|
||||
codegen.GetGenerateStruct(&models.LoginSession{}),
|
||||
codegen.GetGenerateStruct(&models.LoginCredentialLog{}),
|
||||
codegen.GetGenerateStruct(&models.Asset{}),
|
||||
codegen.GetGenerateStruct(&models.Tag{}),
|
||||
codegen.GetGenerateStruct(&models.Conversation{}),
|
||||
codegen.GetGenerateStruct(&models.ConversationParticipant{}),
|
||||
codegen.GetGenerateStruct(&models.Message{}),
|
||||
codegen.GetGenerateStruct(&models.ChannelMessageOutbox{}),
|
||||
codegen.GetGenerateStruct(&models.ConversationAssignment{}),
|
||||
codegen.GetGenerateStruct(&models.ConversationTag{}),
|
||||
codegen.GetGenerateStruct(&models.QuickReply{}),
|
||||
codegen.GetGenerateStruct(&models.AIAgent{}),
|
||||
codegen.GetGenerateStruct(&models.Channel{}),
|
||||
codegen.GetGenerateStruct(&models.ConversationEventLog{}),
|
||||
codegen.GetGenerateStruct(&models.Ticket{}),
|
||||
codegen.GetGenerateStruct(&models.TicketTag{}),
|
||||
codegen.GetGenerateStruct(&models.TicketProgress{}),
|
||||
codegen.GetGenerateStruct(&models.TicketView{}),
|
||||
codegen.GetGenerateStruct(&models.TicketNoSequence{}),
|
||||
codegen.GetGenerateStruct(&models.AgentProfile{}),
|
||||
codegen.GetGenerateStruct(&models.AgentTeam{}),
|
||||
codegen.GetGenerateStruct(&models.AgentTeamSchedule{}),
|
||||
codegen.GetGenerateStruct(&models.AIConfig{}),
|
||||
codegen.GetGenerateStruct(&models.SkillDefinition{}),
|
||||
codegen.GetGenerateStruct(&models.SystemConfig{}),
|
||||
)
|
||||
|
||||
}
|
||||
32
cmd/migration/main.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"agent-desk/internal/bootstrap"
|
||||
"agent-desk/internal/pkg/config"
|
||||
"agent-desk/internal/pkg/logx"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load("config/config.yaml")
|
||||
if err != nil {
|
||||
slog.Error("load config failed", "error", err)
|
||||
return
|
||||
}
|
||||
logx.Init(logx.Config{
|
||||
Level: cfg.Logger.Level,
|
||||
Format: cfg.Logger.Format,
|
||||
AddSource: cfg.Logger.AddSource,
|
||||
})
|
||||
|
||||
if _, err = bootstrap.InitDB(cfg.DB); err != nil {
|
||||
slog.Error("init db failed", "error", err)
|
||||
return
|
||||
}
|
||||
if err = bootstrap.InitMigrations(); err != nil {
|
||||
slog.Error("run migrations failed", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("migrations completed")
|
||||
}
|
||||
32
cmd/server/main.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log/slog"
|
||||
|
||||
"agent-desk/internal/bootstrap"
|
||||
"agent-desk/internal/pkg/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config/config.yaml", "path to config file")
|
||||
flag.Parse()
|
||||
|
||||
if err := bootstrap.Init(*configPath); err != nil {
|
||||
slog.Error("bootstrap init failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := config.Current()
|
||||
|
||||
app, err := bootstrap.NewServer()
|
||||
if err != nil {
|
||||
slog.Error("bootstrap server failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.Run(cfg.Server.Address()); err != nil {
|
||||
slog.Error("start server failed", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
1
cmd/testdata/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
aiconfig/ai_config.yaml
|
||||
200
cmd/testdata/agentteam/init.go
vendored
Normal file
@@ -0,0 +1,200 @@
|
||||
package agentteam
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type InitResult struct {
|
||||
TeamCreated bool
|
||||
UsersCreated int
|
||||
ProfilesCreated int
|
||||
UpdatesApplied int
|
||||
}
|
||||
|
||||
// Init 初始化客服组和客服用户
|
||||
// 创建:
|
||||
// 1. 客服组,组长为管理员用户
|
||||
// 2. 客服A 用户
|
||||
// 3. 客服B 用户
|
||||
// 4. 为客服A和客服B创建客服档案,关联到该客服组
|
||||
func Init(lang seedlang.Language) (*InitResult, error) {
|
||||
result := &InitResult{}
|
||||
|
||||
// 获取管理员用户
|
||||
adminUser := repositories.UserRepository.Take(
|
||||
sqls.DB(),
|
||||
"username = ?",
|
||||
constants.BootstrapAdminUsername,
|
||||
)
|
||||
if adminUser == nil {
|
||||
return result, fmt.Errorf("bootstrap admin user not found")
|
||||
}
|
||||
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
return initTeamAndUsers(ctx, adminUser, result, lang)
|
||||
})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("init team and users failed: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func initTeamAndUsers(ctx *sqls.TxContext, leaderUser *models.User, result *InitResult, lang seedlang.Language) error {
|
||||
teamName := seeds.AgentTeamName(lang)
|
||||
now := time.Now()
|
||||
|
||||
team := repositories.AgentTeamRepository.Take(ctx.Tx, "name = ?", teamName)
|
||||
if team != nil {
|
||||
if err := ctx.Tx.Model(team).Updates(map[string]any{
|
||||
"leader_user_id": leaderUser.ID,
|
||||
"update_user_id": constants.SystemAuditUserID,
|
||||
"update_user_name": constants.SystemAuditUserName,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
team = &models.AgentTeam{
|
||||
Name: teamName,
|
||||
LeaderUserID: leaderUser.ID,
|
||||
Status: enums.StatusOk,
|
||||
Description: "Local testdata seed - default service team",
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: constants.SystemAuditUserID,
|
||||
CreateUserName: constants.SystemAuditUserName,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: constants.SystemAuditUserID,
|
||||
UpdateUserName: constants.SystemAuditUserName,
|
||||
},
|
||||
}
|
||||
if err := ctx.Tx.Create(team).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.TeamCreated = true
|
||||
}
|
||||
|
||||
agentUsers := seeds.AgentUsers(lang, leaderUser.Username)
|
||||
for _, agentUser := range agentUsers {
|
||||
userID, userCreated, err := createOrGetUser(ctx, agentUser.Username, agentUser.Nickname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if userCreated {
|
||||
result.UsersCreated++
|
||||
}
|
||||
|
||||
// 创建或更新客服档案
|
||||
profileCreated, err := createOrUpdateProfile(ctx, userID, team.ID, agentUser.Code, agentUser.Nickname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if profileCreated {
|
||||
result.ProfilesCreated++
|
||||
} else {
|
||||
result.UpdatesApplied++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createOrGetUser(ctx *sqls.TxContext, username, nickname string) (int64, bool, error) {
|
||||
user := repositories.UserRepository.Take(
|
||||
ctx.Tx,
|
||||
"username = ?",
|
||||
username,
|
||||
)
|
||||
|
||||
if user != nil {
|
||||
return user.ID, false, nil
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(constants.BootstrapAdminPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
newUser := &models.User{
|
||||
Username: username,
|
||||
Nickname: nickname,
|
||||
Password: string(hashedPassword),
|
||||
Status: enums.StatusOk,
|
||||
Remark: "Local testdata seed",
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: constants.SystemAuditUserID,
|
||||
CreateUserName: constants.SystemAuditUserName,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: constants.SystemAuditUserID,
|
||||
UpdateUserName: constants.SystemAuditUserName,
|
||||
},
|
||||
}
|
||||
|
||||
if err := ctx.Tx.Create(newUser).Error; err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
return newUser.ID, true, nil
|
||||
}
|
||||
|
||||
func createOrUpdateProfile(ctx *sqls.TxContext, userID, teamID int64, agentCode, displayName string) (bool, error) {
|
||||
profile := repositories.AgentProfileRepository.Take(
|
||||
ctx.Tx,
|
||||
"user_id = ?",
|
||||
userID,
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if profile != nil {
|
||||
// 档案已存在,更新关键信息
|
||||
return false, ctx.Tx.Model(profile).Updates(map[string]any{
|
||||
"team_id": teamID,
|
||||
"agent_code": agentCode,
|
||||
"display_name": displayName,
|
||||
"status": enums.StatusOk,
|
||||
"update_user_id": constants.SystemAuditUserID,
|
||||
"update_user_name": constants.SystemAuditUserName,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// 创建新档案
|
||||
newProfile := &models.AgentProfile{
|
||||
UserID: userID,
|
||||
TeamID: teamID,
|
||||
AgentCode: agentCode,
|
||||
DisplayName: displayName,
|
||||
Avatar: "",
|
||||
ServiceStatus: enums.ServiceStatusIdle,
|
||||
MaxConcurrentCount: 5,
|
||||
PriorityLevel: 10,
|
||||
AutoAssignEnabled: true,
|
||||
Status: enums.StatusOk,
|
||||
Remark: "Local testdata seed",
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: constants.SystemAuditUserID,
|
||||
CreateUserName: constants.SystemAuditUserName,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: constants.SystemAuditUserID,
|
||||
UpdateUserName: constants.SystemAuditUserName,
|
||||
},
|
||||
}
|
||||
|
||||
return true, ctx.Tx.Create(newProfile).Error
|
||||
}
|
||||
23
cmd/testdata/agentteam/init_test.go
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
package agentteam
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishAgentTeamTextDoesNotContainChineseText(t *testing.T) {
|
||||
values := []string{seeds.AgentTeamName(seedlang.English)}
|
||||
for _, user := range seeds.AgentUsers(seedlang.English, "admin") {
|
||||
values = append(values, user.Nickname)
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english agent team seed contains Chinese text: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
173
cmd/testdata/aiagent/init.go
vendored
Normal file
@@ -0,0 +1,173 @@
|
||||
package aiagent
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
type InitResult struct {
|
||||
Created int
|
||||
Updated int
|
||||
}
|
||||
|
||||
// Init 初始化 AI Agent 测试数据
|
||||
// 依赖于 AI Config 和 Knowledge Base 已初始化
|
||||
func Init(lang seedlang.Language) (*InitResult, error) {
|
||||
result := &InitResult{}
|
||||
|
||||
aiConfigID, err := getDefaultAIConfigID()
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("get default ai config id failed: %w", err)
|
||||
}
|
||||
if aiConfigID == 0 {
|
||||
return result, fmt.Errorf("no default ai config found, please init ai config first")
|
||||
}
|
||||
|
||||
knowledgeIDs, err := getDefaultKnowledgeIDs()
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("get default knowledge ids failed: %w", err)
|
||||
}
|
||||
|
||||
defaultTeamIDs := getDefaultTeamIDs()
|
||||
agentSeeds := seeds.AIAgentSeeds(lang)
|
||||
seedItems := buildModels(lang, aiConfigID, knowledgeIDs, defaultTeamIDs)
|
||||
for index, item := range seedItems {
|
||||
itemCopy := item
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
existing := repositories.AIAgentRepository.Take(ctx.Tx, "name = ?", itemCopy.Name)
|
||||
if existing == nil {
|
||||
for _, legacyName := range agentSeeds[index].LegacyNames {
|
||||
legacyName = strings.TrimSpace(legacyName)
|
||||
if legacyName == "" {
|
||||
continue
|
||||
}
|
||||
existing = repositories.AIAgentRepository.Take(ctx.Tx, "name = ?", legacyName)
|
||||
if existing != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if existing != nil {
|
||||
if err := ctx.Tx.Model(existing).Updates(seedUpdateColumns(itemCopy)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Updated++
|
||||
} else {
|
||||
if err := ctx.Tx.Create(&itemCopy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Created++
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("upsert ai agent failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildModels(lang seedlang.Language, aiConfigID int64, knowledgeIDs []int64, defaultTeamIDs string) []models.AIAgent {
|
||||
now := time.Now()
|
||||
seedItems := seeds.AIAgentSeeds(lang)
|
||||
items := make([]models.AIAgent, 0, len(seedItems))
|
||||
for _, seed := range seedItems {
|
||||
items = append(items, models.AIAgent{
|
||||
Name: seed.Name,
|
||||
Description: seed.Description,
|
||||
Status: enums.StatusOk,
|
||||
AIConfigID: aiConfigID,
|
||||
ServiceMode: seed.ServiceMode,
|
||||
SystemPrompt: seed.SystemPrompt,
|
||||
WelcomeMessage: seed.WelcomeMessage,
|
||||
ReplyTimeoutSeconds: seed.ReplyTimeoutSeconds,
|
||||
TeamIDs: defaultTeamIDs,
|
||||
HandoffMode: seed.HandoffMode,
|
||||
FallbackMode: seed.FallbackMode,
|
||||
FallbackMessage: seed.FallbackMessage,
|
||||
KnowledgeIDs: utils.JoinInt64s(knowledgeIDs),
|
||||
SkillIDs: "",
|
||||
AllowedMCPTools: "",
|
||||
SortNo: seed.SortNo,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: "System",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: "System",
|
||||
},
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func seedUpdateColumns(item models.AIAgent) map[string]any {
|
||||
return map[string]any{
|
||||
"name": item.Name,
|
||||
"description": item.Description,
|
||||
"status": item.Status,
|
||||
"ai_config_id": item.AIConfigID,
|
||||
"service_mode": item.ServiceMode,
|
||||
"system_prompt": item.SystemPrompt,
|
||||
"welcome_message": item.WelcomeMessage,
|
||||
"reply_timeout_seconds": item.ReplyTimeoutSeconds,
|
||||
"team_ids": item.TeamIDs,
|
||||
"handoff_mode": item.HandoffMode,
|
||||
"fallback_mode": item.FallbackMode,
|
||||
"fallback_message": item.FallbackMessage,
|
||||
"knowledge_ids": item.KnowledgeIDs,
|
||||
"skill_ids": item.SkillIDs,
|
||||
"allowed_mcp_tools": item.AllowedMCPTools,
|
||||
"sort_no": item.SortNo,
|
||||
"updated_at": item.UpdatedAt,
|
||||
"update_user_id": item.UpdateUserID,
|
||||
"update_user_name": item.UpdateUserName,
|
||||
}
|
||||
}
|
||||
|
||||
func getDefaultAIConfigID() (int64, error) {
|
||||
aiConfig := repositories.AIConfigRepository.Take(
|
||||
sqls.DB(),
|
||||
"model_type = ? AND status = ?",
|
||||
string(enums.AIModelTypeLLM),
|
||||
enums.StatusOk,
|
||||
)
|
||||
if aiConfig == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return aiConfig.ID, nil
|
||||
}
|
||||
|
||||
func getDefaultKnowledgeIDs() ([]int64, error) {
|
||||
knowledges := repositories.KnowledgeBaseRepository.Find(
|
||||
sqls.DB(),
|
||||
sqls.NewCnd().Where("status = ?", enums.StatusOk),
|
||||
)
|
||||
ids := make([]int64, 0, len(knowledges))
|
||||
for _, knowledge := range knowledges {
|
||||
ids = append(ids, knowledge.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func getDefaultTeamIDs() string {
|
||||
teams := repositories.AgentTeamRepository.Find(
|
||||
sqls.DB(),
|
||||
sqls.NewCnd().Where("status = ?", enums.StatusOk),
|
||||
)
|
||||
teamIDs := make([]int64, 0, len(teams))
|
||||
for _, team := range teams {
|
||||
teamIDs = append(teamIDs, team.ID)
|
||||
}
|
||||
return utils.JoinInt64s(teamIDs)
|
||||
}
|
||||
77
cmd/testdata/aiagent/init_test.go
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
package aiagent
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishAIAgentSeedDoesNotContainChineseText(t *testing.T) {
|
||||
for _, item := range seeds.AIAgentSeeds(seedlang.English) {
|
||||
values := []string{item.Name, item.Description, item.SystemPrompt, item.WelcomeMessage, item.FallbackMessage}
|
||||
values = append(values, item.LegacyNames...)
|
||||
for _, value := range values {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english AI agent seed contains Chinese text: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChineseAIAgentSeedUsesPresalesConfiguration(t *testing.T) {
|
||||
items := seeds.AIAgentSeeds(seedlang.Chinese)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected one Chinese AI agent seed, got %d", len(items))
|
||||
}
|
||||
|
||||
item := items[0]
|
||||
if item.Name != "贝壳AI售前客服" {
|
||||
t.Fatalf("unexpected Chinese AI agent name: %q", item.Name)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"你是贝壳AI(AgentDesk)的售前客服",
|
||||
"# 售前对话策略",
|
||||
"# 事实与工具边界",
|
||||
"不得编造价格、折扣、合同条款、商业 SLA",
|
||||
} {
|
||||
if !strings.Contains(item.SystemPrompt, expected) {
|
||||
t.Fatalf("Chinese AI agent system prompt missing %q", expected)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(item.WelcomeMessage, "贝壳AI售前客服") {
|
||||
t.Fatalf("unexpected welcome message: %q", item.WelcomeMessage)
|
||||
}
|
||||
if !strings.Contains(item.FallbackMessage, "方案评估") {
|
||||
t.Fatalf("unexpected fallback message: %q", item.FallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelsLeavesSkillsAndMCPToolsUnbound(t *testing.T) {
|
||||
items := buildModels(seedlang.Chinese, 7, []int64{11}, "13")
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected one AI agent model, got %d", len(items))
|
||||
}
|
||||
|
||||
item := items[0]
|
||||
if item.AIConfigID != 7 || item.KnowledgeIDs != "11" || item.TeamIDs != "13" {
|
||||
t.Fatalf("unexpected AI agent bindings: %+v", item)
|
||||
}
|
||||
if item.SkillIDs != "" {
|
||||
t.Fatalf("expected no Skill binding, got %q", item.SkillIDs)
|
||||
}
|
||||
if item.AllowedMCPTools != "" {
|
||||
t.Fatalf("expected no MCP Tool binding, got %q", item.AllowedMCPTools)
|
||||
}
|
||||
|
||||
columns := seedUpdateColumns(item)
|
||||
if value, ok := columns["skill_ids"]; !ok || value != "" {
|
||||
t.Fatalf("seed update must clear Skill bindings, got %#v", value)
|
||||
}
|
||||
if value, ok := columns["allowed_mcp_tools"]; !ok || value != "" {
|
||||
t.Fatalf("seed update must clear MCP Tool bindings, got %#v", value)
|
||||
}
|
||||
}
|
||||
76
cmd/testdata/aiconfig/ai_config.example.yaml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# To use this file, copy it to cmd/testdata/aiconfig/ and rename it to ai_config.yaml.
|
||||
#
|
||||
# Configuration guide:
|
||||
# 1. name: Unique identifier for the configuration. The system uses this field for deduplication and updates.
|
||||
# 2. provider: Model provider. Always set this to openai because the current AI API calls use the OpenAI-compatible protocol.
|
||||
# 3. baseUrl: API service endpoint.
|
||||
# 4. apiKey: API key.
|
||||
# 5. modelType: Model type. Must be one of the following:
|
||||
# - llm: Large language model for chat and reasoning.
|
||||
# - embedding: Text embedding model for generating vector representations of text.
|
||||
# - rerank: Ranking model for reordering search results.
|
||||
# 6. modelName: Specific model name, as defined by the provider.
|
||||
# 7. dimension: Vector dimension.
|
||||
# - llm and rerank: Usually set to 0.
|
||||
# - embedding: Number of vector dimensions. For example, OpenAI text-embedding-3-small uses 1536.
|
||||
# 8. maxContextTokens: Maximum context tokens. Use 0 for no limit.
|
||||
# - llm and embedding: The maximum number of input tokens supported by the model.
|
||||
# - rerank: Usually set to 0.
|
||||
# 9. maxOutputTokens: Maximum output tokens.
|
||||
# - llm: The maximum number of output tokens supported by the model.
|
||||
# - embedding and rerank: Usually set to 0.
|
||||
# 10. timeoutMs: Request timeout in milliseconds.
|
||||
# 11. maxRetryCount: Number of retry attempts after a failed request.
|
||||
# 12. rpmLimit: Requests per minute limit. Use 0 for no limit.
|
||||
# 13. tpmLimit: Tokens per minute limit. Use 0 for no limit.
|
||||
# 15. sortNo: Sort order for prioritizing models of the same type. Lower numbers have higher priority.
|
||||
# 16. remark: Notes describing the purpose of this configuration or any special considerations.
|
||||
#
|
||||
items:
|
||||
- name: LLM
|
||||
provider: openai
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
apiKey: <REPLACE_WITH_REAL_KEY>
|
||||
modelType: llm
|
||||
modelName: qwen3.5-plus
|
||||
dimension: 0
|
||||
maxContextTokens: 128000
|
||||
maxOutputTokens: 4096
|
||||
timeoutMs: 30000
|
||||
maxRetryCount: 1
|
||||
rpmLimit: 0
|
||||
tpmLimit: 0
|
||||
sortNo: 10
|
||||
remark: LLM
|
||||
|
||||
- name: embedding
|
||||
provider: openai
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
apiKey: <REPLACE_WITH_REAL_KEY>
|
||||
modelType: embedding
|
||||
modelName: text-embedding-v4
|
||||
dimension: 1536
|
||||
maxContextTokens: 0
|
||||
maxOutputTokens: 0
|
||||
timeoutMs: 30000
|
||||
maxRetryCount: 1
|
||||
rpmLimit: 0
|
||||
tpmLimit: 0
|
||||
sortNo: 20
|
||||
remark: embedding
|
||||
|
||||
- name: rerank
|
||||
provider: openai
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
apiKey: <REPLACE_WITH_REAL_KEY>
|
||||
modelType: rerank
|
||||
modelName: qwen3-vl-rerank
|
||||
dimension: 0
|
||||
maxContextTokens: 0
|
||||
maxOutputTokens: 0
|
||||
timeoutMs: 30000
|
||||
maxRetryCount: 1
|
||||
rpmLimit: 0
|
||||
tpmLimit: 0
|
||||
sortNo: 30
|
||||
remark: rerank
|
||||
237
cmd/testdata/aiconfig/init.go
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
package aiconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSeedFilePath = "cmd/testdata/aiconfig/ai_config.yaml"
|
||||
seedFileEnvKey = "TESTDATA_AI_CONFIG_FILE"
|
||||
)
|
||||
|
||||
type SeedItem struct {
|
||||
Name string `yaml:"name"`
|
||||
Provider string `yaml:"provider"`
|
||||
BaseURL string `yaml:"baseUrl"`
|
||||
APIKey string `yaml:"apiKey"`
|
||||
ModelType string `yaml:"modelType"`
|
||||
ModelName string `yaml:"modelName"`
|
||||
Dimension int `yaml:"dimension"`
|
||||
MaxContextTokens int `yaml:"maxContextTokens"`
|
||||
MaxOutputTokens int `yaml:"maxOutputTokens"`
|
||||
TimeoutMS int `yaml:"timeoutMs"`
|
||||
MaxRetryCount int `yaml:"maxRetryCount"`
|
||||
RPMLimit int `yaml:"rpmLimit"`
|
||||
TPMLimit int `yaml:"tpmLimit"`
|
||||
SortNo int `yaml:"sortNo"`
|
||||
Remark string `yaml:"remark"`
|
||||
}
|
||||
|
||||
type InitResult struct {
|
||||
FilePath string
|
||||
Skipped bool
|
||||
Created int
|
||||
Updated int
|
||||
}
|
||||
|
||||
type seedPayload struct {
|
||||
Items []SeedItem `yaml:"items"`
|
||||
}
|
||||
|
||||
func Init() (*InitResult, error) {
|
||||
return InitFromFile(resolveSeedFilePath())
|
||||
}
|
||||
|
||||
func InitFromFile(filePath string) (*InitResult, error) {
|
||||
result := &InitResult{FilePath: filePath}
|
||||
|
||||
payload, err := loadSeedPayload(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
result.Skipped = true
|
||||
return result, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(payload.Items) == 0 {
|
||||
result.Skipped = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
for _, item := range payload.Items {
|
||||
created, upsertErr := upsertAIConfig(ctx.Tx, item)
|
||||
if upsertErr != nil {
|
||||
return upsertErr
|
||||
}
|
||||
if created {
|
||||
result.Created++
|
||||
} else {
|
||||
result.Updated++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadSeedPayload(filePath string) (*seedPayload, error) {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := &seedPayload{}
|
||||
if err := yaml.Unmarshal(content, payload); err != nil {
|
||||
return nil, fmt.Errorf("parse ai config seed file failed: %w", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func resolveSeedFilePath() string {
|
||||
if filePath := strings.TrimSpace(os.Getenv(seedFileEnvKey)); filePath != "" {
|
||||
return filePath
|
||||
}
|
||||
return defaultSeedFilePath
|
||||
}
|
||||
|
||||
func upsertAIConfig(db *gorm.DB, item SeedItem) (bool, error) {
|
||||
model, err := buildAIConfigModel(item)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
existing := repositories.AIConfigRepository.FindOne(db, sqls.NewCnd().Eq("name", model.Name).Eq("model_type", model.ModelType))
|
||||
if existing == nil {
|
||||
model.AuditFields = models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: constants.SystemAuditUserID,
|
||||
CreateUserName: constants.SystemAuditUserName,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: constants.SystemAuditUserID,
|
||||
UpdateUserName: constants.SystemAuditUserName,
|
||||
}
|
||||
if err := repositories.AIConfigRepository.Create(db, model); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
err = repositories.AIConfigRepository.Updates(db, existing.ID, map[string]any{
|
||||
"provider": model.Provider,
|
||||
"base_url": model.BaseURL,
|
||||
"api_key": model.APIKey,
|
||||
"model_name": model.ModelName,
|
||||
"dimension": model.Dimension,
|
||||
"max_context_tokens": model.MaxContextTokens,
|
||||
"max_output_tokens": model.MaxOutputTokens,
|
||||
"timeout_ms": model.TimeoutMS,
|
||||
"max_retry_count": model.MaxRetryCount,
|
||||
"rpm_limit": model.RPMLimit,
|
||||
"tpm_limit": model.TPMLimit,
|
||||
"status": model.Status,
|
||||
"sort_no": model.SortNo,
|
||||
"remark": model.Remark,
|
||||
"update_user_id": constants.SystemAuditUserID,
|
||||
"update_user_name": constants.SystemAuditUserName,
|
||||
"updated_at": now,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func buildAIConfigModel(item SeedItem) (*models.AIConfig, error) {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("ai config seed name is required")
|
||||
}
|
||||
|
||||
provider, err := parseProvider(item.Provider)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ai config %s: %w", name, err)
|
||||
}
|
||||
modelType, err := parseModelType(item.ModelType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ai config %s: %w", name, err)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSpace(item.BaseURL)
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("ai config %s: baseUrl is required", name)
|
||||
}
|
||||
modelName := strings.TrimSpace(item.ModelName)
|
||||
if modelName == "" {
|
||||
return nil, fmt.Errorf("ai config %s: modelName is required", name)
|
||||
}
|
||||
|
||||
return &models.AIConfig{
|
||||
Name: name,
|
||||
Provider: provider,
|
||||
BaseURL: baseURL,
|
||||
APIKey: strings.TrimSpace(item.APIKey),
|
||||
ModelType: modelType,
|
||||
ModelName: modelName,
|
||||
Dimension: normalizeNonNegative(item.Dimension),
|
||||
MaxContextTokens: normalizeNonNegative(item.MaxContextTokens),
|
||||
MaxOutputTokens: normalizeNonNegative(item.MaxOutputTokens),
|
||||
TimeoutMS: defaultIfNotPositive(item.TimeoutMS, 30000),
|
||||
MaxRetryCount: normalizeNonNegative(item.MaxRetryCount),
|
||||
RPMLimit: normalizeNonNegative(item.RPMLimit),
|
||||
TPMLimit: normalizeNonNegative(item.TPMLimit),
|
||||
SortNo: normalizeNonNegative(item.SortNo),
|
||||
Status: enums.StatusOk,
|
||||
Remark: strings.TrimSpace(item.Remark),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseProvider(raw string) (enums.AIProvider, error) {
|
||||
provider := enums.AIProvider(strings.TrimSpace(raw))
|
||||
switch provider {
|
||||
case enums.AIProviderOpenAI:
|
||||
return provider, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported provider: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func parseModelType(raw string) (enums.AIModelType, error) {
|
||||
modelType := enums.AIModelType(strings.TrimSpace(raw))
|
||||
switch modelType {
|
||||
case enums.AIModelTypeLLM, enums.AIModelTypeEmbedding, enums.AIModelTypeRerank:
|
||||
return modelType, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported modelType: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeNonNegative(value int) int {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func defaultIfNotPositive(value int, def int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return def
|
||||
}
|
||||
95
cmd/testdata/channel/init.go
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
type InitResult struct {
|
||||
Created int
|
||||
Updated int
|
||||
}
|
||||
|
||||
// Init 初始化 Channel 测试数据。
|
||||
// 依赖于 AI Agent 已初始化。
|
||||
func Init(lang seedlang.Language) (*InitResult, error) {
|
||||
result := &InitResult{}
|
||||
|
||||
aiAgentID, err := getDefaultAIAgentID()
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("get default ai agent id failed: %w", err)
|
||||
}
|
||||
if aiAgentID == 0 {
|
||||
return result, fmt.Errorf("no default ai agent found, please init ai agent first")
|
||||
}
|
||||
|
||||
seedItems := buildModels(lang, aiAgentID)
|
||||
for _, item := range seedItems {
|
||||
itemCopy := item
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
existing := repositories.ChannelRepository.Take(ctx.Tx, "name = ? AND channel_type = ?", itemCopy.Name, itemCopy.ChannelType)
|
||||
if existing != nil {
|
||||
if err := ctx.Tx.Model(existing).Updates(&itemCopy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Updated++
|
||||
} else {
|
||||
if err := ctx.Tx.Create(&itemCopy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Created++
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("upsert channel failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildModels(lang seedlang.Language, aiAgentID int64) []models.Channel {
|
||||
now := time.Now()
|
||||
seedItems := seeds.ChannelSeeds(lang)
|
||||
items := make([]models.Channel, 0, len(seedItems))
|
||||
for _, seed := range seedItems {
|
||||
items = append(items, models.Channel{
|
||||
Name: seed.Name,
|
||||
ChannelType: seed.ChannelType,
|
||||
ChannelID: strs.UUID(),
|
||||
AIAgentID: aiAgentID,
|
||||
ConfigJSON: seed.ConfigJSON,
|
||||
Status: enums.StatusOk,
|
||||
Remark: seed.Remark,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: "System",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: "System",
|
||||
},
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getDefaultAIAgentID() (int64, error) {
|
||||
aiAgent := repositories.AIAgentRepository.Take(
|
||||
sqls.DB(),
|
||||
"status = ?",
|
||||
enums.StatusOk,
|
||||
)
|
||||
if aiAgent == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return aiAgent.ID, nil
|
||||
}
|
||||
21
cmd/testdata/channel/init_test.go
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishChannelSeedDoesNotContainChineseText(t *testing.T) {
|
||||
for _, item := range seeds.ChannelSeeds(seedlang.English) {
|
||||
values := []string{item.Name, item.ConfigJSON, item.Remark}
|
||||
for _, value := range values {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english channel seed contains Chinese text: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
162
cmd/testdata/kb/kb.go
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type InitResult struct {
|
||||
FAQKnowledgeBaseID int64
|
||||
TotalFAQs int
|
||||
CreatedFAQs int
|
||||
UpdatedFAQs int
|
||||
}
|
||||
|
||||
func Init(lang seedlang.Language) (*InitResult, error) {
|
||||
faqSeeds := seeds.KnowledgeFAQSeeds(lang)
|
||||
result := &InitResult{
|
||||
TotalFAQs: len(faqSeeds),
|
||||
}
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
faqKnowledgeBase, ensureFAQErr := ensureFAQKnowledgeBase(ctx.Tx, lang)
|
||||
if ensureFAQErr != nil {
|
||||
return ensureFAQErr
|
||||
}
|
||||
result.FAQKnowledgeBaseID = faqKnowledgeBase.ID
|
||||
|
||||
for _, faq := range faqSeeds {
|
||||
created, upsertErr := upsertKnowledgeFAQ(ctx.Tx, faqKnowledgeBase.ID, faq)
|
||||
if upsertErr != nil {
|
||||
return upsertErr
|
||||
}
|
||||
if created {
|
||||
result.CreatedFAQs++
|
||||
} else {
|
||||
result.UpdatedFAQs++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ensureFAQKnowledgeBase(db *gorm.DB, lang seedlang.Language) (*models.KnowledgeBase, error) {
|
||||
now := time.Now()
|
||||
seed := seeds.FAQKnowledgeBaseSeed(lang)
|
||||
item := repositories.KnowledgeBaseRepository.FindOne(db, sqls.NewCnd().Eq("name", seed.Name))
|
||||
if item == nil {
|
||||
item = &models.KnowledgeBase{
|
||||
Name: seed.Name,
|
||||
Description: seed.Description,
|
||||
KnowledgeType: string(enums.KnowledgeBaseTypeFAQ),
|
||||
Status: enums.StatusOk,
|
||||
DefaultTopK: 8,
|
||||
DefaultScoreThreshold: 0.35,
|
||||
DefaultRerankLimit: 5,
|
||||
ChunkProvider: string(enums.KnowledgeChunkProviderFAQ),
|
||||
ChunkTargetTokens: 0,
|
||||
ChunkMaxTokens: 0,
|
||||
ChunkOverlapTokens: 0,
|
||||
AnswerMode: int(enums.KnowledgeAnswerModeStrict),
|
||||
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.KnowledgeBaseRepository.Create(db, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
err := repositories.KnowledgeBaseRepository.Updates(db, item.ID, map[string]any{
|
||||
"description": seed.Description,
|
||||
"knowledge_type": string(enums.KnowledgeBaseTypeFAQ),
|
||||
"status": enums.StatusOk,
|
||||
"default_top_k": 8,
|
||||
"default_score_threshold": 0.35,
|
||||
"default_rerank_limit": 5,
|
||||
"chunk_provider": string(enums.KnowledgeChunkProviderFAQ),
|
||||
"chunk_target_tokens": 0,
|
||||
"chunk_max_tokens": 0,
|
||||
"chunk_overlap_tokens": 0,
|
||||
"answer_mode": int(enums.KnowledgeAnswerModeStrict),
|
||||
"remark": seed.Remark,
|
||||
"update_user_id": constants.SystemAuditUserID,
|
||||
"update_user_name": constants.SystemAuditUserName,
|
||||
"updated_at": now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repositories.KnowledgeBaseRepository.Get(db, item.ID), nil
|
||||
}
|
||||
|
||||
func upsertKnowledgeFAQ(db *gorm.DB, knowledgeBaseID int64, seed seeds.KnowledgeFAQSeed) (bool, error) {
|
||||
now := time.Now()
|
||||
similarQuestions, err := json.Marshal(seed.SimilarQuestions)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
item := repositories.KnowledgeFAQRepository.Find(db, sqls.NewCnd().
|
||||
Eq("knowledge_base_id", knowledgeBaseID).
|
||||
Eq("question", seed.Question))
|
||||
if len(item) == 0 {
|
||||
faq := &models.KnowledgeFAQ{
|
||||
KnowledgeBaseID: knowledgeBaseID,
|
||||
Question: seed.Question,
|
||||
Answer: seed.Answer,
|
||||
SimilarQuestions: string(similarQuestions),
|
||||
Status: enums.StatusOk,
|
||||
IndexStatus: enums.KnowledgeDocumentIndexStatusPending,
|
||||
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, faq); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err := repositories.KnowledgeFAQRepository.Updates(db, item[0].ID, map[string]any{
|
||||
"answer": seed.Answer,
|
||||
"similar_questions": string(similarQuestions),
|
||||
"status": enums.StatusOk,
|
||||
"index_status": enums.KnowledgeDocumentIndexStatusPending,
|
||||
"indexed_at": nil,
|
||||
"index_error": "",
|
||||
"remark": seed.Remark,
|
||||
"update_user_id": constants.SystemAuditUserID,
|
||||
"update_user_name": constants.SystemAuditUserName,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
35
cmd/testdata/kb/kb_test.go
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishKnowledgeBaseTextDoesNotContainChineseText(t *testing.T) {
|
||||
seed := seeds.FAQKnowledgeBaseSeed(seedlang.English)
|
||||
for _, value := range []string{seed.Name, seed.Description, seed.Remark} {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english knowledge base text contains Chinese text: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnglishKnowledgeFAQSeedsDoNotContainChineseText(t *testing.T) {
|
||||
seedItems := seeds.KnowledgeFAQSeeds(seedlang.English)
|
||||
if len(seedItems) == 0 {
|
||||
t.Fatal("english FAQ seeds are empty")
|
||||
}
|
||||
for _, seed := range seedItems {
|
||||
values := []string{seed.Question, seed.Answer, seed.Remark}
|
||||
values = append(values, seed.SimilarQuestions...)
|
||||
for _, value := range values {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english FAQ seed contains Chinese text: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
218
cmd/testdata/main.go
vendored
Normal file
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/agentteam"
|
||||
"agent-desk/cmd/testdata/aiagent"
|
||||
"agent-desk/cmd/testdata/aiconfig"
|
||||
"agent-desk/cmd/testdata/channel"
|
||||
"agent-desk/cmd/testdata/kb"
|
||||
"agent-desk/cmd/testdata/quickreply"
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/skill"
|
||||
"agent-desk/cmd/testdata/tag"
|
||||
"agent-desk/internal/bootstrap"
|
||||
"agent-desk/internal/pkg/config"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("init testdata failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
configPath := flag.String("config", "config/config.yaml", "path to config file")
|
||||
autoConfirm := flag.Bool("yes", false, "skip confirmation prompt")
|
||||
langValue := flag.String("lang", string(seedlang.Chinese), "testdata language: zh or en")
|
||||
flag.Parse()
|
||||
|
||||
lang, err := seedlang.Parse(*langValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := confirmDestructiveAction(*autoConfirm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config failed: %w", err)
|
||||
}
|
||||
|
||||
db, err := bootstrap.InitDB(cfg.DB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init db failed: %w", err)
|
||||
}
|
||||
db = withSilentSQLLogger(db)
|
||||
sqls.SetDB(db)
|
||||
slog.Info("connected database success")
|
||||
|
||||
droppedTableCount, err := resetAllTables(db, cfg.DB.Type)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reset all tables failed: %w", err)
|
||||
}
|
||||
slog.Info("reset all tables success", slog.Int("droppedTableCount", droppedTableCount))
|
||||
|
||||
if err := bootstrap.InitMigrations(); err != nil {
|
||||
return fmt.Errorf("run migrations failed: %w", err)
|
||||
}
|
||||
slog.Info("run migrations success")
|
||||
|
||||
aiConfigResult, err := aiconfig.Init()
|
||||
if err != nil {
|
||||
return fmt.Errorf("init ai config failed: %w", err)
|
||||
}
|
||||
slog.Info("ai config init success", slog.Bool("skipped", aiConfigResult.Skipped),
|
||||
slog.String("filePath", aiConfigResult.FilePath),
|
||||
slog.Int("created", aiConfigResult.Created),
|
||||
slog.Int("updated", aiConfigResult.Updated))
|
||||
|
||||
kbResult, err := kb.Init(lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init knowledge base failed: %w", err)
|
||||
}
|
||||
slog.Info("knowledge base init success",
|
||||
slog.Int64("faqKnowledgeBaseID", kbResult.FAQKnowledgeBaseID),
|
||||
slog.Int("createdFAQs", kbResult.CreatedFAQs),
|
||||
slog.Int("updatedFAQs", kbResult.UpdatedFAQs),
|
||||
)
|
||||
|
||||
skillResult, err := skill.Init(lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init skill failed: %w", err)
|
||||
}
|
||||
slog.Info("skill init success", slog.Int("created", skillResult.Created), slog.Int("updated", skillResult.Updated))
|
||||
|
||||
agentTeamResult, err := agentteam.Init(lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init agent team failed: %w", err)
|
||||
}
|
||||
slog.Info("agent team init success", slog.Bool("teamCreated", agentTeamResult.TeamCreated),
|
||||
slog.Int("usersCreated", agentTeamResult.UsersCreated),
|
||||
slog.Int("profilesCreated", agentTeamResult.ProfilesCreated),
|
||||
slog.Int("updatesApplied", agentTeamResult.UpdatesApplied),
|
||||
)
|
||||
|
||||
aiAgentResult, err := aiagent.Init(lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init ai agent failed: %w", err)
|
||||
}
|
||||
slog.Info("ai agent init success", slog.Int("created", aiAgentResult.Created), slog.Int("updated", aiAgentResult.Updated))
|
||||
|
||||
channelResult, err := channel.Init(lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init channel failed: %w", err)
|
||||
}
|
||||
slog.Info("channel init success", slog.Int("created", channelResult.Created), slog.Int("updated", channelResult.Updated))
|
||||
|
||||
if err := tag.Init(lang); err != nil {
|
||||
slog.Error("init tag failed", "error", err)
|
||||
}
|
||||
slog.Info("tag init success")
|
||||
|
||||
if err := quickreply.Init(lang); err != nil {
|
||||
return fmt.Errorf("init quick reply failed: %w", err)
|
||||
}
|
||||
slog.Info("quick reply init success")
|
||||
|
||||
slog.Info("testdata initialization completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func withSilentSQLLogger(db *gorm.DB) *gorm.DB {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
return db.Session(&gorm.Session{Logger: db.Logger.LogMode(gormlogger.Silent)})
|
||||
}
|
||||
|
||||
func confirmDestructiveAction(autoConfirm bool) error {
|
||||
if autoConfirm {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("警告:该操作会清空当前数据库中的所有表和数据。")
|
||||
fmt.Print("请输入 INIT 继续,输入其他任意内容取消:")
|
||||
|
||||
var input string
|
||||
if _, err := fmt.Scanln(&input); err != nil {
|
||||
return fmt.Errorf("read confirmation failed: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(input) != "INIT" {
|
||||
return fmt.Errorf("initialization cancelled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetAllTables(db *gorm.DB, dbType string) (int, error) {
|
||||
tables, err := db.Migrator().GetTables()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
filterSystemTables := func(tables []string) []string {
|
||||
ret := make([]string, 0, len(tables))
|
||||
for _, table := range tables {
|
||||
if strings.HasPrefix(table, "sqlite_") {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, table)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
filtered := filterSystemTables(tables)
|
||||
if len(filtered) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
err = withForeignKeyChecksDisabled(db, dbType, func() error {
|
||||
items := make([]any, 0, len(filtered))
|
||||
for _, table := range filtered {
|
||||
items = append(items, table)
|
||||
}
|
||||
return db.Migrator().DropTable(items...)
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return len(filtered), nil
|
||||
}
|
||||
|
||||
func withForeignKeyChecksDisabled(db *gorm.DB, dbType string, fn func() error) error {
|
||||
var disableSQL string
|
||||
var enableSQL string
|
||||
|
||||
switch dbType {
|
||||
case "sqlite":
|
||||
disableSQL = "PRAGMA foreign_keys = OFF"
|
||||
enableSQL = "PRAGMA foreign_keys = ON"
|
||||
case "mysql":
|
||||
disableSQL = "SET FOREIGN_KEY_CHECKS = 0"
|
||||
enableSQL = "SET FOREIGN_KEY_CHECKS = 1"
|
||||
default:
|
||||
return fn()
|
||||
}
|
||||
|
||||
if err := db.Exec(disableSQL).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = db.Exec(enableSQL).Error
|
||||
}()
|
||||
|
||||
return fn()
|
||||
}
|
||||
56
cmd/testdata/quickreply/init.go
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
package quickreply
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/repositories"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
func Init(lang seedlang.Language) error {
|
||||
seed := seeds.QuickReplySeeds(lang)
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
now := time.Now()
|
||||
for _, row := range seed {
|
||||
existing := repositories.QuickReplyRepository.Get(ctx.Tx, row.ID)
|
||||
if existing == nil {
|
||||
item := &models.QuickReply{
|
||||
ID: row.ID,
|
||||
GroupName: row.GroupName,
|
||||
Title: row.Title,
|
||||
Content: row.Content,
|
||||
Status: row.Status,
|
||||
SortNo: row.SortNo,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: "system",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: "system",
|
||||
},
|
||||
}
|
||||
if err := repositories.QuickReplyRepository.Create(ctx.Tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := repositories.QuickReplyRepository.Updates(ctx.Tx, row.ID, map[string]any{
|
||||
"group_name": row.GroupName,
|
||||
"title": row.Title,
|
||||
"content": row.Content,
|
||||
"status": row.Status,
|
||||
"sort_no": row.SortNo,
|
||||
"updated_at": now,
|
||||
"update_user_id": 0,
|
||||
"update_user_name": "system",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
29
cmd/testdata/quickreply/init_test.go
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
package quickreply
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishSeedItemsMatchChineseCount(t *testing.T) {
|
||||
englishItems := seeds.QuickReplySeeds(seedlang.English)
|
||||
chineseItems := seeds.QuickReplySeeds(seedlang.Chinese)
|
||||
|
||||
if len(englishItems) != len(chineseItems) {
|
||||
t.Fatalf("english seed count = %d, want %d", len(englishItems), len(chineseItems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnglishSeedItemsDoNotContainChineseText(t *testing.T) {
|
||||
for _, item := range seeds.QuickReplySeeds(seedlang.English) {
|
||||
for _, value := range []string{item.GroupName, item.Title, item.Content} {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english quick reply %d contains Chinese text: %q", item.ID, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
cmd/testdata/seedlang/language.go
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
package seedlang
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Language string
|
||||
|
||||
const (
|
||||
Chinese Language = "zh"
|
||||
English Language = "en"
|
||||
)
|
||||
|
||||
func Parse(raw string) (Language, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch normalized {
|
||||
case "", "zh", "zh-cn", "chinese":
|
||||
return Chinese, nil
|
||||
case "en", "en-us", "english":
|
||||
return English, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported testdata language %q, supported: zh, en", raw)
|
||||
}
|
||||
}
|
||||
31
cmd/testdata/seedlang/language_test.go
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
package seedlang
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDefaultsToChinese(t *testing.T) {
|
||||
lang, err := Parse("")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse empty returned error: %v", err)
|
||||
}
|
||||
if lang != Chinese {
|
||||
t.Fatalf("Parse empty = %q, want %q", lang, Chinese)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnglishAliases(t *testing.T) {
|
||||
for _, raw := range []string{"en", "EN", "english", "English"} {
|
||||
lang, err := Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(%q) returned error: %v", raw, err)
|
||||
}
|
||||
if lang != English {
|
||||
t.Fatalf("Parse(%q) = %q, want %q", raw, lang, English)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsUnsupportedLanguage(t *testing.T) {
|
||||
if _, err := Parse("fr"); err == nil {
|
||||
t.Fatal("Parse unsupported language returned nil error")
|
||||
}
|
||||
}
|
||||
7
cmd/testdata/seeds/README.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Testdata Seeds
|
||||
|
||||
This package is the single maintenance point for built-in testdata content.
|
||||
|
||||
- Put language-specific demo text and seed metadata here.
|
||||
- Keep database writes, transactions, repository calls, and model audit fields in the resource init packages.
|
||||
- Add new languages through `seedlang.Language` and the `*Seeds(lang)` functions.
|
||||
55
cmd/testdata/seeds/agentteam.go
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
package seeds
|
||||
|
||||
import "agent-desk/cmd/testdata/seedlang"
|
||||
|
||||
type AgentUserSeed struct {
|
||||
Username string
|
||||
Nickname string
|
||||
Code string
|
||||
}
|
||||
|
||||
func AgentTeamName(lang seedlang.Language) string {
|
||||
if lang == seedlang.English {
|
||||
return "Default Support Team"
|
||||
}
|
||||
return "默认客服组"
|
||||
}
|
||||
|
||||
func AgentUsers(lang seedlang.Language, leaderUsername string) []AgentUserSeed {
|
||||
if lang == seedlang.English {
|
||||
return []AgentUserSeed{
|
||||
{
|
||||
Username: leaderUsername,
|
||||
Nickname: "Support Lead",
|
||||
Code: "AGENT_LEADER_A",
|
||||
},
|
||||
{
|
||||
Username: "agent_a",
|
||||
Nickname: "Agent A",
|
||||
Code: "AGENT_A",
|
||||
},
|
||||
{
|
||||
Username: "agent_b",
|
||||
Nickname: "Agent B",
|
||||
Code: "AGENT_B",
|
||||
},
|
||||
}
|
||||
}
|
||||
return []AgentUserSeed{
|
||||
{
|
||||
Username: leaderUsername,
|
||||
Nickname: "客服组长",
|
||||
Code: "AGENT_LEADER_A",
|
||||
},
|
||||
{
|
||||
Username: "agent_a",
|
||||
Nickname: "客服A",
|
||||
Code: "AGENT_A",
|
||||
},
|
||||
{
|
||||
Username: "agent_b",
|
||||
Nickname: "客服B",
|
||||
Code: "AGENT_B",
|
||||
},
|
||||
}
|
||||
}
|
||||
141
cmd/testdata/seeds/aiagent.go
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
package seeds
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type AIAgentSeed struct {
|
||||
Name string
|
||||
LegacyNames []string
|
||||
Description string
|
||||
ServiceMode enums.IMConversationServiceMode
|
||||
SystemPrompt string
|
||||
WelcomeMessage string
|
||||
ReplyTimeoutSeconds int
|
||||
HandoffMode enums.AIAgentHandoffMode
|
||||
FallbackMode enums.AIAgentFallbackMode
|
||||
FallbackMessage string
|
||||
SortNo int
|
||||
}
|
||||
|
||||
func AIAgentSeeds(lang seedlang.Language) []AIAgentSeed {
|
||||
if lang == seedlang.English {
|
||||
return []AIAgentSeed{
|
||||
{
|
||||
Name: "AgentDesk Pre-sales Support",
|
||||
LegacyNames: []string{"Test AI Support Agent", "After-sales AI Support Agent"},
|
||||
Description: "Introduces AgentDesk to prospective customers, answers product, use-case, deployment, and integration questions, qualifies requirements, and hands off to a human consultant when needed.",
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
SystemPrompt: `# Role
|
||||
|
||||
You are the pre-sales support agent for AgentDesk, serving prospective customers, product owners, support administrators, and engineers who are evaluating, testing, or preparing to integrate AgentDesk.
|
||||
|
||||
Your goal is to explain the product accurately, assess whether it fits the customer's needs, clarify how it can be adopted, and guide the customer to an appropriate next step. You are not an after-sales agent and do not handle refunds, repairs, logistics, or unrelated after-sales requests.
|
||||
|
||||
# Product positioning
|
||||
|
||||
AgentDesk is an open-source AI Agent customer support system that unifies online conversations, knowledge-base Q&A, AI-first service, human handoff, the agent workspace, customer and conversation management, ticket follow-up, channel integration, and private deployment. It is not merely an LLM embedded in a chat box; it enables AI, knowledge bases, human agents, and tickets to work together in one support workflow.
|
||||
|
||||
Use the bound knowledge base as the source of truth when describing capabilities. You may answer and qualify requirements around:
|
||||
- Product positioning, suitable teams, and typical support scenarios;
|
||||
- AI Agents, knowledge-base RAG, model configuration, Skills, Workflows, and MCP Tools;
|
||||
- AI and human collaboration, handoff, teams, schedules, conversations, and ticket workflows;
|
||||
- Web Widget, channel integration, the admin console, and the agent workspace;
|
||||
- Local evaluation, Docker Compose, private deployment, and secondary development;
|
||||
- Differences from basic chatbots and traditional support systems.
|
||||
|
||||
# Pre-sales conversation strategy
|
||||
|
||||
1. Answer the user's current question first, then ask one or two essential follow-up questions only when useful.
|
||||
2. For general inquiries, briefly explain the product positioning and ask about the customer's scenario or primary concern.
|
||||
3. For product evaluation, prioritize the business scenario, customer channels, inquiry volume, private-deployment needs, existing knowledge and model setup, human handoff, and ticket follow-up requirements.
|
||||
4. When the requirement matches current capabilities, explain the fit and offer an actionable next step, such as reviewing a feature, preparing the deployment environment, trying a demo, or contacting a human consultant.
|
||||
5. Clearly distinguish current standard capabilities from features that require secondary development. Never present extensibility as an out-of-the-box feature.
|
||||
6. When comparing products, describe only verifiable differences. Do not disparage competitors or invent competitor information.
|
||||
7. If the user has no new question, acknowledge briefly instead of adding unsolicited marketing content.
|
||||
|
||||
# Facts and tool boundaries
|
||||
|
||||
1. Product facts, deployment requirements, supported scope, and feature descriptions must be grounded in the knowledge base or tool results. If reliable information is unavailable, say that you cannot currently confirm it and suggest adding details or contacting a human consultant.
|
||||
2. Never invent pricing, discounts, contract terms, commercial SLAs, customer cases, launch dates, roadmaps, version capabilities, compatibility, or service commitments.
|
||||
3. Never claim that a demo, account, request, document delivery, or sales contact has been arranged unless a tool actually completed that action.
|
||||
4. Call only authorized tools that are directly relevant to the current question. Report failures or empty results truthfully.
|
||||
5. Do not request passwords, API keys, complete configuration files, or other sensitive information. Collect only the minimum business context needed for pre-sales qualification.
|
||||
6. Guide the user to human support for pricing, solution assessment, commercial commitments, explicit human consultation, or questions that the knowledge base cannot answer. Do not make commitments on behalf of sales or implementation teams.
|
||||
|
||||
# Response style
|
||||
|
||||
- Use English by default and follow the user's language when they use another language.
|
||||
- Be professional, natural, and friendly. Avoid exaggerated marketing language and empty slogans.
|
||||
- Use one to three short paragraphs for simple questions and clear bullets or steps for complex ones.
|
||||
- Lead with the conclusion and explain terms such as RAG or Embedding in plain language.
|
||||
- End with one natural question or next-step suggestion only when it is genuinely helpful.`,
|
||||
WelcomeMessage: "Hello, I am the AgentDesk pre-sales support agent. I can help with product capabilities, use cases, private deployment, and integration options. What would you like to explore first, or what is your business scenario?",
|
||||
ReplyTimeoutSeconds: 180,
|
||||
HandoffMode: enums.AIAgentHandoffModeWaitPool,
|
||||
FallbackMode: enums.AIAgentFallbackModeSuggestRetry,
|
||||
FallbackMessage: "I could not find sufficiently reliable product information for this question. Please add your business scenario, deployment preference, or specific concern. For pricing, solution assessment, or commercial commitments, I can help you reach a human consultant.",
|
||||
SortNo: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
return []AIAgentSeed{
|
||||
{
|
||||
Name: "贝壳AI售前客服",
|
||||
LegacyNames: []string{"测试AI客服", "售后AI客服"},
|
||||
Description: "面向潜在客户介绍贝壳AI(AgentDesk),解答产品能力、适用场景、部署与接入问题,协助需求梳理并在需要时转接人工顾问。",
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
SystemPrompt: `# 角色定位
|
||||
|
||||
你是贝壳AI(AgentDesk)的售前客服,服务对象是正在了解、评估、试用或准备接入贝壳AI的潜在客户、产品负责人、客服团队管理员和技术人员。
|
||||
|
||||
你的目标是:准确介绍产品,判断客户需求与产品的匹配度,帮助客户理解落地方式,并引导到合适的下一步。你不是售后处理人员,不处理退款、维修、物流等与本产品无关的售后事项。
|
||||
|
||||
# 产品定位
|
||||
|
||||
贝壳AI是一套开源的 AI Agent 客服系统,围绕真实客服链路统一在线咨询、知识库问答、AI 优先接待、人工接管、客服工作台、客户与会话管理、工单跟进、渠道接入和私有化部署。它不是单纯把大模型接入聊天框,而是让 AI、知识库、人工客服和工单在同一套系统中协同工作。
|
||||
|
||||
介绍能力时,以已绑定知识库中的信息为准。可以围绕以下方向回答和梳理需求:
|
||||
- 产品定位、适用团队和典型客服场景;
|
||||
- AI Agent、知识库 RAG、模型配置、Skills、Workflow 与 MCP Tool;
|
||||
- AI 与人工客服协同、转人工、客服组、排班、会话和工单闭环;
|
||||
- Web Widget、渠道接入、管理后台与客服工作台;
|
||||
- 本地体验、Docker Compose、私有化部署和二次开发;
|
||||
- 与普通聊天机器人、传统客服系统的差异。
|
||||
|
||||
# 售前对话策略
|
||||
|
||||
1. 先直接回答用户当前问题,再根据需要提出 1 至 2 个关键问题,不要一开始连续盘问。
|
||||
2. 当用户只是泛泛了解时,先用简短语言说明产品定位,再询问其业务场景或最关心的能力。
|
||||
3. 当用户在做选型时,优先了解:业务场景、客户接入渠道、咨询量、是否需要私有化部署、现有知识库与模型条件、是否需要人工接管和工单跟进。
|
||||
4. 当需求与现有能力匹配时,说明匹配点,并给出可执行的下一步,例如查看相关能力、准备部署环境、体验演示或联系人工顾问。
|
||||
5. 当需求只可通过二次开发实现时,明确区分“当前标准能力”和“可扩展方向”,不要把可定制能力说成开箱即用。
|
||||
6. 当用户比较其他产品时,基于可确认的能力客观说明差异,不贬低竞品,不编造竞品信息。
|
||||
7. 当用户没有新问题,只需简短确认,不主动堆砌营销内容。
|
||||
|
||||
# 事实与工具边界
|
||||
|
||||
1. 产品事实、部署要求、支持范围和功能说明必须以知识库或工具返回结果为依据;没有可靠依据时,明确说“目前无法确认”,并建议补充信息或联系人工顾问。
|
||||
2. 不得编造价格、折扣、合同条款、商业 SLA、客户案例、上线时间、路线图、版本能力、兼容性或服务承诺。
|
||||
3. 不得声称已经安排演示、创建账号、提交需求、发送资料或联系销售,除非工具确实执行成功。
|
||||
4. 只调用与当前问题直接相关且已授权的工具;如实说明工具失败或无结果,不得伪造执行结果。
|
||||
5. 不索要密码、密钥、完整配置文件或其他敏感信息。售前需求澄清只收集必要的业务背景。
|
||||
6. 用户明确需要人工咨询、商务报价、方案评估或知识库无法支持回答时,引导转人工;不要代表销售或实施团队作出承诺。
|
||||
|
||||
# 回复风格
|
||||
|
||||
- 默认使用中文;用户使用其他语言时跟随用户语言。
|
||||
- 专业、自然、友好,避免夸张营销和空泛口号。
|
||||
- 简单问题用 1 至 3 个短段落回答;复杂问题使用清晰的要点或步骤。
|
||||
- 先给结论,再补充必要说明;涉及 RAG、Embedding 等术语时用通俗语言解释。
|
||||
- 只在确有帮助时,以一个自然的问题或下一步建议结尾。`,
|
||||
WelcomeMessage: "您好,我是贝壳AI售前客服。可以帮你了解产品能力、适用场景、私有化部署和接入方式。你想先了解哪一部分,或者可以直接告诉我你的业务场景。",
|
||||
ReplyTimeoutSeconds: 180,
|
||||
HandoffMode: enums.AIAgentHandoffModeWaitPool,
|
||||
FallbackMode: enums.AIAgentFallbackModeSuggestRetry,
|
||||
FallbackMessage: "关于这个问题,我暂时没有找到足够准确的产品信息。你可以补充业务场景、部署方式或具体关注点;如果涉及报价、方案评估或商务承诺,我可以为你转接人工顾问。",
|
||||
SortNo: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
49
cmd/testdata/seeds/channel.go
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
package seeds
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
|
||||
"github.com/mlogclub/simple/common/jsons"
|
||||
)
|
||||
|
||||
type ChannelSeed struct {
|
||||
Name string
|
||||
ChannelType string
|
||||
ConfigJSON string
|
||||
Remark string
|
||||
}
|
||||
|
||||
func ChannelSeeds(lang seedlang.Language) []ChannelSeed {
|
||||
if lang == seedlang.English {
|
||||
return []ChannelSeed{
|
||||
{
|
||||
Name: "Website Support",
|
||||
ChannelType: enums.ChannelTypeWeb,
|
||||
ConfigJSON: jsons.ToJsonStr(dto.WebChannelConfig{
|
||||
Title: "Online Support",
|
||||
Subtitle: "Powered by AgentDesk",
|
||||
ThemeColor: "#2563eb",
|
||||
Position: "right",
|
||||
Width: "780px",
|
||||
}),
|
||||
Remark: "Local testdata seed",
|
||||
},
|
||||
}
|
||||
}
|
||||
return []ChannelSeed{
|
||||
{
|
||||
Name: "官网客服",
|
||||
ChannelType: enums.ChannelTypeWeb,
|
||||
ConfigJSON: jsons.ToJsonStr(dto.WebChannelConfig{
|
||||
Title: "在线客服",
|
||||
Subtitle: "AgentDesk 提供技术支持",
|
||||
ThemeColor: "#2563eb",
|
||||
Position: "right",
|
||||
Width: "780px",
|
||||
}),
|
||||
Remark: "Local testdata seed",
|
||||
},
|
||||
}
|
||||
}
|
||||
201
cmd/testdata/seeds/kb.go
vendored
Normal file
@@ -0,0 +1,201 @@
|
||||
package seeds
|
||||
|
||||
import "agent-desk/cmd/testdata/seedlang"
|
||||
|
||||
type KnowledgeBaseSeed struct {
|
||||
Name string
|
||||
Description string
|
||||
Remark string
|
||||
}
|
||||
|
||||
type KnowledgeFAQSeed struct {
|
||||
Question string
|
||||
Answer string
|
||||
SimilarQuestions []string
|
||||
Remark string
|
||||
}
|
||||
|
||||
func FAQKnowledgeBaseSeed(lang seedlang.Language) KnowledgeBaseSeed {
|
||||
if lang == seedlang.English {
|
||||
return KnowledgeBaseSeed{
|
||||
Name: "AgentDesk Support Platform FAQ",
|
||||
Description: "FAQ test data that simulates real support scenarios, covering accounts, agents, AI bots, knowledge bases, tickets, billing, invoices, and troubleshooting.",
|
||||
Remark: "Generated by testdata initialization",
|
||||
}
|
||||
}
|
||||
return KnowledgeBaseSeed{
|
||||
Name: "贝壳客服平台 FAQ",
|
||||
Description: "模拟真实客服场景的 FAQ 测试数据,覆盖账号、坐席、机器人、知识库、工单、计费与发票等常见问题。",
|
||||
Remark: "测试数据初始化自动生成",
|
||||
}
|
||||
}
|
||||
|
||||
func KnowledgeFAQSeeds(lang seedlang.Language) []KnowledgeFAQSeed {
|
||||
if lang == seedlang.English {
|
||||
return englishKnowledgeFAQSeeds()
|
||||
}
|
||||
return chineseKnowledgeFAQSeeds()
|
||||
}
|
||||
|
||||
func englishKnowledgeFAQSeeds() []KnowledgeFAQSeed {
|
||||
return []KnowledgeFAQSeed{
|
||||
{Question: "What should I do if the admin console says my account or password is incorrect?", Answer: "First confirm that the account is complete and that the email domain is correct. Passwords are case-sensitive. After 5 failed attempts, the account may be locked for 15 minutes. If you still cannot sign in, ask an organization administrator to reset the password in Organization Settings > Members, or use Forgot Password on the sign-in page.", SimilarQuestions: []string{"admin password is wrong", "cannot sign in to the console", "username or password error"}, Remark: "Account login"},
|
||||
{Question: "How do I reset a forgotten password?", Answer: "Click Forgot Password on the sign-in page, enter the bound email address, and use the verification code to reset the password. If the account has no usable email address, contact an enterprise administrator for a reset. After a successful reset, the account must sign in again on all devices.", SimilarQuestions: []string{"forgot password", "reset console password", "recover login password"}, Remark: "Account login"},
|
||||
{Question: "Why am I not receiving the login verification email?", Answer: "Check spam, promotions, and your enterprise mail quarantine first. If no email arrives within 5 minutes, confirm the email address and ask your IT team to allowlist the platform sender domain. Repeated missing emails may indicate provider throttling, so try a backup email if available.", SimilarQuestions: []string{"verification email not received", "no login code email", "email code missing"}, Remark: "Account login"},
|
||||
{Question: "Can multiple people use the same agent account at the same time?", Answer: "Sharing one agent account is not recommended. The platform may allow the same account on multiple devices, but login logs and abnormal activity reminders are recorded. For traceability, permission isolation, and accurate conversation assignment, each agent should use a separate account.", SimilarQuestions: []string{"share one account", "multiple people one login", "same account on many computers"}, Remark: "Account login"},
|
||||
{Question: "How do I create a console account for a new team member?", Answer: "An administrator can open Organization Settings > Members, click Add Member, fill in name, email, team, and role, then save. The system sends an activation email and the member sets a password on first sign-in. If SSO is enabled, members can also be synced from the enterprise identity provider.", SimilarQuestions: []string{"create new agent account", "add employee account", "invite member to console"}, Remark: "Member management"},
|
||||
{Question: "What is the difference between online, busy, and offline agent statuses?", Answer: "Online agents can receive new conversations. Busy agents stop receiving new conversations but can continue existing ones. Offline agents do not join assignment or receive real-time reminders. If automatic status switching is enabled, long inactivity or sign-out can move the agent offline.", SimilarQuestions: []string{"agent status meaning", "online busy offline difference", "support agent status"}, Remark: "Agent service"},
|
||||
{Question: "How are conversations assigned to agents?", Answer: "By default, conversations are assigned by skill group and round-robin rules. The platform can also consider current workload, recent response time, and priority. If a customer matches channel, language, or tag conditions, routing can prefer the matching team or agent.", SimilarQuestions: []string{"conversation routing rule", "how chats are assigned", "new conversation distribution"}, Remark: "Agent service"},
|
||||
{Question: "Can a conversation be automatically closed when the customer does not reply?", Answer: "Yes. In Reception Settings > Conversation Rules, configure the no-response timeout, such as closing the conversation after 30 minutes without a customer reply. If the customer sends another message later, a new conversation is created and history remains visible in the customer timeline.", SimilarQuestions: []string{"auto close conversation", "customer no reply timeout", "inactive chat closing"}, Remark: "Agent service"},
|
||||
{Question: "When does the AI bot take over a conversation?", Answer: "When the channel uses AI-first service and the message matches the bot's service hours, language, and business scope, the bot replies first. If the customer asks for a human, matches a handoff rule, or the bot misses knowledge repeatedly, the system can switch to a human agent.", SimilarQuestions: []string{"AI bot service rules", "when does AI answer first", "bot to human conditions"}, Remark: "AI bot"},
|
||||
{Question: "What should I check first when the bot answer is inaccurate?", Answer: "First check whether the knowledge base covers the question, whether FAQ wording is too far from user wording, whether the recall threshold is too high, and whether the wrong knowledge base is enabled. If retrieval hits are good but answers still drift, review the prompt, answer mode, and reranking strategy.", SimilarQuestions: []string{"AI answer inaccurate", "bot answers wrong", "what config to check first"}, Remark: "AI bot"},
|
||||
{Question: "How do I restrict a bot to after-sales questions only?", Answer: "Bind the bot to an after-sales knowledge base and restrict routing to after-sales channels, entry points, or tagged conversations. Set the fallback policy to guide users to a human agent so the bot avoids answering out-of-scope questions.", SimilarQuestions: []string{"bot only after sales", "limit bot answer scope", "bind bot to business scope"}, Remark: "AI bot"},
|
||||
{Question: "Does the bot support multilingual replies?", Answer: "Yes, it can support English, Chinese, and other languages supported by the connected model, but the knowledge base should contain the corresponding language content. If only Chinese knowledge is uploaded, English business answers may not be stable. Use separate language-specific knowledge bases for better quality.", SimilarQuestions: []string{"AI supports English", "multilingual bot", "serve English customers"}, Remark: "AI bot"},
|
||||
{Question: "What is the difference between a document knowledge base and an FAQ knowledge base?", Answer: "A document knowledge base works well for long help articles, policies, and manuals because the system chunks and retrieves passages. An FAQ knowledge base works better for standard question-answer pairs and high-frequency support questions. If the content has fixed questions and fixed answers, use FAQ first.", SimilarQuestions: []string{"FAQ vs document knowledge base", "when to use FAQ", "knowledge base type selection"}, Remark: "Knowledge base"},
|
||||
{Question: "How long after uploading a document can the bot retrieve it?", Answer: "After upload, the document enters the indexing queue. Small text documents usually finish in 1 to 3 minutes. Longer files, image-heavy documents, or busy indexing queues can take longer. You can check indexing status in the knowledge base list.", SimilarQuestions: []string{"document indexing time", "knowledge upload effective time", "when can AI use the document"}, Remark: "Knowledge base"},
|
||||
{Question: "Can FAQ entries be imported in bulk?", Answer: "Yes. Download the template and fill in question, answer, similar questions, and remark columns, then import it from the FAQ page. Similar questions should be separated by line breaks or semicolons, and duplicate questions should be removed before import.", SimilarQuestions: []string{"bulk FAQ upload", "FAQ import template", "batch import Q&A"}, Remark: "Knowledge base"},
|
||||
{Question: "Why does an uploaded document show indexing failed?", Answer: "Common causes include empty documents, text copied as images, excessive content length, unavailable vector service, or model configuration errors. Check the indexing error first, then convert the file to plain text or Markdown and upload it again if needed.", SimilarQuestions: []string{"document indexing failed", "knowledge processing failed", "upload error after indexing"}, Remark: "Knowledge base"},
|
||||
{Question: "How do I embed the website support button on our site?", Answer: "Open Channel Access > Web Widget, copy the generated script, and paste it before the closing body tag on your website. If the site uses a Content Security Policy, add the platform domain to the allowed list. After publishing, test both desktop and mobile pages.", SimilarQuestions: []string{"embed web widget", "website chat button", "install support script"}, Remark: "Channel access"},
|
||||
{Question: "Can the web widget use our brand color and logo?", Answer: "Yes. In the web channel configuration, set the title, subtitle, theme color, position, width, and brand assets. After saving, refresh the website page. If CDN caching is enabled, it may take a few minutes for the change to appear.", SimilarQuestions: []string{"custom web widget brand", "change chat color", "support widget logo"}, Remark: "Channel access"},
|
||||
{Question: "How do I create a support ticket from a conversation?", Answer: "In the conversation detail page, click Create Ticket, select the issue category and priority, fill in the title and description, then submit. Conversation context can be linked to the ticket so the follow-up team can see the original messages.", SimilarQuestions: []string{"create ticket from chat", "turn conversation into ticket", "submit support ticket"}, Remark: "Tickets"},
|
||||
{Question: "What information should be included when reporting an urgent issue?", Answer: "Include the issue time, organization ID, affected channel or customer scope, screenshots, error messages, and reproduction steps. Clear impact and steps help the support team triage faster and avoid repeated clarification.", SimilarQuestions: []string{"urgent incident information", "report system failure", "what to include in a ticket"}, Remark: "Tickets"},
|
||||
{Question: "How do I configure automatic welcome messages?", Answer: "Enable the welcome message in the channel or bot configuration. You can use different text by working hours, channel, customer tag, or page URL. Keep the message concise and clarify whether the current service is AI or human.", SimilarQuestions: []string{"automatic greeting", "welcome message setup", "first message to customer"}, Remark: "Automation"},
|
||||
{Question: "Can keywords automatically trigger human handoff?", Answer: "Yes. Add handoff rules in bot strategy and enter keywords such as complaint, refund, human agent, or invoice reissue. Include synonyms and common user phrasing to reduce missed matches.", SimilarQuestions: []string{"keyword handoff", "words trigger human agent", "automatic human transfer"}, Remark: "Automation"},
|
||||
{Question: "How do I view conversation volume trends and peak hours?", Answer: "Open Data Reports > Traffic Analysis to view conversations, visitors, queue peaks, and human service rate by hour, date, and channel. Peak-hour analysis should be compared with staffing schedules.", SimilarQuestions: []string{"conversation trend report", "peak traffic hours", "traffic analytics"}, Remark: "Reports"},
|
||||
{Question: "What do bot hit rate and resolution rate mean?", Answer: "Hit rate is the percentage of in-scope bot messages where the bot retrieves and returns an answer. Resolution rate is the percentage of conversations completed without human handoff after bot handling. A high hit rate does not always mean a high resolution rate, so review both metrics together.", SimilarQuestions: []string{"AI hit rate meaning", "bot resolution rate", "bot metrics explanation"}, Remark: "Reports"},
|
||||
{Question: "How do I upgrade a plan or add more seats?", Answer: "Contact your customer success manager or submit an upgrade request in Billing Center > Plan Management. Added seats are usually prorated for the remaining billing cycle, and permissions and quotas are expanded after the upgrade takes effect.", SimilarQuestions: []string{"add seats", "upgrade subscription", "buy more agents"}, Remark: "Billing"},
|
||||
{Question: "Can we issue a VAT invoice?", Answer: "Yes. Maintain complete invoice information in Billing Center > Invoice Information, including company name, tax number, registered address, phone, and bank information. After approval, invoices are issued according to the billing cycle.", SimilarQuestions: []string{"request invoice", "VAT invoice", "invoice information setup"}, Remark: "Billing"},
|
||||
{Question: "What happens after AI usage exceeds the included quota?", Answer: "If your plan includes a fixed AI quota, excess usage is usually billed by actual calls or token usage. Enable usage alerts in the billing center to avoid unexpected overage near month end.", SimilarQuestions: []string{"AI overage billing", "token usage charge", "quota exceeded"}, Remark: "Billing"},
|
||||
{Question: "What should I do if messages are sent but customers do not receive them?", Answer: "Confirm that the message status is successful, the customer channel is online, and the third-party channel has no delivery failure receipt. If only one channel is affected, the cause is often channel throttling, template review, or network issues.", SimilarQuestions: []string{"customer cannot receive message", "message delivery failed", "sent but not received"}, Remark: "Troubleshooting"},
|
||||
{Question: "Why is the page slow or the message list loading slowly?", Answer: "Common causes include too much browser cache, too many conversation tabs open, high network latency, or very large attachments in the page. Refresh the page, close unnecessary tabs, and check the local network and browser version first.", SimilarQuestions: []string{"console is slow", "message list slow", "chat page lag"}, Remark: "Troubleshooting"},
|
||||
{Question: "Why does a newly configured bot not take effect?", Answer: "Check whether the bot is bound to the correct channel, whether service hours cover the current time, and whether the channel is still using human-first service. If configuration looks correct, inspect debug logs to confirm whether routing conditions were matched.", SimilarQuestions: []string{"bot not working", "AI not taking over", "configuration not effective"}, Remark: "Troubleshooting"},
|
||||
{Question: "How do I decide whether an FAQ should be optimized?", Answer: "Prioritize three signals: high hit rate but high human handoff rate, high hit rate but low satisfaction, and repeated customer follow-up questions. These usually mean the answer is incomplete, inconsistent, or missing similar question coverage.", SimilarQuestions: []string{"FAQ optimization signals", "which FAQ to improve", "knowledge base quality review"}, Remark: "Knowledge operations"},
|
||||
{Question: "How long should an FAQ answer be?", Answer: "Start with the conclusion, then add steps and notes. Most support FAQ answers work best at roughly 80 to 220 Chinese characters or the English equivalent. Very short answers miss context; very long answers are harder for bots to cite and customers to read quickly.", SimilarQuestions: []string{"FAQ answer length", "how detailed should answers be", "write Q&A content"}, Remark: "Knowledge operations"},
|
||||
}
|
||||
}
|
||||
|
||||
func chineseKnowledgeFAQSeeds() []KnowledgeFAQSeed {
|
||||
return []KnowledgeFAQSeed{
|
||||
{Question: "登录后台时提示账号或密码错误怎么办?", Answer: "请先确认账号是否输入完整,邮箱登录要区分公司域名,密码需注意大小写。如果连续输错 5 次,系统会临时锁定 15 分钟。仍无法登录时,可让企业管理员在“组织设置-成员管理”中重置密码,或通过登录页的“忘记密码”重新设置。", SimilarQuestions: []string{"后台密码错误怎么处理", "账号登录不上去怎么办", "提示用户名或密码错误"}, Remark: "账号登录"},
|
||||
{Question: "忘记登录密码后怎么找回?", Answer: "在登录页点击“忘记密码”,输入绑定邮箱后获取验证码即可重置密码。如果账号未绑定邮箱或邮箱无法使用,请联系企业管理员协助重置。为了安全,重置成功后系统会让该账号在所有设备重新登录。", SimilarQuestions: []string{"忘记密码了怎么办", "怎么重置后台密码", "找回登录密码"}, Remark: "账号登录"},
|
||||
{Question: "为什么收不到登录验证码邮件?", Answer: "请先检查垃圾邮箱、广告邮件和企业邮箱的安全隔离区。若 5 分钟内仍未收到,建议确认邮箱地址是否填写正确,并联系企业 IT 将平台发信域名加入白名单。如果多次重发都未收到,可能是该邮箱服务商限流,建议改用备用邮箱。", SimilarQuestions: []string{"验证码邮件收不到", "邮箱没有收到验证码", "登录验证码不见了"}, Remark: "账号登录"},
|
||||
{Question: "同一个账号可以多人同时登录吗?", Answer: "不建议多人共用同一个坐席账号。平台默认允许同账号在多个设备登录,但会记录登录日志并触发异常提醒。为保证操作留痕、权限隔离和会话分配准确,建议每位坐席使用独立账号。", SimilarQuestions: []string{"一个账号能不能多人共用", "支持多人同时登录同一账号吗", "账号能在多台电脑登录吗"}, Remark: "账号登录"},
|
||||
{Question: "新成员加入后如何开通后台账号?", Answer: "企业管理员进入“组织设置-成员管理”,点击“新增成员”,填写姓名、邮箱、所属团队和角色后保存。系统会自动发送激活邮件,成员首次登录时设置密码即可。若你们开通了单点登录,也可以直接从企业身份系统同步成员。", SimilarQuestions: []string{"怎么给新客服开账号", "新增员工账号在哪里", "成员怎么加入后台"}, Remark: "成员管理"},
|
||||
{Question: "成员离职后如何停用账号?", Answer: "请在“组织设置-成员管理”中找到对应成员,点击“停用”即可。停用后该账号无法继续登录,但历史会话、工单处理记录和质检数据会保留,不会影响报表统计。若后续确认不再使用,也可以在完成交接后删除账号。", SimilarQuestions: []string{"离职员工账号怎么处理", "怎么禁用成员账号", "停用客服账号"}, Remark: "成员管理"},
|
||||
{Question: "角色权限修改后多久生效?", Answer: "角色权限保存后通常即时生效。已在线的成员可能需要刷新页面或重新登录,才能拿到最新权限菜单和接口授权。如果修改后仍能访问原页面,请清理浏览器缓存后再试。", SimilarQuestions: []string{"权限修改什么时候生效", "调整角色后没变化", "角色更新后要重登吗"}, Remark: "成员管理"},
|
||||
{Question: "支持企业微信或钉钉单点登录吗?", Answer: "支持对接企业微信、钉钉和标准 SAML/OIDC 单点登录。开通后成员可通过企业身份系统直接登录平台,无需单独维护密码。具体配置需要管理员在“安全设置-单点登录”中填写回调地址、应用凭证并完成测试。", SimilarQuestions: []string{"能接企业微信登录吗", "支持钉钉 SSO 吗", "单点登录怎么接"}, Remark: "成员管理"},
|
||||
{Question: "坐席在线、忙碌、离线状态有什么区别?", Answer: "在线表示可正常接待新会话,忙碌表示当前暂不分配新会话但仍可处理已有会话,离线表示不参与会话分配也不接收实时提醒。若开启自动状态切换,长时间无操作或退出登录后,系统会自动变更为离线。", SimilarQuestions: []string{"客服状态怎么理解", "在线忙碌离线区别", "坐席状态说明"}, Remark: "坐席接待"},
|
||||
{Question: "会话是怎么分配给坐席的?", Answer: "默认按技能组和轮询策略分配,也可结合坐席当前负载、最近响应时长和优先级规则进行智能分流。若客户命中了指定渠道、语言或标签条件,系统会优先路由到匹配该条件的团队或坐席。", SimilarQuestions: []string{"客户咨询怎么分配", "会话路由规则是什么", "新会话按什么分给客服"}, Remark: "坐席接待"},
|
||||
{Question: "如何把会话转接给其他团队?", Answer: "在会话详情页点击“转接”,选择目标团队或指定坐席,并填写转接备注即可。转接后,原坐席仍可在历史记录中查看会话内容,但新消息会优先提醒接收方。若目标团队离线人数过多,建议先确认有人值班。", SimilarQuestions: []string{"会话怎么转给别的组", "转接客服在哪里", "咨询如何分配给其他团队"}, Remark: "坐席接待"},
|
||||
{Question: "客户长时间不回复,会话会自动结束吗?", Answer: "可以。在“接待设置-会话规则”里可配置无响应超时时间,例如客户 30 分钟未回复后自动关闭会话。关闭后客户再次发消息会新建会话,历史内容仍可在客户轨迹中查看。", SimilarQuestions: []string{"会话会自动关闭吗", "多久不回复算结束", "无响应自动结束如何设置"}, Remark: "坐席接待"},
|
||||
{Question: "坐席最大并发接待数在哪里设置?", Answer: "管理员可在“接待设置-分配规则”中设置团队级默认并发数,也可以在成员详情中为个别坐席单独调整。建议新手坐席从 3 到 5 路并发开始,成熟团队再根据平均处理时长逐步放开。", SimilarQuestions: []string{"并发接待数怎么改", "一个客服最多接多少会话", "坐席并发设置"}, Remark: "坐席接待"},
|
||||
{Question: "如何查看客服首响应时长和平均处理时长?", Answer: "进入“数据报表-会话分析”,可按团队、坐席、渠道和日期查看首响应时长、平均响应时长、平均处理时长和超时占比。报表支持导出 CSV,便于做班次复盘或绩效分析。", SimilarQuestions: []string{"首响时长在哪里看", "客服处理时长报表", "怎么查响应效率"}, Remark: "坐席接待"},
|
||||
{Question: "客户排队时能看到前面还有多少人吗?", Answer: "支持。你可以在 Web 小部件或 H5 咨询页里开启“排队展示”,客户会看到预估等待人数和等待时长。若不希望暴露具体排队数据,也可以只展示“当前咨询较多,请耐心等待”的文案。", SimilarQuestions: []string{"排队人数能展示吗", "客户能看到等待人数吗", "排队提示怎么设置"}, Remark: "坐席接待"},
|
||||
{Question: "机器人什么时候会接管会话?", Answer: "当渠道启用了 AI 机器人优先接待,且用户消息命中机器人的服务时段、语言和业务范围时,会先由机器人回复。若客户主动要求人工、命中转人工规则,或机器人连续多轮未命中知识,系统会自动切换到人工。", SimilarQuestions: []string{"机器人接待规则是什么", "什么情况下先走 AI", "机器人何时转人工"}, Remark: "AI机器人"},
|
||||
{Question: "机器人回答不准时应该先调哪里?", Answer: "优先检查知识库是否覆盖了该问题、FAQ 表述是否过于口语化、召回阈值是否设得过高,以及是否开启了错误的知识库。若命中很多但答案仍跑偏,再看模型提示词、回答模式和重排策略。", SimilarQuestions: []string{"AI 回复不准确怎么办", "机器人答非所问怎么调", "先检查哪些配置"}, Remark: "AI机器人"},
|
||||
{Question: "怎么限制机器人只回答售后相关问题?", Answer: "可以给该机器人绑定专门的售后知识库,并在意图路由里限定只处理售后渠道、售后入口或带售后标签的会话。同时建议把兜底策略设为“引导转人工”,避免机器人回答超出范围的问题。", SimilarQuestions: []string{"只让机器人回答售后", "机器人回答范围怎么限制", "怎么绑定指定业务"}, Remark: "AI机器人"},
|
||||
{Question: "机器人可以识别情绪激动的客户并转人工吗?", Answer: "可以。你可以在策略中配置敏感词、情绪标签和高风险场景,例如投诉、退款争议、辱骂等。一旦命中条件,系统会立即转人工并在会话顶部提示“高风险用户”,方便优先接待。", SimilarQuestions: []string{"负面情绪自动转人工", "投诉用户能自动升级吗", "敏感客户转人工"}, Remark: "AI机器人"},
|
||||
{Question: "AI 生成答案时会引用知识来源吗?", Answer: "支持。在开启知识引用后,机器人会在调试信息或内部坐席辅助面板中展示命中的知识条目、相似度分数和引用片段。面向终端用户是否展示引用,可按渠道单独控制。", SimilarQuestions: []string{"AI 能显示知识来源吗", "答案引用在哪里看", "机器人回复能带出处吗"}, Remark: "AI机器人"},
|
||||
{Question: "如何测试机器人而不影响线上客户?", Answer: "建议使用“调试问答”或专门的测试渠道。测试渠道可以绑定同一套知识库和机器人配置,但不进入正式报表,也不会分配给真实坐席。上线前至少验证高频问题、异常问法和兜底流程。", SimilarQuestions: []string{"怎么做机器人测试", "测试 AI 会不会影响线上", "有没有沙箱渠道"}, Remark: "AI机器人"},
|
||||
{Question: "机器人支持多语言回复吗?", Answer: "支持中文、英文及其他已接入模型支持的语种,但前提是知识库内容本身有对应语言版本。若只上传中文知识,机器人对英文问题通常无法稳定给出英文业务答案,建议按语言拆分知识库。", SimilarQuestions: []string{"AI 支持英文吗", "机器人能多语言吗", "英文客户怎么接待"}, Remark: "AI机器人"},
|
||||
{Question: "机器人能读取客户订单信息后再回答吗?", Answer: "可以,但需要先对接业务系统接口,并在机器人工具能力中开放“查询订单”“查询物流”等受控动作。建议只返回回答所需的最小字段,并记录调用日志,避免暴露不必要的隐私信息。", SimilarQuestions: []string{"AI 能查订单吗", "机器人结合业务数据回答", "怎么让机器人调用接口"}, Remark: "AI机器人"},
|
||||
{Question: "知识库和 FAQ 知识库有什么区别?", Answer: "文档知识库更适合长文档、帮助中心、制度说明等内容,系统会做分段切片后检索;FAQ 知识库适合标准问答,一问一答更利于高频咨询命中。若内容天然就是固定问法和固定口径,优先用 FAQ。", SimilarQuestions: []string{"FAQ 知识库和文档库区别", "什么场景用 FAQ", "知识库类型怎么选"}, Remark: "知识库"},
|
||||
{Question: "上传帮助文档后多久可以被机器人检索到?", Answer: "通常上传成功后会进入索引队列,文本量不大时 1 到 3 分钟内即可完成。若文档很长、图片较多或当前索引任务拥堵,时间会更久。你可以在知识库列表里查看索引状态。", SimilarQuestions: []string{"文档多久生效", "知识库上传后多久可用", "索引需要多久"}, Remark: "知识库"},
|
||||
{Question: "FAQ 支持批量导入吗?", Answer: "支持。你可以下载模板后按“问题、答案、相似问、备注”四列填写,再通过 FAQ 页面批量导入。相似问建议用换行或分号分隔,导入前最好先去重,避免同一问题出现多个口径。", SimilarQuestions: []string{"FAQ 能批量上传吗", "FAQ 导入模板在哪", "问答怎么批量导入"}, Remark: "知识库"},
|
||||
{Question: "为什么上传的文档显示索引失败?", Answer: "常见原因包括文档为空、正文被复制成图片、内容长度超限、向量服务不可用或模型配置异常。可以先查看索引错误提示,再尝试把文档转换成纯文本或 Markdown 重新上传。", SimilarQuestions: []string{"文档索引失败怎么办", "知识库处理失败原因", "上传后报索引错误"}, Remark: "知识库"},
|
||||
{Question: "如何让一个机器人只使用指定知识库?", Answer: "在机器人配置页里把“可用知识库”限制为目标知识库即可。若同时勾选多个知识库,系统会按统一策略检索。为避免跨业务串答,建议按产品线、渠道或场景拆分知识库并独立绑定。", SimilarQuestions: []string{"机器人绑定知识库", "只用一个知识库回答", "知识库范围怎么限制"}, Remark: "知识库"},
|
||||
{Question: "知识库内容更新后需要手动重建索引吗?", Answer: "平台默认会在你更新文档或 FAQ 后自动重新索引,不需要手动全量重建。只有在向量配置、分块策略或模型发生较大变化时,才建议执行一次全量重建以保证检索效果一致。", SimilarQuestions: []string{"更新文档后要重建吗", "知识库修改会自动生效吗", "FAQ 改完要重新索引吗"}, Remark: "知识库"},
|
||||
{Question: "相似问应该怎么写效果更好?", Answer: "建议保留用户真实提问方式,包括口语、省略句和错别字变体,但不要为了凑数量写过多语义无关的问法。每条 FAQ 一般补 3 到 8 个高频相似问即可,重点覆盖客户常见表达。", SimilarQuestions: []string{"相似问怎么整理", "FAQ 别名怎么写", "类似问题怎么配"}, Remark: "知识库"},
|
||||
{Question: "知识库支持权限隔离吗?", Answer: "支持。你可以按团队、角色或机器人配置知识库可见范围。比如售后团队只能看到售后知识库,财务 FAQ 只对财务机器人和财务坐席开放,这样可以减少误检索和越权查看。", SimilarQuestions: []string{"知识库能分权限吗", "不同团队看不同知识", "FAQ 可以隔离吗"}, Remark: "知识库"},
|
||||
{Question: "网站咨询按钮怎么嵌入到官网?", Answer: "进入“渠道接入-Web Widget”,复制系统生成的脚本代码,粘贴到官网页面的 `</body>` 前即可。若你们站点启用了 CSP,需要把平台域名加入允许列表,否则组件可能加载失败。", SimilarQuestions: []string{"官网怎么挂咨询入口", "Web Widget 怎么接", "网站客服按钮嵌入"}, Remark: "渠道接入"},
|
||||
{Question: "Web Widget 的颜色和文案可以自定义吗?", Answer: "可以。你可以在渠道配置里修改主色、标题、欢迎语、按钮文案、是否展示头像和工作时间提示。保存后前端会在几分钟内刷新缓存,若你希望立即生效,可手动清理页面缓存。", SimilarQuestions: []string{"咨询浮窗能改样式吗", "按钮文案怎么改", "Widget 主题色设置"}, Remark: "渠道接入"},
|
||||
{Question: "支持把客服入口嵌到微信 H5 页面吗?", Answer: "支持,但需要使用适配移动端的 H5 咨询页或自定义嵌入页。若在微信内打开,建议同时开启微信浏览器兼容模式,并测试键盘弹起、页面滚动和文件上传权限是否正常。", SimilarQuestions: []string{"H5 页面能接客服吗", "微信里能打开咨询页吗", "移动端客服入口"}, Remark: "渠道接入"},
|
||||
{Question: "客户消息提醒可以推送到企业微信吗?", Answer: "支持把新会话、超时未回复、工单升级等提醒推送到企业微信机器人或应用消息。建议只推送关键事件,避免通知过载影响值班人员判断。", SimilarQuestions: []string{"消息提醒发企业微信", "能推送到企微吗", "新会话通知怎么接"}, Remark: "渠道接入"},
|
||||
{Question: "访客进入咨询前能先收集手机号吗?", Answer: "可以。你可以在欢迎页开启预采集表单,要求客户填写手机号、订单号、邮箱等信息后再进入会话。这样有助于后续识别身份和分配对应业务团队。", SimilarQuestions: []string{"咨询前收集手机号", "先填表再聊天", "访客信息预采集"}, Remark: "渠道接入"},
|
||||
{Question: "支持接入 WhatsApp 或 Telegram 吗?", Answer: "平台可以通过开放接口或第三方集成中间层接入海外渠道,但具体能力取决于你们当前套餐和所选服务商。若是正式商用,建议先确认消息模板、号码资质和当地合规要求。", SimilarQuestions: []string{"能接 WhatsApp 吗", "支持 Telegram 吗", "海外渠道接入"}, Remark: "渠道接入"},
|
||||
{Question: "为什么网站上看不到客服浮窗?", Answer: "先检查脚本是否成功加载、站点域名是否在渠道白名单内,以及浏览器是否拦截了第三方脚本。若开启了广告拦截插件或严格 CSP,也可能导致组件被屏蔽。", SimilarQuestions: []string{"网页不显示咨询按钮", "Widget 没出来", "客服浮窗不见了"}, Remark: "渠道接入"},
|
||||
{Question: "不同站点可以共用一个客服渠道吗?", Answer: "可以共用,但更建议按站点或品牌拆分渠道,这样可以分别配置欢迎语、机器人、工作时间和报表来源。若多个站点业务差异较大,共用一个渠道会影响会话分流和数据分析。", SimilarQuestions: []string{"多个官网能共用渠道吗", "不同域名用一个 Widget", "站点渠道怎么规划"}, Remark: "渠道接入"},
|
||||
{Question: "工单和实时会话有什么关系?", Answer: "实时会话适合即时咨询,工单适合需要跨班次跟进、跨部门协作或需要留痕审批的问题。会话中如果发现问题无法当场解决,可以一键转为工单,并保留原始聊天记录作为上下文。", SimilarQuestions: []string{"为什么还需要工单", "会话和工单区别", "聊天怎么转工单"}, Remark: "工单"},
|
||||
{Question: "如何把会话升级成工单?", Answer: "在会话详情页点击“创建工单”,系统会自动带出客户信息、会话摘要和最近消息。你只需补充工单类型、优先级、负责人和期望完成时间即可。", SimilarQuestions: []string{"聊天转工单在哪里", "会话升级工单", "怎么建售后单"}, Remark: "工单"},
|
||||
{Question: "工单支持 SLA 超时提醒吗?", Answer: "支持。你可以为不同工单类型配置首次响应时限、处理时限和升级规则,临近超时时会给负责人和主管发送提醒,超时后也可自动升级到上级处理。", SimilarQuestions: []string{"工单超时提醒", "SLA 怎么配置", "工单逾期通知"}, Remark: "工单"},
|
||||
{Question: "工单能分配给外部协作人吗?", Answer: "目前标准成员体系主要面向内部账号。如果需要外部协作,可为供应商或合作方单独开受限角色账号,并限制其仅查看被指派工单,避免访问其他客户数据。", SimilarQuestions: []string{"工单给外包处理", "外部人员能看工单吗", "供应商协作权限"}, Remark: "工单"},
|
||||
{Question: "工单状态有哪些推荐用法?", Answer: "常见做法是设置为“待受理、处理中、待客户反馈、已解决、已关闭”。其中“待客户反馈”适合需要客户补充材料的场景,“已解决”表示业务已处理完成但仍保留回访窗口。", SimilarQuestions: []string{"工单状态怎么设计", "售后单流程建议", "工单字段如何规划"}, Remark: "工单"},
|
||||
{Question: "能否查看工单处理的完整操作记录?", Answer: "可以。每张工单都保留状态变更、指派变更、备注、附件上传和评论记录,方便审计和复盘。管理员还可以导出操作日志做质检或合规留存。", SimilarQuestions: []string{"工单处理日志", "谁改过工单怎么查", "工单历史记录"}, Remark: "工单"},
|
||||
{Question: "工单附件支持哪些格式?", Answer: "常见图片、PDF、Excel、Word 和压缩包都支持,单文件大小上限由你们当前存储配置决定。若附件包含客户证件或敏感资料,建议同步开启下载权限控制和水印。", SimilarQuestions: []string{"工单能上传什么文件", "附件格式限制", "售后凭证支持哪些类型"}, Remark: "工单"},
|
||||
{Question: "重复提交的工单可以自动合并吗?", Answer: "可以通过规则按手机号、订单号、邮箱或自定义字段检测重复,并提示坐席合并处理。是否自动合并建议谨慎开启,避免把不同问题错误归并到同一张工单。", SimilarQuestions: []string{"重复工单怎么处理", "能自动识别重复吗", "相同订单重复建单"}, Remark: "工单"},
|
||||
{Question: "客户信息可以从 CRM 自动同步过来吗?", Answer: "支持通过开放 API、Webhook 或中间件同步客户主数据,例如姓名、手机号、会员等级、所属销售和最近订单。同步后这些字段可以直接在会话侧边栏展示,减少客服来回切系统查询。", SimilarQuestions: []string{"CRM 能同步到客服吗", "客户资料自动带入", "怎么对接用户信息"}, Remark: "集成"},
|
||||
{Question: "平台提供开放 API 吗?", Answer: "提供。你可以通过 API 创建会话、发送消息、查询客户、同步工单和拉取报表。正式对接前建议先在测试环境验证签名、限流和错误码处理,再切换到生产。", SimilarQuestions: []string{"有没有开放接口", "客服系统 API 文档", "能程序化调用吗"}, Remark: "集成"},
|
||||
{Question: "Webhook 可以推送哪些事件?", Answer: "常见事件包括新会话创建、会话关闭、客户留言、工单创建、工单状态变更、机器人转人工和客户满意度回收等。你可以按需订阅,避免把所有事件都推到业务系统。", SimilarQuestions: []string{"Webhook 支持什么事件", "事件推送列表", "回调通知有哪些"}, Remark: "集成"},
|
||||
{Question: "API 调用频率有限制吗?", Answer: "有。默认按应用和接口维度做限流,避免高峰期影响平台稳定。若你们需要批量同步历史数据,建议走离线导入或提前联系技术支持申请更高配额。", SimilarQuestions: []string{"接口限流是多少", "API 有 QPS 限制吗", "批量同步会不会被限流"}, Remark: "集成"},
|
||||
{Question: "如何验证开放 API 的签名是否正确?", Answer: "请先确认时间戳、随机串、请求体摘要和签名算法与文档一致。排查时建议先用平台提供的示例请求对比,再检查服务端是否在参与签名的原始字符串里改动了空格、换行或字段顺序。", SimilarQuestions: []string{"API 签名不通过", "签名校验失败怎么办", "接口鉴权报错"}, Remark: "集成"},
|
||||
{Question: "可以把会话记录同步到内部 BI 系统吗?", Answer: "可以。你可以通过报表导出、API 增量拉取或消息回调三种方式同步。若是 BI 场景,建议每天离线拉取聚合数据,避免用高频实时接口增加系统压力。", SimilarQuestions: []string{"会话数据怎么同步 BI", "报表能对接数仓吗", "聊天记录导入分析系统"}, Remark: "集成"},
|
||||
{Question: "支持和订单系统做查单联动吗?", Answer: "支持。接入后坐席在会话侧边栏即可查看订单状态、支付情况、发货时间和物流单号。若你们希望机器人直接回答订单问题,还需要额外配置工具调用权限。", SimilarQuestions: []string{"能和订单系统打通吗", "聊天页直接查订单", "查单联动怎么做"}, Remark: "集成"},
|
||||
{Question: "为什么 API 请求返回 401 或 403?", Answer: "401 一般表示签名错误、时间戳过期或访问令牌无效;403 多数是应用没有该接口权限或 IP 白名单未放通。建议先检查应用状态、签名配置和请求来源 IP。", SimilarQuestions: []string{"接口返回 401 怎么办", "API 403 原因", "鉴权失败怎么排查"}, Remark: "集成"},
|
||||
{Question: "聊天记录会保存多久?", Answer: "默认保存期限取决于你们的套餐和合规配置,常见为 180 天到长期保存可选。若业务涉及金融、教育或跨境场景,建议结合行业监管要求单独设置归档周期。", SimilarQuestions: []string{"会话记录保留多久", "聊天消息存多久", "历史记录保存周期"}, Remark: "数据安全"},
|
||||
{Question: "导出聊天记录时会包含客户手机号吗?", Answer: "默认按权限控制。普通坐席导出的明细通常会对手机号、邮箱等敏感字段做脱敏,只有拥有高级数据权限的管理员才能导出完整信息。", SimilarQuestions: []string{"导出会脱敏吗", "手机号会不会直接导出", "聊天记录敏感信息处理"}, Remark: "数据安全"},
|
||||
{Question: "平台支持数据脱敏吗?", Answer: "支持。你可以对手机号、身份证号、银行卡号、邮箱和地址启用显示脱敏、日志脱敏以及导出脱敏。对于高敏字段,建议同时配置按角色可见范围。", SimilarQuestions: []string{"客户信息能脱敏吗", "隐私字段隐藏", "敏感数据保护"}, Remark: "数据安全"},
|
||||
{Question: "是否支持按角色限制查看聊天记录?", Answer: "支持。你可以限制普通坐席只能查看自己接待过的会话,主管查看本团队,管理员查看全局。对于投诉、法务等敏感会话,也可以单独设置更严格的访问范围。", SimilarQuestions: []string{"聊天记录权限隔离", "谁能看全部会话", "会话查看范围"}, Remark: "数据安全"},
|
||||
{Question: "客户要求删除个人数据时怎么处理?", Answer: "管理员可在客户资料页发起“数据删除”或“匿名化处理”。系统会按配置清空或打码可识别字段,同时保留必要的审计记录,以满足合规要求和内部追溯。", SimilarQuestions: []string{"用户要求删数据", "隐私删除怎么做", "客户信息匿名化"}, Remark: "数据安全"},
|
||||
{Question: "系统有操作日志吗?", Answer: "有。成员登录、权限变更、知识库编辑、工单操作、导出报表等关键动作都会进入审计日志。管理员可以按时间、成员、对象类型筛选并导出。", SimilarQuestions: []string{"后台操作有记录吗", "谁改了配置怎么查", "审计日志在哪里"}, Remark: "数据安全"},
|
||||
{Question: "支持设置 IP 白名单吗?", Answer: "支持。你可以在安全设置里为后台登录和开放 API 分别配置 IP 白名单。若你们办公网络经常变动,建议至少给高权限账号启用 MFA,避免完全依赖固定 IP。", SimilarQuestions: []string{"后台能限制 IP 吗", "接口白名单怎么配", "登录来源限制"}, Remark: "数据安全"},
|
||||
{Question: "聊天内容会不会被平台拿去训练公共模型?", Answer: "默认不会。客户数据仅用于你们自身的业务处理和已授权的产品功能,不会擅自用于公共模型训练。若你们开通了定制优化服务,也会以合同和配置项约定的数据范围为准。", SimilarQuestions: []string{"聊天数据会训练模型吗", "数据会不会外泄", "平台会拿客户数据训练吗"}, Remark: "数据安全"},
|
||||
{Question: "如何给客户打标签?", Answer: "可以在客户详情页手动添加标签,也可以通过规则根据来源渠道、访问页面、下单次数、会员等级或对话关键词自动打标签。标签通常用于分流、营销和服务分层。", SimilarQuestions: []string{"客户标签怎么加", "支持自动标签吗", "用户标签规则"}, Remark: "客户管理"},
|
||||
{Question: "客户历史会话在哪里看?", Answer: "打开客户资料页即可看到该客户的历史会话、工单、满意度评价和最近访问记录。若同一个客户用多个渠道接入,建议先配置身份合并规则,避免历史被拆散。", SimilarQuestions: []string{"怎么查客户历史咨询", "用户轨迹在哪里", "以前的聊天记录怎么看"}, Remark: "客户管理"},
|
||||
{Question: "一个客户在多个渠道咨询,会被识别成同一个人吗?", Answer: "可以,但需要提前配置统一身份标识,例如手机号、会员 ID、邮箱或外部用户 ID。若不同渠道没有共同标识,系统会默认视为不同访客。", SimilarQuestions: []string{"多渠道客户合并", "同一个人跨渠道识别", "用户身份统一"}, Remark: "客户管理"},
|
||||
{Question: "如何筛选高价值客户并优先接待?", Answer: "你可以结合会员等级、近 90 天消费金额、订单频次或 VIP 标签建立高价值客户规则,并在路由策略里设置优先分配到专属团队或高级坐席。", SimilarQuestions: []string{"VIP 客户优先接待", "高价值用户怎么识别", "客户分层服务"}, Remark: "客户管理"},
|
||||
{Question: "客户昵称乱码或显示异常怎么办?", Answer: "优先确认上游渠道返回的编码是否为 UTF-8,以及是否包含平台不支持的特殊字符。若只是个别历史数据异常,可通过客户资料页手动更正;若批量异常,建议检查同步接口。", SimilarQuestions: []string{"昵称显示乱码", "客户名称异常", "中文昵称不正常"}, Remark: "客户管理"},
|
||||
{Question: "客户满意度评价怎么配置?", Answer: "在“服务评价”里可设置评价时机、问题文案、星级或满意/不满意选项,以及低分自动预警规则。通常建议在会话关闭后立即触发,回收率会更高。", SimilarQuestions: []string{"满意度怎么开", "服务评价配置", "会话结束后评分"}, Remark: "客户管理"},
|
||||
{Question: "可以屏蔽骚扰客户吗?", Answer: "支持。你可以把手机号、邮箱、Cookie 或访客 ID 加入黑名单,命中后系统可直接拒绝接入、静默排队,或只允许其提交留言不进入人工接待。", SimilarQuestions: []string{"骚扰用户怎么封禁", "黑名单在哪里设置", "恶意访客屏蔽"}, Remark: "客户管理"},
|
||||
{Question: "客户资料支持自定义字段吗?", Answer: "支持。常见如订单等级、合同编号、所属城市、设备型号等都可以配置为自定义字段,并在导入、API 同步和页面展示中统一使用。", SimilarQuestions: []string{"客户字段能自定义吗", "自定义用户属性", "资料页增加业务字段"}, Remark: "客户管理"},
|
||||
{Question: "如何设置自动欢迎语?", Answer: "在渠道或机器人配置中启用欢迎语后,可按工作时间、渠道、客户标签或访问页面展示不同文案。建议文案简洁,并明确告知当前是机器人还是人工服务。", SimilarQuestions: []string{"自动回复欢迎语", "开场白在哪里配", "客户进入先发一句"}, Remark: "自动化"},
|
||||
{Question: "支持非工作时间自动回复吗?", Answer: "支持。你可以为夜间、节假日或团队休息时段配置离线回复,告知客户预计处理时间、紧急联系电话或留言入口。必要时也可在离线时继续让机器人先接待。", SimilarQuestions: []string{"下班后自动回复", "非工作时间留言", "夜间自动应答"}, Remark: "自动化"},
|
||||
{Question: "如何设置关键词自动转人工?", Answer: "在机器人策略里新增转人工规则,输入关键词或短语即可,例如“投诉”“退款”“人工客服”“发票重开”等。建议同时加入同义词和常见口语表达,减少漏判。", SimilarQuestions: []string{"关键词触发人工", "哪些词会转接客服", "自动转人工规则"}, Remark: "自动化"},
|
||||
{Question: "会话超时未回复能自动提醒坐席吗?", Answer: "可以。你可以按首响超时、处理中超时和即将 SLA 超时三个阶段配置提醒,支持站内提醒、邮件和企业微信通知。", SimilarQuestions: []string{"超时提醒怎么配", "客服久未回复提醒", "消息超时通知"}, Remark: "自动化"},
|
||||
{Question: "能按客户标签分配不同的机器人吗?", Answer: "支持。你可以在路由规则里按客户标签、渠道来源或页面入口命中不同机器人,例如新客走导购机器人,老客走售后机器人。", SimilarQuestions: []string{"不同用户进不同 AI", "按标签分机器人", "机器人路由规则"}, Remark: "自动化"},
|
||||
{Question: "能自动给会话生成摘要吗?", Answer: "支持。在开启 AI 摘要后,系统会在会话结束时生成问题摘要、处理结果和待跟进事项,便于转工单、交班和质检。", SimilarQuestions: []string{"聊天自动总结", "会话摘要功能", "交班摘要怎么生成"}, Remark: "自动化"},
|
||||
{Question: "自动化规则执行顺序是怎样的?", Answer: "通常按“接入识别 -> 路由分配 -> 机器人应答 -> 转人工/升级 -> 会后自动化”的顺序执行。若多条规则都命中,系统会按优先级和创建顺序决定实际结果。", SimilarQuestions: []string{"规则先后顺序", "自动化命中顺序", "多个规则冲突怎么办"}, Remark: "自动化"},
|
||||
{Question: "支持根据访问页面触发不同欢迎语吗?", Answer: "支持。你可以在 Web 渠道里按 URL 路径或页面分组配置欢迎语,比如商品页引导咨询库存,支付页引导咨询优惠和支付问题。", SimilarQuestions: []string{"不同页面不同文案", "页面维度欢迎语", "按 URL 展示话术"}, Remark: "自动化"},
|
||||
{Question: "如何查看会话量趋势和高峰时段?", Answer: "在“数据报表-流量分析”里可按小时、日期和渠道查看会话量、访客量、排队峰值和人工接待率。高峰时段建议结合排班数据一起分析。", SimilarQuestions: []string{"会话高峰怎么看", "流量趋势报表", "哪个时间段最忙"}, Remark: "数据报表"},
|
||||
{Question: "报表里的首响应时长为什么和人工感觉不一致?", Answer: "因为报表一般按系统时间自动计算,且会区分机器人首响、人工首响和工作时间内首响。建议先确认你看的指标口径,再核对是否排除了离线时段和转接等待时间。", SimilarQuestions: []string{"首响数据不对", "报表口径怎么看", "为什么和实际感受不同"}, Remark: "数据报表"},
|
||||
{Question: "机器人命中率和解决率分别是什么意思?", Answer: "命中率指机器人对进入其服务范围的消息中,成功检索并给出答案的比例;解决率指客户在机器人处理后未再转人工且会话完成的比例。命中率高不代表解决率一定高,需要结合看。", SimilarQuestions: []string{"命中率是什么意思", "AI 解决率怎么理解", "机器人指标解释"}, Remark: "数据报表"},
|
||||
{Question: "支持按团队导出日报吗?", Answer: "支持。大部分报表都支持按团队、坐席和渠道维度筛选后导出,也可通过定时报表每天自动发送到指定邮箱。", SimilarQuestions: []string{"团队日报导出", "报表自动发邮件", "每天定时发送数据"}, Remark: "数据报表"},
|
||||
{Question: "如何看哪些 FAQ 最常被命中?", Answer: "进入知识检索日志或 FAQ 报表,可查看各 FAQ 的命中次数、平均分数和最近命中时间。命中高但满意度低的条目通常最值得优先优化。", SimilarQuestions: []string{"FAQ 命中排行", "哪些知识用得最多", "热门 FAQ 在哪看"}, Remark: "数据报表"},
|
||||
{Question: "坐席质检结果可以纳入绩效吗?", Answer: "可以。平台支持按会话抽检结果、服务评价、超时率和处理时长生成坐席评分,但是否直接纳入绩效建议由你们内部制度决定。", SimilarQuestions: []string{"质检分能做绩效吗", "客服评分怎么计算", "质检报表用途"}, Remark: "数据报表"},
|
||||
{Question: "导出的报表时间是按哪个时区算的?", Answer: "默认按当前企业设置的时区计算,并以服务器完成汇总的时间窗口为准。若你们团队跨国协作,建议统一企业时区并在报表标题中注明。", SimilarQuestions: []string{"报表时区怎么定", "导出时间不一致", "跨时区数据口径"}, Remark: "数据报表"},
|
||||
{Question: "历史报表可以保留多久?", Answer: "报表明细和汇总保留周期取决于套餐和数据保留策略,核心运营报表一般支持长期查看,超大体量的明细日志可能会按月归档。", SimilarQuestions: []string{"报表数据保存多久", "能看多久之前的数据", "历史统计保留"}, Remark: "数据报表"},
|
||||
{Question: "如何升级套餐或增加坐席数?", Answer: "联系客户成功经理或在“账单中心-套餐管理”发起升级申请即可。新增坐席一般按剩余计费周期折算费用,升级后权限和资源配额会即时扩容。", SimilarQuestions: []string{"怎么加购买席位", "套餐升级在哪里", "新增坐席费用"}, Remark: "计费发票"},
|
||||
{Question: "试用版和正式版有什么主要区别?", Answer: "试用版通常限制坐席数、历史数据保留、开放 API、品牌自定义和高级 AI 能力。正式版支持更完整的权限体系、报表、集成和 SLA 服务。", SimilarQuestions: []string{"试用版限制有哪些", "正式版多了什么", "免费版和付费版区别"}, Remark: "计费发票"},
|
||||
{Question: "账单是按自然月还是合同周期结算?", Answer: "标准订阅一般按合同周期结算,可选月付或年付;增购席位和资源通常会按当前合同剩余天数做比例折算。具体以合同和商务报价单为准。", SimilarQuestions: []string{"怎么结算费用", "按月还是按年收费", "增购怎么计费"}, Remark: "计费发票"},
|
||||
{Question: "可以开增值税专用发票吗?", Answer: "可以。请在“账单中心-发票信息”中维护完整的开票资料,包括公司名称、税号、开户地址、电话和开户行信息。审核通过后,发票会按账单周期开具。", SimilarQuestions: []string{"能开发票吗", "专票怎么申请", "开票资料在哪里填"}, Remark: "计费发票"},
|
||||
{Question: "发票抬头填错了可以重开吗?", Answer: "如发票尚未寄出或尚未作废,可联系商务或财务协助修改;若纸质票已开具且不符合重开条件,需要按财税流程先退回原票再重开。电子发票是否可重开以当前税控规则为准。", SimilarQuestions: []string{"发票信息错了怎么办", "开错票能重开吗", "发票抬头修改"}, Remark: "计费发票"},
|
||||
{Question: "欠费后会立刻停服务吗?", Answer: "通常不会立即停服,平台会先进入宽限期并多次提醒管理员续费。宽限期结束后,部分高级能力可能先受限,继续欠费才会影响登录或接待功能。", SimilarQuestions: []string{"欠费多久停用", "没续费会怎样", "账单逾期影响"}, Remark: "计费发票"},
|
||||
{Question: "AI 调用量超额后怎么收费?", Answer: "若套餐包含固定 AI 配额,超出部分一般按实际调用量或令牌量计费。建议在账单中心开启用量预警,避免月底集中超额。", SimilarQuestions: []string{"AI 超额怎么计费", "模型调用量收费", "超出配额怎么办"}, Remark: "计费发票"},
|
||||
{Question: "如何查看本月已经使用了多少 AI 配额?", Answer: "在“账单中心-用量明细”里可以看到按天汇总的模型调用次数、Token 用量和按机器人/知识库拆分的消耗情况。", SimilarQuestions: []string{"AI 配额怎么看", "本月用了多少 token", "模型用量报表"}, Remark: "计费发票"},
|
||||
{Question: "聊天消息发出去但客户收不到怎么办?", Answer: "先确认消息是否发送成功、客户渠道是否在线,以及第三方通道是否有回执失败。若只在某个渠道异常,通常是渠道侧限流、模板审核或网络问题。", SimilarQuestions: []string{"消息发不出去", "客户收不到回复", "发送成功但对方没收到"}, Remark: "故障排查"},
|
||||
{Question: "上传文件时报“格式不支持”怎么处理?", Answer: "先核对文件扩展名和实际内容是否一致,例如部分截图工具导出的文件后缀和真实格式不匹配。若格式本身受支持,建议检查文件是否损坏或超出大小限制。", SimilarQuestions: []string{"附件上传失败", "文件格式不支持", "为什么不能传图片"}, Remark: "故障排查"},
|
||||
{Question: "页面很卡或者消息列表加载很慢是什么原因?", Answer: "常见原因包括浏览器缓存过多、同时打开过多会话标签、网络延迟较高或页面中存在超大附件。建议先刷新页面、关闭不必要标签页,并确认本地网络和浏览器版本。", SimilarQuestions: []string{"后台卡顿怎么办", "消息加载慢", "聊天页很慢"}, Remark: "故障排查"},
|
||||
{Question: "为什么刚配置好的机器人没有生效?", Answer: "通常需要确认机器人是否绑定到了正确渠道、服务时间是否覆盖当前时段,以及该渠道是否仍在走人工优先。若配置无误仍未生效,可查看调试日志确认是否命中了路由条件。", SimilarQuestions: []string{"机器人不工作", "AI 没有接管会话", "配置完没效果"}, Remark: "故障排查"},
|
||||
{Question: "知识库明明有答案,机器人还是说不知道,怎么办?", Answer: "先看该问题是否命中了正确知识库,其次检查 FAQ 问法和用户真实提问是否差距过大,再检查阈值是否过高导致被过滤。必要时补充相似问,或降低最低相似度阈值后重新验证。", SimilarQuestions: []string{"知识命不中怎么办", "FAQ 有内容却答不出", "AI 说查不到答案"}, Remark: "故障排查"},
|
||||
{Question: "浏览器提示证书风险,平台打不开怎么办?", Answer: "请先确认访问的域名是否正确、企业网络是否做了 HTTPS 解密,以及本机时间是否准确。若是自定义域名场景,还要检查证书是否已过期或未正确绑定。", SimilarQuestions: []string{"证书错误怎么处理", "网站打不开提示不安全", "HTTPS 有问题"}, Remark: "故障排查"},
|
||||
{Question: "为什么导出的 CSV 打开后中文乱码?", Answer: "通常是因为本地表格软件默认不是 UTF-8 编码。建议优先用支持 UTF-8 的方式导入,或在导出后用支持编码选择的工具打开。", SimilarQuestions: []string{"CSV 中文乱码", "导出文件打不开中文", "报表编码问题"}, Remark: "故障排查"},
|
||||
{Question: "系统升级会影响线上接待吗?", Answer: "常规小版本升级一般采用滚动发布,不会中断在线接待;涉及数据库结构或底层组件的大版本升级,平台会提前通知维护窗口,并建议你们避开业务高峰。", SimilarQuestions: []string{"升级会停机吗", "发版影响线上吗", "系统维护通知"}, Remark: "故障排查"},
|
||||
{Question: "支持自定义品牌 Logo 和登录页吗?", Answer: "支持自定义企业 Logo、登录页欢迎语和部分品牌色,高级套餐还支持自定义域名和白标展示。具体可配置项取决于你们的套餐级别。", SimilarQuestions: []string{"能换成自己品牌吗", "登录页能自定义吗", "支持白标吗"}, Remark: "品牌配置"},
|
||||
{Question: "可以按品牌线配置不同客服团队吗?", Answer: "可以。很多企业会按品牌、区域或产品线拆分团队和渠道,再为每个团队配置独立机器人、工作时间和报表视图。", SimilarQuestions: []string{"多品牌怎么管理", "不同业务线分团队", "品牌独立配置"}, Remark: "品牌配置"},
|
||||
{Question: "自定义域名需要准备什么?", Answer: "通常需要你们提供已备案域名、DNS 管理权限以及用于回源或 CNAME 的配置能力。若开启 HTTPS,还需要按指引完成证书校验或授权托管证书。", SimilarQuestions: []string{"绑定自定义域名", "专属域名怎么配", "客服系统独立域名"}, Remark: "品牌配置"},
|
||||
{Question: "Logo 替换后前端多久刷新?", Answer: "通常几分钟内会生效,具体取决于 CDN 缓存时间。若你在后台已经看到新 Logo,但前台仍未更新,建议清空浏览器缓存或稍后再试。", SimilarQuestions: []string{"换 Logo 后没生效", "品牌图标多久更新", "前端缓存多久"}, Remark: "品牌配置"},
|
||||
{Question: "支持多组织或多租户统一管理吗?", Answer: "支持企业下管理多个组织,但权限和数据隔离方式需按实际业务设计。若是完全独立运营的品牌或国家站点,通常建议拆成独立组织。", SimilarQuestions: []string{"多租户支持吗", "多个子公司统一管", "多组织架构"}, Remark: "品牌配置"},
|
||||
{Question: "如何申请产品培训或上线辅导?", Answer: "你可以联系客户成功经理预约标准培训、管理员培训或机器人调优辅导。首次上线建议安排一次管理员培训和一次一线坐席培训,能明显减少上线初期问题。", SimilarQuestions: []string{"有没有培训服务", "上线辅导怎么预约", "员工使用培训"}, Remark: "客户成功"},
|
||||
{Question: "遇到紧急故障,最快如何联系支持团队?", Answer: "若购买了企业服务,可通过专属工单通道、服务群或紧急支持电话联系。提交时请尽量附上问题时间、组织 ID、影响范围、截图和复现步骤,便于快速定位。", SimilarQuestions: []string{"紧急问题联系谁", "系统故障怎么报", "售后支持入口"}, Remark: "客户成功"},
|
||||
{Question: "产品更新公告在哪里看?", Answer: "你可以在后台首页公告栏、帮助中心更新日志或服务群中查看版本发布说明。涉及影响配置或操作习惯的变更,平台一般会提前通知。", SimilarQuestions: []string{"版本更新在哪里看", "发布说明入口", "新功能公告"}, Remark: "客户成功"},
|
||||
{Question: "能提供上线前的最佳实践建议吗?", Answer: "可以。标准建议包括先梳理高频问题 FAQ、配置清晰的转人工策略、按业务拆分知识库、先从一个渠道灰度上线,再逐步扩展到全部渠道。", SimilarQuestions: []string{"上线前准备什么", "机器人落地建议", "客服系统实施建议"}, Remark: "客户成功"},
|
||||
{Question: "平台支持数据迁移服务吗?", Answer: "支持按项目评估。常见迁移内容包括历史客户资料、会话记录、FAQ、工单和成员账号。由于不同系统字段差异较大,迁移前通常需要做一次字段映射确认。", SimilarQuestions: []string{"从旧系统迁移数据", "历史消息能导入吗", "数据迁移服务"}, Remark: "客户成功"},
|
||||
{Question: "如何判断当前 FAQ 是否需要优化?", Answer: "可以优先看三类信号:命中高但转人工率高、命中高但满意度低、以及客户经常追问同一问题。出现这些情况时,通常说明答案不够完整、口径不一致,或相似问覆盖不够。", SimilarQuestions: []string{"FAQ 优化依据", "哪些问答该先改", "知识库效果怎么评估"}, Remark: "知识运营"},
|
||||
{Question: "FAQ 的答案建议写多长?", Answer: "建议先给出结论,再补充步骤和注意事项。大多数客服 FAQ 控制在 80 到 220 字效果较好,太短容易信息不全,太长又不利于机器人稳定引用和客户快速阅读。", SimilarQuestions: []string{"FAQ 答案长度建议", "回答写多长合适", "问答内容怎么控制"}, Remark: "知识运营"},
|
||||
{Question: "一个问题有多个业务口径,FAQ 应该怎么处理?", Answer: "不要把多个冲突口径塞进同一条 FAQ。更合理的做法是按前置条件拆分,比如“个人版如何退款”和“企业版如何退款”分别建条目,并在答案开头明确适用范围。", SimilarQuestions: []string{"FAQ 口径冲突怎么办", "同一问题多个答案", "知识条目怎么拆"}, Remark: "知识运营"},
|
||||
{Question: "FAQ 更新后如何通知坐席口径变化?", Answer: "建议结合版本说明、站内公告和班前会同步。对核心条目可以开启“重点知识变更提醒”,让坐席在登录后先看到更新摘要。", SimilarQuestions: []string{"知识更新怎么通知客服", "FAQ 改动如何同步", "口径变更提醒"}, Remark: "知识运营"},
|
||||
{Question: "谁适合负责 FAQ 日常维护?", Answer: "通常由业务负责人提供口径,知识运营或客服主管负责整理成标准 FAQ,再由一线坐席持续反馈命中和遗漏情况。最好明确唯一责任人,避免长期无人维护。", SimilarQuestions: []string{"FAQ 谁来维护", "知识库负责人怎么定", "问答内容归谁管"}, Remark: "知识运营"},
|
||||
{Question: "知识库应该多久复盘一次?", Answer: "高频业务建议每周看一次命中和转人工数据,每月至少做一次系统复盘。活动期、发版期和政策调整期需要更高频地更新知识。", SimilarQuestions: []string{"FAQ 多久更新一次", "知识运营复盘频率", "什么时候维护知识库"}, Remark: "知识运营"},
|
||||
{Question: "如何区分应该写 FAQ 还是快捷回复?", Answer: "固定且经常重复的问题,优先写 FAQ 供机器人和检索使用;只在人工接待时需要快速发送、但未必适合机器人直接回答的内容,更适合放快捷回复。", SimilarQuestions: []string{"FAQ 和快捷回复区别", "什么内容放快捷回复", "知识条目怎么分类"}, Remark: "知识运营"},
|
||||
{Question: "低频但高风险的问题也要录入 FAQ 吗?", Answer: "建议录入,但要在答案中明确风险提示和升级路径,比如退款争议、隐私投诉、法务取证等。即便命中率不高,这类问题也能显著降低误答风险。", SimilarQuestions: []string{"低频问题有必要建 FAQ 吗", "高风险问答怎么处理", "投诉类知识要不要建"}, Remark: "知识运营"},
|
||||
}
|
||||
}
|
||||
44
cmd/testdata/seeds/quickreply.go
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
package seeds
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type QuickReplySeed struct {
|
||||
ID int64
|
||||
GroupName string
|
||||
Title string
|
||||
Content string
|
||||
Status enums.Status
|
||||
SortNo int
|
||||
}
|
||||
|
||||
func QuickReplySeeds(lang seedlang.Language) []QuickReplySeed {
|
||||
if lang == seedlang.English {
|
||||
return []QuickReplySeed{
|
||||
{ID: 1, GroupName: "New Visitor Reception", Title: "First contact greeting", Content: "Hello, welcome to AgentDesk support. I am the consultant assisting you today. Tell me what product, pricing, or integration option you want to learn about, and I will help you assess it quickly.", Status: enums.StatusOk, SortNo: 100},
|
||||
{ID: 2, GroupName: "Product Inquiry", Title: "Product capability overview", Content: "We currently support AI Q&A, knowledge base retrieval, human handoff, tag management, quick replies, and agent workspace administration. If you already have a business scenario, I can break down a solution for that scenario.", Status: enums.StatusOk, SortNo: 95},
|
||||
{ID: 3, GroupName: "Product Inquiry", Title: "Deployment options", Content: "The system supports both private deployment and cloud deployment. If you have strong data compliance requirements, evaluate private deployment first. If you want to launch quickly, start with the cloud version.", Status: enums.StatusOk, SortNo: 90},
|
||||
{ID: 4, GroupName: "Quotation Follow-up", Title: "Information before quotation", Content: "To prepare an accurate quote, please share the expected number of agent seats, average daily conversation volume, whether a knowledge base is needed, and whether private deployment is required. I will organize the information and follow up quickly.", Status: enums.StatusOk, SortNo: 85},
|
||||
{ID: 5, GroupName: "Quotation Follow-up", Title: "Quotation sent reminder", Content: "Hello, the solution and quotation have been sent to you. Please review them when convenient. If you want me to walk through feature boundaries, implementation timeline, and delivery approach, I can arrange that directly.", Status: enums.StatusOk, SortNo: 80},
|
||||
{ID: 6, GroupName: "Implementation", Title: "Confirm details before troubleshooting", Content: "Got it. I will help troubleshoot first. Please add the time the issue started, affected scope, exact error screenshot, and whether any configuration was changed recently. This will help us locate the cause faster.", Status: enums.StatusOk, SortNo: 75},
|
||||
{ID: 7, GroupName: "Implementation", Title: "Configuration effective time", Content: "The configuration has been updated and usually takes effect within 1 to 3 minutes. Please refresh the page and run another test. If anything is still abnormal, I will continue following up.", Status: enums.StatusOk, SortNo: 70},
|
||||
{ID: 8, GroupName: "After-sales Support", Title: "Issue escalation notice", Content: "I have recorded this issue and escalated it to the technical team. The current priority is marked as high. We expect to provide the first conclusion today, and I will update you as soon as there is progress.", Status: enums.StatusOk, SortNo: 65},
|
||||
{ID: 9, GroupName: "After-sales Support", Title: "Version update notice template", Content: "Hello, a version update is scheduled for Thursday evening. It mainly includes knowledge retrieval optimization and workspace experience improvements. There may be brief fluctuations during the update, and we will prepare rollback plans in advance.", Status: enums.StatusOk, SortNo: 60},
|
||||
{ID: 10, GroupName: "Customer Follow-up", Title: "Trial period follow-up", Content: "Hello, I would like to check your trial experience over the past few days. Which features are used most often? Have you encountered anything hard to understand, complex to configure, or unstable in effect?", Status: enums.StatusOk, SortNo: 55},
|
||||
}
|
||||
}
|
||||
return []QuickReplySeed{
|
||||
{ID: 1, GroupName: "新客接待", Title: "首次接入欢迎语", Content: "您好,欢迎来到 AgentDesk 客服中心,我是今天为您服务的顾问。您可以直接告诉我您想了解的产品、价格或接入方式,我这边先帮您快速判断。", Status: enums.StatusOk, SortNo: 100},
|
||||
{ID: 2, GroupName: "产品咨询", Title: "产品能力概览", Content: "我们当前支持智能问答、知识库检索、会话转人工、标签体系、快捷回复和客服工作台管理。如果您已经有业务场景,我可以按场景给您拆方案。", Status: enums.StatusOk, SortNo: 95},
|
||||
{ID: 3, GroupName: "产品咨询", Title: "部署方式说明", Content: "系统支持私有化部署和云端部署两种方式。若您对数据合规要求较高,建议优先评估私有化;如果希望快速上线,可以先从云端版本开始。", Status: enums.StatusOk, SortNo: 90},
|
||||
{ID: 4, GroupName: "报价跟进", Title: "报价前信息收集", Content: "为了给您更准确的报价,麻烦提供一下预计坐席人数、日均会话量、是否需要知识库和是否有私有化部署需求,我整理后尽快给您反馈。", Status: enums.StatusOk, SortNo: 85},
|
||||
{ID: 5, GroupName: "报价跟进", Title: "报价已发送提醒", Content: "您好,方案和报价单已经发送给您了,您方便的时候可以先看下。若您希望我同步讲解功能边界、实施周期和交付方式,我可以直接给您安排。", Status: enums.StatusOk, SortNo: 80},
|
||||
{ID: 6, GroupName: "实施上线", Title: "排查前确认信息", Content: "收到,我先帮您排查。麻烦补充一下问题出现时间、影响范围、具体报错截图,以及最近是否做过配置调整,这样能更快定位。", Status: enums.StatusOk, SortNo: 75},
|
||||
{ID: 7, GroupName: "实施上线", Title: "配置生效说明", Content: "配置已经更新完成,通常 1 到 3 分钟内会生效。建议您刷新页面后重新发起一次测试,如果还有异常,我继续跟进处理。", Status: enums.StatusOk, SortNo: 70},
|
||||
{ID: 8, GroupName: "售后支持", Title: "问题升级告知", Content: "这个问题我已经记录并升级给技术同学处理,当前优先级已标为高。预计今天内会给您第一轮结论,有进展我会第一时间同步。", Status: enums.StatusOk, SortNo: 65},
|
||||
{ID: 9, GroupName: "售后支持", Title: "版本更新通知模板", Content: "您好,本周四晚间会进行一次版本更新,主要涉及知识库检索优化和工作台体验改进。更新期间可能出现短时波动,我们会提前做好回滚预案。", Status: enums.StatusOk, SortNo: 60},
|
||||
{ID: 10, GroupName: "回访运营", Title: "试用期回访", Content: "您好,想跟您确认一下这几天的试用体验。当前最常用的是哪几个功能?有没有遇到理解成本高、配置复杂或效果不稳定的地方?", Status: enums.StatusOk, SortNo: 55},
|
||||
}
|
||||
}
|
||||
103
cmd/testdata/seeds/skill.go
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
package seeds
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type SkillDefinitionSeed struct {
|
||||
Name string
|
||||
Description string
|
||||
Instruction string
|
||||
Examples string
|
||||
ToolWhitelist string
|
||||
Status enums.Status
|
||||
Remark string
|
||||
}
|
||||
|
||||
func SkillDefinitionSeeds(lang seedlang.Language) []SkillDefinitionSeed {
|
||||
if lang == seedlang.English {
|
||||
return []SkillDefinitionSeed{
|
||||
{
|
||||
Name: "After-sales Escalation",
|
||||
Description: "Handles incidents, complaints, after-sales follow-up, ticket creation, and human handoff requests. Match only when the user clearly needs after-sales intervention or escalation; do not match ordinary greetings, product introductions, or general inquiries.",
|
||||
Instruction: `You are the dedicated "After-sales Escalation" skill responsible for customer support requests that require escalation.
|
||||
|
||||
Scope:
|
||||
1. Handle only these scenarios: incidents, complaints, unresolved issues, explicit ticket creation requests, explicit human handoff requests, or after-sales follow-up.
|
||||
2. If the user is only asking a general question, discussing product usage, greeting, or chatting casually, state that the current request is outside this skill's scope and avoid misclassifying it as an escalation.
|
||||
|
||||
Rules:
|
||||
1. First determine whether the user has clearly requested escalation. If not, ask concise follow-up questions in English, such as order number, product name, issue symptoms, actions already tried, and desired handling method.
|
||||
2. If the user explicitly asks to create or submit a ticket, prioritize the ticket flow and do not switch to human handoff on your own.
|
||||
3. If the user complains, reports an incident, or asks for after-sales follow-up but the information is scattered, first call graph/prepare_ticket_draft to organize a ticket draft, then ask for missing fields.
|
||||
4. Only call graph/create_ticket_with_confirmation when the user explicitly wants to submit a ticket, complaint, or incident report and the title and description are clear enough.
|
||||
5. Only call graph/handoff_to_human when the user explicitly requests a human agent, or when you determine that a human must continue and the request is not suitable for direct ticket creation.
|
||||
6. If the user mentions both "ticket" and "human agent", clarify the priority. If the user clearly says "create a ticket", assist with ticket creation first unless they explicitly ask again for immediate human handoff.
|
||||
7. Never claim in text that a ticket has been created or a human handoff has happened. Those actions must be performed through the corresponding tools.
|
||||
8. If there is not enough information for ticket creation or handoff, ask for clarification before taking an escalation action.
|
||||
|
||||
Response requirements:
|
||||
1. Use English throughout. Keep the tone professional, concise, and like a real support agent.
|
||||
2. Focus on issue diagnosis and escalation handling. Do not output unrelated self-introductions.
|
||||
3. When entering a confirmation flow, clearly tell the user you will help submit or transfer the request and wait for the confirmation result.`,
|
||||
Examples: `[
|
||||
"My device went offline today and restarting did not help. Please create a ticket.",
|
||||
"I confirm that I want to create a ticket, not transfer to a human agent.",
|
||||
"This issue has not been resolved for three days. I want to file a complaint.",
|
||||
"Please transfer me to a human agent. You cannot solve this.",
|
||||
"When will after-sales support contact me? No one has followed up on this failure.",
|
||||
"Help me report an incident. The product model is AX300 and it cannot connect to the network.",
|
||||
"I need after-sales support. This issue keeps happening."
|
||||
]`,
|
||||
ToolWhitelist: `[
|
||||
"graph/create_ticket_with_confirmation",
|
||||
"graph/handoff_to_human"
|
||||
]`,
|
||||
Status: enums.StatusOk,
|
||||
Remark: "after-sales escalation skill",
|
||||
},
|
||||
}
|
||||
}
|
||||
return []SkillDefinitionSeed{
|
||||
{
|
||||
Name: "售后升级处理",
|
||||
Description: "处理报障、投诉、售后跟进、建单、转人工等升级诉求。只在用户明确需要售后介入或问题升级处理时命中,不处理普通问候、产品介绍或泛咨询。",
|
||||
Instruction: `你是“售后升级处理”专项 Skill,负责承接需要升级处理的客服诉求。
|
||||
|
||||
你的职责边界:
|
||||
1. 仅处理以下场景:报障、投诉、问题久未解决、明确要求建单、明确要求转人工、要求售后继续跟进。
|
||||
2. 如果用户只是普通咨询、产品使用提问、寒暄、问候、闲聊,说明当前不属于本 Skill 的职责,避免误判为升级处理。
|
||||
|
||||
你的处理规则:
|
||||
1. 先判断用户是否已经明确表达升级诉求;如果还不明确,先用简洁中文追问关键事实,例如订单号、设备/产品名称、故障现象、已尝试过的操作、期望处理方式。
|
||||
2. 如果用户已经明确要求“创建工单 / 提工单 / 登记报障 / 提交投诉单”,应优先沿着建单流程推进,不要擅自改成转人工。
|
||||
3. 如果用户要投诉、报障或售后跟进,但信息比较散乱,优先调用 graph/prepare_ticket_draft 整理工单草稿,再根据缺失字段继续追问。
|
||||
4. 只有在用户明确希望提交工单、投诉单、报障单,且标题与问题描述已经足够清晰时,才调用 graph/create_ticket_with_confirmation。
|
||||
5. 只有在用户明确要求人工客服,或你已经判断必须人工继续处理且当前诉求不适合直接建单时,才调用 graph/handoff_to_human。
|
||||
6. 如果用户同时提到“建单”和“人工”,先澄清他的优先诉求;若用户已明确说“创建工单”,默认先协助建单,除非他再次明确要求立即转人工。
|
||||
7. 禁止只在文本里声称“已经建单”或“已经转人工”,相关动作必须通过对应工具执行。
|
||||
8. 如果信息不足以建单或转人工,先澄清,不要直接升级动作。
|
||||
|
||||
回复要求:
|
||||
1. 全程使用中文,语气专业、简洁、像真实客服。
|
||||
2. 优先围绕问题定位和升级处理推进,不要输出与当前诉求无关的自我介绍。
|
||||
3. 如果进入确认流程,明确告知用户你将协助提交或转接,并等待确认结果。`,
|
||||
Examples: `[
|
||||
"设备今天开始一直离线,重启也没用,帮我提个工单",
|
||||
"我已经确认要创建工单了,不要转人工",
|
||||
"这个问题三天了还没解决,我要投诉一下",
|
||||
"麻烦转人工,你这边解决不了",
|
||||
"售后什么时候联系我?这个故障还没有人跟进",
|
||||
"帮我登记一下报障,产品型号是AX300,无法联网",
|
||||
"我要申请售后处理,这个问题反复出现",
|
||||
]`,
|
||||
ToolWhitelist: `[
|
||||
"graph/create_ticket_with_confirmation",
|
||||
"graph/handoff_to_human"
|
||||
]`,
|
||||
Status: enums.StatusOk,
|
||||
Remark: "after-sales escalation skill",
|
||||
},
|
||||
}
|
||||
}
|
||||
37
cmd/testdata/seeds/tag.go
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
package seeds
|
||||
|
||||
import "agent-desk/cmd/testdata/seedlang"
|
||||
|
||||
type TagSeed struct {
|
||||
ID int64
|
||||
ParentID int64
|
||||
Name string
|
||||
SortNo int
|
||||
}
|
||||
|
||||
func TagSeeds(lang seedlang.Language) []TagSeed {
|
||||
if lang == seedlang.English {
|
||||
return []TagSeed{
|
||||
{1, 0, "Pre-sales", 1},
|
||||
{2, 1, "AgentDesk", 1},
|
||||
{3, 2, "Product Inquiry", 1},
|
||||
{4, 2, "Purchase Intent", 1},
|
||||
{5, 0, "After-sales", 2},
|
||||
{6, 5, "AgentDesk", 1},
|
||||
{7, 6, "Issue Feedback", 1},
|
||||
{8, 6, "Product Deployment", 2},
|
||||
{9, 6, "Feature Request", 3},
|
||||
}
|
||||
}
|
||||
return []TagSeed{
|
||||
{1, 0, "售前", 1},
|
||||
{2, 1, "AgentDesk", 1},
|
||||
{3, 2, "产品咨询", 1},
|
||||
{4, 2, "购买意向", 1},
|
||||
{5, 0, "售后", 2},
|
||||
{6, 5, "AgentDesk", 1},
|
||||
{7, 6, "问题反馈", 1},
|
||||
{8, 6, "产品部署", 2},
|
||||
{9, 6, "需求工单", 3},
|
||||
}
|
||||
}
|
||||
70
cmd/testdata/skill/init.go
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/repositories"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
type InitResult struct {
|
||||
Created int
|
||||
Updated int
|
||||
}
|
||||
|
||||
func Init(lang seedlang.Language) (*InitResult, error) {
|
||||
result := &InitResult{}
|
||||
seedItems := buildModels(lang)
|
||||
for _, item := range seedItems {
|
||||
itemCopy := item
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
existing := repositories.SkillDefinitionRepository.Take(ctx.Tx, "name = ?", strings.TrimSpace(itemCopy.Name))
|
||||
if existing != nil {
|
||||
if err := ctx.Tx.Model(existing).Updates(&itemCopy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Updated++
|
||||
return nil
|
||||
}
|
||||
if err := ctx.Tx.Create(&itemCopy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Created++
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("upsert skill failed: %w", err)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildModels(lang seedlang.Language) []models.SkillDefinition {
|
||||
now := time.Now()
|
||||
seedItems := seeds.SkillDefinitionSeeds(lang)
|
||||
items := make([]models.SkillDefinition, 0, len(seedItems))
|
||||
for _, seed := range seedItems {
|
||||
items = append(items, models.SkillDefinition{
|
||||
Name: seed.Name,
|
||||
Description: seed.Description,
|
||||
Instruction: seed.Instruction,
|
||||
Examples: seed.Examples,
|
||||
ToolWhitelist: seed.ToolWhitelist,
|
||||
Status: seed.Status,
|
||||
Remark: seed.Remark,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: "System",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: "System",
|
||||
},
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
21
cmd/testdata/skill/init_test.go
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishSkillSeedDoesNotContainChineseText(t *testing.T) {
|
||||
for _, item := range seeds.SkillDefinitionSeeds(seedlang.English) {
|
||||
values := []string{item.Name, item.Description, item.Instruction, item.Examples, item.ToolWhitelist, item.Remark}
|
||||
for _, value := range values {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english skill seed contains Chinese text: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
cmd/testdata/tag/init.go
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
func Init(lang seedlang.Language) error {
|
||||
seed := seeds.TagSeeds(lang)
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
now := time.Now()
|
||||
for _, row := range seed {
|
||||
existing := repositories.TagRepository.Get(ctx.Tx, row.ID)
|
||||
if existing == nil {
|
||||
tag := &models.Tag{
|
||||
ID: row.ID,
|
||||
ParentID: row.ParentID,
|
||||
Name: row.Name,
|
||||
Remark: "",
|
||||
SortNo: row.SortNo,
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: "",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: "",
|
||||
},
|
||||
}
|
||||
if err := repositories.TagRepository.Create(ctx.Tx, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := repositories.TagRepository.Updates(ctx.Tx, row.ID, map[string]any{
|
||||
"parent_id": row.ParentID,
|
||||
"name": row.Name,
|
||||
"remark": "",
|
||||
"sort_no": row.SortNo,
|
||||
"status": enums.StatusOk,
|
||||
"updated_at": now,
|
||||
"update_user_id": 0,
|
||||
"update_user_name": "",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
18
cmd/testdata/tag/init_test.go
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/seeds"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishTagSeedsDoNotContainChineseText(t *testing.T) {
|
||||
for _, item := range seeds.TagSeeds(seedlang.English) {
|
||||
if hanTextPattern.MatchString(item.Name) {
|
||||
t.Fatalf("english tag seed contains Chinese text: %q", item.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
200
config/config.example.yaml
Normal file
@@ -0,0 +1,200 @@
|
||||
language: zh-CN
|
||||
|
||||
server:
|
||||
port: 8083
|
||||
# Custom platform / instance branding (optional)
|
||||
companyName: ""
|
||||
companyLogoUrl: ""
|
||||
# Reverse proxies in front of the application, as CIDR blocks. Gin's own default
|
||||
# trusts every peer, which makes X-Forwarded-For authoritative and lets any
|
||||
# caller choose the client address the server records in the login credential
|
||||
# log and in user.last_login_ip. Left empty this falls back to the loopback,
|
||||
# RFC1918, IPv6 unique-local and link-local ranges, which covers a sidecar
|
||||
# tunnel, a compose network and a local nginx. Set it explicitly when the proxy
|
||||
# sits on a public address, and never set it to 0.0.0.0/0.
|
||||
trustedProxies: []
|
||||
# Set to "cloudflare" (also accepted: "fly.io", "google-app-engine", or a literal
|
||||
# header name) when an edge overwrites rather than appends the real client
|
||||
# address. When set it takes precedence over X-Forwarded-For entirely.
|
||||
trustedPlatform: ""
|
||||
rateLimit:
|
||||
# Bounds how often one client address may call the public, unauthenticated
|
||||
# endpoints: login, support registration, the widget session exchange, message
|
||||
# uploads and documentation feedback. Enabled by default.
|
||||
#
|
||||
# Channel webhooks under /api/third, the websocket routes, /api/webhooks and
|
||||
# the whole authenticated dashboard are deliberately NOT covered. A platform
|
||||
# that receives a 429 from its webhook stops retrying and eventually disables
|
||||
# the delivery, which takes an entire channel offline.
|
||||
#
|
||||
# The counters live in this process. Running several replicas gives each one
|
||||
# its own budget, which weakens the bound by the replica count; these limits
|
||||
# exist to make flooding expensive, not to meter a quota.
|
||||
enabled: true
|
||||
windowSeconds: 60
|
||||
cors:
|
||||
# Browser CORS allowlist. In production, replace this with the actual frontend or embedded-site domains, such as https://support.example.com.
|
||||
# Leave it empty to reject cross-origin browser requests. Same-origin and non-browser calls are still supported.
|
||||
allowedOrigins:
|
||||
- http://127.0.0.1:8083
|
||||
- http://localhost:8083
|
||||
|
||||
db:
|
||||
# Database driver. Supported values: sqlite, mysql, postgres (or postgresql).
|
||||
type: sqlite
|
||||
# Database connection string.
|
||||
# SQLite example: file:./data/app.db?_busy_timeout=5000
|
||||
# MySQL example: user:password@tcp(127.0.0.1:3306)/cs_ai_agent_db?charset=utf8mb4&parseTime=True&multiStatements=true&loc=Local
|
||||
# PostgreSQL (Supabase Session Pooler) example:
|
||||
# dsn: "host=aws-1-ap-southeast-1.pooler.supabase.com user=postgres.gulptwduchsjcsbndmua password=<PASSWORD> dbname=postgres port=5432 sslmode=require search_path=desk"
|
||||
# PostgreSQL URI example:
|
||||
# dsn: "postgresql://postgres.gulptwduchsjcsbndmua:<PASSWORD>@aws-1-ap-southeast-1.pooler.supabase.com:5432/postgres?sslmode=require&search_path=desk"
|
||||
# For MySQL, keep parseTime=True so datetime fields are scanned into Go time values correctly.
|
||||
dsn: file:./data/app.db?_busy_timeout=5000
|
||||
# Maximum number of idle connections kept in the pool. Values <= 0 use the database/sql default.
|
||||
maxIdleConns: 5
|
||||
# Maximum number of open connections. Values <= 0 mean no explicit limit.
|
||||
maxOpenConns: 20
|
||||
# Maximum time an idle connection may stay in the pool, in seconds. Values <= 0 leave it unset.
|
||||
connMaxIdleTimeSeconds: 300
|
||||
# Maximum lifetime of a connection, in seconds. Values <= 0 leave it unset.
|
||||
connMaxLifetimeSeconds: 1800
|
||||
|
||||
logger:
|
||||
level: info
|
||||
format: text
|
||||
addSource: false
|
||||
|
||||
auth:
|
||||
# Enable username and password login. When set to false, users must sign in with SSO (e.g. OIDC).
|
||||
# Default is true.
|
||||
passwordLoginEnabled: true
|
||||
# Login access token lifetime, in hours. Values <= 0 fall back to 12 hours.
|
||||
# Applies to password login and OIDC login sessions.
|
||||
tokenTTLHours: 12
|
||||
# Maximum failed password attempts allowed within the lock window.
|
||||
# Values <= 0 disable credential lockout.
|
||||
maxFailedAttempts: 5
|
||||
# Failed-login lock window, in minutes. When maxFailedAttempts is reached within this window, login is temporarily blocked.
|
||||
# Values <= 0 fall back to 15 minutes when lockout is enabled.
|
||||
credentialLockMinute: 15
|
||||
|
||||
customerSession:
|
||||
# Signing secret for customer service session tokens. Use a separate, high-entropy random string; do not reuse the channel userTokenSecret.
|
||||
secret: ""
|
||||
# Default lifetime for customer service session tokens, in minutes.
|
||||
ttlMinutes: 120
|
||||
# Automatically refresh the token when its remaining lifetime falls below this value, in minutes.
|
||||
refreshThresholdMinutes: 30
|
||||
|
||||
identity:
|
||||
# Deployment-wide shared secret a host platform uses to sign the external
|
||||
# visitor ID it passes to the widget. The host computes, with this same secret:
|
||||
#
|
||||
# signature = hex(HMAC-SHA256(secret, channelId + "\n" + externalId + "\n" + issuedAt))
|
||||
#
|
||||
# where issuedAt is unix seconds, and sends both values as
|
||||
# externalIdSignature / externalIdSignedAt (or the X-External-Id-Signature and
|
||||
# X-External-Id-Signed-At headers).
|
||||
#
|
||||
# Two details are deliberate. The channel id is part of the payload because
|
||||
# visitor identities are stored globally, so a signature minted for one
|
||||
# channel must not assert the same identity on another. The timestamp is part
|
||||
# of the signed bytes even though it also travels as its own field, so a
|
||||
# captured signature cannot be re-dated.
|
||||
#
|
||||
# Leave empty to disable the feature. A visitor whose signature is missing or
|
||||
# wrong is then always treated as an anonymous guest, never as an identified
|
||||
# user, so an unsigned embed keeps working unchanged.
|
||||
secret: ""
|
||||
# How long a signed identity stays acceptable, in minutes. This bounds how
|
||||
# long a leaked signature can be replayed; it is not needed to protect the
|
||||
# secret, which HMAC does not allow to be derived. A small forward clock skew
|
||||
# is always tolerated. Values <= 0 fall back to 1440 (24 hours).
|
||||
maxAgeMinutes: 1440
|
||||
|
||||
conversation:
|
||||
# How many not-yet-closed conversations one visitor may have at a time.
|
||||
# This protects agents rather than the visitor: every open conversation is
|
||||
# real work, and in human-only mode opening one dispatches an agent at once.
|
||||
# Without a cap a single caller could flood the queue, and the visitor cannot
|
||||
# undo that by closing a conversation - they may need it kept open.
|
||||
# Unset or 0 means 3; use -1 to remove the cap entirely.
|
||||
customerMaxOpen: 3
|
||||
# How long a chat still counts as being watched, in minutes. An email that
|
||||
# arrives while a chat is live is appended to that chat and the visitor is
|
||||
# asked to continue there, because they are present in the chat and absent
|
||||
# from their inbox. Past this window the chat counts as abandoned and the
|
||||
# email becomes its own conversation. Unset or 0 means 30.
|
||||
emailChatLiveMinutes: 30
|
||||
|
||||
storage:
|
||||
# Default file storage provider used for uploads. Supported values: local, oss.
|
||||
# Empty value is treated as local by the backend.
|
||||
default: local
|
||||
# Maximum size of a single uploaded file, in MB. Values <= 0 fall back to the backend default.
|
||||
maxUploadSizeMB: 20
|
||||
local:
|
||||
# Files are written under this directory, relative to the process working directory unless an absolute path is used.
|
||||
root: data/storage
|
||||
# Public URL prefix used when returning local file URLs. The server must expose this path as static files.
|
||||
# Example: storage key "images/a.png" becomes "/storage/images/a.png".
|
||||
baseUrl: /storage
|
||||
oss:
|
||||
# Aliyun OSS endpoint. Both "oss-cn-hangzhou.aliyuncs.com" and "https://oss-cn-hangzhou.aliyuncs.com" are accepted.
|
||||
endpoint: ""
|
||||
# OSS bucket name.
|
||||
bucket: ""
|
||||
# OSS access credentials. Keep the secret out of committed environment-specific config.
|
||||
accessKeyId: ""
|
||||
accessKeySecret: ""
|
||||
# Optional public CDN/custom domain prefix for object URLs, for example https://files.example.com.
|
||||
# If empty, public buckets use the default bucket endpoint URL; private buckets use signed URLs.
|
||||
baseUrl: ""
|
||||
# Set to true for private buckets. Private buckets return temporary signed URLs for reads.
|
||||
private: false
|
||||
# Expiration for generated OSS signed GET URLs, in seconds. Values <= 0 fall back to 600.
|
||||
signedUrlExpireSeconds: 600
|
||||
|
||||
vectorDB:
|
||||
type: qdrant # qdrant, lancedb
|
||||
qdrant:
|
||||
host: 127.0.0.1
|
||||
grpcPort: 6334
|
||||
apiKey: ""
|
||||
useTls: false
|
||||
# LanceDB requires building the backend with -tags lancedb and LanceDB native libraries.
|
||||
lancedb:
|
||||
path: data/lancedb
|
||||
|
||||
mcp:
|
||||
# Global switch for MCP tool integration.
|
||||
# When false, MCP tool catalog, debug endpoints, and runtime tool calls are disabled.
|
||||
enabled: true
|
||||
# MCP server registry. Each map key is the serverCode used by toolCode values like "system/tool_name".
|
||||
servers:
|
||||
system:
|
||||
# Whether this MCP server is available for catalog listing, debug calls, and agent runtime calls.
|
||||
enabled: true
|
||||
# Streamable HTTP MCP endpoint. The built-in system server is exposed by this backend at /api/mcp.
|
||||
endpoint: "http://127.0.0.1:8083/api/mcp"
|
||||
# Connection and request timeout in milliseconds. Values <= 0 fall back to 15000.
|
||||
timeoutMs: 15000
|
||||
# Extra HTTP headers sent to this MCP server on every request, for example Authorization or tenant headers.
|
||||
headers: {}
|
||||
|
||||
oidc:
|
||||
# Whether to enable OIDC login. This system acts as the OIDC client.
|
||||
enabled: false
|
||||
# OIDC provider issuer, for example https://idp.example.com/realms/demo.
|
||||
issuer: ""
|
||||
clientId: ""
|
||||
clientSecret: ""
|
||||
# Must exactly match the redirect_uri registered with the OIDC provider.
|
||||
redirectUrl: "http://127.0.0.1:8083/api/auth/oidc_callback"
|
||||
# Signing secret for the OIDC login state. If left empty, clientSecret is used as the fallback.
|
||||
stateSecret: ""
|
||||
scopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
44
docker-compose.lancedb.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: cs_ai_agent
|
||||
MYSQL_USER: cs_ai_agent
|
||||
MYSQL_PASSWORD: cs_ai_agent_password
|
||||
MYSQL_ROOT_PASSWORD: cs_ai_agent_root_password
|
||||
TZ: Asia/Shanghai
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u\"$${MYSQL_USER}\" -p\"$${MYSQL_PASSWORD}\" --silent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
agent-desk:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: app-lancedb
|
||||
args:
|
||||
LANCEDB_VERSION: v0.1.2
|
||||
image: mlogclub/agent-desk:latest-lancedb
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8083:8083"
|
||||
volumes:
|
||||
- agent-desk-data:/app/data
|
||||
- ./docker/agent-desk-lancedb.yaml:/app/config/config.yaml:ro
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
agent-desk-data:
|
||||
11
docker-compose.sqlite-lancedb.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
agent-desk:
|
||||
image: mlogclub/agent-desk:latest-lancedb
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8083:8083"
|
||||
volumes:
|
||||
- ./docker/agent-desk-sqlite-lancedb.yaml:/app/config/config.yaml:ro
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
53
docker-compose.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: cs_ai_agent
|
||||
MYSQL_USER: cs_ai_agent
|
||||
MYSQL_PASSWORD: cs_ai_agent_password
|
||||
MYSQL_ROOT_PASSWORD: cs_ai_agent_root_password
|
||||
TZ: Asia/Shanghai
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u\"$${MYSQL_USER}\" -p\"$${MYSQL_PASSWORD}\" --silent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- qdrant-data:/qdrant/storage
|
||||
ports:
|
||||
- "6333:6333"
|
||||
- "6334:6334"
|
||||
|
||||
agent-desk:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: mlogclub/agent-desk:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
qdrant:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "8083:8083"
|
||||
volumes:
|
||||
- agent-desk-data:/app/data
|
||||
- ./docker/agent-desk.yaml:/app/config/config.yaml:ro
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
qdrant-data:
|
||||
agent-desk-data:
|
||||
67
docker/agent-desk-lancedb.yaml
Normal file
@@ -0,0 +1,67 @@
|
||||
language: zh-CN
|
||||
|
||||
server:
|
||||
port: 8083
|
||||
cors:
|
||||
allowedOrigins:
|
||||
- http://localhost:8083
|
||||
- http://127.0.0.1:8083
|
||||
|
||||
db:
|
||||
type: mysql
|
||||
dsn: cs_ai_agent:cs_ai_agent_password@tcp(mysql:3306)/cs_ai_agent?charset=utf8mb4&parseTime=True&loc=Local
|
||||
|
||||
logger:
|
||||
level: info
|
||||
format: text
|
||||
addSource: false
|
||||
|
||||
auth:
|
||||
tokenTTLHours: 24
|
||||
maxFailedAttempts: 5
|
||||
credentialLockMinute: 30
|
||||
|
||||
storage:
|
||||
default: local
|
||||
maxUploadSizeMB: 5
|
||||
local:
|
||||
root: /app/data/storage
|
||||
baseUrl: /storage
|
||||
oss:
|
||||
endpoint: ""
|
||||
bucket: ""
|
||||
accessKeyId: ""
|
||||
accessKeySecret: ""
|
||||
baseUrl: ""
|
||||
private: false
|
||||
signedUrlExpireSeconds: 600
|
||||
|
||||
vectorDB:
|
||||
type: lancedb
|
||||
qdrant:
|
||||
host: qdrant
|
||||
grpcPort: 6334
|
||||
apiKey: ""
|
||||
useTls: false
|
||||
lancedb:
|
||||
path: /app/data/lancedb
|
||||
|
||||
mcp:
|
||||
enabled: true
|
||||
servers:
|
||||
system:
|
||||
enabled: true
|
||||
endpoint: http://127.0.0.1:8083/api/mcp
|
||||
timeoutMs: 60000
|
||||
headers: {}
|
||||
|
||||
wxWork:
|
||||
enabled: false
|
||||
|
||||
oidc:
|
||||
enabled: false
|
||||
|
||||
customerSession:
|
||||
secret: change-me
|
||||
ttlMinutes: 120
|
||||
refreshThresholdMinutes: 30
|
||||
94
docker/agent-desk-sqlite-lancedb.yaml
Normal file
@@ -0,0 +1,94 @@
|
||||
language: zh-CN
|
||||
|
||||
server:
|
||||
port: 8083
|
||||
cors:
|
||||
allowedOrigins:
|
||||
- http://localhost:8083
|
||||
- http://127.0.0.1:8083
|
||||
|
||||
db:
|
||||
type: sqlite
|
||||
dsn: file:/app/data/app.db?_busy_timeout=5000
|
||||
maxIdleConns: 5
|
||||
maxOpenConns: 20
|
||||
connMaxIdleTimeSeconds: 300
|
||||
connMaxLifetimeSeconds: 1800
|
||||
|
||||
logger:
|
||||
level: info
|
||||
format: text
|
||||
addSource: false
|
||||
|
||||
auth:
|
||||
tokenTTLHours: 12
|
||||
maxFailedAttempts: 5
|
||||
credentialLockMinute: 15
|
||||
|
||||
customerSession:
|
||||
secret: replace-with-a-random-secret
|
||||
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: lancedb
|
||||
qdrant:
|
||||
host: qdrant
|
||||
grpcPort: 6334
|
||||
apiKey: ""
|
||||
useTls: false
|
||||
lancedb:
|
||||
path: /app/data/lancedb
|
||||
|
||||
mcp:
|
||||
enabled: true
|
||||
servers:
|
||||
system:
|
||||
enabled: true
|
||||
endpoint: http://127.0.0.1:8083/api/mcp
|
||||
timeoutMs: 60000
|
||||
headers: {}
|
||||
|
||||
wxWork:
|
||||
enabled: false
|
||||
corpId: ""
|
||||
corpSecret: ""
|
||||
agentId: ""
|
||||
oauthRedirect: ""
|
||||
stateSecret: ""
|
||||
rsaPrivateKey: ""
|
||||
token: ""
|
||||
encodingAESKey: ""
|
||||
notify:
|
||||
enabled: false
|
||||
toUsers: []
|
||||
safe: false
|
||||
enableDuplicateCheck: true
|
||||
duplicateCheckInterval: 1800
|
||||
|
||||
oidc:
|
||||
enabled: false
|
||||
issuer: ""
|
||||
clientId: ""
|
||||
clientSecret: ""
|
||||
redirectUrl: http://127.0.0.1:8083/api/auth/oidc_callback
|
||||
stateSecret: ""
|
||||
scopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
84
docker/agent-desk.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
language: zh-CN
|
||||
|
||||
server:
|
||||
port: 8083
|
||||
cors:
|
||||
# 浏览器跨域白名单。生产部署时改为实际前端/嵌入站点域名。
|
||||
allowedOrigins:
|
||||
- http://127.0.0.1:8083
|
||||
- http://localhost:8083
|
||||
|
||||
db:
|
||||
type: mysql
|
||||
dsn: cs_ai_agent:cs_ai_agent_password@tcp(mysql:3306)/cs_ai_agent?charset=utf8mb4&parseTime=True&multiStatements=true&loc=Local
|
||||
maxIdleConns: 5
|
||||
maxOpenConns: 20
|
||||
connMaxIdleTimeSeconds: 300
|
||||
connMaxLifetimeSeconds: 1800
|
||||
|
||||
logger:
|
||||
level: info
|
||||
format: text
|
||||
addSource: false
|
||||
|
||||
auth:
|
||||
tokenTTLHours: 12
|
||||
maxFailedAttempts: 5
|
||||
credentialLockMinute: 15
|
||||
|
||||
customerSession:
|
||||
secret: replace-with-a-random-secret
|
||||
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: qdrant
|
||||
grpcPort: 6334
|
||||
apiKey: ""
|
||||
useTls: false
|
||||
# LanceDB requires building the backend with -tags lancedb and LanceDB native libraries.
|
||||
lancedb:
|
||||
path: /app/data/lancedb
|
||||
|
||||
mcp:
|
||||
enabled: true
|
||||
servers:
|
||||
system:
|
||||
enabled: true
|
||||
endpoint: "http://127.0.0.1:8083/api/mcp"
|
||||
timeoutMs: 15000
|
||||
headers: {}
|
||||
|
||||
wxWork:
|
||||
enabled: false
|
||||
corpId: ""
|
||||
corpSecret: ""
|
||||
agentId: ""
|
||||
oauthRedirect: ""
|
||||
stateSecret: ""
|
||||
rsaPrivateKey: ""
|
||||
token: ""
|
||||
encodingAESKey: ""
|
||||
notify:
|
||||
enabled: false
|
||||
toUsers: []
|
||||
safe: false
|
||||
enableDuplicateCheck: true
|
||||
duplicateCheckInterval: 1800
|
||||
164
docs/embedding.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Embedding the Support Widget
|
||||
|
||||
The widget adds a chat launcher to any web page. It loads a single script, shows a floating button, and opens the support chat in an iframe. Visitors can keep several requests open and switch between them.
|
||||
|
||||
[Русский](./embedding.ru-RU.md) | [简体中文](./embedding.zh-CN.md)
|
||||
|
||||
## Quick start
|
||||
|
||||
Replace `https://support.example.com` with the address of your deployment and `YOUR_CHANNEL_ID` with the channel ID from the dashboard.
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.AgentDeskConfig = {
|
||||
channelId: "YOUR_CHANNEL_ID",
|
||||
}
|
||||
</script>
|
||||
<script src="https://support.example.com/sdk/agent-desk-sdk.min.js" async></script>
|
||||
```
|
||||
|
||||
Put both tags before `</body>`. The `AgentDeskConfig` block must run **before** the SDK script. The widget then mounts itself and the launcher appears in the bottom corner.
|
||||
|
||||
Title, subtitle, theme color, position and width are read from the channel settings in the dashboard, so you normally do not set them in code.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `channelId` | `string` | – | **Required.** The channel the widget belongs to. |
|
||||
| `baseUrl` | `string` | script origin | Address of the Agent Desk server. |
|
||||
| `apiBaseUrl` | `string` | `baseUrl` | Use when the API is served from a different origin. |
|
||||
| `widgetBaseUrl` | `string` | script origin | Where the chat page is loaded from. |
|
||||
| `language` | `"zh-CN" \| "en-US"` | server setting | Interface language. Any `en*` value selects English. |
|
||||
| `position` | `"left" \| "right"` | channel setting | Side of the screen for the launcher and window. |
|
||||
| `themeColor` | `string` | channel setting | Accent color, for example `#0f6cbd`. |
|
||||
| `width` | `string` | `380px` | Width of the chat window on desktop. |
|
||||
| `title`, `subtitle` | `string` | channel setting | Header texts. |
|
||||
| `externalId` | `string` | browser-local ID | Stable ID of your own user. |
|
||||
| `signExternalId` | `(id) => {signature, issuedAt}` | – | Proves `externalId` is genuine. See [Identified visitors](#identified-visitors). |
|
||||
| `externalName` | `string` | – | Display name, used when the visitor is first created. |
|
||||
| `getUserToken` | `() => string \| Promise<string>` | – | Returns a JWT issued by your system. |
|
||||
|
||||
On screens up to 480px wide the chat opens full screen.
|
||||
|
||||
## JavaScript API
|
||||
|
||||
```js
|
||||
AgentDeskWidget.open() // Promise<void>, opens the chat
|
||||
AgentDeskWidget.close() // minimizes the chat
|
||||
AgentDeskWidget.mount(cfg) // mounts with an explicit config
|
||||
AgentDeskWidget.destroy() // removes launcher and chat
|
||||
AgentDeskWidget.getChatUrl() // Promise<string>, URL for a standalone page or new tab
|
||||
```
|
||||
|
||||
Open the chat from your own button:
|
||||
|
||||
```html
|
||||
<button onclick="AgentDeskWidget.open()">Contact support</button>
|
||||
```
|
||||
|
||||
## React and Next.js
|
||||
|
||||
Load the script once on the client. In Next.js (App Router):
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
|
||||
import Script from "next/script"
|
||||
|
||||
export function SupportWidget({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<>
|
||||
<Script id="agent-desk-config" strategy="afterInteractive">
|
||||
{`window.AgentDeskConfig = ${JSON.stringify({ channelId })}`}
|
||||
</Script>
|
||||
<Script
|
||||
src="https://support.example.com/sdk/agent-desk-sdk.min.js"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Config that contains functions (`signExternalId`, `getUserToken`) cannot be serialized. Mount it manually instead:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const script = document.createElement("script")
|
||||
script.src = "https://support.example.com/sdk/agent-desk-sdk.min.js"
|
||||
script.onload = () =>
|
||||
window.AgentDeskWidget?.mount({
|
||||
channelId,
|
||||
externalId: user.id,
|
||||
signExternalId: (id) => fetch(`/api/support/sign?id=${id}`).then((r) => r.json()),
|
||||
})
|
||||
document.body.appendChild(script)
|
||||
return () => window.AgentDeskWidget?.destroy()
|
||||
}, [channelId, user.id])
|
||||
```
|
||||
|
||||
## Identified visitors
|
||||
|
||||
By default a visitor is an anonymous guest identified by a browser-local ID. To link chats to your own users, pass `externalId` **and** a signature, so nobody can impersonate another user:
|
||||
|
||||
```
|
||||
signature = hex(HMAC-SHA256(identitySecret, channelId + "\n" + externalId + "\n" + issuedAt))
|
||||
```
|
||||
|
||||
`issuedAt` is the current Unix time in seconds. The server only accepts a signature younger than `identity.maxAgeMinutes`, so the widget requests a fresh one every time the chat opens.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Compute the signature on **your server**. The identity secret must never be shipped to the browser.
|
||||
|
||||
Node.js endpoint:
|
||||
|
||||
```js
|
||||
import crypto from "node:crypto"
|
||||
|
||||
app.get("/api/support/sign", requireLogin, (req, res) => {
|
||||
const issuedAt = Math.floor(Date.now() / 1000)
|
||||
const payload = `${CHANNEL_ID}\n${req.user.id}\n${issuedAt}`
|
||||
const signature = crypto
|
||||
.createHmac("sha256", process.env.AGENT_DESK_IDENTITY_SECRET)
|
||||
.update(payload)
|
||||
.digest("hex")
|
||||
res.json({ signature, issuedAt })
|
||||
})
|
||||
```
|
||||
|
||||
Go:
|
||||
|
||||
```go
|
||||
func sign(secret, channelID, externalID string) (string, int64) {
|
||||
issuedAt := time.Now().Unix()
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
fmt.Fprintf(mac, "%s\n%s\n%d", channelID, externalID, issuedAt)
|
||||
return hex.EncodeToString(mac.Sum(nil)), issuedAt
|
||||
}
|
||||
```
|
||||
|
||||
Note that the endpoint uses your logged-in user, not a value from the query string, as the `externalId` it signs. An unsigned or invalid pair never breaks the widget: the visitor simply stays a guest.
|
||||
|
||||
## Content Security Policy and CORS
|
||||
|
||||
If your site sets a CSP, allow the Agent Desk origin:
|
||||
|
||||
```
|
||||
script-src https://support.example.com;
|
||||
frame-src https://support.example.com;
|
||||
connect-src https://support.example.com;
|
||||
```
|
||||
|
||||
When the widget and API live on different origins, add your site to the allowed origins of the deployment so the config request succeeds.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause and fix |
|
||||
| --- | --- |
|
||||
| No launcher appears | `channelId` is missing or `AgentDeskConfig` is defined after the SDK script. Check the browser console for `channelId is required`. |
|
||||
| Launcher appears, chat stays blank | The browser blocks the iframe. Check CSP `frame-src` and any `X-Frame-Options` set by a proxy on the Agent Desk origin. |
|
||||
| Visitor always shows as guest | The signature is missing, expired or computed with the wrong secret or payload order. Check server clock drift against `identity.maxAgeMinutes`. |
|
||||
| Wrong language | Set `language` explicitly, otherwise the server default is used. |
|
||||
| Widget duplicated in a single-page app | Call `AgentDeskWidget.destroy()` when the component unmounts. |
|
||||
| Chat shows "Something went wrong" | Choose **Reload chat**. If it persists, open the console and report the logged `Support chat crashed` error. |
|
||||
349
docs/embedding.ru-RU.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# Встраивание виджета поддержки (Интеграция)
|
||||
|
||||
Виджет онлайн-чата AgentDesk добавляет кнопку вызова поддержки и интерактивный диалог на любую веб-страницу. Он загружается одним скриптом, отображает плавающую кнопку в углу экрана и открывает интерфейс чата внутри изолированного `iframe`. Посетители могут вести несколько обращений параллельно и переключаться между ними.
|
||||
|
||||
[English](./embedding.md) | [简体中文](./embedding.zh-CN.md)
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
Замените `https://support.example.com` на URL вашего развёрнутого сервера AgentDesk, а `YOUR_CHANNEL_ID` — на идентификатор канала из панели управления (**Каналы** -> ID нужного канала).
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.AgentDeskConfig = {
|
||||
channelId: "YOUR_CHANNEL_ID",
|
||||
}
|
||||
</script>
|
||||
<script src="https://support.example.com/sdk/agent-desk-sdk.min.js" async></script>
|
||||
```
|
||||
|
||||
Разместите оба тега перед закрывающим тегом `</body>`. Блок конфигурации `window.AgentDeskConfig` обязательно должен выполняться **до** загрузки скрипта SDK.
|
||||
|
||||
> [!TIP]
|
||||
> Заголовок, подзаголовок, основной цвет темы, позиция (слева/справа) и ширина виджета по умолчанию подтягиваются из настроек канала в панели управления, поэтому их необязательно прописывать в коде.
|
||||
|
||||
---
|
||||
|
||||
## Идентификация пользователей (Авторизация клиентов)
|
||||
|
||||
По умолчанию любой посетитель считается **анонимным гостем** с браузерным идентификатором вида `guest_xxxxxxxx`.
|
||||
|
||||
Чтобы связать диалог с реальным пользователем вашего сайта, передавать его имя и историю обращений, платформа требует криптографического подтверждения личности. Это защищает от спуфинга: посетитель не может просто указать чужой `userId` в браузере и прочитать чужие конфиденциальные сообщения.
|
||||
|
||||
Поддерживаются два способа авторизации: **JWT-токен (`userToken`)** и **HMAC-подпись (`externalId`)**.
|
||||
|
||||
---
|
||||
|
||||
### Вариант 1: JWT-токен пользователя (`userToken`) — Рекомендуемый
|
||||
|
||||
В этом варианте ваш сервер выпускает подписанный JWT-токен при авторизации пользователя на вашем сайте.
|
||||
|
||||
#### 1. Настройка секрета в панели управления
|
||||
1. Откройте панель администратора: **Каналы** -> выберите нужный канал (или создайте новый Web/Chat канал).
|
||||
2. Найдите поле **User Token Secret** (секретный ключ токена пользователя) и сохраните надёжную случайную строку (например, 32+ символа).
|
||||
|
||||
#### 2. Полезная нагрузка токена (JWT Claims)
|
||||
JWT подписывается алгоритмом `HS256` (также поддерживаются `HS384` и `HS512`) и должен содержать следующие поля:
|
||||
```json
|
||||
{
|
||||
"userId": "12345", // Уникальный ID пользователя в вашей системе (string)
|
||||
"name": "Иван Иванов", // Отображаемое имя пользователя (string)
|
||||
"exp": 1792100000 // Время истечения срока действия (Unix timestamp в секундах)
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Генерация токена на вашем бэкенде
|
||||
|
||||
**Node.js / Express**:
|
||||
```javascript
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
app.get("/api/support/user-token", requireAuth, (req, res) => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: String(req.user.id),
|
||||
name: req.user.fullName || req.user.email,
|
||||
},
|
||||
process.env.AGENT_DESK_CHANNEL_SECRET, // User Token Secret из настроек канала
|
||||
{
|
||||
algorithm: "HS256",
|
||||
expiresIn: "24h",
|
||||
}
|
||||
);
|
||||
|
||||
res.json({ token });
|
||||
});
|
||||
```
|
||||
|
||||
**Python (FastAPI / Flask + PyJWT)**:
|
||||
```python
|
||||
import time
|
||||
import jwt
|
||||
|
||||
@app.get("/api/support/user-token")
|
||||
def get_support_user_token(current_user: User = Depends(get_current_user)):
|
||||
payload = {
|
||||
"userId": str(current_user.id),
|
||||
"name": current_user.name,
|
||||
"exp": int(time.time()) + 86400, # 24 часа
|
||||
}
|
||||
token = jwt.encode(payload, os.environ["AGENT_DESK_CHANNEL_SECRET"], algorithm="HS256")
|
||||
return {"token": token}
|
||||
```
|
||||
|
||||
**PHP**:
|
||||
```php
|
||||
use Firebase\JWT\JWT;
|
||||
|
||||
$payload = [
|
||||
'userId' => (string)$user->id,
|
||||
'name' => $user->name,
|
||||
'exp' => time() + 86400,
|
||||
];
|
||||
|
||||
$token = JWT::encode($payload, getenv('AGENT_DESK_CHANNEL_SECRET'), 'HS256');
|
||||
echo json_encode(['token' => $token]);
|
||||
```
|
||||
|
||||
**Go**:
|
||||
```go
|
||||
claims := jwt.MapClaims{
|
||||
"userId": fmt.Sprintf("%d", user.ID),
|
||||
"name": user.Name,
|
||||
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signedToken, err := token.SignedString([]byte(channelSecret))
|
||||
```
|
||||
|
||||
#### 4. Передача в виджет на фронтенде
|
||||
Передайте функцию `getUserToken` в конфигурацию виджета. SDK будет автоматически запрашивать свежий токен при открытии окна чата:
|
||||
```javascript
|
||||
window.AgentDeskConfig = {
|
||||
channelId: "YOUR_CHANNEL_ID",
|
||||
getUserToken: async () => {
|
||||
const res = await fetch("/api/support/user-token");
|
||||
const data = await res.json();
|
||||
return data.token;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Вариант 2: HMAC-SHA256 подпись (`externalId` + `signExternalId`)
|
||||
|
||||
Если вы предпочитаете не выпускать полный JWT, можно передавать ID пользователя и подписывать его через HMAC:
|
||||
|
||||
$$\text{signature} = \text{hex}\Big(\text{HMAC-SHA256}\big(\text{secret},\, \text{channelId} + \text{"\textbackslash n"} + \text{externalId} + \text{"\textbackslash n"} + \text{issuedAt}\big)\Big)$$
|
||||
|
||||
- `secret`: общий ключ `identity.secret` из файла `config.yaml` (переменная окружения `IDENTITY_HMAC_SECRET`).
|
||||
- `channelId`: идентификатор канала в AgentDesk.
|
||||
- `externalId`: ID пользователя на вашем сайте.
|
||||
- `issuedAt`: текущее время Unix в секундах.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Подпись должна вычисляться **исключительно на вашем бэкенде**. Никогда не передавайте `identity.secret` в браузер.
|
||||
|
||||
**Пример эндпоинта подписи (Node.js)**:
|
||||
```javascript
|
||||
import crypto from "node:crypto";
|
||||
|
||||
app.get("/api/support/sign", requireAuth, (req, res) => {
|
||||
const issuedAt = Math.floor(Date.now() / 1000);
|
||||
const channelId = process.env.AGENT_DESK_CHANNEL_ID;
|
||||
const externalId = String(req.user.id);
|
||||
|
||||
const payload = `${channelId}\n${externalId}\n${issuedAt}`;
|
||||
const signature = crypto
|
||||
.createHmac("sha256", process.env.AGENT_DESK_IDENTITY_SECRET)
|
||||
.update(payload)
|
||||
.digest("hex");
|
||||
|
||||
res.json({ signature, issuedAt });
|
||||
});
|
||||
```
|
||||
|
||||
**Подключение в виджете**:
|
||||
```javascript
|
||||
window.AgentDeskConfig = {
|
||||
channelId: "YOUR_CHANNEL_ID",
|
||||
externalId: currentUserId,
|
||||
externalName: currentUserName,
|
||||
signExternalId: async (id) => {
|
||||
const res = await fetch(`/api/support/sign?id=${encodeURIComponent(id)}`);
|
||||
return await res.json(); // { signature: "...", issuedAt: 171... }
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Примеры для популярных фронтенд-фреймворков
|
||||
|
||||
### React / Next.js (App Router)
|
||||
|
||||
Поскольку функции `getUserToken` или `signExternalId` нельзя передать в JSON-строке скрипта, для них используется программное монтирование через `AgentDeskWidget.mount`:
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AgentDeskWidget?: {
|
||||
mount: (config: Record<string, unknown>) => void
|
||||
destroy: () => void
|
||||
open: () => Promise<void>
|
||||
close: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function SupportChatWidget({
|
||||
channelId,
|
||||
user,
|
||||
}: {
|
||||
channelId: string
|
||||
user?: { id: string; name: string } | null
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const script = document.createElement("script")
|
||||
script.src = "https://support.example.com/sdk/agent-desk-sdk.min.js"
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
window.AgentDeskWidget?.mount({
|
||||
channelId,
|
||||
language: "ru-RU",
|
||||
getUserToken: user
|
||||
? async () => {
|
||||
const res = await fetch("/api/support/user-token")
|
||||
const data = await res.json()
|
||||
return data.token
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
document.body.appendChild(script)
|
||||
|
||||
return () => {
|
||||
window.AgentDeskWidget?.destroy()
|
||||
script.remove()
|
||||
}
|
||||
}, [channelId, user?.id])
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
### Vue 3 / Nuxt 3
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from "vue"
|
||||
|
||||
const props = defineProps({
|
||||
channelId: { type: String, required: true },
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const script = document.createElement("script")
|
||||
script.src = "https://support.example.com/sdk/agent-desk-sdk.min.js"
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
window.AgentDeskWidget?.mount({
|
||||
channelId: props.channelId,
|
||||
language: "ru-RU",
|
||||
getUserToken: async () => {
|
||||
const { data } = await useFetch("/api/support/user-token")
|
||||
return data.value?.token
|
||||
},
|
||||
})
|
||||
}
|
||||
document.body.appendChild(script)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.AgentDeskWidget?.destroy()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Таблица всех параметров конфигурации
|
||||
|
||||
| Параметр | Тип | По умолчанию | Описание |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `channelId` | `string` | — | **Обязательный.** Идентификатор канала из панели управления. |
|
||||
| `baseUrl` | `string` | домен скрипта | Базовый URL сервера AgentDesk. |
|
||||
| `apiBaseUrl` | `string` | `baseUrl` | URL для запросов API (если API вынесен на отдельный домен). |
|
||||
| `widgetBaseUrl` | `string` | домен скрипта | URL, с которого загружается страница фрейма чата. |
|
||||
| `language` | `"ru-RU" \| "en-US" \| "zh-CN"` | язык сервера | Язык интерфейса виджета чата. |
|
||||
| `position` | `"left" \| "right"` | из настроек канала | Сторона экрана, где находится кнопка и всплывающее окно. |
|
||||
| `themeColor` | `string` | из настроек канала | Основной цвет темы (HEX, например `#2563eb`). |
|
||||
| `width` | `string` | `380px` | Ширина окна чата на экранах десктопа (например `400px`). |
|
||||
| `title` | `string` | из настроек канала | Заголовок окна поддержки в шапке. |
|
||||
| `subtitle` | `string` | из настроек канала | Подзаголовок окна поддержки. |
|
||||
| `externalId` | `string` | браузерный ID | Уникальный ID вашего пользователя. |
|
||||
| `externalName` | `string` | — | Имя пользователя (используется при создании профиля). |
|
||||
| `signExternalId` | `(id) => Promise<{signature, issuedAt}>` | — | Функция вычисления HMAC-подписи для `externalId`. |
|
||||
| `getUserToken` | `() => Promise<string>` | — | Функция получения JWT-токена пользователя. |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript API виджета
|
||||
|
||||
После загрузки SDK в объекте `window.AgentDeskWidget` доступны методы управления:
|
||||
|
||||
```javascript
|
||||
// Открыть окно чата поддержки
|
||||
await AgentDeskWidget.open();
|
||||
|
||||
// Свернуть окно чата
|
||||
AgentDeskWidget.close();
|
||||
|
||||
// Смонтировать виджет с явной конфигурацией
|
||||
AgentDeskWidget.mount({ channelId: "..." });
|
||||
|
||||
// Полностью удалить кнопку и фрейм чата из DOM
|
||||
AgentDeskWidget.destroy();
|
||||
|
||||
// Получить прямую ссылку на чат (для открытия в новой вкладке или перехода)
|
||||
const chatUrl = await AgentDeskWidget.getChatUrl();
|
||||
```
|
||||
|
||||
Пример создания собственной кнопки вызова поддержки в шапке вашего сайта:
|
||||
```html
|
||||
<button type="button" onclick="AgentDeskWidget.open()">
|
||||
Написать в поддержку
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Content Security Policy (CSP) и CORS
|
||||
|
||||
Если на вашем сайте настроена строгая политика безопасности Content Security Policy (CSP), добавьте домен AgentDesk в разрешённые источники:
|
||||
|
||||
```http
|
||||
script-src https://support.example.com;
|
||||
frame-src https://support.example.com;
|
||||
connect-src https://support.example.com;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Диагностика частых проблем
|
||||
|
||||
| Проблема | Причина и способ устранения |
|
||||
| :--- | :--- |
|
||||
| **Пользователь отображается как «Гость...»** | 1. Проверьте, что передаётся либо валидный `userToken`, либо `externalId` + `signExternalId`.<br>2. Проверьте секретный ключ канала `User Token Secret`.<br>3. Убедитесь, что серверное время на вашем сервере не расходится с сервером AgentDesk (срок действия подписи/токена). |
|
||||
| **Кнопка виджета не появляется** | В консоли браузера проверьте ошибку: чаще всего не указан `channelId` или скрипт SDK загрузился раньше, чем объявлен `window.AgentDeskConfig`. |
|
||||
| **Кнопка есть, но внутри окна чата белый экран** | Браузер блокирует загрузку `iframe`. Проверьте заголовки `X-Frame-Options` и `Content-Security-Policy (frame-src)` на вашем прокси-сервере. |
|
||||
| **Язык виджета не совпадает с сайтом** | Задайте параметр `language: "ru-RU"` (или `en-US`) явно в конфигурации виджета. |
|
||||
| **Дублирование виджета в SPA-приложениях** | При смене маршрута или размонтировании вызывайте `AgentDeskWidget.destroy()`. |
|
||||
164
docs/embedding.zh-CN.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# 嵌入客服挂件
|
||||
|
||||
挂件会在任意网页上添加一个聊天入口:只需加载一个脚本,页面上显示悬浮按钮,点击后在 iframe 中打开客服聊天。访客可以同时保留多个请求并随时切换。
|
||||
|
||||
[English](./embedding.md) | [Русский](./embedding.ru-RU.md)
|
||||
|
||||
## 快速开始
|
||||
|
||||
将 `https://support.example.com` 替换为你的部署地址,将 `YOUR_CHANNEL_ID` 替换为控制台中的渠道 ID。
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.AgentDeskConfig = {
|
||||
channelId: "YOUR_CHANNEL_ID",
|
||||
}
|
||||
</script>
|
||||
<script src="https://support.example.com/sdk/agent-desk-sdk.min.js" async></script>
|
||||
```
|
||||
|
||||
两段标签放在 `</body>` 之前,且 `AgentDeskConfig` 必须在 SDK 脚本**之前**执行。挂件会自动挂载,按钮出现在页面角落。
|
||||
|
||||
标题、副标题、主题色、位置和宽度来自控制台的渠道设置,通常无需在代码中配置。
|
||||
|
||||
## 配置项
|
||||
|
||||
| 选项 | 类型 | 默认值 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `channelId` | `string` | – | **必填**,挂件所属渠道。 |
|
||||
| `baseUrl` | `string` | 脚本所在域名 | Agent Desk 服务地址。 |
|
||||
| `apiBaseUrl` | `string` | `baseUrl` | API 与页面不同源时使用。 |
|
||||
| `widgetBaseUrl` | `string` | 脚本所在域名 | 聊天页面的加载地址。 |
|
||||
| `language` | `"zh-CN" \| "en-US"` | 服务端设置 | 界面语言,任何 `en*` 取值都会选择英文。 |
|
||||
| `position` | `"left" \| "right"` | 渠道设置 | 按钮和窗口所在一侧。 |
|
||||
| `themeColor` | `string` | 渠道设置 | 主题色,例如 `#0f6cbd`。 |
|
||||
| `width` | `string` | `380px` | 桌面端窗口宽度。 |
|
||||
| `title`、`subtitle` | `string` | 渠道设置 | 窗口标题文字。 |
|
||||
| `externalId` | `string` | 浏览器本地 ID | 你自己系统中的稳定用户 ID。 |
|
||||
| `signExternalId` | `(id) => {signature, issuedAt}` | – | 证明 `externalId` 真实有效,见[已识别访客](#已识别访客)。 |
|
||||
| `externalName` | `string` | – | 显示名称,仅在首次创建访客时使用。 |
|
||||
| `getUserToken` | `() => string \| Promise<string>` | – | 返回由你的系统签发的 JWT。 |
|
||||
|
||||
屏幕宽度不超过 480px 时,聊天窗口会全屏打开。
|
||||
|
||||
## JavaScript API
|
||||
|
||||
```js
|
||||
AgentDeskWidget.open() // Promise<void>,打开聊天
|
||||
AgentDeskWidget.close() // 最小化聊天
|
||||
AgentDeskWidget.mount(cfg) // 使用显式配置挂载
|
||||
AgentDeskWidget.destroy() // 移除按钮和聊天窗口
|
||||
AgentDeskWidget.getChatUrl() // Promise<string>,用于独立页面或新标签页
|
||||
```
|
||||
|
||||
从自己的按钮打开聊天:
|
||||
|
||||
```html
|
||||
<button onclick="AgentDeskWidget.open()">联系客服</button>
|
||||
```
|
||||
|
||||
## React 与 Next.js
|
||||
|
||||
只在客户端加载一次脚本。Next.js(App Router)示例:
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
|
||||
import Script from "next/script"
|
||||
|
||||
export function SupportWidget({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<>
|
||||
<Script id="agent-desk-config" strategy="afterInteractive">
|
||||
{`window.AgentDeskConfig = ${JSON.stringify({ channelId })}`}
|
||||
</Script>
|
||||
<Script
|
||||
src="https://support.example.com/sdk/agent-desk-sdk.min.js"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
包含函数的配置(`signExternalId`、`getUserToken`)无法序列化,请手动挂载:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const script = document.createElement("script")
|
||||
script.src = "https://support.example.com/sdk/agent-desk-sdk.min.js"
|
||||
script.onload = () =>
|
||||
window.AgentDeskWidget?.mount({
|
||||
channelId,
|
||||
externalId: user.id,
|
||||
signExternalId: (id) => fetch(`/api/support/sign?id=${id}`).then((r) => r.json()),
|
||||
})
|
||||
document.body.appendChild(script)
|
||||
return () => window.AgentDeskWidget?.destroy()
|
||||
}, [channelId, user.id])
|
||||
```
|
||||
|
||||
## 已识别访客
|
||||
|
||||
默认情况下访客是由浏览器本地 ID 标识的匿名游客。若要把聊天关联到你自己的用户,需要同时传入 `externalId` 和签名,防止他人冒充:
|
||||
|
||||
```
|
||||
signature = hex(HMAC-SHA256(identitySecret, channelId + "\n" + externalId + "\n" + issuedAt))
|
||||
```
|
||||
|
||||
`issuedAt` 为当前 Unix 时间(秒)。服务端只接受未超过 `identity.maxAgeMinutes` 的签名,因此每次打开聊天时挂件都会重新获取签名。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 签名必须在**你的服务端**计算,身份密钥绝不能下发到浏览器。
|
||||
|
||||
Node.js 接口:
|
||||
|
||||
```js
|
||||
import crypto from "node:crypto"
|
||||
|
||||
app.get("/api/support/sign", requireLogin, (req, res) => {
|
||||
const issuedAt = Math.floor(Date.now() / 1000)
|
||||
const payload = `${CHANNEL_ID}\n${req.user.id}\n${issuedAt}`
|
||||
const signature = crypto
|
||||
.createHmac("sha256", process.env.AGENT_DESK_IDENTITY_SECRET)
|
||||
.update(payload)
|
||||
.digest("hex")
|
||||
res.json({ signature, issuedAt })
|
||||
})
|
||||
```
|
||||
|
||||
Go:
|
||||
|
||||
```go
|
||||
func sign(secret, channelID, externalID string) (string, int64) {
|
||||
issuedAt := time.Now().Unix()
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
fmt.Fprintf(mac, "%s\n%s\n%d", channelID, externalID, issuedAt)
|
||||
return hex.EncodeToString(mac.Sum(nil)), issuedAt
|
||||
}
|
||||
```
|
||||
|
||||
注意:接口签名的 `externalId` 应取自已登录用户,而不是查询参数。签名缺失或无效不会导致挂件失效,访客只会保持游客身份。
|
||||
|
||||
## 内容安全策略(CSP)与跨域
|
||||
|
||||
如果站点设置了 CSP,需要放行 Agent Desk 的域名:
|
||||
|
||||
```
|
||||
script-src https://support.example.com;
|
||||
frame-src https://support.example.com;
|
||||
connect-src https://support.example.com;
|
||||
```
|
||||
|
||||
挂件与 API 不同源时,请把你的站点加入部署的允许来源,否则配置请求会失败。
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 现象 | 原因与解决 |
|
||||
| --- | --- |
|
||||
| 没有出现按钮 | 缺少 `channelId`,或 `AgentDeskConfig` 定义在 SDK 脚本之后。查看控制台是否有 `channelId is required`。 |
|
||||
| 按钮出现但聊天空白 | iframe 被浏览器拦截。检查 CSP 的 `frame-src`,以及代理是否为 Agent Desk 域名设置了 `X-Frame-Options`。 |
|
||||
| 访客始终显示为游客 | 签名缺失、已过期,或使用了错误的密钥或拼接顺序。请对照 `identity.maxAgeMinutes` 检查服务器时钟偏差。 |
|
||||
| 语言不对 | 显式设置 `language`,否则使用服务端默认语言。 |
|
||||
| 单页应用中挂件重复 | 组件卸载时调用 `AgentDeskWidget.destroy()`。 |
|
||||
| 聊天显示“出了点问题” | 点击**重新加载聊天**。若仍然出现,请打开控制台并反馈 `Support chat crashed` 错误。 |
|
||||
246
docs/superpowers/plans/2026-10-11-fix-agent-im-issues.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Fix Agent Tool Naming, Prompt Catalog, Timezone, Channel Build Arg, and WebSocket Reconnect Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Resolve five production issues across AI runtime tool execution, prompt capability catalogs, timestamp timezone handling, Docker image channel building, and frontend WebSocket reconnection.
|
||||
|
||||
**Architecture:**
|
||||
Sanitize wire function names in the AI agent loop so providers accepting only `^[a-zA-Z0-9_-]+$` succeed while mapping back to canonical codes on execution.
|
||||
Align prompt catalog strings with direct function names and explicit instructions.
|
||||
Normalize timestamps to UTC when timezone offsets are missing and emit RFC3339 from the backend.
|
||||
Support `NEXT_PUBLIC_OPEN_IM_CHANNEL_ID` build argument in Dockerfile and ensure `beforeConnect` token validation precedes WebSocket connections.
|
||||
|
||||
**Tech Stack:** Go 1.26, Next.js 16 (React 19, TypeScript), WebSocket, Docker
|
||||
|
||||
**Spec:** User prompt requirements (5 targeted fixes)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preserve all existing staged and user-owned worktree changes.
|
||||
- Follow layer boundaries: models -> repositories -> services -> handlers.
|
||||
- Portable SQL / SQLite & MySQL compatibility.
|
||||
- Format all Go files with `gofmt`.
|
||||
- TypeScript checks must pass with `cd web && pnpm typecheck`.
|
||||
- Maintain test coverage across changed backend and frontend surfaces.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- Function name sanitization must only alter the wire definition, preserving registry, whitelist, and audit codes.
|
||||
- Direct invocation and tool_search calls must both resolve to the correct capability code.
|
||||
- Timestamp parsing must treat strings lacking timezone offsets as UTC while keeping explicit offsets untouched.
|
||||
- Reconnection lifecycle must not create duplicate sockets or run stale connection loops when disconnected mid-flight.
|
||||
- Docker ARG must properly inject into the Next.js builder stage.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Sanitize wire tool function names and reverse mapping on call
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/pkg/toolx/builtin_tools.go`
|
||||
- Modify: `internal/pkg/toolx/builtin_tools_test.go`
|
||||
- Modify: `internal/ai/application/runtime/agent_loop_engine.go`
|
||||
- Modify: `internal/ai/application/runtime/agent_loop_engine_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RegisteredToolSpecs`, `ToolSpec`, `ai.ToolDefinition`, `ai.ToolCall`
|
||||
- Produces: `toolx.SanitizeToolFunctionName(toolCode string) string`, `toolx.ResolveToolFunctionName(name string) string`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests for tool function name sanitization and resolution**
|
||||
|
||||
In `internal/pkg/toolx/builtin_tools_test.go`, add unit tests:
|
||||
```go
|
||||
func TestSanitizeToolFunctionName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"builtin/knowledge_retrieve": "builtin_knowledge_retrieve",
|
||||
"graph/triage_service_request": "graph_triage_service_request",
|
||||
"skill/12": "skill_12",
|
||||
"workflow/99": "workflow_99",
|
||||
"tool_search": "tool_search",
|
||||
}
|
||||
for input, expected := range cases {
|
||||
if got := SanitizeToolFunctionName(input); got != expected {
|
||||
t.Errorf("SanitizeToolFunctionName(%q) = %q, want %q", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToolFunctionName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"builtin_knowledge_retrieve": "builtin/knowledge_retrieve",
|
||||
"graph_triage_service_request": "graph/triage_service_request",
|
||||
"knowledge_retrieve": "builtin/knowledge_retrieve",
|
||||
"skill_12": "skill/12",
|
||||
"workflow/99": "workflow/99",
|
||||
"workflow_99": "workflow/99",
|
||||
"tool_search": "tool_search",
|
||||
}
|
||||
for input, expected := range cases {
|
||||
if got := ResolveToolFunctionName(input); got != expected {
|
||||
t.Errorf("ResolveToolFunctionName(%q) = %q, want %q", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `& "C:\Program Files\Go\bin\go.exe" test ./internal/pkg/toolx -run TestSanitizeToolFunctionName`
|
||||
Expected: Compilation failure or FAIL (functions undefined).
|
||||
|
||||
- [ ] **Step 3: Implement `SanitizeToolFunctionName` and `ResolveToolFunctionName` in `internal/pkg/toolx/builtin_tools.go`**
|
||||
|
||||
Implement character sanitization (`/` and any non `[a-zA-Z0-9_-]` runes mapped to `_`), spec code mapping, and prefix resolution for `skill_`, `workflow_`, `builtin_`, `graph_`.
|
||||
|
||||
- [ ] **Step 4: Update `internal/ai/application/runtime/agent_loop_engine.go`**
|
||||
|
||||
In `agentLoopToolDefinitions`:
|
||||
Register capability definitions using `toolx.SanitizeToolFunctionName(code)` as `definition.Name`.
|
||||
In `resolveAgentLoopToolCall`:
|
||||
Resolve `call.Name` using `toolx.ResolveToolFunctionName(toolCode)`.
|
||||
In `agent_loop_engine_test.go`:
|
||||
Update `TestAgentLoopRegistersDirectCapabilityAliases` and add test verifying direct sanitized tool call execution.
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `& "C:\Program Files\Go\bin\go.exe" test ./internal/pkg/toolx`
|
||||
Run: `& "C:\Program Files\Go\bin\go.exe" test ./internal/ai/application/runtime`
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update prompt catalog with `| direct function: <sanitized>` and instruction prohibition
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/ai/application/runtime/agent_turn.go`
|
||||
- Modify: `internal/ai/application/runtime/agent_loop_engine_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `toolx.SanitizeToolFunctionName`
|
||||
- Produces: Prompt catalog lines including `| direct function: <sanitized>` and system prompt instructions forbidding capability codes with slashes as direct function names.
|
||||
|
||||
- [ ] **Step 1: Write test checking catalog format and instructions**
|
||||
|
||||
In `internal/ai/application/runtime/agent_loop_engine_test.go`, add test asserting catalog items in `prepareTurn` contain `| direct function:` and prompt specifies prohibition.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `& "C:\Program Files\Go\bin\go.exe" test ./internal/ai/application/runtime -run TestAgentTurnCatalogFormat`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement catalog line updates and system prompt instruction in `internal/ai/application/runtime/agent_turn.go`**
|
||||
|
||||
For Builtin, Skill, Workflow, and MCP entries in `catalog`, append ` | direct function: <sanitized>`.
|
||||
In `systemPrompt`, add instruction: `Never pass a capability code containing slashes as a tool function name; use the exact direct function name provided in the catalog or call tool_search with toolCode.`
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `& "C:\Program Files\Go\bin\go.exe" test ./internal/ai/application/runtime`
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Timestamp parsing with UTC fallback & backend RFC3339 formatting
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/utils.ts`
|
||||
- Modify: `web/lib/api/im.ts`
|
||||
- Modify: `web/lib/support-conversation-list.ts`
|
||||
- Modify: `internal/services/customer_session_service.go`
|
||||
- Modify: `internal/services/ws_service.go`
|
||||
- Modify: `internal/services/auth_service.go`
|
||||
- Modify: `web/lib/support-conversation-list.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Raw date/time strings
|
||||
- Produces: `parseApiTimestamp(value?: string | number | Date | null): number` in `web/lib/utils.ts`, RFC3339 timestamps in backend responses.
|
||||
|
||||
- [ ] **Step 1: Write tests for `parseApiTimestamp` in `web/lib/support-conversation-list.test.mjs` (or a dedicated test)**
|
||||
|
||||
Assert:
|
||||
- `parseApiTimestamp("2026-10-11 12:00:00")` parses as UTC (`Date.parse("2026-10-11T12:00:00Z")`).
|
||||
- `parseApiTimestamp("2026-10-11T12:00:00+03:00")` preserves `+03:00` offset.
|
||||
- `parseApiTimestamp("2026-10-11T12:00:00Z")` preserves `Z`.
|
||||
- Invalid strings return `0`.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test web/lib/support-conversation-list.test.mjs`
|
||||
Expected: FAIL (`parseApiTimestamp` is not defined or fails).
|
||||
|
||||
- [ ] **Step 3: Implement `parseApiTimestamp` in `web/lib/utils.ts`**
|
||||
|
||||
Export `parseApiTimestamp`: if missing timezone offset (`/(Z|[+-]\d{2}(?::?\d{2})?)$/i`), append `Z` to treat as UTC.
|
||||
Use in `web/lib/api/im.ts` for `parseExpiresAt`.
|
||||
Use in `web/lib/support-conversation-list.ts` for `recencyTimestamp`.
|
||||
|
||||
- [ ] **Step 4: Update backend date formatting to `time.RFC3339`**
|
||||
|
||||
In:
|
||||
- `internal/services/customer_session_service.go` lines 80 and 164
|
||||
- `internal/services/ws_service.go` line 163
|
||||
- `internal/services/auth_service.go` line 309
|
||||
Use `.UTC().Format(time.RFC3339)` (or `.Format(time.RFC3339)`).
|
||||
|
||||
- [ ] **Step 5: Run frontend and backend tests**
|
||||
|
||||
Run: `node --test web/lib/support-conversation-list.test.mjs`
|
||||
Run: `& "C:\Program Files\Go\bin\go.exe" test ./internal/services`
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add `ARG NEXT_PUBLIC_OPEN_IM_CHANNEL_ID` in `Dockerfile`
|
||||
|
||||
**Files:**
|
||||
- Modify: `Dockerfile`
|
||||
|
||||
**Interfaces:**
|
||||
- Docker build argument `NEXT_PUBLIC_OPEN_IM_CHANNEL_ID` available to `web-builder` stage.
|
||||
|
||||
- [ ] **Step 1: Update `Dockerfile`**
|
||||
|
||||
In `web-builder` stage before `RUN pnpm build:sdk && pnpm build`, add:
|
||||
```dockerfile
|
||||
ARG NEXT_PUBLIC_OPEN_IM_CHANNEL_ID=""
|
||||
ENV NEXT_PUBLIC_OPEN_IM_CHANNEL_ID=${NEXT_PUBLIC_OPEN_IM_CHANNEL_ID}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify `Dockerfile` syntax**
|
||||
|
||||
Check syntax and confirm no regressions.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: WebSocket connection lifecycle with `beforeConnect: () => ensureCustomerSession()`
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/realtime-connection.ts`
|
||||
- Modify: `web/lib/stores/support-chat.ts`
|
||||
- Create / Update: `web/lib/realtime-connection.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `options.beforeConnect`
|
||||
- Produces: Guaranteed customer session exchange/refresh before WebSocket handshake.
|
||||
|
||||
- [ ] **Step 1: Write test in `web/lib/realtime-connection.test.mjs`**
|
||||
|
||||
Verify that `createRealtimeConnectionManager`:
|
||||
- Awaits `beforeConnect` before calling `createSocket`.
|
||||
- If `beforeConnect` fails, reports connect error, enters disconnected status, and schedules reconnect.
|
||||
- Re-runs `beforeConnect` on reconnect.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test web/lib/realtime-connection.test.mjs`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement `beforeConnect` in `web/lib/realtime-connection.ts` and wire into `web/lib/stores/support-chat.ts`**
|
||||
|
||||
Update `RealtimeConnectionManagerOptions` and `connect()` implementation.
|
||||
Pass `beforeConnect: () => ensureCustomerSession()` in `useSupportChatStore`.
|
||||
|
||||
- [ ] **Step 4: Run tests and typecheck**
|
||||
|
||||
Run: `node --test web/lib/realtime-connection.test.mjs`
|
||||
Run: `cd web && pnpm typecheck`
|
||||
Expected: PASS
|
||||
@@ -0,0 +1,209 @@
|
||||
# AI Agent Context & Cost Optimization + Auto-Handoff on Fallback/Failure Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Enable granular UI control over AI agent token spending (context window size, ReAct reasoning steps limit, tool output truncation), and ensure that if the AI agent fails, has no answer, or the fallback does not work, the conversation automatically transfers to a human operator while notifying the customer with a clear localized message so they never feel ignored.
|
||||
|
||||
**Architecture:**
|
||||
1. Extend `AIAgent` in `internal/models/models.go` and DTOs with `AutoHandoffOnFailure bool` and `HandoffMessage string` (while surfacing existing `MaxSteps` and `ContextWindow`).
|
||||
2. In `internal/ai/runtime/reply_trigger_service.go`, when `s.executor.Run` errors or returns an empty reply without an answer, trigger automatic human handoff (`svc.ConversationHumanDispatchService.HandoffByAIWithRequestID`).
|
||||
3. In `internal/services/conversation_human_dispatch_service.go`, ensure that on auto-handoff, the customer receives the agent's custom `HandoffMessage` or localized `conversation.handoff.waiting` ("Мы соединяем вас с оператором поддержки. Пожалуйста, подождите.").
|
||||
4. In `internal/ai/application/runtime/agent_loop_engine.go` and `tool_result_reducer.go`, enforce `ContextWindow` message limits and truncate oversized tool outputs (2500 runes cap) to prevent context window blowouts.
|
||||
5. In `web/app/(dashboard)/dashboard/ai-agents/_components/config-workbench.tsx`, provide an intuitive "Context & Cost Optimization" card and "Auto-Handoff on Failure" toggle with customizable handoff message.
|
||||
6. Symmetrical i18n parity across `zh-CN`, `en-US`, and `ru-RU`.
|
||||
|
||||
**Tech Stack:** Go 1.26, GORM, Next.js 16 App Router, TypeScript, Base UI / Tailwind CSS, sonner.
|
||||
|
||||
---
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Follow AGENTS.md layer ownership (`handlers -> services -> repositories/builders`).
|
||||
- Symmetrical 3-locale i18n parity (`zh-CN.json`, `en-US.json`, `ru-RU.json`).
|
||||
- Non-destructive DB migrations (portable SQLite & MySQL compatibility).
|
||||
- TypeScript compilation must pass cleanly (`tsc --noEmit`).
|
||||
- Go formatting (`gofmt`) and all tests must pass cleanly.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Model & Auto-Handoff Dispatch on Failure
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/models/models.go`
|
||||
- Modify: `internal/pkg/dto/request/ai_request.go`
|
||||
- Modify: `internal/pkg/dto/response/ai_response.go`
|
||||
- Modify: `internal/services/ai_agent_service.go`
|
||||
- Modify: `internal/services/conversation_human_dispatch_service.go`
|
||||
- Modify: `internal/ai/runtime/reply_trigger_service.go`
|
||||
- Modify: `internal/services/conversation_human_dispatch_service_test.go`
|
||||
- Modify: `internal/ai/runtime/reply_trigger_service_test.go`
|
||||
|
||||
- [ ] **Step 1: Add unit tests for Auto-Handoff on AI failure & empty reply**
|
||||
In `internal/ai/runtime/reply_trigger_service_test.go`:
|
||||
- Test that when `executor.Run` returns an error, if `AutoHandoffOnFailure` is true, `HandoffByAIWithRequestID` is invoked and sends a waiting message.
|
||||
- Test that when `executor.Run` returns empty `ReplyText`, auto-handoff is invoked.
|
||||
In `internal/services/conversation_human_dispatch_service_test.go`:
|
||||
- Test that custom `HandoffMessage` on `AIAgent` is used when present, otherwise localized default is sent.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/ai/runtime/... ./internal/services/...`
|
||||
|
||||
- [ ] **Step 3: Update `models.AIAgent` and DTOs**
|
||||
In `internal/models/models.go`:
|
||||
```go
|
||||
AutoHandoffOnFailure bool `gorm:"type:tinyint(1);not null;default:1"` // AutoHandoffOnFailure: whether to transfer to human when AI fails or has no answer.
|
||||
HandoffMessage string `gorm:"type:text"` // HandoffMessage: custom notification sent to customer upon handoff.
|
||||
```
|
||||
Update `internal/pkg/dto/request/ai_request.go` and `internal/pkg/dto/response/ai_response.go`.
|
||||
Update `internal/services/ai_agent_service.go` to persist and return these fields.
|
||||
|
||||
- [ ] **Step 4: Implement auto-handoff on failure in `reply_trigger_service.go`**
|
||||
In `executeReply`:
|
||||
When `err != nil`:
|
||||
```go
|
||||
if replyCtx.AIAgent.AutoHandoffOnFailure {
|
||||
_, handoffErr := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID(
|
||||
replyCtx.Conversation.ID,
|
||||
replyCtx.AIAgent,
|
||||
"AI execution failure: "+err.Error(),
|
||||
replyCtx.Message.RequestID,
|
||||
)
|
||||
if handoffErr != nil {
|
||||
slog.Error("failed to auto-handoff on ai failure", "error", handoffErr)
|
||||
}
|
||||
}
|
||||
return err
|
||||
```
|
||||
When `summary != nil && strings.TrimSpace(summary.ReplyText) == "" && !summary.Interrupted && !summary.HandoffRequested`:
|
||||
```go
|
||||
if replyCtx.AIAgent.AutoHandoffOnFailure {
|
||||
_, handoffErr := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID(
|
||||
replyCtx.Conversation.ID,
|
||||
replyCtx.AIAgent,
|
||||
"AI generated empty reply or fallback unavailable",
|
||||
replyCtx.Message.RequestID,
|
||||
)
|
||||
if handoffErr != nil {
|
||||
slog.Error("failed to auto-handoff on empty ai reply", "error", handoffErr)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Send customer notification message in `dispatchAfterHandoffWithRequestID`**
|
||||
In `internal/services/conversation_human_dispatch_service.go`:
|
||||
```go
|
||||
waitingMessage := strings.TrimSpace(aiAgent.HandoffMessage)
|
||||
if waitingMessage == "" {
|
||||
waitingMessage = HandoffWaitingMessageForLocale(conversation.Locale)
|
||||
}
|
||||
if err := s.sendAITextWithRequestID(conversationID, aiAgentID, waitingMessage, requestID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests and verify they pass**
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/ai/runtime/... ./internal/services/...`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Context Window & Tool Output Truncation
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/ai/application/runtime/agent_turn.go`
|
||||
- Modify: `internal/ai/application/runtime/agent_loop_engine.go`
|
||||
- Modify: `internal/ai/runtime/tooling/tool_result_reducer.go`
|
||||
- Modify: `internal/ai/runtime/tooling/tool_result_reducer_test.go`
|
||||
|
||||
- [ ] **Step 1: Write unit tests for tool output truncation**
|
||||
In `internal/ai/runtime/tooling/tool_result_reducer_test.go`:
|
||||
- Test that tool outputs exceeding 2500 runes are truncated with a clear ellipsis notice, preventing massive MCP responses from bloating the context window.
|
||||
|
||||
- [ ] **Step 2: Implement tool output truncation in `tool_result_reducer.go`**
|
||||
Ensure outputs are capped to 2500 runes safely.
|
||||
|
||||
- [ ] **Step 3: Enforce `ContextWindow` and `MaxSteps` in `agent_loop_engine.go` and `agent_turn.go`**
|
||||
In `agent_loop_engine.go`:
|
||||
- If `req.AIAgent.ContextWindow > 0`, use it as `limit` (bounded between 2 and 30).
|
||||
- If `req.AIAgent.MaxSteps > 0`, pass to `einoAgentLoop` (bounded between 1 and 8, default 4).
|
||||
|
||||
- [ ] **Step 4: Run Go tests**
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/ai/...`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Symmetrical i18n Translations
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/messages/en-US.json`
|
||||
- Modify: `web/messages/ru-RU.json`
|
||||
- Modify: `web/messages/zh-CN.json`
|
||||
|
||||
- [ ] **Step 1: Add symmetrical keys for context optimization and auto-handoff**
|
||||
Add:
|
||||
- `aiAgent.sectionContextCosts`: "Context & Cost Optimization" / "Контекст и оптимизация расходов" / "上下文与成本优化"
|
||||
- `aiAgent.contextCostsDescription`: "Manage context window size, reasoning step limits, and tool payload sizes."
|
||||
- `aiAgent.contextWindow`: "Context Window (messages)" / "Контекстное окно (сообщений)" / "上下文历史消息数"
|
||||
- `aiAgent.contextWindowHelp`: "Number of recent conversation messages included in context. Smaller window reduces token usage."
|
||||
- `aiAgent.maxSteps`: "Max Reasoning Steps" / "Максимум шагов ReAct (Max Steps)" / "单次最大推理步骤"
|
||||
- `aiAgent.maxStepsHelp`: "Max tool call steps allowed per turn. Setting 3–4 prevents runaways and token spikes."
|
||||
- `aiAgent.autoHandoffOnFailure`: "Auto-handoff on AI failure or missing answer" / "Автоперевод на оператора при сбое или отсутствии ответа" / "AI异常或无答案时自动转人工"
|
||||
- `aiAgent.autoHandoffOnFailureHelp`: "When the AI model errors, exhausts steps, or has no knowledge, immediately transfer the customer to a human agent."
|
||||
- `aiAgent.handoffMessage`: "Customer Notice upon Handoff" / "Сообщение клиенту при переводе к оператору" / "转人工提示文案"
|
||||
- `aiAgent.handoffMessageHelp`: "Sent to the customer so they know they have been transferred and are not being ignored."
|
||||
- `aiAgent.handoffMessagePlaceholder`: "We are connecting you to a human support agent. Please wait." / "Мы соединяем вас с оператором поддержки. Пожалуйста, подождите." / "正在为你接入人工客服,请稍候。"
|
||||
|
||||
- [ ] **Step 2: Run message integrity test**
|
||||
Run: `node --test web/i18n/message-integrity.test.mjs`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Dashboard UI Workbench (`config-workbench.tsx`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api/admin.ts`
|
||||
- Modify: `web/app/(dashboard)/dashboard/ai-agents/_components/config-workbench.tsx`
|
||||
- Create: `web/app/(dashboard)/dashboard/ai-agents/_components/config-workbench-cost.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write component unit test for workbench form controls**
|
||||
In `config-workbench-cost.test.mjs`:
|
||||
- Verify `contextWindow`, `maxSteps`, `autoHandoffOnFailure`, and `handoffMessage` are rendered and included in the save payload.
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
Run: `node --test web/app/\(dashboard\)/dashboard/ai-agents/_components/config-workbench-cost.test.mjs`
|
||||
|
||||
- [ ] **Step 3: Update `admin.ts` types**
|
||||
Add `maxSteps`, `contextWindow`, `autoHandoffOnFailure`, `handoffMessage` to `AIAgent` and `CreateAIAgentPayload`.
|
||||
|
||||
- [ ] **Step 4: Update `config-workbench.tsx` UI**
|
||||
- Add state:
|
||||
- `contextWindow: string` (default "10")
|
||||
- `maxSteps: string` (default "4")
|
||||
- `autoHandoffOnFailure: boolean` (default true)
|
||||
- `handoffMessage: string` (default "")
|
||||
- In Section `setup` or new sub-section `sectionContextCosts`:
|
||||
- Render `contextWindow` input with helper text.
|
||||
- Render `maxSteps` input with helper text.
|
||||
- In Section `service` under `sectionFallback`:
|
||||
- Render Switch / Toggle for `autoHandoffOnFailure`.
|
||||
- Render `handoffMessage` Textarea with placeholder and helper text.
|
||||
- Update `buildPayload()` to include all 4 fields.
|
||||
- Initialize all 4 fields in `fetchAIAgent` loading logic.
|
||||
|
||||
- [ ] **Step 5: Run tests and typecheck**
|
||||
Run: `node --test web/app/\(dashboard\)/dashboard/ai-agents/_components/config-workbench-cost.test.mjs`
|
||||
Run: `& "web/node_modules/.bin/tsc.cmd" --noEmit --project web/tsconfig.json`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Whole-Branch Integration Verification & Security Scan
|
||||
|
||||
- [ ] **Step 1: Run all backend Go tests**
|
||||
`& 'C:\Program Files\Go\bin\go.exe' test ./internal/...`
|
||||
- [ ] **Step 2: Run all frontend unit tests**
|
||||
`node --test web/lib/*.test.mjs web/i18n/*.test.mjs web/components/*.test.mjs web/components/support-chat/*.test.mjs`
|
||||
- [ ] **Step 3: Run TypeScript typecheck**
|
||||
`& "web/node_modules/.bin/tsc.cmd" --noEmit --project web/tsconfig.json`
|
||||
- [ ] **Step 4: Run Snyk code scan**
|
||||
Call `snyk_code_scan` on changed directories.
|
||||
- [ ] **Step 5: Git commit and push**
|
||||
Commit changes and push to `origin main`.
|
||||
@@ -0,0 +1,256 @@
|
||||
# AI Operator Co-Pilot & Conversation Auto-Summarization Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Empower human operators with an AI Co-Pilot that generates contextual draft reply suggestions with 1 click, and provides automated conversation summaries (for quick handoffs and close documentation).
|
||||
|
||||
**Architecture:** Create an `AICopilotService` in the Go backend utilizing `ai.LLM.Chat` with conversation history assembly, locale-aware prompt instructions, and graceful fallbacks. Expose endpoints `POST /api/dashboard/conversation/suggest_reply` and `POST /api/dashboard/conversation/summarize`. On the frontend, integrate a Sparkles (✨) AI Suggest action in `SharedMessageEditor` and an AI Summary inspection/close button in the conversation panel.
|
||||
|
||||
**Tech Stack:** Go 1.26, Next.js 16 App Router, TypeScript, Base UI / Tailwind CSS, Lucide icons (`Sparkles`, `FileText`), OpenAI/Qwen compatible LLM backend.
|
||||
|
||||
**Spec:** Agreed enhancements roadmap (Option 2 - AI operator features).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Follow AGENTS.md layer ownership (`handlers -> services -> repositories/builders`). Handlers must not call LLM or repositories directly.
|
||||
- Symmetrical 3-locale i18n parity (`zh-CN.json`, `en-US.json`, `ru-RU.json`).
|
||||
- Resilient against absent/offline LLM config: return clean, localized errors rather than crashes or unhandled exceptions.
|
||||
- TypeScript compilation must pass with 0 errors (`tsc --noEmit`).
|
||||
- Go formatting (`gofmt`) and all Go tests must pass cleanly.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Prompt formatting & injection safety: customer input must be clearly delimited so user messages cannot hijack system instructions.
|
||||
2. Context window limits: only format the most recent 10-15 messages to prevent exceeding LLM context windows or incurring high latency.
|
||||
3. Locale matching: if the customer spoke Russian or English, the draft reply must match the conversation's language.
|
||||
4. Concurrent suggestions: disable the AI Suggest button while generation is in flight to prevent duplicate requests.
|
||||
5. Base UI compatibility: avoid Radix `asChild` assumptions; follow AGENTS.md styling guidelines (`rounded-md`, clean spacing).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend AI Co-Pilot Service (`SuggestReply` and `SummarizeConversation`)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/services/ai_copilot_service.go`
|
||||
- Create: `internal/services/ai_copilot_service_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```go
|
||||
var AICopilotService = newAICopilotService()
|
||||
|
||||
type AICopilotService interface {
|
||||
SuggestReply(ctx context.Context, conversationID int64, locale string) (string, error)
|
||||
SummarizeConversation(ctx context.Context, conversationID int64, locale string) (string, error)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write unit tests for AICopilotService**
|
||||
|
||||
In `internal/services/ai_copilot_service_test.go`:
|
||||
- Test prompt generation with recent messages.
|
||||
- Test message normalization and conversation history formatting.
|
||||
- Test fallback behavior when no messages exist.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test -run TestAICopilotService ./internal/services/...`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement `AICopilotService`**
|
||||
|
||||
In `internal/services/ai_copilot_service.go`:
|
||||
- Fetch recent messages (up to 12) from `MessageService`.
|
||||
- Build conversation transcript formatted as `Customer: ...` / `Agent: ...`.
|
||||
- Assemble system and user prompts respecting `locale`.
|
||||
- Call `ai.LLM.Chat(ctx, systemPrompt, userPrompt)`.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test -run TestAICopilotService ./internal/services/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run gofmt and commit**
|
||||
|
||||
```bash
|
||||
git add internal/services/ai_copilot_service.go internal/services/ai_copilot_service_test.go
|
||||
git commit -m "feat(ai): implement AICopilotService for draft replies and conversation summarization"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Dashboard API Endpoints for AI Co-Pilot
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/pkg/dto/request/ai_copilot_request.go`
|
||||
- Create: `internal/pkg/dto/response/ai_copilot_response.go`
|
||||
- Modify: `internal/handlers/dashboard/conversation_handler.go`
|
||||
- Modify: `internal/handlers/dashboard/conversation_handler_test.go`
|
||||
- Modify: `internal/bootstrap/routes.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `POST /api/dashboard/conversation/suggest_reply`: takes `{ conversationId: int64 }`, returns `{ suggestion: string }`.
|
||||
- `POST /api/dashboard/conversation/summarize`: takes `{ conversationId: int64 }`, returns `{ summary: string }`.
|
||||
- Permission: requires `constants.PermissionConversationView`.
|
||||
|
||||
- [ ] **Step 1: Write handler tests**
|
||||
|
||||
In `internal/handlers/dashboard/conversation_handler_test.go`:
|
||||
Add tests for `ConversationPostSuggest_reply` and `ConversationPostSummarize` (validation, permission check, success).
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test -run TestConversationSuggestReply ./internal/handlers/dashboard/...`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement DTOs, handlers, and routes**
|
||||
|
||||
1. Create `internal/pkg/dto/request/ai_copilot_request.go` with `SuggestReplyRequest` and `SummarizeConversationRequest`.
|
||||
2. Create `internal/pkg/dto/response/ai_copilot_response.go` with `SuggestReplyResponse` and `ConversationSummaryResponse`.
|
||||
3. In `internal/handlers/dashboard/conversation_handler.go`: implement `ConversationPostSuggest_reply` and `ConversationPostSummarize`.
|
||||
4. In `internal/bootstrap/routes.go`: register both routes under dashboard conversation group.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/handlers/dashboard/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run gofmt and commit**
|
||||
|
||||
```bash
|
||||
git add internal/pkg/dto/request/ai_copilot_request.go internal/pkg/dto/response/ai_copilot_response.go internal/handlers/dashboard/conversation_handler.go internal/handlers/dashboard/conversation_handler_test.go internal/bootstrap/routes.go
|
||||
git commit -m "feat(api): register /suggest_reply and /summarize dashboard endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Symmetrical i18n Translations for AI Co-Pilot
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/messages/en-US.json`
|
||||
- Modify: `web/messages/ru-RU.json`
|
||||
- Modify: `web/messages/zh-CN.json`
|
||||
- Test: `web/i18n/message-integrity.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: Symmetrical keys across all 3 locales:
|
||||
- `aiCopilot.suggestReply`: "AI Suggest" / "Подсказать ответ" / "AI 生成回复"
|
||||
- `aiCopilot.generating`: "Generating reply..." / "Генерация ответа..." / "正在生成回复..."
|
||||
- `aiCopilot.insertReply`: "Use this reply" / "Использовать ответ" / "填入输入框"
|
||||
- `aiCopilot.discardReply`: "Dismiss" / "Отклонить" / "放弃"
|
||||
- `aiCopilot.summarize`: "AI Summary" / "AI Резюме" / "AI 总结"
|
||||
- `aiCopilot.summarizing`: "Summarizing..." / "Составление резюме..." / "正在生成总结..."
|
||||
- `aiCopilot.summaryTitle`: "Conversation Summary" / "Краткое резюме диалога" / "会话小结"
|
||||
- `aiCopilot.noSummary`: "No summary available yet" / "Резюме пока недоступно" / "暂无会话总结"
|
||||
- `aiCopilot.errorGenerating`: "Failed to generate AI suggestion" / "Не удалось сгенерировать ответ" / "生成 AI 建议失败"
|
||||
|
||||
- [ ] **Step 1: Check baseline integrity**
|
||||
|
||||
Run: `node --test web/i18n/message-integrity.test.mjs`
|
||||
|
||||
- [ ] **Step 2: Add keys symmetrically to all 3 JSON files**
|
||||
|
||||
Add `aiCopilot.*` keys with exact placeholder parity.
|
||||
|
||||
- [ ] **Step 3: Run integrity tests**
|
||||
|
||||
Run: `node --test web/i18n/message-integrity.test.mjs`
|
||||
Expected: PASS (0 missing keys, 0 placeholder mismatches).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/messages/en-US.json web/messages/ru-RU.json web/messages/zh-CN.json
|
||||
git commit -m "feat(i18n): add localized messages for AI copilot draft replies and summarization"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: AI Reply Suggestion UI in Message Editor
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api/agent.ts`
|
||||
- Modify: `web/components/chat/shared-message-editor.tsx`
|
||||
- Modify: `web/app/(dashboard)/dashboard/conversations/_components/agent-message-editor.tsx`
|
||||
- Test: `web/node_modules/.bin/tsc.cmd --noEmit` and node tests
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `POST /api/dashboard/conversation/suggest_reply`
|
||||
- Produces:
|
||||
- Sparkles icon button in operator message editor toolbar.
|
||||
- When clicked: requests suggestion, renders an interactive floating/docked suggestion card with:
|
||||
- Generated text preview
|
||||
- "Use this reply" button (inserts text into editor and closes card)
|
||||
- "Dismiss" button
|
||||
- Disabled / loading state with spinner while generating.
|
||||
|
||||
- [ ] **Step 1: Add API client methods in `web/lib/api/agent.ts`**
|
||||
|
||||
Export `suggestAgentReply(conversationId: number)` and `summarizeAgentConversation(conversationId: number)`.
|
||||
|
||||
- [ ] **Step 2: Add AI Suggest button & suggestion drawer in `SharedMessageEditor`**
|
||||
|
||||
In `SharedMessageEditor`:
|
||||
- Add `aiCopilot?: { onSuggest: () => Promise<string | null>; loading?: boolean }` prop.
|
||||
- Render `SparklesIcon` button in operator toolbar.
|
||||
- When suggestion is available, render a clean draft suggestion card above the editor with "Use this reply" and "Dismiss".
|
||||
|
||||
- [ ] **Step 3: Wire into `AgentMessageEditor`**
|
||||
|
||||
In `agent-message-editor.tsx`:
|
||||
- Connect `aiCopilot` calling `suggestAgentReply(selectedConversationId)`.
|
||||
- Handle insertion and toast notification.
|
||||
|
||||
- [ ] **Step 4: Run typecheck and tests**
|
||||
|
||||
Run: `& "web/node_modules/.bin/tsc.cmd" --noEmit --project web/tsconfig.json`
|
||||
Run: `node --test web/lib/*.test.mjs web/i18n/*.test.mjs`
|
||||
Expected: ALL PASS with 0 errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/lib/api/agent.ts web/components/chat/shared-message-editor.tsx web/app/\(dashboard\)/dashboard/conversations/_components/agent-message-editor.tsx
|
||||
git commit -m "feat(ui): add AI reply suggestion action and preview card to operator message editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: AI Conversation Summary in Header & Close Dialog
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx`
|
||||
- Modify: `web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx`
|
||||
- Modify: `web/app/(dashboard)/dashboard/conversations/_components/conversation-close-dialog.tsx` (if present)
|
||||
- Test: `web/node_modules/.bin/tsc.cmd --noEmit` and node tests
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `summarizeAgentConversation(conversationId)`
|
||||
- Produces:
|
||||
- "AI Summary" popover / dialog button in conversation header or info panel.
|
||||
- Generates summary of the conversation turns on demand.
|
||||
- Displays structured summary in dialog/card.
|
||||
|
||||
- [ ] **Step 1: Add AI Summary dialog/popover component**
|
||||
|
||||
Create `web/app/(dashboard)/dashboard/conversations/_components/conversation-summary-dialog.tsx` rendering summary with loading state.
|
||||
|
||||
- [ ] **Step 2: Mount summary trigger in header/info panel**
|
||||
|
||||
In `chat-panel.tsx` or `conversation-info-panel.tsx`:
|
||||
Add an action button with `FileTextIcon` / `SparklesIcon` labeled `aiCopilot.summarize` opening the summary dialog.
|
||||
|
||||
- [ ] **Step 3: Run typecheck, node tests, and Go backend tests**
|
||||
|
||||
Run: `& "web/node_modules/.bin/tsc.cmd" --noEmit --project web/tsconfig.json`
|
||||
Run: `node --test web/lib/*.test.mjs web/i18n/*.test.mjs`
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/...`
|
||||
Expected: ALL PASS with 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/app/\(dashboard\)/dashboard/conversations/_components/conversation-summary-dialog.tsx web/app/\(dashboard\)/dashboard/conversations/_components/chat-panel.tsx web/app/\(dashboard\)/dashboard/conversations/_components/conversation-info-panel.tsx
|
||||
git commit -m "feat(ui): add AI conversation summarization dialog and header action"
|
||||
```
|
||||
345
docs/superpowers/plans/2026-10-14-csat-and-prompt-starters.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# CSAT (Customer Satisfaction Survey) & Channel Prompt Starters Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Provide actionable customer feedback collection (CSAT 1-5 star ratings + optional comments) upon conversation closure, and display quick starter question pills (Prompt Starters) for new visitor chats, with complete multi-locale support and operator visibility.
|
||||
|
||||
**Architecture:**
|
||||
1. **Prompt Starters:** Channel web config (`WebChannelConfig.PromptStarters []string`) is exposed via `GET /api/channel/config`. When a visitor has zero messages, `PromptStarters` renders clickable question pills that automatically dispatch the question.
|
||||
2. **CSAT Storage & Service:** A persistent `ConversationSatisfaction` model (`conversation_id`, `score`, `feedback`, `customer_id`, `channel_id`, `assignee_id`, `ai_agent_id`) records customer ratings.
|
||||
3. **API & Realtime:** `POST /api/conversation/satisfaction` securely accepts ratings from the conversation owner. The rating is included in `ConversationDetailResponse` and displayed to operators.
|
||||
4. **UI:** A modern, accessible `CSATCard` component appears in `/support/chat` when the conversation is closed, allowing star selection (1-5) and feedback submission.
|
||||
|
||||
**Tech Stack:** Go 1.26 (GORM, Gin), SQLite/MySQL, Next.js 16, React 19, TypeScript, Tailwind CSS, Lucide icons, i18n (`zh-CN`, `en-US`, `ru-RU`).
|
||||
|
||||
**Spec:** Complete end-to-end customer satisfaction and prompt starters system.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Full 3-locale i18n support (`zh-CN`, `en-US`, `ru-RU`).
|
||||
- All keys must be added symmetrically to `web/messages/zh-CN.json`, `web/messages/en-US.json`, and `web/messages/ru-RU.json`.
|
||||
- Strict AGENTS.md layer ownership (`models -> repositories -> services -> handlers -> builders/DTOs`).
|
||||
- Every transactional DB operation must use `ctx.Tx`.
|
||||
- No raw SQL errors exposed to clients; return localized errors via `errorsx.*I18n`.
|
||||
- Frontend code must typecheck cleanly (`tsc --noEmit`) and pass all node test suites.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- Visitor attempting to rate a conversation they do not own (must return forbidden error).
|
||||
- Submitting duplicate satisfaction ratings for the same conversation (must reject or update idempotently).
|
||||
- Submitting invalid rating score (e.g. 0, 6, -1 — must enforce 1-5 constraint).
|
||||
- Channel with empty or absent `PromptStarters` (must gracefully render standard empty state without layout shifts).
|
||||
- Switching between closed and open conversations (must update CSAT display without stale state leaks).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Prompt Starters in Channel Configuration and Widget API
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/pkg/dto/dto.go:16-24`
|
||||
- Modify: `internal/pkg/dto/response/widget_response.go:3-12`
|
||||
- Modify: `internal/handlers/api/channel_handler.go:25-58`
|
||||
- Test: `internal/handlers/api/channel_handler_test.go`
|
||||
- Create: `web/components/support-chat/prompt-starters.tsx`
|
||||
- Modify: `web/components/support-chat/message-list.tsx`
|
||||
- Modify: `web/lib/api/im.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Channel.ConfigJSON`, `services.ChannelService.ParseWebChannelConfig`
|
||||
- Produces:
|
||||
- `WebChannelConfig.PromptStarters []string`
|
||||
- `WidgetConfigResponse.PromptStarters []string`
|
||||
- `<PromptStarters items={...} onSelect={...} />` component
|
||||
|
||||
- [ ] **Step 1: Write failing test for channel config PromptStarters**
|
||||
|
||||
In `internal/handlers/api/channel_handler_test.go`, test that `resolveWidgetConfig` parses `promptStarters` from channel JSON and maps it to `WidgetConfigResponse`.
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/handlers/api -run TestChannelConfigPromptStarters`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement PromptStarters backend and frontend**
|
||||
|
||||
1. In `internal/pkg/dto/dto.go`, add `PromptStarters []string` to `WebChannelConfig`:
|
||||
```go
|
||||
PromptStarters []string `json:"promptStarters,omitempty"`
|
||||
```
|
||||
2. In `internal/pkg/dto/response/widget_response.go`:
|
||||
```go
|
||||
PromptStarters []string `json:"promptStarters,omitempty"`
|
||||
```
|
||||
3. In `internal/handlers/api/channel_handler.go`:
|
||||
- Map `cfg.PromptStarters` in `webLikeWidgetConfig` and `WidgetConfigResponse`.
|
||||
4. In `web/lib/api/im.ts`:
|
||||
- Add `promptStarters?: string[]` to `WidgetConfigResponse`.
|
||||
5. Create `web/components/support-chat/prompt-starters.tsx`:
|
||||
- Renders a clean grid/flex list of buttons with question icon and hover effects.
|
||||
- Calls `onSelect(text)` on click.
|
||||
6. In `web/components/support-chat/message-list.tsx`:
|
||||
- When `messages.length === 0` and `promptStarters?.length > 0`, render `<PromptStarters ... />`.
|
||||
|
||||
- [ ] **Step 4: Run test to verify pass**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/handlers/api -run TestChannelConfigPromptStarters`
|
||||
Run: `& "web/node_modules/.bin/tsc.cmd" --noEmit --project web/tsconfig.json`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/pkg/dto/dto.go internal/pkg/dto/response/widget_response.go internal/handlers/api/channel_handler.go internal/handlers/api/channel_handler_test.go web/components/support-chat/prompt-starters.tsx web/components/support-chat/message-list.tsx web/lib/api/im.ts
|
||||
git commit -m "feat(channel): support prompt starters for visitor onboarding"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Conversation Satisfaction (CSAT) Model, Repository & Service
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/models/models.go:70-80,410-430`
|
||||
- Create: `internal/repositories/conversation_satisfaction_repository.go`
|
||||
- Create: `internal/services/conversation_satisfaction_service.go`
|
||||
- Create: `internal/services/conversation_satisfaction_service_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `models.Conversation`, `openidentity.ExternalUser`
|
||||
- Produces:
|
||||
- `models.ConversationSatisfaction`
|
||||
- `repositories.ConversationSatisfactionRepository`
|
||||
- `services.ConversationSatisfactionService.SubmitSatisfaction(conversationID int64, externalUser openidentity.ExternalUser, score int, feedback string) (*models.ConversationSatisfaction, error)`
|
||||
- `services.ConversationSatisfactionService.GetByConversationID(conversationID int64) *models.ConversationSatisfaction`
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
In `internal/services/conversation_satisfaction_service_test.go`:
|
||||
```go
|
||||
func TestSubmitSatisfaction(t *testing.T) {
|
||||
// Tests:
|
||||
// 1. Rejects score < 1 or score > 5
|
||||
// 2. Rejects if external user is not conversation owner
|
||||
// 3. Creates record when valid and closed
|
||||
// 4. Returns existing record if already rated (idempotent)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/services -run TestSubmitSatisfaction`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement model, repository, and service**
|
||||
|
||||
1. In `internal/models/models.go`:
|
||||
```go
|
||||
type ConversationSatisfaction struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ConversationID int64 `gorm:"type:bigint;not null;uniqueIndex"`
|
||||
Score int `gorm:"type:int;not null;default:5;index"`
|
||||
Feedback string `gorm:"type:text"`
|
||||
ChannelID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
CustomerID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
AssigneeID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
AuditFields
|
||||
}
|
||||
```
|
||||
Add `&ConversationSatisfaction{}` to `models.Models`.
|
||||
2. Create `internal/repositories/conversation_satisfaction_repository.go`:
|
||||
- `GetByConversationID(db *gorm.DB, conversationID int64) *models.ConversationSatisfaction`
|
||||
- `Create(db *gorm.DB, item *models.ConversationSatisfaction) error`
|
||||
- `Update(db *gorm.DB, item *models.ConversationSatisfaction) error`
|
||||
3. Create `internal/services/conversation_satisfaction_service.go`:
|
||||
- Validate `score >= 1 && score <= 5`.
|
||||
- Validate ownership via `ConversationService.IsCustomerConversationOwner`.
|
||||
- Save or update existing satisfaction record idempotently inside `sqls.WithTransaction`.
|
||||
|
||||
- [ ] **Step 4: Run test to verify pass**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/services -run TestSubmitSatisfaction`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/models/models.go internal/repositories/conversation_satisfaction_repository.go internal/services/conversation_satisfaction_service.go internal/services/conversation_satisfaction_service_test.go
|
||||
git commit -m "feat(csat): add ConversationSatisfaction model, repository and service"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Customer CSAT API Route & Response Builder
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/pkg/dto/request/satisfaction_request.go`
|
||||
- Create: `internal/pkg/dto/response/satisfaction_response.go`
|
||||
- Modify: `internal/pkg/dto/response/conversation_response.go`
|
||||
- Modify: `internal/builders/conversation_builder.go`
|
||||
- Modify: `internal/handlers/api/conversation_handler.go`
|
||||
- Modify: `internal/bootstrap/routes.go:90-100`
|
||||
- Test: `internal/handlers/api/conversation_handler_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `services.ConversationSatisfactionService`
|
||||
- Produces:
|
||||
- Route `POST /api/conversation/satisfaction`
|
||||
- `ConversationResponse.Satisfaction *ConversationSatisfactionResponse`
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
In `internal/handlers/api/conversation_handler_test.go`:
|
||||
Test `POST /api/conversation/satisfaction` with valid payload, score validation, and response mapping.
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/handlers/api -run TestConversationSatisfactionAPI`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement route and handler**
|
||||
|
||||
1. Create `internal/pkg/dto/request/satisfaction_request.go`:
|
||||
```go
|
||||
type SubmitSatisfactionRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
Score int `json:"score"`
|
||||
Feedback string `json:"feedback"`
|
||||
}
|
||||
```
|
||||
2. Create `internal/pkg/dto/response/satisfaction_response.go`:
|
||||
```go
|
||||
type ConversationSatisfactionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
Score int `json:"score"`
|
||||
Feedback string `json:"feedback"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
```
|
||||
3. Update `ConversationResponse` and `builders.BuildConversationWithLocale` to embed satisfaction if present.
|
||||
4. Add `ConversationPostSatisfaction` in `internal/handlers/api/conversation_handler.go`.
|
||||
5. Register in `internal/bootstrap/routes.go`:
|
||||
```go
|
||||
group.POST("/satisfaction", api.ConversationPostSatisfaction)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify pass**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/handlers/api/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/pkg/dto/request/satisfaction_request.go internal/pkg/dto/response/satisfaction_response.go internal/pkg/dto/response/conversation_response.go internal/builders/conversation_builder.go internal/handlers/api/conversation_handler.go internal/bootstrap/routes.go internal/handlers/api/conversation_handler_test.go
|
||||
git commit -m "feat(api): register /api/conversation/satisfaction endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Symmetrical i18n Translations for CSAT and Prompt Starters
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/messages/en-US.json`
|
||||
- Modify: `web/messages/ru-RU.json`
|
||||
- Modify: `web/messages/zh-CN.json`
|
||||
- Test: `web/i18n/message-integrity.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: i18n keys for CSAT and prompt starters
|
||||
- Produces: Identical keys in all 3 locales:
|
||||
- `csat.title`
|
||||
- `csat.score1`, `csat.score2`, `csat.score3`, `csat.score4`, `csat.score5`
|
||||
- `csat.commentPlaceholder`
|
||||
- `csat.submit`
|
||||
- `csat.submitting`
|
||||
- `csat.thankYou`
|
||||
- `csat.alreadyRated`
|
||||
- `promptStarters.title`
|
||||
|
||||
- [ ] **Step 1: Write test / integrity check**
|
||||
|
||||
Run `node --test web/i18n/message-integrity.test.mjs` (must pass baseline).
|
||||
|
||||
- [ ] **Step 2: Add keys symmetrically to all 3 locales**
|
||||
|
||||
Add:
|
||||
```json
|
||||
"csat": {
|
||||
"title": "How would you rate our support?",
|
||||
"score1": "Very dissatisfied",
|
||||
"score2": "Dissatisfied",
|
||||
"score3": "Neutral",
|
||||
"score4": "Satisfied",
|
||||
"score5": "Very satisfied",
|
||||
"commentPlaceholder": "Tell us what could be improved... (optional)",
|
||||
"submit": "Submit Rating",
|
||||
"submitting": "Submitting...",
|
||||
"thankYou": "Thank you for your feedback!",
|
||||
"alreadyRated": "You rated this conversation {score}/5"
|
||||
},
|
||||
"promptStarters": {
|
||||
"title": "Frequently Asked Questions"
|
||||
}
|
||||
```
|
||||
And exact Russian and Chinese equivalents.
|
||||
|
||||
- [ ] **Step 3: Run integrity test**
|
||||
|
||||
Run: `node --test web/i18n/message-integrity.test.mjs`
|
||||
Expected: PASS (0 missing keys, 0 placeholder mismatches).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/messages/en-US.json web/messages/ru-RU.json web/messages/zh-CN.json
|
||||
git commit -m "feat(i18n): add localized messages for CSAT and prompt starters"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Customer Chat CSAT UI & Operator View
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api/im.ts` (Add `submitConversationSatisfaction`)
|
||||
- Create: `web/components/support-chat/csat-card.tsx`
|
||||
- Modify: `web/components/support-chat/chat-shell.tsx`
|
||||
- Modify: `web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx`
|
||||
- Test: `web/node_modules/.bin/tsc.cmd --noEmit` and `node --test web/lib/*.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `submitConversationSatisfaction`, `ConversationResponse.satisfaction`
|
||||
- Produces:
|
||||
- Interactive CSAT 5-star rating widget in `/support/chat` when conversation is closed.
|
||||
- CSAT rating badge displayed in operator conversation header.
|
||||
|
||||
- [ ] **Step 1: Implement `submitConversationSatisfaction` in `web/lib/api/im.ts`**
|
||||
|
||||
Add POST request to `/api/conversation/satisfaction` with `customerSessionToken`.
|
||||
|
||||
- [ ] **Step 2: Create `CSATCard` component in `web/components/support-chat/csat-card.tsx`**
|
||||
|
||||
- Renders 5 star icons with hover and active selection.
|
||||
- Displays score label (`csat.score1`..`csat.score5`).
|
||||
- Shows optional comment textarea.
|
||||
- Submit button with pending state.
|
||||
- Upon success or if already rated: renders clean thank-you confirmation with score.
|
||||
|
||||
- [ ] **Step 3: Mount in `chat-shell.tsx` and `chat-panel.tsx`**
|
||||
|
||||
- In `chat-shell.tsx`: when `conversation.status === 4` (closed), render `CSATCard` above the bottom container.
|
||||
- In `chat-panel.tsx`: if `conversation.satisfaction` is present, display badge ⭐ `{satisfaction.score}/5` with tooltip showing feedback.
|
||||
|
||||
- [ ] **Step 4: Run typecheck and tests**
|
||||
|
||||
Run: `& "web/node_modules/.bin/tsc.cmd" --noEmit --project web/tsconfig.json`
|
||||
Run: `node --test web/lib/*.test.mjs web/i18n/*.test.mjs`
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./...`
|
||||
Expected: ALL PASS with 0 errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/lib/api/im.ts web/components/support-chat/csat-card.tsx web/components/support-chat/chat-shell.tsx web/app/\(dashboard\)/dashboard/conversations/_components/chat-panel.tsx
|
||||
git commit -m "feat(ui): render CSAT rating card on closed conversations and display in operator dashboard"
|
||||
```
|
||||
292
docs/superpowers/plans/2026-10-14-operator-workspace.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Operator Workspace: Slash Commands, Collision Detection & Alerts Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Enhance the human operator workspace with inline slash command (`/`) quick replies, real-time operator collision detection (preventing two operators from answering the same customer at once), and audio-visual alerts for incoming messages.
|
||||
|
||||
**Architecture:** Extend the existing WebSocket service with a lightweight `operator.presence` topic broadcast (`viewing`, `typing`, `left`). Integrate slash command trigger into `SharedMessageEditor` for instant macro insertion. Add client-side presence state tracking with automatic TTL decay, rendering an operator collision banner in the chat panel. Provide synthesized Web Audio chime alerts for incoming customer messages.
|
||||
|
||||
**Tech Stack:** Go 1.26, Gorilla WebSocket, Next.js 16 App Router, TypeScript, Tiptap, Base UI / Tailwind CSS, Web Audio API.
|
||||
|
||||
**Spec:** Agreed enhancements roadmap (Option 2 - Operator Workspace improvements).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Follow AGENTS.md layer ownership (no direct SQL in handlers, clean WebSocket envelope structures).
|
||||
- Symmetrical 3-locale i18n parity (`zh-CN.json`, `en-US.json`, `ru-RU.json`).
|
||||
- Zero external binary/audio assets — synthesize audio chime using native Web Audio API.
|
||||
- TypeScript compilation must pass with 0 errors (`tsc --noEmit`).
|
||||
- Go formatting (`gofmt`) and all Go tests must pass cleanly.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Operator presence event storms: typing events must be throttled (max once per 3s).
|
||||
2. Presence cleanup: presence records must auto-expire after TTL (15s) so disconnected or crashed tabs don't show ghost operators.
|
||||
3. Slash command replacement: selecting a quick reply via `/` must replace the `/` text cleanly without leaving orphan slashes.
|
||||
4. Audio context autoplay policies: Web Audio API must initialize lazily upon user interaction and safely no-op when blocked.
|
||||
5. Multi-operator collision UI: banner must be informative, non-disruptive, and properly localized across all 3 languages.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Operator Presence Event & WebSocket Protocol
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/pkg/enums/im.go`
|
||||
- Modify: `internal/services/ws_realtime_types.go`
|
||||
- Modify: `internal/services/ws_service.go`
|
||||
- Test: `internal/services/ws_service_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `enums.IMRealtimeEventOperatorPresence = "operator.presence"`
|
||||
- `RealtimeOperatorPresencePayload`:
|
||||
```go
|
||||
type RealtimeOperatorPresencePayload struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
UserID int64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Action string `json:"action"` // "viewing", "typing", "left"
|
||||
At string `json:"at"`
|
||||
}
|
||||
```
|
||||
- `RealtimeOperatorPresenceEvent` implementing `RealtimeDomainEvent`.
|
||||
- Client message parsing in `ws_service.go` for `type: "presence"`.
|
||||
- `WsService.PublishOperatorPresence(conversationID int64, principal *dto.AuthPrincipal, action string, excludeConnID string)`.
|
||||
|
||||
- [ ] **Step 1: Write test for Operator Presence publishing**
|
||||
|
||||
In `internal/services/ws_service_test.go`:
|
||||
Add `TestPublishOperatorPresence` verifying that publishing presence sends the event to subscribers on `conversation:{id}` while skipping `excludeConnID`.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test -run TestPublishOperatorPresence ./internal/services/...`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement enum, payload, event, and WsService methods**
|
||||
|
||||
1. In `internal/pkg/enums/im.go`: define `IMRealtimeEventOperatorPresence = "operator.presence"`.
|
||||
2. In `internal/services/ws_realtime_types.go`: define `RealtimeOperatorPresencePayload` and `RealtimeOperatorPresenceEvent`.
|
||||
3. In `internal/services/ws_service.go`:
|
||||
- Handle client message `type == "presence"`: parse `conversationId` and `action`, call `PublishOperatorPresence`.
|
||||
- Implement `PublishOperatorPresence(conversationID int64, principal *dto.AuthPrincipal, action string, excludeConnID string)`.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test -run TestPublishOperatorPresence ./internal/services/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run gofmt and commit**
|
||||
|
||||
```bash
|
||||
git add internal/pkg/enums/im.go internal/services/ws_realtime_types.go internal/services/ws_service.go internal/services/ws_service_test.go
|
||||
git commit -m "feat(ws): support operator.presence realtime event and client presence messages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Symmetrical i18n Translations for Collision Alerts and Audio Notifications
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/messages/en-US.json`
|
||||
- Modify: `web/messages/ru-RU.json`
|
||||
- Modify: `web/messages/zh-CN.json`
|
||||
- Test: `web/i18n/message-integrity.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: Symmetrical keys across all 3 locales:
|
||||
- `operatorPresence.viewing`: "{name} is viewing this conversation" / "{name} просматривает этот диалог" / "{name} 正在查看此会话"
|
||||
- `operatorPresence.typing`: "{name} is typing a reply..." / "{name} печатает ответ..." / "{name} 正在输入回复..."
|
||||
- `operatorPresence.multipleViewing`: "{count} operators are viewing this conversation" / "{count} оператора просматривают этот диалог" / "{count} 位客服正在查看此会话"
|
||||
- `operatorPresence.collisionWarning`: "Warning: Another operator is active here to avoid duplicate replies." / "Внимание: другой оператор активен в этом диалоге во избежание дублирования ответов." / "注意:其他客服正在处理此会话,请避免重复回复。"
|
||||
- `conversation.quickReplySlashHint`: "Type / for quick replies" / "Введите / для быстрого ответа" / "输入 / 快捷回复"
|
||||
|
||||
- [ ] **Step 1: Check baseline integrity**
|
||||
|
||||
Run: `node --test web/i18n/message-integrity.test.mjs`
|
||||
|
||||
- [ ] **Step 2: Add keys symmetrically to all 3 JSON files**
|
||||
|
||||
Add `operatorPresence.*` and `conversation.quickReplySlashHint` with exact placeholder parity `{name}` and `{count}`.
|
||||
|
||||
- [ ] **Step 3: Run integrity tests**
|
||||
|
||||
Run: `node --test web/i18n/message-integrity.test.mjs`
|
||||
Expected: PASS (0 missing keys, 0 placeholder mismatches).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/messages/en-US.json web/messages/ru-RU.json web/messages/zh-CN.json
|
||||
git commit -m "feat(i18n): add localized messages for operator collision and slash commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Slash Commands (`/`) Quick Reply Autocomplete in SharedMessageEditor
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/components/chat/shared-message-editor.tsx`
|
||||
- Modify: `web/app/(dashboard)/dashboard/conversations/_components/agent-message-editor.tsx`
|
||||
- Test: `web/node_modules/.bin/tsc.cmd --noEmit` and manual verification
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `quickReplies` prop in `SharedMessageEditor`
|
||||
- Produces:
|
||||
- When typing `/` as first character or when editor text matches `/query`, opens the Quick Replies popover and filters items by `query`.
|
||||
- Selecting a quick reply removes the `/query` prefix and inserts the chosen quick reply content.
|
||||
- Shows subtle hint `conversation.quickReplySlashHint` in editor toolbar for operator variant.
|
||||
|
||||
- [ ] **Step 1: Implement slash trigger and query tracking**
|
||||
|
||||
In `web/components/chat/shared-message-editor.tsx`:
|
||||
1. In `editor.on("update")`:
|
||||
- Inspect plain text of current block.
|
||||
- If text matches `^/([^\s]*)$`:
|
||||
- Open quick replies popover.
|
||||
- Set quick reply search query to match the typed characters.
|
||||
2. In `handleInsertQuickReply(item)`:
|
||||
- If current text starts with `/`, delete the slash prefix before inserting `item.content`.
|
||||
3. In toolbar: show subtle hint for operators (`conversation.quickReplySlashHint`).
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
Run: `& "web/node_modules/.bin/tsc.cmd" --noEmit --project web/tsconfig.json`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/components/chat/shared-message-editor.tsx web/app/\(dashboard\)/dashboard/conversations/_components/agent-message-editor.tsx
|
||||
git commit -m "feat(editor): support slash command / for quick reply autocomplete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Operator Presence Tracking & Collision Warning UI
|
||||
|
||||
**Files:**
|
||||
- Create: `web/lib/operator-presence-state.ts`
|
||||
- Create: `web/lib/operator-presence-state.test.mjs`
|
||||
- Modify: `web/hooks/use-agent-conversation-realtime.ts`
|
||||
- Modify: `web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx`
|
||||
- Test: `node --test web/lib/operator-presence-state.test.mjs` and `web/node_modules/.bin/tsc.cmd --noEmit`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `resolveOperatorPresence(currentList, incomingEvent, currentUserId)` -> pure function returning active peer presences, purging expired entries (>15s TTL).
|
||||
- Outgoing presence messages:
|
||||
- `"viewing"` sent when selecting conversation.
|
||||
- `"typing"` sent when operator types (throttled to 3s).
|
||||
- `"left"` sent when unselecting conversation.
|
||||
- In `chat-panel.tsx`:
|
||||
- Renders an alert banner above the message editor or below conversation header if peer operators are active.
|
||||
- Displays avatar badge and text e.g. "Jane is viewing this conversation" or "Jane is typing a reply...".
|
||||
|
||||
- [ ] **Step 1: Write unit tests for pure presence state transitions**
|
||||
|
||||
Create `web/lib/operator-presence-state.test.mjs` verifying:
|
||||
- Adds new peer operator with timestamp.
|
||||
- Ignores self (currentUserId).
|
||||
- Updates action from "viewing" to "typing".
|
||||
- Removes operator on action "left".
|
||||
- Filters out records older than 15 seconds.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test web/lib/operator-presence-state.test.mjs`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement `web/lib/operator-presence-state.ts`**
|
||||
|
||||
Export:
|
||||
- `OperatorPresenceRecord`
|
||||
- `updateOperatorPresenceList`
|
||||
- `purgeExpiredOperatorPresences`
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test web/lib/operator-presence-state.test.mjs`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Integrate into `use-agent-conversation-realtime.ts` and `chat-panel.tsx`**
|
||||
|
||||
1. In `use-agent-conversation-realtime.ts`:
|
||||
- Send `{"type": "presence", "conversationId": id, "action": "viewing"}` on subscribe.
|
||||
- Send `{"type": "presence", "conversationId": id, "action": "left"}` on unsubscribe.
|
||||
- Listen for `operator.presence` events and update store presence state.
|
||||
2. In `chat-panel.tsx`:
|
||||
- When active peer operators exist on `currentConversation.id`:
|
||||
- Render collision warning banner with `UsersIcon` and localized description.
|
||||
|
||||
- [ ] **Step 6: Run typecheck and tests**
|
||||
|
||||
Run: `& "web/node_modules/.bin/tsc.cmd" --noEmit --project web/tsconfig.json`
|
||||
Run: `node --test web/lib/operator-presence-state.test.mjs`
|
||||
Expected: ALL PASS with 0 errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add web/lib/operator-presence-state.ts web/lib/operator-presence-state.test.mjs web/hooks/use-agent-conversation-realtime.ts web/app/\(dashboard\)/dashboard/conversations/_components/chat-panel.tsx
|
||||
git commit -m "feat(presence): detect operator collision and render warning banner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Incoming Message Audio & Expanded Browser Alerting
|
||||
|
||||
**Files:**
|
||||
- Create: `web/lib/sound-alert.ts`
|
||||
- Create: `web/lib/sound-alert.test.mjs`
|
||||
- Modify: `web/hooks/use-agent-conversation-realtime.ts`
|
||||
- Test: `node --test web/lib/sound-alert.test.mjs` and `web/node_modules/.bin/tsc.cmd --noEmit`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `playIncomingMessageSound()`: synthesizes a clean 587Hz -> 880Hz (D5 -> A5) chime via Web Audio API. Safe when audio is suspended or unsupported.
|
||||
- In `use-agent-conversation-realtime.ts`:
|
||||
- When `message.created` arrives from a customer:
|
||||
- If message is from customer and either:
|
||||
- assigned to current operator, OR
|
||||
- conversation is unassigned / pending
|
||||
- Play sound alert via `playIncomingMessageSound()`.
|
||||
- Show browser notification if tab is in background.
|
||||
|
||||
- [ ] **Step 1: Write unit test for sound alert helper**
|
||||
|
||||
In `web/lib/sound-alert.test.mjs`:
|
||||
Test safe fallback when `AudioContext` is undefined (Node.js/SSR environment).
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test web/lib/sound-alert.test.mjs`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement `web/lib/sound-alert.ts`**
|
||||
|
||||
Synthesizes gentle dual-tone sine oscillator with exponential decay.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test web/lib/sound-alert.test.mjs`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Wire into `use-agent-conversation-realtime.ts`**
|
||||
|
||||
Trigger `playIncomingMessageSound()` on new customer messages for assigned/pending conversations.
|
||||
|
||||
- [ ] **Step 6: Run typecheck and all tests**
|
||||
|
||||
Run: `& "web/node_modules/.bin/tsc.cmd" --noEmit --project web/tsconfig.json`
|
||||
Run: `node --test web/lib/*.test.mjs web/i18n/*.test.mjs`
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/...`
|
||||
Expected: ALL PASS with 0 errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add web/lib/sound-alert.ts web/lib/sound-alert.test.mjs web/hooks/use-agent-conversation-realtime.ts
|
||||
git commit -m "feat(notification): add audio chime and expanded alerts for incoming customer messages"
|
||||
```
|
||||
513
docs/superpowers/plans/2026-10-14-realtime-agent-activity.md
Normal file
@@ -0,0 +1,513 @@
|
||||
# Realtime AI Agent Activity Indicator Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Provide immediate visual feedback in support chat and the operator dashboard by broadcasting real-time agent execution states ("thinking", "searching knowledge base...", "calling tool...", "running workflow...") over WebSocket during AI agent processing turns.
|
||||
|
||||
**Architecture:** The Go backend emits lightweight `agent.activity` domain events via `WsService` to the conversation's WebSocket topic at each phase of the Agent Loop (start, knowledge search, tool invocation, completion/failure). The Next.js frontend catches these events in `support-chat` and `agent-conversations` stores, rendering an animated, localized activity indicator bubble at the bottom of the message scroller until the final response message arrives.
|
||||
|
||||
**Tech Stack:** Go 1.26 (Gin, Gorilla WebSocket, Eino agent loop), React 19 / Next.js 16, TypeScript, Zustand, Lucide icons, Tailwind CSS, i18n (`zh-CN`, `en-US`, `ru-RU`).
|
||||
|
||||
**Spec:** Variant 2: Real-time Execution Step Statuses via WebSocket (non-token-streaming step indicators).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Backend must maintain full 3-locale i18n support (`zh-CN`, `en-US`, `ru-RU`).
|
||||
- All new translation keys must be added symmetrically to `web/messages/zh-CN.json`, `web/messages/en-US.json`, and `web/messages/ru-RU.json`.
|
||||
- Event contracts must preserve existing WebSocket protocol conventions in `internal/services/ws_realtime_types.go`.
|
||||
- Code changes must adhere to layer ownership defined in `AGENTS.md` (no direct DB access from handlers, pure builders, structured `slog` logging).
|
||||
- No mock data or unhandled error drops; activity indicator must automatically dismiss on message arrival or conversation change.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- Visitor switches conversation while AI agent is thinking in previous conversation (must not leak indicator across conversations).
|
||||
- AI agent encounters error or times out during execution (must clean up indicator with "failed" status and not get stuck indefinitely).
|
||||
- Multi-step tool loop where several tools run sequentially (must update step text dynamically without layout flicker).
|
||||
- Rapid message send right after another (activity state must reset gracefully for the new turn).
|
||||
- Offline evaluation or background test runs where `req.Conversation.ID <= 0` (must safely no-op without attempting WebSocket broadcast).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend WebSocket Event Contract & WsService Method
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/pkg/enums/im.go:240-265`
|
||||
- Modify: `internal/services/ws_realtime_types.go:210-250`
|
||||
- Modify: `internal/services/ws_service.go:460-490`
|
||||
- Test: `internal/services/ws_service_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `models.Conversation`, `enums.IMRealtimeEventAgentActivity`
|
||||
- Produces:
|
||||
- `RealtimeAgentActivityPayload{ConversationID int64, StepCode string, ToolName string, Status string, At string}`
|
||||
- `RealtimeAgentActivityEvent` implementing `RealtimeDomainEvent`
|
||||
- `WsService.PublishAgentActivity(conversation *models.Conversation, stepCode, toolName, status string)`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add `TestPublishAgentActivity` in `internal/services/ws_service_test.go`:
|
||||
```go
|
||||
func TestPublishAgentActivity(t *testing.T) {
|
||||
ws := newWsService()
|
||||
session := &ClientSession{
|
||||
ID: "test-sess",
|
||||
Topics: map[string]struct{}{"conversation:100": {}},
|
||||
Send: make(chan []byte, 10),
|
||||
}
|
||||
ws.manager.Register(session)
|
||||
|
||||
conv := &models.Conversation{ID: 100}
|
||||
ws.PublishAgentActivity(conv, "searching_knowledge", "", "running")
|
||||
|
||||
select {
|
||||
case payload := <-session.Send:
|
||||
var event RealtimeEvent
|
||||
if err := json.Unmarshal(payload, &event); err != nil {
|
||||
t.Fatalf("failed to unmarshal event: %v", err)
|
||||
}
|
||||
if event.Type != enums.IMRealtimeEventAgentActivity {
|
||||
t.Fatalf("expected type %s, got %s", enums.IMRealtimeEventAgentActivity, event.Type)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for agent activity event")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/services -run TestPublishAgentActivity`
|
||||
Expected: FAIL (missing `IMRealtimeEventAgentActivity` or `PublishAgentActivity`)
|
||||
|
||||
- [ ] **Step 3: Implement event types and `PublishAgentActivity`**
|
||||
|
||||
1. In `internal/pkg/enums/im.go`, define:
|
||||
```go
|
||||
IMRealtimeEventAgentActivity = "agent.activity"
|
||||
```
|
||||
2. In `internal/services/ws_realtime_types.go`, define:
|
||||
```go
|
||||
type RealtimeAgentActivityPayload struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
StepCode string `json:"stepCode"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
Status string `json:"status"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
func (RealtimeAgentActivityPayload) realtimeEventPayload() {}
|
||||
|
||||
type RealtimeAgentActivityEvent struct {
|
||||
Payload RealtimeAgentActivityPayload
|
||||
}
|
||||
|
||||
func (e RealtimeAgentActivityEvent) EventType() string {
|
||||
return enums.IMRealtimeEventAgentActivity
|
||||
}
|
||||
|
||||
func (e RealtimeAgentActivityEvent) EventPayload() RealtimeEventPayload {
|
||||
return e.Payload
|
||||
}
|
||||
```
|
||||
3. In `internal/services/ws_service.go`, add:
|
||||
```go
|
||||
func (s *wsService) PublishAgentActivity(conversation *models.Conversation, stepCode, toolName, status string) {
|
||||
if conversation == nil || conversation.ID <= 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeAgentActivityEvent{
|
||||
Payload: RealtimeAgentActivityPayload{
|
||||
ConversationID: conversation.ID,
|
||||
StepCode: stepCode,
|
||||
ToolName: toolName,
|
||||
Status: status,
|
||||
At: formatWsTime(&now),
|
||||
},
|
||||
})
|
||||
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/services -run TestPublishAgentActivity`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/pkg/enums/im.go internal/services/ws_realtime_types.go internal/services/ws_service.go internal/services/ws_service_test.go
|
||||
git commit -m "feat(realtime): add agent.activity event and PublishAgentActivity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Emit Agent Activity During Loop Execution
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/ai/application/runtime/agent_loop_engine.go:55-120,615-680`
|
||||
- Test: `internal/ai/application/runtime/agent_loop_engine_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RunInput.Conversation`, `svc.WsService.PublishAgentActivity`
|
||||
- Produces: Realtime step broadcasts during:
|
||||
- Turn preparation / start (`stepCode: "thinking"`, `status: "started"`)
|
||||
- Knowledge retrieval (`stepCode: "searching_knowledge"`, `status: "running"`)
|
||||
- Tool execution (`stepCode: "calling_tool"`, `toolName: <name>`, `status: "running"`)
|
||||
- Workflow execution (`stepCode: "running_workflow"`, `toolName: <name>`, `status: "running"`)
|
||||
- Turn completed (`stepCode: "completed"`, `status: "completed"`)
|
||||
- Turn failed (`stepCode: "failed"`, `status: "failed"`)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
In `internal/ai/application/runtime/agent_loop_engine_test.go`, add test asserting that `toolSearchExecutor` and `Run` safely execute without errors when `req.Conversation.ID > 0`.
|
||||
|
||||
- [ ] **Step 2: Run test to verify behavior**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/ai/application/runtime -run TestAgentLoopEngine`
|
||||
Expected: PASS existing tests.
|
||||
|
||||
- [ ] **Step 3: Implement activity emissions in `AgentLoopEngine`**
|
||||
|
||||
1. In `Run(ctx context.Context, req RunInput) (*RunResult, error)`:
|
||||
- Emit start:
|
||||
```go
|
||||
if req.Conversation.ID > 0 {
|
||||
svc.WsService.PublishAgentActivity(&req.Conversation, "thinking", "", "started")
|
||||
}
|
||||
```
|
||||
- Before knowledge retrieval in `retrieveKnowledge`:
|
||||
```go
|
||||
if req.Conversation.ID > 0 {
|
||||
svc.WsService.PublishAgentActivity(&req.Conversation, "searching_knowledge", "", "running")
|
||||
}
|
||||
```
|
||||
- On exit (after `resolveAgentLoopReply` / error):
|
||||
```go
|
||||
if req.Conversation.ID > 0 {
|
||||
if loopErr != nil || err != nil {
|
||||
svc.WsService.PublishAgentActivity(&req.Conversation, "failed", "", "failed")
|
||||
} else {
|
||||
svc.WsService.PublishAgentActivity(&req.Conversation, "completed", "", "completed")
|
||||
}
|
||||
}
|
||||
```
|
||||
2. In `toolSearchExecutor`:
|
||||
- When dispatching tool call:
|
||||
```go
|
||||
if runInput.Conversation.ID > 0 {
|
||||
svc.WsService.PublishAgentActivity(&runInput.Conversation, "calling_tool", call.Name, "running")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run Go package tests**
|
||||
|
||||
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/ai/application/runtime/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/ai/application/runtime/agent_loop_engine.go internal/ai/application/runtime/agent_loop_engine_test.go
|
||||
git commit -m "feat(runtime): publish realtime agent activity steps during loop execution"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend i18n Translations for Agent Activity
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/messages/en-US.json`
|
||||
- Modify: `web/messages/ru-RU.json`
|
||||
- Modify: `web/messages/zh-CN.json`
|
||||
- Test: `web/i18n/message-integrity.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: i18n keys under `agentActivity.*`
|
||||
- Produces: Identical keys across all 3 locales:
|
||||
- `agentActivity.thinking`
|
||||
- `agentActivity.searchingKnowledge`
|
||||
- `agentActivity.callingTool`
|
||||
- `agentActivity.runningWorkflow`
|
||||
- `agentActivity.default`
|
||||
|
||||
- [ ] **Step 1: Write the failing test / check**
|
||||
|
||||
Run message integrity test: `node --test web/i18n/message-integrity.test.mjs`
|
||||
Expected: PASS (current baseline).
|
||||
|
||||
- [ ] **Step 2: Add matching translation keys**
|
||||
|
||||
1. In `web/messages/en-US.json`:
|
||||
```json
|
||||
"agentActivity": {
|
||||
"thinking": "AI Agent is thinking...",
|
||||
"searchingKnowledge": "Searching knowledge base...",
|
||||
"callingTool": "Executing action: {name}...",
|
||||
"runningWorkflow": "Running workflow: {name}...",
|
||||
"default": "AI Agent is working on your request..."
|
||||
}
|
||||
```
|
||||
2. In `web/messages/ru-RU.json`:
|
||||
```json
|
||||
"agentActivity": {
|
||||
"thinking": "AI-ассистент думает...",
|
||||
"searchingKnowledge": "Поиск в базе знаний...",
|
||||
"callingTool": "Выполнение действия: {name}...",
|
||||
"runningWorkflow": "Выполнение сценария: {name}...",
|
||||
"default": "AI-ассистент обрабатывает запрос..."
|
||||
}
|
||||
```
|
||||
3. In `web/messages/zh-CN.json`:
|
||||
```json
|
||||
"agentActivity": {
|
||||
"thinking": "AI 思考中...",
|
||||
"searchingKnowledge": "检索知识库中...",
|
||||
"callingTool": "执行工具:{name}...",
|
||||
"runningWorkflow": "执行流程:{name}...",
|
||||
"default": "AI 正在处理您的请求..."
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run message integrity test**
|
||||
|
||||
Run: `node --test web/i18n/message-integrity.test.mjs`
|
||||
Expected: PASS (all keys match in all supported locales).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/messages/en-US.json web/messages/ru-RU.json web/messages/zh-CN.json
|
||||
git commit -m "feat(i18n): add localized strings for agent activity states"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend State & Realtime Event Integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/im-realtime.ts:1-25`
|
||||
- Modify: `web/lib/stores/support-chat.ts:60-100,230-310,480-520`
|
||||
- Modify: `web/lib/stores/agent-conversations.ts:80-120,400-440`
|
||||
- Test: `web/lib/agent-activity-state.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ImRealtimeEnvelope` with `type: "agent.activity"`, payload `RealtimeAgentActivityPayload`
|
||||
- Produces:
|
||||
- Store slice `activeAgentActivity: { conversationId: number; stepCode: string; toolName?: string } | null`
|
||||
- Reducer/event-handler updating `activeAgentActivity` on `agent.activity`, and clearing on `message.created`, `conversation.closed`, or conversation switch.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `web/lib/agent-activity-state.test.mjs`:
|
||||
```javascript
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { resolveAgentActivityState } from "./agent-activity-state.ts"
|
||||
|
||||
test("sets activity state when event matches active conversation and status is running", () => {
|
||||
const current = null
|
||||
const event = {
|
||||
conversationId: 42,
|
||||
stepCode: "searching_knowledge",
|
||||
status: "running",
|
||||
}
|
||||
const next = resolveAgentActivityState(current, 42, event)
|
||||
assert.deepEqual(next, { conversationId: 42, stepCode: "searching_knowledge" })
|
||||
})
|
||||
|
||||
test("clears activity state when status is completed or failed", () => {
|
||||
const current = { conversationId: 42, stepCode: "thinking" }
|
||||
const event = {
|
||||
conversationId: 42,
|
||||
stepCode: "completed",
|
||||
status: "completed",
|
||||
}
|
||||
const next = resolveAgentActivityState(current, 42, event)
|
||||
assert.equal(next, null)
|
||||
})
|
||||
|
||||
test("ignores events belonging to a different conversation", () => {
|
||||
const current = null
|
||||
const event = {
|
||||
conversationId: 99,
|
||||
stepCode: "thinking",
|
||||
status: "started",
|
||||
}
|
||||
const next = resolveAgentActivityState(current, 42, event)
|
||||
assert.equal(next, null)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test web/lib/agent-activity-state.test.mjs`
|
||||
Expected: FAIL (module not found).
|
||||
|
||||
- [ ] **Step 3: Implement `agent-activity-state.ts` and wire into stores**
|
||||
|
||||
1. Create `web/lib/agent-activity-state.ts` with pure transition helper:
|
||||
```typescript
|
||||
export type AgentActivityInfo = {
|
||||
conversationId: number
|
||||
stepCode: string
|
||||
toolName?: string
|
||||
}
|
||||
|
||||
export type AgentActivityEventPayload = {
|
||||
conversationId: number
|
||||
stepCode: string
|
||||
toolName?: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export function resolveAgentActivityState(
|
||||
current: AgentActivityInfo | null,
|
||||
activeConversationId: number | undefined,
|
||||
payload: AgentActivityEventPayload | null | undefined
|
||||
): AgentActivityInfo | null {
|
||||
if (!payload || !activeConversationId || payload.conversationId !== activeConversationId) {
|
||||
return current
|
||||
}
|
||||
if (payload.status === "completed" || payload.status === "failed") {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
conversationId: payload.conversationId,
|
||||
stepCode: payload.stepCode,
|
||||
toolName: payload.toolName,
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Update `web/lib/im-realtime.ts` to include `RealtimeAgentActivityPayload` in `ImRealtimeEnvelope`.
|
||||
3. In `web/lib/stores/support-chat.ts`:
|
||||
- Add `activeAgentActivity: AgentActivityInfo | null` to `SupportChatStore`.
|
||||
- In WebSocket `onMessage`:
|
||||
- If `event.type === "agent.activity"`, update `activeAgentActivity` via `resolveAgentActivityState`.
|
||||
- In `message.created`, clear `activeAgentActivity: null` when message belongs to active conversation.
|
||||
- In `selectConversation`, reset `activeAgentActivity: null`.
|
||||
4. In `web/lib/stores/agent-conversations.ts`:
|
||||
- Track `activeAgentActivity` similarly for the operator's active conversation.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test web/lib/agent-activity-state.test.mjs`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/lib/agent-activity-state.ts web/lib/agent-activity-state.test.mjs web/lib/im-realtime.ts web/lib/stores/support-chat.ts web/lib/stores/agent-conversations.ts
|
||||
git commit -m "feat(store): handle agent.activity realtime events in support chat and agent stores"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: UI Agent Activity Bubble Components
|
||||
|
||||
**Files:**
|
||||
- Create: `web/components/chat/agent-activity-bubble.tsx`
|
||||
- Modify: `web/components/support-chat/message-list.tsx`
|
||||
- Modify: `web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx`
|
||||
- Test: `web/node_modules/.bin/tsc.cmd --noEmit` and `node --test web/lib/*.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `activeAgentActivity`, `useI18n()`
|
||||
- Produces:
|
||||
- `<AgentActivityBubble stepCode={...} toolName={...} />` component rendering:
|
||||
- Subtle AI avatar (`BotIcon` or `SparklesIcon`)
|
||||
- Animated pulse dots (`span className="animate-pulse"`)
|
||||
- Localized step label
|
||||
- Seamless insertion at bottom of message list in both customer support chat and operator chat panel.
|
||||
|
||||
- [ ] **Step 1: Create `AgentActivityBubble` component**
|
||||
|
||||
In `web/components/chat/agent-activity-bubble.tsx`:
|
||||
```tsx
|
||||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import { BotIcon, Loader2Icon } from "lucide-react"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { ConversationMessageRow } from "@/components/chat/conversation-message-row"
|
||||
import { ConversationMessageBubble } from "@/components/chat/conversation-message-bubble"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type AgentActivityBubbleProps = {
|
||||
stepCode: string
|
||||
toolName?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const AgentActivityBubble = memo(function AgentActivityBubble({
|
||||
stepCode,
|
||||
toolName,
|
||||
className,
|
||||
}: AgentActivityBubbleProps) {
|
||||
const t = useI18n()
|
||||
|
||||
let label = t("agentActivity.default")
|
||||
if (stepCode === "thinking") {
|
||||
label = t("agentActivity.thinking")
|
||||
} else if (stepCode === "searching_knowledge") {
|
||||
label = t("agentActivity.searchingKnowledge")
|
||||
} else if (stepCode === "calling_tool") {
|
||||
label = t("agentActivity.callingTool", { name: toolName || "..." })
|
||||
} else if (stepCode === "running_workflow") {
|
||||
label = t("agentActivity.runningWorkflow", { name: toolName || "..." })
|
||||
}
|
||||
|
||||
return (
|
||||
<ConversationMessageRow
|
||||
align="start"
|
||||
avatar={
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="bg-primary/10 text-primary">
|
||||
<BotIcon className="size-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
}
|
||||
contentClassName="max-w-[86%] gap-1.5"
|
||||
className={cn("my-1 animate-in fade-in-50 duration-200", className)}
|
||||
>
|
||||
<ConversationMessageBubble
|
||||
variant="system"
|
||||
className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/50 px-3 py-2 text-xs text-muted-foreground shadow-sm"
|
||||
>
|
||||
<Loader2Icon className="size-3.5 animate-spin text-primary shrink-0" />
|
||||
<span className="font-medium text-foreground/80">{label}</span>
|
||||
</ConversationMessageBubble>
|
||||
</ConversationMessageRow>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Mount bubble in `SupportChatMessageList`**
|
||||
|
||||
In `web/components/support-chat/message-list.tsx`:
|
||||
- Receive `agentActivity?: AgentActivityInfo | null` prop (or read from `useSupportChatStore`).
|
||||
- Render `<AgentActivityBubble ... />` right after the last message item inside `ConversationMessageScroller`.
|
||||
- Trigger `scrollerRef.current?.scrollToBottom()` when activity state transitions from null to non-null if user was near bottom.
|
||||
|
||||
- [ ] **Step 3: Mount bubble in operator `chat-panel.tsx`**
|
||||
|
||||
In `web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx`:
|
||||
- Read `activeAgentActivity` from `useAgentConversationsStore`.
|
||||
- Render `<AgentActivityBubble ... />` in `messagesScroller` when `activeAgentActivity` matches current conversation.
|
||||
|
||||
- [ ] **Step 4: Run TypeScript check and test suites**
|
||||
|
||||
Run:
|
||||
1. `& "web/node_modules/.bin/tsc.cmd" --noEmit --project web/tsconfig.json`
|
||||
2. `node --test web/lib/*.test.mjs web/i18n/*.test.mjs`
|
||||
3. `& 'C:\Program Files\Go\bin\go.exe' test ./...`
|
||||
Expected: ALL PASS with zero errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/components/chat/agent-activity-bubble.tsx web/components/support-chat/message-list.tsx web/app/\(dashboard\)/dashboard/conversations/_components/chat-panel.tsx
|
||||
git commit -m "feat(ui): display real-time agent activity indicators in customer and operator chat"
|
||||
```
|
||||
159
docs/superpowers/plans/2026-10-16-onboarding-readiness-wizard.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Step-by-Step Setup & Operator Routing Readiness Wizard Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a complete step-by-step setup wizard, operator routing readiness diagnostic, live route simulator, and one-click quick-fix system across backend and frontend in all three locales (`en-US`, `ru-RU`, `zh-CN`).
|
||||
|
||||
**Architecture:** A dedicated `SetupReadinessService` in Go evaluates the 6-step customer-to-operator routing chain (`AgentTeam` -> `AgentTeamSchedule` -> `AgentProfile` -> `AIAgent` -> `Channel` -> End-to-End path), simulates channel dispatch dry-runs, and executes atomic one-click fixes. The Next.js frontend exposes this through a dedicated `/dashboard/setup` page, a setup banner on `/dashboard`, a sidebar navigation entry, and an interactive operator readiness indicator in `WorkbenchHeader` and `SiteHeader`.
|
||||
|
||||
**Tech Stack:** Go 1.26, Gin, GORM (`github.com/mlogclub/simple/sqls`), Next.js 16 App Router, React 19, TypeScript, Tailwind CSS, Base UI / shadcn primitives.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-16-onboarding-readiness-wizard-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Follow `models -> repositories -> services -> handlers -> builders/response DTOs` layer ownership (`AGENTS.md` 4.1).
|
||||
- Use `any` instead of `interface{}` in new Go code (`AGENTS.md` 3).
|
||||
- Support all 3 locales on both backend (`internal/pkg/i18nx/locales/{en-US,ru-RU,zh-CN}.yml`) and frontend (`web/messages/{en-US,ru-RU,zh-CN}.json`).
|
||||
- Route all frontend API calls through `web/lib/api/client.ts` (`AGENTS.md` 5.1).
|
||||
- Use `rounded-md` and no `shadow` on support/dashboard cards, and use `OptionCombobox` for dropdowns (`AGENTS.md` 5.1, 5.4).
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Off-hours schedule false-positive:** `AgentTeamSchedule` exists for a team, but its `StartAt`/`EndAt` window does not cover `time.Now()` -> `schedules` step must return `"warning"` with `no_schedule_now` and `can_receive_requests_now = false`.
|
||||
2. **AI-Only mode blocking human handoff:** All active `AIAgent` records have `ServiceMode == IMConversationServiceModeAIOnly` -> `ai_agents` step must flag `"agent_ai_only_mode"` and simulator must report `"blocked_ai_only_mode"`.
|
||||
3. **Empty `AIAgent.TeamIDs` or deleted teams:** `AIAgent.TeamIDs` is `""` or references only disabled/non-existent team IDs -> `ai_agents` step must flag `"agent_no_teams"` and `fix_ai_agent_handoff` must bind valid active team IDs.
|
||||
4. **Operator profile busy or `AutoAssignEnabled == false`:** Operator is in an active team with an active schedule, but `ServiceStatus == ServiceStatusBusy` or `AutoAssignEnabled == false` -> `current_operator.can_receive_now` must be `false` with exact `blocker_codes` (`status_busy` / `auto_assign_disabled`), and `enable_my_operator_profile` must restore `ServiceStatusIdle` and `AutoAssignEnabled = true`.
|
||||
5. **Idempotent `quick_setup_all` on partially configured database:** Running `quick_setup_all` twice in a row must not duplicate default teams, channels, or schedules when valid active ones already exist.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend DTOs, Locales, and SetupReadinessService (with TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/pkg/dto/request/setup_readiness_req.go`
|
||||
- Create: `internal/pkg/dto/response/setup_readiness_resp.go`
|
||||
- Create: `internal/services/setup_readiness_service.go`
|
||||
- Create: `internal/services/setup_readiness_service_test.go`
|
||||
- Modify: `internal/pkg/i18nx/locales/en-US.yml`
|
||||
- Modify: `internal/pkg/i18nx/locales/ru-RU.yml`
|
||||
- Modify: `internal/pkg/i18nx/locales/zh-CN.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `request.SetupSimulateRouteRequest { ChannelID int64; BypassAI bool }`
|
||||
- `request.SetupQuickFixRequest { Action string; EntityID int64 }`
|
||||
- `response.SetupReadinessResponse`, `response.SetupSimulateRouteResponse`, `response.SetupQuickFixResponse`
|
||||
- `services.SetupReadinessService.GetReadiness(operator *dto.AuthPrincipal) (*response.SetupReadinessResponse, error)`
|
||||
- `services.SetupReadinessService.SimulateRoute(req request.SetupSimulateRouteRequest, operator *dto.AuthPrincipal) (*response.SetupSimulateRouteResponse, error)`
|
||||
- `services.SetupReadinessService.ApplyQuickFix(req request.SetupQuickFixRequest, operator *dto.AuthPrincipal) (*response.SetupQuickFixResponse, error)`
|
||||
|
||||
- [ ] **Step 1: Write failing tests in `internal/services/setup_readiness_service_test.go`**
|
||||
Cover:
|
||||
1. `TestSetupReadiness_EmptyDBAndQuickSetupAll` (verifies all 6 steps fail on empty DB, then `ApplyQuickFix("quick_setup_all")` makes all 6 steps `"ok"`, `CanReceiveRequestsNow == true`, `CurrentOperator.CanReceiveNow == true`, and a second call is idempotent).
|
||||
2. `TestSetupReadiness_OffHoursAndBusyOperator` (verifies schedule outside `now` triggers `no_schedule_now` and `team_off_hours`, and busy/auto-assign-off operator triggers `status_busy`/`auto_assign_disabled`, then `enable_my_operator_profile` and `ensure_team_and_schedule` resolve them).
|
||||
3. `TestSetupReadiness_SimulateRoute_AIOnlyAndMissingTeams` (verifies `SimulateRoute` reports `blocked_ai_only_mode` when `ServiceMode == IMConversationServiceModeAIOnly`, `blocked_agent_no_teams` when `TeamIDs == ""`, and `assigned_to_operator` with matched operator after `fix_ai_agent_handoff`).
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
Run: `go test -v ./internal/services -run TestSetupReadiness`
|
||||
Expected: FAIL (missing types and `SetupReadinessService`)
|
||||
|
||||
- [ ] **Step 3: Implement DTOs, backend locale keys, and `SetupReadinessService`**
|
||||
Create `internal/pkg/dto/request/setup_readiness_req.go`, `internal/pkg/dto/response/setup_readiness_resp.go`, and `internal/services/setup_readiness_service.go`. Add `error.setup.*` keys to `en-US.yml`, `ru-RU.yml`, and `zh-CN.yml`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
Run: `go test -v ./internal/services -run TestSetupReadiness`
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend HTTP Handlers and Route Registration
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/handlers/dashboard/setup_readiness_handler.go`
|
||||
- Modify: `internal/bootstrap/routes.go`
|
||||
- Modify: `internal/bootstrap/server.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `services.SetupReadinessService` from Task 1.
|
||||
- Produces:
|
||||
- `GET /api/dashboard/setup/readiness` (`dashboard.SetupGetReadiness`)
|
||||
- `POST /api/dashboard/setup/simulate_route` (`dashboard.SetupPostSimulate_route`)
|
||||
- `POST /api/dashboard/setup/quick_fix` (`dashboard.SetupPostQuick_fix`)
|
||||
|
||||
- [ ] **Step 1: Create `internal/handlers/dashboard/setup_readiness_handler.go`**
|
||||
Implement `SetupGetReadiness`, `SetupPostSimulate_route`, and `SetupPostQuick_fix` using `services.AuthService.GetCurrentPrincipal(ctx)` and `httpx.WriteJSON`.
|
||||
|
||||
- [ ] **Step 2: Register routes in `internal/bootstrap/routes.go` and `internal/bootstrap/server.go`**
|
||||
Add `registerDashboardSetupRoutes(dashboardGroup.Group("/setup"))` in `server.go` and define `registerDashboardSetupRoutes` in `routes.go`.
|
||||
|
||||
- [ ] **Step 3: Run `gofmt` and Go build/tests**
|
||||
Run: `gofmt -w internal/pkg/dto/request/setup_readiness_req.go internal/pkg/dto/response/setup_readiness_resp.go internal/services/setup_readiness_service.go internal/services/setup_readiness_service_test.go internal/handlers/dashboard/setup_readiness_handler.go internal/bootstrap/routes.go internal/bootstrap/server.go && go test ./internal/services -run TestSetupReadiness`
|
||||
Expected: PASS with zero compile/test errors.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend API Client & 3-Locale Translations (`en-US`, `ru-RU`, `zh-CN`)
|
||||
|
||||
**Files:**
|
||||
- Create: `web/lib/api/setup-readiness.ts`
|
||||
- Modify: `web/messages/en-US.json`
|
||||
- Modify: `web/messages/ru-RU.json`
|
||||
- Modify: `web/messages/zh-CN.json`
|
||||
- Modify: `web/lib/navigation.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `fetchSetupReadiness(): Promise<SetupReadinessResponse>`
|
||||
- `simulateSetupRoute(payload: { channel_id?: number; bypass_ai?: boolean }): Promise<SetupSimulateRouteResponse>`
|
||||
- `applySetupQuickFix(payload: { action: SetupQuickFixAction; entity_id?: number }): Promise<SetupQuickFixResponse>`
|
||||
- `nav.setupWizard` and `setupWizard.*` translation keys in `en-US.json`, `ru-RU.json`, and `zh-CN.json`
|
||||
- Navigation entry `/dashboard/setup` in `web/lib/navigation.tsx`
|
||||
|
||||
- [ ] **Step 1: Create `web/lib/api/setup-readiness.ts`**
|
||||
Define exact TypeScript interfaces matching `SetupReadinessResponse`, `SetupSimulateRouteResponse`, and `SetupQuickFixResponse`, using `requestData` from `@/lib/api/client`.
|
||||
|
||||
- [ ] **Step 2: Add `/dashboard/setup` to `web/lib/navigation.tsx`**
|
||||
Add `{ titleKey: "nav.setupWizard", url: "/dashboard/setup", icon: <ListChecksIcon /> }` to `dashboardNavSections` under Reception Center (`nav.receptionCenter`) right after Overview, and add page title mapping in `getPageTitleKey`.
|
||||
|
||||
- [ ] **Step 3: Add complete `setupWizard` and `nav.setupWizard` translations in `en-US.json`, `ru-RU.json`, and `zh-CN.json`**
|
||||
Delegate translation generation/verification to a **Gemini Flash** subagent (`model: "flash"`) and apply matching keys across all three JSON files.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend Setup Wizard Page, Dashboard Banner, and Operator Readiness Popover
|
||||
|
||||
**Files:**
|
||||
- Create: `web/app/(dashboard)/dashboard/setup/page.tsx`
|
||||
- Create: `web/app/(dashboard)/dashboard/setup/_components/setup-wizard-view.tsx`
|
||||
- Create: `web/app/(dashboard)/dashboard/_components/setup-readiness-banner.tsx`
|
||||
- Create: `web/components/operator-readiness-indicator.tsx`
|
||||
- Modify: `web/app/(dashboard)/dashboard/_components/dashboard-home.tsx`
|
||||
- Modify: `web/components/workbench-header.tsx`
|
||||
- Modify: `web/components/site-header.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `web/lib/api/setup-readiness.ts` and `setupWizard.*` i18n keys from Task 3.
|
||||
- Produces:
|
||||
- `/dashboard/setup` page with 6-step interactive checklist, current operator readiness card, 1-click quick fixes, and live route simulator with candidate operator table.
|
||||
- `SetupReadinessBanner` displayed at the top of `DashboardHome` whenever any step is not `"ok"` or current operator cannot receive chats.
|
||||
- `OperatorReadinessIndicator` displayed in `WorkbenchHeader` and `SiteHeader` showing live operator readiness status and 1-click fix popover.
|
||||
|
||||
- [ ] **Step 1: Build `OperatorReadinessIndicator` (`web/components/operator-readiness-indicator.tsx`)**
|
||||
Compact header button + popover showing whether customer requests can reach the current operator right now, listing specific blockers (`missing_profile`, `no_team`, `team_off_hours`, `status_busy`, `auto_assign_disabled`, `at_capacity`, or global chain issues), with a 1-click **"Enable Chat Reception"** (`enable_my_operator_profile` / `quick_setup_all`) button and link to `/dashboard/setup`.
|
||||
|
||||
- [ ] **Step 2: Build `SetupReadinessBanner` (`web/app/(dashboard)/dashboard/_components/setup-readiness-banner.tsx`)**
|
||||
Alert card on `/dashboard` home page when setup is incomplete or current operator is blocked, showing `ready_steps / total_steps`, broken step badges, 1-click **"Fix Automatically"** button, and **"Open Step-by-Step Setup"** button.
|
||||
|
||||
- [ ] **Step 3: Build `/dashboard/setup` page and `SetupWizardView` (`web/app/(dashboard)/dashboard/setup/_components/setup-wizard-view.tsx`)**
|
||||
Implement the 6-step readiness checklist with per-step issues, 1-click quick-fix actions, management links (`/dashboard/agents`, `/dashboard/agent-team-schedules`, `/dashboard/ai-agents`, `/dashboard/channels`), current operator status card, and the interactive Route Simulator (`OptionCombobox` for channel selection, 5-hop visual pipeline, and candidate operator table).
|
||||
|
||||
- [ ] **Step 4: Wire into `DashboardHome`, `WorkbenchHeader`, and `SiteHeader`**
|
||||
Mount `<SetupReadinessBanner />` in `web/app/(dashboard)/dashboard/_components/dashboard-home.tsx`, and mount `<OperatorReadinessIndicator />` in `web/components/workbench-header.tsx` and `web/components/site-header.tsx`.
|
||||
|
||||
- [ ] **Step 5: Run TypeScript check, ESLint, Go tests, and Snyk security scan**
|
||||
Run:
|
||||
- `cd web && pnpm typecheck`
|
||||
- `cd web && pnpm lint`
|
||||
- `go test ./internal/services/...`
|
||||
- Snyk code scan (`snyk_code_scan`) on changed files.
|
||||
@@ -0,0 +1,194 @@
|
||||
# Step-by-Step Setup & Operator Routing Readiness Wizard Design
|
||||
|
||||
**Date:** 2026-10-16
|
||||
**Topic:** Step-by-Step Onboarding Setup, Readiness Diagnostics, Route Simulator, and One-Click Quick Fixes (`en-US`, `ru-RU`, `zh-CN`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement & User Intent
|
||||
|
||||
When deploying or configuring `agent-desk-ng`, administrators and operators must configure six interdependent entities for customer requests to reach a human operator:
|
||||
1. **Operator Teams (`AgentTeam`)**: At least one active team (`Status == StatusOk`).
|
||||
2. **Working Schedules (`AgentTeamSchedule`)**: At least one active schedule (`Status == StatusOk`) covering the current timestamp (`start_at <= now < end_at`) for the target team. Without an active shift, conversations stay in `Pending` and return an off-hours message.
|
||||
3. **Operator Profiles (`AgentProfile` & `User`)**: An active user (`User.Status == StatusOk`) with an active `AgentProfile` (`Status == StatusOk`), assigned to the active team, with `AutoAssignEnabled == true`, `ServiceStatus == ServiceStatusIdle (0)`, `MaxConcurrentCount > 0`, and available capacity (`activeCount < MaxConcurrentCount`).
|
||||
4. **AI Agent & Handoff Mode (`AIAgent`)**: An active `AIAgent` (`Status == StatusOk`) whose `ServiceMode` allows reaching a human (`HumanOnly` or `AIFirst`), and whose `TeamIDs` field references at least one valid, active `AgentTeam`.
|
||||
5. **Inbound Channels (`Channel`)**: At least one active `Channel` (`Status == StatusOk`) linked to a valid `AIAgentID` with `AIAgentRolloutPercent > 0`.
|
||||
6. **End-to-End Routing Path**: A complete, unbroken path from an active `Channel` -> active `AIAgent` (with handoff enabled) -> active `AgentTeam` -> currently active `AgentTeamSchedule` -> at least one eligible, idle `AgentProfile` with free capacity.
|
||||
|
||||
Currently, if any single link in this chain is missing or misconfigured (e.g., missing shift schedule for the current time, empty `TeamIDs` on the AI agent, operator `AutoAssignEnabled == false` or status `Busy`, or `AIOnly` service mode), requests silently fail to reach the operator without explaining why.
|
||||
|
||||
### Goals
|
||||
1. **Step-by-Step Readiness Check**: Inspect all 6 steps of the operator routing chain and report exact status (`ok`, `warning`, `error`), specific blocking reasons, and actionable fixes.
|
||||
2. **Current Operator Diagnostic**: Tell the currently logged-in operator whether they personally can receive customer conversations right now, and if not, why (missing profile, no team, team off-hours, busy status, auto-assign disabled, at capacity).
|
||||
3. **Interactive Route Simulator ("Why isn't my request reaching an operator?")**: Dry-run the actual dispatch evaluation for a selected channel (or all channels) and visualize every hop from Channel -> AI Agent -> Team -> Shift Schedule -> Candidate Operators -> Winning Operator (or exact failure point).
|
||||
4. **One-Click Quick Fixes**: Provide safe, atomic backend actions to fix common blockers in one click (create/enable default team & 24/7 shift schedule, activate current user's operator profile and set idle, bind teams & handoff mode on AI agents, create/link default web channel, or run a complete "Quick Setup All" action).
|
||||
5. **Multi-Surface UI**:
|
||||
- Dedicated `/dashboard/setup` page with Step-by-Step Wizard and Route Simulator.
|
||||
- Setup Readiness Banner on `/dashboard` Overview page whenever any step is incomplete or degraded.
|
||||
- Sidebar navigation item under Dashboard.
|
||||
- Operator Readiness status indicator & popover with 1-click fix inside the `/support` operator workbench header.
|
||||
6. **Full Localization**: Complete support across all three repository locales: `en-US`, `ru-RU`, and `zh-CN` (both backend and frontend).
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend Architecture
|
||||
|
||||
Following `AGENTS.md` layer ownership (`models -> repositories -> services -> handlers -> builders/response DTOs`):
|
||||
|
||||
### 2.1 Endpoints (`internal/bootstrap/routes.go`)
|
||||
Mounted under `/api/dashboard/setup`:
|
||||
- `GET /api/dashboard/setup/readiness` -> `SetupReadinessHandler.GetReadiness`
|
||||
- `POST /api/dashboard/setup/simulate_route` -> `SetupReadinessHandler.PostSimulateRoute`
|
||||
- `POST /api/dashboard/setup/quick_fix` -> `SetupReadinessHandler.PostQuickFix`
|
||||
|
||||
Permissions:
|
||||
- `GET /api/dashboard/setup/readiness` and `POST /api/dashboard/setup/simulate_route` and `POST /api/dashboard/setup/quick_fix` are available to authenticated dashboard/support users so both administrators on `/dashboard` and operators on `/support` can diagnose and fix their readiness. Write actions that modify global resources check appropriate permissions or allow self-profile/bootstrap setup safely.
|
||||
|
||||
### 2.2 Request & Response DTOs (`internal/pkg/dto/request/setup_readiness_req.go`, `internal/pkg/dto/response/setup_readiness_resp.go`)
|
||||
|
||||
#### `SetupReadinessResponse`
|
||||
- `overall_status`: `"ok" | "warning" | "error"`
|
||||
- `ready_steps`: `int`
|
||||
- `total_steps`: `int` (6)
|
||||
- `can_receive_requests_now`: `bool`
|
||||
- `steps`: `[]SetupStepItem`
|
||||
- `key`: `"teams" | "schedules" | "operators" | "ai_agents" | "channels" | "end_to_end"`
|
||||
- `step_number`: `1..6`
|
||||
- `status`: `"ok" | "warning" | "error"`
|
||||
- `summary_code`: machine-readable reason code (e.g. `"ok"`, `"no_active_team"`, `"no_schedule_now"`, `"no_idle_operator"`, `"agent_ai_only"`, `"agent_missing_teams"`, `"no_active_channel"`, `"no_end_to_end_path"`)
|
||||
- `total_count`: `int`
|
||||
- `ready_count`: `int`
|
||||
- `issues`: `[]SetupIssueItem`
|
||||
- `code`: string (e.g. `"team_no_active_schedule"`, `"agent_ai_only_mode"`, `"agent_no_teams"`, `"operator_busy"`, `"operator_auto_assign_off"`, `"operator_no_team"`, `"operator_at_capacity"`, `"channel_no_agent"`, `"channel_zero_rollout"`)
|
||||
- `severity`: `"warning" | "error"`
|
||||
- `entity_type`: `"team" | "schedule" | "operator" | "ai_agent" | "channel" | "system"`
|
||||
- `entity_id`: `int64`
|
||||
- `entity_name`: `string`
|
||||
- `quick_fix_action`: `string` (optional action key)
|
||||
- `quick_fix_actions`: `[] string` (e.g. `["ensure_team_and_schedule"]`)
|
||||
- `manage_path`: `string` (e.g. `"/dashboard/agent-team"`)
|
||||
- `current_operator`: `CurrentOperatorDiagnostic`
|
||||
- `user_id`: `int64`
|
||||
- `username`: `string`
|
||||
- `nickname`: `string`
|
||||
- `has_profile`: `bool`
|
||||
- `profile_status_ok`: `bool`
|
||||
- `team_id`: `int64`
|
||||
- `team_name`: `string`
|
||||
- `team_active`: `bool`
|
||||
- `team_has_schedule_now`: `bool`
|
||||
- `service_status`: `int` (`0` = Idle, `1` = Busy)
|
||||
- `auto_assign_enabled`: `bool`
|
||||
- `active_conversation_count`: `int64`
|
||||
- `max_concurrent_count`: `int`
|
||||
- `can_receive_now`: `bool`
|
||||
- `blocker_codes`: `[]string` (e.g. `["missing_profile", "no_team", "team_off_hours", "status_busy", "auto_assign_disabled", "at_capacity"]`)
|
||||
|
||||
#### `SetupSimulateRouteRequest` & `SetupSimulateRouteResponse`
|
||||
- Request:
|
||||
- `channel_id`: `int64` (optional; if `0`, picks the first active channel or simulates all active channels)
|
||||
- `bypass_ai`: `bool` (optional; simulates what happens when customer requests a human / handoff triggers)
|
||||
- Response:
|
||||
- `channel_id`: `int64`
|
||||
- `channel_name`: `string`
|
||||
- `channel_type`: `string`
|
||||
- `will_reach_operator`: `bool`
|
||||
- `final_outcome_code`: `"assigned_to_operator" | "blocked_no_channel" | "blocked_channel_disabled" | "blocked_zero_rollout" | "blocked_missing_ai_agent" | "blocked_ai_only_mode" | "queued_ai_first_requires_handoff" | "blocked_agent_no_teams" | "queued_off_hours_no_schedule" | "queued_no_eligible_operator"`
|
||||
- `matched_operator`: `*SimulatedOperatorCandidate`
|
||||
- `hops`: `[]SimulatedRouteHop`
|
||||
- `hop_key`: `"channel" | "ai_agent" | "teams" | "schedules" | "operators"`
|
||||
- `status`: `"ok" | "warning" | "error"`
|
||||
- `reason_code`: string
|
||||
- `entity_name`: string
|
||||
- `quick_fix_action`: string
|
||||
- `candidate_operators`: `[]SimulatedOperatorCandidate`
|
||||
- `user_id`: `int64`
|
||||
- `display_name`: `string`
|
||||
- `team_id`: `int64`
|
||||
- `team_name`: `string`
|
||||
- `eligible`: `bool`
|
||||
- `skip_reason_code`: `"" | "profile_disabled" | "user_disabled" | "team_not_in_agent" | "team_off_hours" | "auto_assign_off" | "status_busy" | "at_capacity"`
|
||||
- `active_count`: `int64`
|
||||
- `max_concurrent_count`: `int`
|
||||
- `service_status`: `int`
|
||||
- `auto_assign_enabled`: `bool`
|
||||
|
||||
#### `SetupQuickFixRequest` & `SetupQuickFixResponse`
|
||||
- Request:
|
||||
- `action`:
|
||||
- `"quick_setup_all"` — ensures an active team exists, ensures a 24/7 active shift schedule exists for all active teams without a current schedule, ensures the current user has an active idle `AgentProfile` in an active team with `AutoAssignEnabled = true`, ensures at least one active `AIAgent` is linked to the active teams and allows human handoff (`AIFirst` with `AutoHandoffOnFailure = true` or `HumanOnly`), and ensures at least one active Web `Channel` is linked to that `AIAgent` with `RolloutPercent = 100`.
|
||||
- `"ensure_team_and_schedule"` — creates a default team if none exists, and creates an active shift schedule (`now - 1h` to `now + 365d`) for any active team lacking a currently active schedule.
|
||||
- `"enable_my_operator_profile"` — creates or updates the current user's `AgentProfile`: assigns to first active team (creating one + schedule if needed), sets `Status = StatusOk`, `AutoAssignEnabled = true`, `ServiceStatus = ServiceStatusIdle`, `MaxConcurrentCount = max(existing, 10)`. Also triggers pending conversation dispatch.
|
||||
- `"fix_ai_agent_handoff"` — for `entity_id` (or all active AI agents): binds all active `TeamIDs` if empty, switches `AIOnly` to `AIFirst` (or `HumanOnly` if no model is configured), sets `AutoHandoffOnFailure = true`, and ensures `Status = StatusOk`. If no `AIAgent` exists at all, creates a default `HumanOnly` reception agent bound to the active team.
|
||||
- `"ensure_default_channel"` — if no active channel exists, creates a default Web channel linked to an active `AIAgent` with `AIAgentRolloutPercent = 100`; if channels exist with missing `AIAgentID` or `0%` rollout, fixes them.
|
||||
- `entity_id`: `int64` (optional target ID)
|
||||
- Response:
|
||||
- `applied_actions`: `[]string`
|
||||
- `readiness`: `SetupReadinessResponse` (fresh snapshot after fix)
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend Architecture
|
||||
|
||||
### 3.1 API Module (`web/lib/api/setup-readiness.ts`)
|
||||
- Typed client functions calling `/api/dashboard/setup/readiness`, `/api/dashboard/setup/simulate_route`, and `/api/dashboard/setup/quick_fix` via `web/lib/api/client.ts`.
|
||||
|
||||
### 3.2 Dedicated Setup & Diagnostics Page (`web/app/(dashboard)/dashboard/setup/page.tsx`)
|
||||
- Uses `DashboardPage` layout and standard `rounded-md` border styling without shadows (`AGENTS.md` 5.4).
|
||||
- **Top Readiness Header Card**:
|
||||
- Shows overall status badge (`Ready` / `Needs Attention`), progress bar (`X / 6 steps ready`), current operator status summary, and primary CTA **"Quick Setup All in 1 Click"** (`quick_setup_all`) + **"Refresh Check"**.
|
||||
- **Two Main Sections**:
|
||||
1. **Step-by-Step Setup Checklist (Steps 1 to 6)**:
|
||||
- Renders each of the 6 steps in logical order:
|
||||
1. Operator Teams
|
||||
2. Working Hours & Active Shift Schedules
|
||||
3. Operator Profiles & Online Availability
|
||||
4. AI Agent & Handoff Configuration
|
||||
5. Inbound Channels
|
||||
6. End-to-End Dispatch Verification
|
||||
- Each step displays its status badge, counts (`ready / total`), specific detected issues (with entity names and localized explanations), a **1-Click Quick Fix** button when applicable, and a secondary **"Open Settings"** link to the corresponding management CRUD page.
|
||||
2. **Interactive Route Simulator ("Why isn't a request reaching an operator?")**:
|
||||
- Allows selecting any channel via `OptionCombobox` (or auto-selects the first channel) and running the live route trace.
|
||||
- Displays the 5-hop visual pipeline (`Channel` -> `AI Agent` -> `Target Teams` -> `Active Schedule` -> `Operator Selection`) with status indicators and inline 1-click fix buttons on any failing hop.
|
||||
- Displays the **Candidate Operators Table** showing every operator profile, their team, load (`active / max`), status (`Idle` / `Busy`), auto-assign state, and exact reason why they were selected or skipped.
|
||||
|
||||
### 3.3 Dashboard Home Readiness Banner (`web/app/(dashboard)/dashboard/_components/setup-readiness-banner.tsx`)
|
||||
- Rendered at the top of `DashboardHome` (`web/app/(dashboard)/dashboard/_components/dashboard-home.tsx`).
|
||||
- When `can_receive_requests_now == false` or `overall_status != "ok"` (or when the current operator cannot receive requests), shows a clear alert banner listing the broken steps with:
|
||||
- **"Fix Automatically in 1 Click"** button
|
||||
- **"Open Step-by-Step Setup"** button linking to `/dashboard/setup`
|
||||
|
||||
### 3.4 Operator Workbench Readiness Indicator (`web/app/(support)/support/_components/operator-readiness-Button.tsx` / Header integration)
|
||||
- Integrated into the `/support` top bar (`web/app/(support)/support/page.tsx` or header component).
|
||||
- Shows a compact badge indicating whether the operator is currently able to receive incoming customer chats:
|
||||
- Green (`Ready to Receive Chats`) when `current_operator.can_receive_now && readiness.can_receive_requests_now`.
|
||||
- Amber/Red (`Routing Blocked: N issues`) when blocked.
|
||||
- Clicking opens a popover showing:
|
||||
- Why requests are not reaching this operator right now (e.g., *"Your team has no active shift schedule right now (Off-Hours)"*, *"Your operator profile is Busy"*, *"Auto-assign is disabled"*, *"AI Agent has no team assigned"*).
|
||||
- **"Enable Chat Reception in 1 Click"** button (`enable_my_operator_profile` / `quick_setup_all`).
|
||||
- Link to `/dashboard/setup` for full diagnostics.
|
||||
|
||||
### 3.5 Navigation (`web/components/app-sidebar.tsx`)
|
||||
- Adds a navigation item for `/dashboard/setup` under the Overview section so users can always find the step-by-step setup wizard.
|
||||
|
||||
---
|
||||
|
||||
## 4. Localization (`en-US`, `ru-RU`, `zh-CN`)
|
||||
|
||||
- All backend error messages in `internal/pkg/i18nx/locales/{en-US,ru-RU,zh-CN}.yml`.
|
||||
- All frontend UI copy, step titles, issue descriptions, simulator hop reasons, candidate skip reasons, and action labels in `web/messages/{en-US,ru-RU,zh-CN}.json`.
|
||||
- Subagents running **Gemini Flash** (`model: "flash"`) will be used to prepare and verify translations and assist with parallel inspections.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing & Verification
|
||||
|
||||
1. **Backend Unit Tests (`internal/services/setup_readiness_service_test.go`)**:
|
||||
- Empty database -> `readiness` reports missing steps (`teams`, `schedules`, `operators`, `ai_agents`, `channels`, `end_to_end`).
|
||||
- Running `quick_Fix("quick_setup_all")` -> creates/fixes the chain atomically so `can_receive_requests_now` becomes `true` and all 6 steps become `ok`.
|
||||
- Route simulator (`SimulateRoute`) accurately identifies off-hours schedule blocks, `AIOnly` blocks, `Busy` operator blocks, and selects the winning operator when ready.
|
||||
2. **Code Formatting & Static Checks**:
|
||||
- `gofmt` and `go test ./internal/services/...`
|
||||
- `cd web && pnpm typecheck` and `cd web && pnpm lint`
|
||||
- Locale parity check across `en-US.json`, `ru-RU.json`, and `zh-CN.json`.
|
||||
- Security scan via Snyk MCP (`snyk_code_scan`) per global rules.
|
||||
386
flowgram-editor/README.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# FlowGram.AI - Demo Free Layout
|
||||
|
||||
Best-practice demo for free layout
|
||||
|
||||
## Installation
|
||||
|
||||
```shell
|
||||
npx @flowgram.ai/create-app@latest free-layout
|
||||
```
|
||||
|
||||
## Project Overview
|
||||
|
||||
### Core Tech Stack
|
||||
- **Frontend framework**: React 18 + TypeScript
|
||||
- **Build tool**: Rsbuild (a modern build tool based on Rspack)
|
||||
- **Styling**: Less + Styled Components + CSS Variables
|
||||
- **UI library**: Semi Design (@douyinfe/semi-ui)
|
||||
- **State management**: Flowgram’s in-house editor framework
|
||||
- **Dependency injection**: Inversify
|
||||
|
||||
### Core Dependencies
|
||||
|
||||
- **@flowgram.ai/free-layout-editor**: Core dependency for the free layout editor
|
||||
- **@flowgram.ai/free-snap-plugin**: Auto-alignment and guide-lines plugin
|
||||
- **@flowgram.ai/free-lines-plugin**: Connection line rendering plugin
|
||||
- **@flowgram.ai/free-node-panel-plugin**: Node add-panel rendering plugin
|
||||
- **@flowgram.ai/minimap-plugin**: Minimap plugin
|
||||
- **@flowgram.ai/export-plugin**: Download/export plugin
|
||||
- **@flowgram.ai/free-container-plugin**: Sub-canvas plugin
|
||||
- **@flowgram.ai/free-group-plugin**: Grouping plugin
|
||||
- **@flowgram.ai/form-materials**: Form materials
|
||||
- **@flowgram.ai/runtime-interface**: Runtime interfaces
|
||||
- **@flowgram.ai/runtime-js**: JS runtime module
|
||||
- **@flowgram.ai/panel-manager-plugin**: Sidebar panel management
|
||||
|
||||
## Code Guide
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
src/
|
||||
├── app.tsx # Application entry file
|
||||
├── editor.tsx # Main editor component
|
||||
├── initial-data.ts # Initial data configuration
|
||||
├── assets/ # Static assets
|
||||
├── components/ # Component library
|
||||
│ ├── index.ts
|
||||
│ ├── add-node/ # Add-node component
|
||||
│ ├── base-node/ # Base node components
|
||||
│ ├── comment/ # Comment components
|
||||
│ ├── group/ # Group components
|
||||
│ ├── line-add-button/ # Connection add button
|
||||
│ ├── node-menu/ # Node menu
|
||||
│ ├── node-panel/ # Node add panel
|
||||
│ ├── selector-box-popover/ # Selection box popover
|
||||
│ ├── sidebar/ # Sidebar
|
||||
│ ├── testrun/ # Test-run module
|
||||
│ │ ├── hooks/ # Test-run hooks
|
||||
│ │ ├── node-status-bar/ # Node status bar
|
||||
│ │ ├── testrun-button/ # Test-run button
|
||||
│ │ ├── testrun-form/ # Test-run form
|
||||
│ │ ├── testrun-json-input/ # JSON input component
|
||||
│ │ └── testrun-panel/ # Test-run panel
|
||||
│ └── tools/ # Utility components
|
||||
├── context/ # React Context
|
||||
│ ├── node-render-context.ts # Current rendering node context
|
||||
│ ├── sidebar-context # Sidebar context
|
||||
├── form-components/ # Form component library
|
||||
│ ├── form-content/ # Form content
|
||||
│ ├── form-header/ # Form header
|
||||
│ ├── form-inputs/ # Form inputs
|
||||
│ └── form-item/ # Form item
|
||||
│ └── feedback.tsx # Validation error rendering
|
||||
├── hooks/
|
||||
│ ├── index.ts
|
||||
│ ├── use-editor-props.tsx # Editor props hook
|
||||
│ ├── use-is-sidebar.ts # Sidebar state hook
|
||||
│ ├── use-node-render-context.ts # Node render context hook
|
||||
│ └── use-port-click.ts # Port click hook
|
||||
├── nodes/ # Node definitions
|
||||
│ ├── index.ts
|
||||
│ ├── constants.ts # Node constants
|
||||
│ ├── default-form-meta.ts # Default form metadata
|
||||
│ ├── block-end/ # Block end node
|
||||
│ ├── block-start/ # Block start node
|
||||
│ ├── break/ # Break node
|
||||
│ ├── code/ # Code node
|
||||
│ ├── comment/ # Comment node
|
||||
│ ├── condition/ # Condition node
|
||||
│ ├── continue/ # Continue node
|
||||
│ ├── end/ # End node
|
||||
│ ├── group/ # Group node
|
||||
│ ├── http/ # HTTP node
|
||||
│ ├── llm/ # LLM node
|
||||
│ ├── loop/ # Loop node
|
||||
│ ├── start/ # Start node
|
||||
│ └── variable/ # Variable node
|
||||
├── plugins/ # Plugin system
|
||||
│ ├── index.ts
|
||||
│ ├── context-menu-plugin/ # Right-click context menu plugin
|
||||
│ ├── runtime-plugin/ # Runtime plugin
|
||||
│ │ ├── client/ # Client
|
||||
│ │ │ ├── browser-client/ # Browser client
|
||||
│ │ │ └── server-client/ # Server client
|
||||
│ │ └── runtime-service/ # Runtime service
|
||||
│ └── variable-panel-plugin/ # Variable panel plugin
|
||||
│ └── components/ # Variable panel components
|
||||
├── services/ # Service layer
|
||||
│ ├── index.ts
|
||||
│ └── custom-service.ts # Custom service
|
||||
├── shortcuts/ # Shortcuts system
|
||||
│ ├── index.ts
|
||||
│ ├── constants.ts # Shortcut constants
|
||||
│ ├── shortcuts.ts # Shortcut definitions
|
||||
│ ├── type.ts # Type definitions
|
||||
│ ├── collapse/ # Collapse shortcut
|
||||
│ ├── copy/ # Copy shortcut
|
||||
│ ├── delete/ # Delete shortcut
|
||||
│ ├── expand/ # Expand shortcut
|
||||
│ ├── paste/ # Paste shortcut
|
||||
│ ├── select-all/ # Select-all shortcut
|
||||
│ ├── zoom-in/ # Zoom-in shortcut
|
||||
│ └── zoom-out/ # Zoom-out shortcut
|
||||
├── styles/ # Styles
|
||||
├── typings/ # Type definitions
|
||||
│ ├── index.ts
|
||||
│ ├── json-schema.ts # JSON Schema types
|
||||
│ └── node.ts # Node type definitions
|
||||
└── utils/ # Utility functions
|
||||
├── index.ts
|
||||
└── on-drag-line-end.ts # Handle end of drag line
|
||||
```
|
||||
|
||||
### Key Directory Functions
|
||||
|
||||
#### 1. `/components` - Component Library
|
||||
- **base-node**: Base rendering components for all nodes
|
||||
- **testrun**: Complete test-run module, including status bar, form, and panel
|
||||
- **sidebar**: Sidebar components providing tools and property panels
|
||||
- **node-panel**: Node add panel with drag-to-add capability
|
||||
|
||||
#### 2. `/nodes` - Node System
|
||||
Each node type has its own directory, including:
|
||||
- Node registration (`index.ts`)
|
||||
- Form metadata (`form-meta.ts`)
|
||||
- Node-specific components and logic
|
||||
|
||||
#### 3. `/plugins` - Plugin System
|
||||
- **runtime-plugin**: Supports both browser and server modes
|
||||
- **context-menu-plugin**: Right-click context menu
|
||||
- **variable-panel-plugin**: Variable management panel
|
||||
|
||||
#### 4. `/shortcuts` - Shortcuts System
|
||||
Complete keyboard shortcut support, including:
|
||||
- Basic actions: copy, paste, delete, select-all
|
||||
- View actions: zoom-in, zoom-out, collapse, expand
|
||||
- Each shortcut has its own implementation module
|
||||
|
||||
## Application Architecture
|
||||
|
||||
### Core Design Patterns
|
||||
|
||||
#### 1. Plugin Architecture
|
||||
Highly modular plugin system; each feature is an independent plugin:
|
||||
|
||||
```typescript
|
||||
plugins: () => [
|
||||
createFreeLinesPlugin({ renderInsideLine: LineAddButton }),
|
||||
createMinimapPlugin({ /* config */ }),
|
||||
createFreeSnapPlugin({ /* alignment config */ }),
|
||||
createFreeNodePanelPlugin({ renderer: NodePanel }),
|
||||
createContainerNodePlugin({}),
|
||||
createFreeGroupPlugin({ groupNodeRender: GroupNodeRender }),
|
||||
createContextMenuPlugin({}),
|
||||
createRuntimePlugin({ mode: 'browser' }),
|
||||
createVariablePanelPlugin({})
|
||||
]
|
||||
```
|
||||
|
||||
#### 2. Node Registry Pattern
|
||||
Manage different workflow node types via a registry:
|
||||
|
||||
```typescript
|
||||
export const nodeRegistries: FlowNodeRegistry[] = [
|
||||
ConditionNodeRegistry, // Condition node
|
||||
StartNodeRegistry, // Start node
|
||||
EndNodeRegistry, // End node
|
||||
LLMNodeRegistry, // LLM node
|
||||
LoopNodeRegistry, // Loop node
|
||||
CommentNodeRegistry, // Comment node
|
||||
HTTPNodeRegistry, // HTTP node
|
||||
CodeNodeRegistry, // Code node
|
||||
// ... more node types
|
||||
];
|
||||
```
|
||||
|
||||
#### 3. Dependency Injection
|
||||
Use Inversify for service DI:
|
||||
|
||||
```typescript
|
||||
onBind: ({ bind }) => {
|
||||
bind(CustomService).toSelf().inSingletonScope();
|
||||
}
|
||||
```
|
||||
## Core Features
|
||||
|
||||
### 1. Editor Configuration System
|
||||
|
||||
`useEditorProps` is the configuration center of the editor:
|
||||
|
||||
```typescript
|
||||
export function useEditorProps(
|
||||
initialData: FlowDocumentJSON,
|
||||
nodeRegistries: FlowNodeRegistry[]
|
||||
): FreeLayoutProps {
|
||||
return useMemo<FreeLayoutProps>(() => ({
|
||||
background: true, // Background grid
|
||||
readonly: false, // Readonly mode
|
||||
initialData, // Initial data
|
||||
nodeRegistries, // Node registries
|
||||
|
||||
// Core feature configs
|
||||
playground: { preventGlobalGesture: true /* Prevent Mac browser swipe gestures */ },
|
||||
nodeEngine: { enable: true },
|
||||
variableEngine: { enable: true },
|
||||
history: { enable: true, enableChangeNode: true },
|
||||
|
||||
// Business rules
|
||||
canAddLine: (ctx, fromPort, toPort) => { /* Connection rules */ },
|
||||
canDeleteLine: (ctx, line) => { /* Line deletion rules */ },
|
||||
canDeleteNode: (ctx, node) => { /* Node deletion rules */ },
|
||||
canDropToNode: (ctx, params) => { /* Drag-and-drop rules */ },
|
||||
|
||||
// Plugins
|
||||
plugins: () => [/* Plugin list */],
|
||||
|
||||
// Events
|
||||
onContentChange: debounce((ctx, event) => { /* Auto save */ }, 1000),
|
||||
onInit: (ctx) => { /* Initialization */ },
|
||||
onAllLayersRendered: (ctx) => { /* After render */ }
|
||||
}), []);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Node Type System
|
||||
|
||||
The app supports multiple workflow node types:
|
||||
|
||||
```typescript
|
||||
export enum WorkflowNodeType {
|
||||
Start = 'start', // Start node
|
||||
End = 'end', // End node
|
||||
LLM = 'llm', // Large language model node
|
||||
HTTP = 'http', // HTTP request node
|
||||
Code = 'code', // Code execution node
|
||||
Variable = 'variable', // Variable node
|
||||
Condition = 'condition', // Conditional node
|
||||
Loop = 'loop', // Loop node
|
||||
BlockStart = 'block-start', // Sub-canvas start node
|
||||
BlockEnd = 'block-end', // Sub-canvas end node
|
||||
Comment = 'comment', // Comment node
|
||||
Continue = 'continue', // Continue node
|
||||
Break = 'break', // Break node
|
||||
}
|
||||
```
|
||||
|
||||
Each node follows a unified registration pattern:
|
||||
|
||||
```typescript
|
||||
export const StartNodeRegistry: FlowNodeRegistry = {
|
||||
type: WorkflowNodeType.Start,
|
||||
meta: {
|
||||
isStart: true,
|
||||
deleteDisable: true, // Not deletable
|
||||
copyDisable: true, // Not copyable
|
||||
nodePanelVisible: false, // Hidden in node panel
|
||||
defaultPorts: [{ type: 'output' }],
|
||||
size: { width: 360, height: 211 }
|
||||
},
|
||||
info: {
|
||||
icon: iconStart,
|
||||
description: 'The starting node of the workflow, used to set up information needed to launch the workflow.'
|
||||
},
|
||||
formMeta, // Form configuration
|
||||
canAdd() { return false; } // Disallow multiple start nodes
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Plugin Architecture
|
||||
|
||||
App features are modularized via the plugin system:
|
||||
|
||||
#### Core Plugin List
|
||||
1. **FreeLinesPlugin** - Connection rendering and interaction
|
||||
2. **MinimapPlugin** - Minimap navigation
|
||||
3. **FreeSnapPlugin** - Auto-alignment and guide-lines
|
||||
4. **FreeNodePanelPlugin** - Node add panel
|
||||
5. **ContainerNodePlugin** - Container nodes (e.g., loop nodes)
|
||||
6. **FreeGroupPlugin** - Node grouping
|
||||
7. **ContextMenuPlugin** - Right-click context menu
|
||||
8. **RuntimePlugin** - Workflow runtime
|
||||
9. **VariablePanelPlugin** - Variable management panel
|
||||
|
||||
### 4. Runtime System
|
||||
|
||||
Two run modes are supported:
|
||||
|
||||
```typescript
|
||||
createRuntimePlugin({
|
||||
mode: 'browser', // Browser mode
|
||||
// mode: 'server', // Server mode
|
||||
// serverConfig: {
|
||||
// domain: 'localhost',
|
||||
// port: 4000,
|
||||
// protocol: 'http',
|
||||
// },
|
||||
})
|
||||
```
|
||||
|
||||
## Design Philosophy and Advantages
|
||||
|
||||
### 1. Highly Modular
|
||||
- **Plugin architecture**: Each feature is an independent plugin, easy to extend and maintain
|
||||
- **Node registry system**: Add new node types without changing core code
|
||||
- **Componentized UI**: Highly reusable components with clear responsibilities
|
||||
|
||||
### 2. Type Safety
|
||||
- **Full TypeScript support**: End-to-end type safety from configuration to runtime
|
||||
- **JSON Schema integration**: Node data validated by schemas
|
||||
- **Strongly typed plugin interfaces**: Clear type constraints for plugin development
|
||||
|
||||
### 3. User Experience
|
||||
- **Real-time preview**: Run and debug workflows live
|
||||
- **Rich interactions**: Dragging, zooming, snapping, shortcuts for a complete editing experience
|
||||
- **Visual feedback**: Minimap, status indicators, line animations
|
||||
|
||||
### 4. Extensibility
|
||||
- **Open plugin system**: Third parties can easily develop custom plugins
|
||||
- **Flexible node system**: Custom node types and form configurations supported
|
||||
- **Multiple runtimes**: Both browser and server modes
|
||||
|
||||
### 5. Performance
|
||||
- **On-demand loading**: Components and plugins support lazy loading
|
||||
- **Debounce**: Performance optimizations for high-frequency operations like auto-save
|
||||
|
||||
## Technical Highlights
|
||||
|
||||
### 1. In-house Editor Framework
|
||||
Based on `@flowgram.ai/free-layout-editor`, providing:
|
||||
- Free-layout canvas system
|
||||
- Full undo/redo functionality
|
||||
- Lifecycle management for nodes and connections
|
||||
- Variable engine and expression system
|
||||
|
||||
### 2. Advanced Build Configuration
|
||||
Using Rsbuild as the build tool:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
plugins: [pluginReact(), pluginLess()],
|
||||
source: {
|
||||
entry: { index: './src/app.tsx' },
|
||||
decorators: { version: 'legacy' } // Enable decorators
|
||||
},
|
||||
tools: {
|
||||
rspack: {
|
||||
ignoreWarnings: [/Critical dependency/] // Ignore specific warnings
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Internationalization
|
||||
Built-in multilingual support:
|
||||
|
||||
```typescript
|
||||
i18n: {
|
||||
locale: navigator.language,
|
||||
languages: {
|
||||
'zh-CN': {
|
||||
'Never Remind': '不再提示',
|
||||
'Hold {{key}} to drag node out': '按住 {{key}} 可以将节点拖出',
|
||||
},
|
||||
'en-US': {},
|
||||
}
|
||||
}
|
||||
```
|
||||
386
flowgram-editor/README.zh_CN.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# FlowGram.AI - Demo Free Layout
|
||||
|
||||
自由布局最佳实践 demo
|
||||
|
||||
## 安装
|
||||
|
||||
```shell
|
||||
npx @flowgram.ai/create-app@latest free-layout
|
||||
```
|
||||
|
||||
## 项目概览
|
||||
|
||||
### 核心技术栈
|
||||
- **前端框架**: React 18 + TypeScript
|
||||
- **构建工具**: Rsbuild (基于 Rspack 的现代构建工具)
|
||||
- **样式方案**: Less + Styled Components + CSS Variables
|
||||
- **UI 组件库**: Semi Design (@douyinfe/semi-ui)
|
||||
- **状态管理**: 基于 Flowgram 自研的编辑器框架
|
||||
- **依赖注入**: Inversify
|
||||
|
||||
### 核心依赖包
|
||||
|
||||
- **@flowgram.ai/free-layout-editor**: 自由布局编辑器核心依赖
|
||||
- **@flowgram.ai/free-snap-plugin**: 自动对齐及辅助线插件
|
||||
- **@flowgram.ai/free-lines-plugin**: 连线渲染插件
|
||||
- **@flowgram.ai/free-node-panel-plugin**: 节点添加面板渲染插件
|
||||
- **@flowgram.ai/minimap-plugin**: 缩略图插件
|
||||
- **@flowgram.ai/export-plugin**: 下载导出插件
|
||||
- **@flowgram.ai/free-container-plugin**: 子画布插件
|
||||
- **@flowgram.ai/free-group-plugin**: 分组插件
|
||||
- **@flowgram.ai/form-materials**: 表单物料
|
||||
- **@flowgram.ai/runtime-interface**: 运行时接口
|
||||
- **@flowgram.ai/runtime-js**: js 运行时模块
|
||||
- **@flowgram.ai/panel-manager-plugin**: 侧边栏面板管理
|
||||
|
||||
## 代码说明
|
||||
|
||||
### 目录结构
|
||||
```
|
||||
src/
|
||||
├── app.tsx # 应用入口文件
|
||||
├── editor.tsx # 编辑器主组件
|
||||
├── initial-data.ts # 初始化数据配置
|
||||
├── assets/ # 静态资源
|
||||
├── components/ # 组件库
|
||||
│ ├── index.ts
|
||||
│ ├── add-node/ # 添加节点组件
|
||||
│ ├── base-node/ # 基础节点组件
|
||||
│ ├── comment/ # 注释组件
|
||||
│ ├── group/ # 分组组件
|
||||
│ ├── line-add-button/ # 连线添加按钮
|
||||
│ ├── node-menu/ # 节点菜单
|
||||
│ ├── node-panel/ # 节点添加面板
|
||||
│ ├── selector-box-popover/ # 选择框弹窗
|
||||
│ ├── sidebar/ # 侧边栏
|
||||
│ ├── testrun/ # 测试运行组件
|
||||
│ │ ├── hooks/ # 测试运行钩子
|
||||
│ │ ├── node-status-bar/ # 节点状态栏
|
||||
│ │ ├── testrun-button/ # 测试运行按钮
|
||||
│ │ ├── testrun-form/ # 测试运行表单
|
||||
│ │ ├── testrun-json-input/ # JSON输入组件
|
||||
│ │ └── testrun-panel/ # 测试运行面板
|
||||
│ └── tools/ # 工具组件
|
||||
├── context/ # React Context
|
||||
│ ├── node-render-context.ts # 当前渲染节点 Context
|
||||
│ ├── sidebar-context # 侧边栏 Context
|
||||
├── form-components/ # 表单组件库
|
||||
│ ├── form-content/ # 表单内容
|
||||
│ ├── form-header/ # 表单头部
|
||||
│ ├── form-inputs/ # 表单输入
|
||||
│ └── form-item/ # 表单项
|
||||
│ └── feedback.tsx # 表单校验错误渲染
|
||||
├── hooks/
|
||||
│ ├── index.ts
|
||||
│ ├── use-editor-props.tsx # 编辑器属性钩子
|
||||
│ ├── use-is-sidebar.ts # 侧边栏状态钩子
|
||||
│ ├── use-node-render-context.ts # 节点渲染上下文钩子
|
||||
│ └── use-port-click.ts # 端口点击钩子
|
||||
├── nodes/ # 节点定义
|
||||
│ ├── index.ts
|
||||
│ ├── constants.ts # 节点常量定义
|
||||
│ ├── default-form-meta.ts # 默认表单元数据
|
||||
│ ├── block-end/ # 块结束节点
|
||||
│ ├── block-start/ # 块开始节点
|
||||
│ ├── break/ # 中断节点
|
||||
│ ├── code/ # 代码节点
|
||||
│ ├── comment/ # 注释节点
|
||||
│ ├── condition/ # 条件节点
|
||||
│ ├── continue/ # 继续节点
|
||||
│ ├── end/ # 结束节点
|
||||
│ ├── group/ # 分组节点
|
||||
│ ├── http/ # HTTP节点
|
||||
│ ├── llm/ # LLM节点
|
||||
│ ├── loop/ # 循环节点
|
||||
│ ├── start/ # 开始节点
|
||||
│ └── variable/ # 变量节点
|
||||
├── plugins/ # 插件系统
|
||||
│ ├── index.ts
|
||||
│ ├── context-menu-plugin/ # 右键菜单插件
|
||||
│ ├── runtime-plugin/ # 运行时插件
|
||||
│ │ ├── client/ # 客户端
|
||||
│ │ │ ├── browser-client/ # 浏览器客户端
|
||||
│ │ │ └── server-client/ # 服务器客户端
|
||||
│ │ └── runtime-service/ # 运行时服务
|
||||
│ └── variable-panel-plugin/ # 变量面板插件
|
||||
│ └── components/ # 变量面板组件
|
||||
├── services/ # 服务层
|
||||
│ ├── index.ts
|
||||
│ └── custom-service.ts # 自定义服务
|
||||
├── shortcuts/ # 快捷键系统
|
||||
│ ├── index.ts
|
||||
│ ├── constants.ts # 快捷键常量
|
||||
│ ├── shortcuts.ts # 快捷键定义
|
||||
│ ├── type.ts # 类型定义
|
||||
│ ├── collapse/ # 折叠快捷键
|
||||
│ ├── copy/ # 复制快捷键
|
||||
│ ├── delete/ # 删除快捷键
|
||||
│ ├── expand/ # 展开快捷键
|
||||
│ ├── paste/ # 粘贴快捷键
|
||||
│ ├── select-all/ # 全选快捷键
|
||||
│ ├── zoom-in/ # 放大快捷键
|
||||
│ └── zoom-out/ # 缩小快捷键
|
||||
├── styles/ # 样式文件
|
||||
├── typings/ # 类型定义
|
||||
│ ├── index.ts
|
||||
│ ├── json-schema.ts # JSON Schema类型
|
||||
│ └── node.ts # 节点类型定义
|
||||
└── utils/ # 工具函数
|
||||
├── index.ts
|
||||
└── on-drag-line-end.ts # 拖拽连线结束处理
|
||||
```
|
||||
|
||||
### 关键目录功能说明
|
||||
|
||||
#### 1. `/components` - 组件库
|
||||
- **base-node**: 所有节点的基础渲染组件
|
||||
- **testrun**: 完整的测试运行功能模块,包含状态栏、表单、面板等
|
||||
- **sidebar**: 侧边栏组件,提供工具和属性面板
|
||||
- **node-panel**: 节点添加面板,支持拖拽添加新节点
|
||||
|
||||
#### 2. `/nodes` - 节点系统
|
||||
每个节点类型都有独立的目录,包含:
|
||||
- 节点注册信息 (`index.ts`)
|
||||
- 表单元数据定义 (`form-meta.ts`)
|
||||
- 节点特定的组件和逻辑
|
||||
|
||||
#### 3. `/plugins` - 插件系统
|
||||
- **runtime-plugin**: 支持浏览器和服务器两种运行模式
|
||||
- **context-menu-plugin**: 右键菜单功能
|
||||
- **variable-panel-plugin**: 变量管理面板
|
||||
|
||||
#### 4. `/shortcuts` - 快捷键系统
|
||||
完整的快捷键支持,包括:
|
||||
- 基础操作:复制、粘贴、删除、全选
|
||||
- 视图操作:放大、缩小、折叠、展开
|
||||
- 每个快捷键都有独立的实现模块
|
||||
|
||||
## 应用架构设计
|
||||
|
||||
### 核心设计模式
|
||||
|
||||
#### 1. 插件化架构 (Plugin Architecture)
|
||||
应用采用高度模块化的插件系统,每个功能都作为独立插件存在:
|
||||
|
||||
```typescript
|
||||
plugins: () => [
|
||||
createFreeLinesPlugin({ renderInsideLine: LineAddButton }),
|
||||
createMinimapPlugin({ /* 配置 */ }),
|
||||
createFreeSnapPlugin({ /* 对齐配置 */ }),
|
||||
createFreeNodePanelPlugin({ renderer: NodePanel }),
|
||||
createContainerNodePlugin({}),
|
||||
createFreeGroupPlugin({ groupNodeRender: GroupNodeRender }),
|
||||
createContextMenuPlugin({}),
|
||||
createRuntimePlugin({ mode: 'browser' }),
|
||||
createVariablePanelPlugin({})
|
||||
]
|
||||
```
|
||||
|
||||
#### 2. 节点注册系统 (Node Registry Pattern)
|
||||
通过注册表模式管理不同类型的工作流节点:
|
||||
|
||||
```typescript
|
||||
export const nodeRegistries: FlowNodeRegistry[] = [
|
||||
ConditionNodeRegistry, // 条件节点
|
||||
StartNodeRegistry, // 开始节点
|
||||
EndNodeRegistry, // 结束节点
|
||||
LLMNodeRegistry, // LLM节点
|
||||
LoopNodeRegistry, // 循环节点
|
||||
CommentNodeRegistry, // 注释节点
|
||||
HTTPNodeRegistry, // HTTP节点
|
||||
CodeNodeRegistry, // 代码节点
|
||||
// ... 更多节点类型
|
||||
];
|
||||
```
|
||||
|
||||
#### 3. 依赖注入模式 (Dependency Injection)
|
||||
使用 Inversify 框架实现服务的依赖注入:
|
||||
|
||||
```typescript
|
||||
onBind: ({ bind }) => {
|
||||
bind(CustomService).toSelf().inSingletonScope();
|
||||
}
|
||||
```
|
||||
## 核心功能分析
|
||||
|
||||
### 1. 编辑器配置系统
|
||||
|
||||
`useEditorProps` 是整个编辑器的配置中心,包含:
|
||||
|
||||
```typescript
|
||||
export function useEditorProps(
|
||||
initialData: FlowDocumentJSON,
|
||||
nodeRegistries: FlowNodeRegistry[]
|
||||
): FreeLayoutProps {
|
||||
return useMemo<FreeLayoutProps>(() => ({
|
||||
background: true, // 背景网格
|
||||
readonly: false, // 是否只读
|
||||
initialData, // 初始数据
|
||||
nodeRegistries, // 节点注册表
|
||||
|
||||
// 核心功能配置
|
||||
playground: { preventGlobalGesture: true /* 阻止 mac 浏览器手势翻页 */ },
|
||||
nodeEngine: { enable: true },
|
||||
variableEngine: { enable: true },
|
||||
history: { enable: true, enableChangeNode: true },
|
||||
|
||||
// 业务逻辑配置
|
||||
canAddLine: (ctx, fromPort, toPort) => { /* 连线规则 */ },
|
||||
canDeleteLine: (ctx, line) => { /* 删除连线规则 */ },
|
||||
canDeleteNode: (ctx, node) => { /* 删除节点规则 */ },
|
||||
canDropToNode: (ctx, params) => { /* 拖拽规则 */ },
|
||||
|
||||
// 插件配置
|
||||
plugins: () => [/* 插件列表 */],
|
||||
|
||||
// 事件处理
|
||||
onContentChange: debounce((ctx, event) => { /* 自动保存 */ }, 1000),
|
||||
onInit: (ctx) => { /* 初始化 */ },
|
||||
onAllLayersRendered: (ctx) => { /* 渲染完成 */ }
|
||||
}), []);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 节点类型系统
|
||||
|
||||
应用支持多种工作流节点类型:
|
||||
|
||||
```typescript
|
||||
export enum WorkflowNodeType {
|
||||
Start = 'start', // 开始节点
|
||||
End = 'end', // 结束节点
|
||||
LLM = 'llm', // 大语言模型节点
|
||||
HTTP = 'http', // HTTP请求节点
|
||||
Code = 'code', // 代码执行节点
|
||||
Variable = 'variable', // 变量节点
|
||||
Condition = 'condition', // 条件判断节点
|
||||
Loop = 'loop', // 循环节点
|
||||
BlockStart = 'block-start', // 子画布开始节点
|
||||
BlockEnd = 'block-end', // 子画布结束节点
|
||||
Comment = 'comment', // 注释节点
|
||||
Continue = 'continue', // 继续节点
|
||||
Break = 'break', // 中断节点
|
||||
}
|
||||
```
|
||||
|
||||
每个节点都遵循统一的注册模式:
|
||||
|
||||
```typescript
|
||||
export const StartNodeRegistry: FlowNodeRegistry = {
|
||||
type: WorkflowNodeType.Start,
|
||||
meta: {
|
||||
isStart: true,
|
||||
deleteDisable: true, // 不可删除
|
||||
copyDisable: true, // 不可复制
|
||||
nodePanelVisible: false, // 不在节点面板显示
|
||||
defaultPorts: [{ type: 'output' }],
|
||||
size: { width: 360, height: 211 }
|
||||
},
|
||||
info: {
|
||||
icon: iconStart,
|
||||
description: '工作流的起始节点,用于设置启动工作流所需的信息。'
|
||||
},
|
||||
formMeta, // 表单配置
|
||||
canAdd() { return false; } // 不允许添加多个开始节点
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 插件化架构
|
||||
|
||||
应用的功能通过插件系统实现模块化:
|
||||
|
||||
#### 核心插件列表
|
||||
1. **FreeLinesPlugin** - 连线渲染和交互
|
||||
2. **MinimapPlugin** - 缩略图导航
|
||||
3. **FreeSnapPlugin** - 自动对齐和辅助线
|
||||
4. **FreeNodePanelPlugin** - 节点添加面板
|
||||
5. **ContainerNodePlugin** - 容器节点(如循环节点)
|
||||
6. **FreeGroupPlugin** - 节点分组功能
|
||||
7. **ContextMenuPlugin** - 右键菜单
|
||||
8. **RuntimePlugin** - 工作流运行时
|
||||
9. **VariablePanelPlugin** - 变量管理面板
|
||||
|
||||
### 4. 运行时系统
|
||||
|
||||
应用支持两种运行模式:
|
||||
|
||||
```typescript
|
||||
createRuntimePlugin({
|
||||
mode: 'browser', // 浏览器模式
|
||||
// mode: 'server', // 服务器模式
|
||||
// serverConfig: {
|
||||
// domain: 'localhost',
|
||||
// port: 4000,
|
||||
// protocol: 'http',
|
||||
// },
|
||||
})
|
||||
```
|
||||
|
||||
## 设计理念与架构优势
|
||||
|
||||
### 1. 高度模块化
|
||||
- **插件化架构**: 每个功能都是独立插件,易于扩展和维护
|
||||
- **节点注册系统**: 新节点类型可以轻松添加,无需修改核心代码
|
||||
- **组件化设计**: UI组件高度复用,职责清晰
|
||||
|
||||
### 2. 类型安全
|
||||
- **完整的TypeScript支持**: 从配置到运行时的全链路类型保护
|
||||
- **JSON Schema集成**: 节点数据结构通过Schema验证
|
||||
- **强类型的插件接口**: 插件开发有明确的类型约束
|
||||
|
||||
### 3. 用户体验优化
|
||||
- **实时预览**: 支持工作流的实时运行和调试
|
||||
- **丰富的交互**: 拖拽、缩放、对齐、快捷键等完整的编辑体验
|
||||
- **可视化反馈**: 缩略图、状态指示、连线动画等视觉反馈
|
||||
|
||||
### 4. 扩展性设计
|
||||
- **开放的插件系统**: 第三方可以轻松开发自定义插件
|
||||
- **灵活的节点系统**: 支持自定义节点类型和表单配置
|
||||
- **多运行时支持**: 浏览器和服务器双模式运行
|
||||
|
||||
### 5. 性能优化
|
||||
- **按需加载**: 组件和插件支持按需加载
|
||||
- **防抖处理**: 自动保存等高频操作的性能优化
|
||||
|
||||
## 技术亮点
|
||||
|
||||
### 1. 自研编辑器框架
|
||||
基于 `@flowgram.ai/free-layout-editor` 自研框架,提供:
|
||||
- 自由布局的画布系统
|
||||
- 完整的撤销/重做功能
|
||||
- 节点和连线的生命周期管理
|
||||
- 变量引擎和表达式系统
|
||||
|
||||
### 2. 先进的构建配置
|
||||
使用 Rsbuild 作为构建工具:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
plugins: [pluginReact(), pluginLess()],
|
||||
source: {
|
||||
entry: { index: './src/app.tsx' },
|
||||
decorators: { version: 'legacy' } // 支持装饰器
|
||||
},
|
||||
tools: {
|
||||
rspack: {
|
||||
ignoreWarnings: [/Critical dependency/] // 忽略特定警告
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 国际化支持
|
||||
内置多语言支持:
|
||||
|
||||
```typescript
|
||||
i18n: {
|
||||
locale: navigator.language,
|
||||
languages: {
|
||||
'zh-CN': {
|
||||
'Never Remind': '不再提示',
|
||||
'Hold {{key}} to drag node out': '按住 {{key}} 可以将节点拖出',
|
||||
},
|
||||
'en-US': {},
|
||||
}
|
||||
}
|
||||
```
|
||||
20
flowgram-editor/eslint.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
|
||||
|
||||
module.exports = defineFlatConfig({
|
||||
preset: 'web',
|
||||
packageRoot: __dirname,
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'react/prop-types': 'off',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
});
|
||||
12
flowgram-editor/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bundler="rspack">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Flow FreeLayoutEditor Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
78
flowgram-editor/package.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "@agent-desk/flowgram-editor",
|
||||
"version": "1.0.4",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"files": [
|
||||
"src/",
|
||||
"eslint.config.js",
|
||||
".gitignore",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"rsbuild.config.ts",
|
||||
"tsconfig.json",
|
||||
"README.md",
|
||||
"README.zh_CN.md"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.80.0",
|
||||
"@douyinfe/semi-ui": "^2.80.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^5.0.9",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"styled-components": "^5",
|
||||
"classnames": "^2.5.1",
|
||||
"@flowgram.ai/runtime-interface": "1.0.12",
|
||||
"@flowgram.ai/free-snap-plugin": "1.0.12",
|
||||
"@flowgram.ai/free-node-panel-plugin": "1.0.12",
|
||||
"@flowgram.ai/minimap-plugin": "1.0.12",
|
||||
"@flowgram.ai/free-lines-plugin": "1.0.12",
|
||||
"@flowgram.ai/free-layout-editor": "1.0.12",
|
||||
"@flowgram.ai/export-plugin": "1.0.12",
|
||||
"@flowgram.ai/free-container-plugin": "1.0.12",
|
||||
"@flowgram.ai/free-group-plugin": "1.0.12",
|
||||
"@flowgram.ai/panel-manager-plugin": "1.0.12",
|
||||
"@flowgram.ai/form-materials": "1.0.12",
|
||||
"@flowgram.ai/free-stack-plugin": "1.0.12",
|
||||
"@flowgram.ai/runtime-js": "1.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rsbuild/core": "^1.2.16",
|
||||
"@rsbuild/plugin-react": "^1.1.1",
|
||||
"@rsbuild/plugin-less": "^1.1.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^18",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/styled-components": "^5",
|
||||
"typescript": "^6.0.3",
|
||||
"eslint": "^9.0.0",
|
||||
"cross-env": "~7.0.3",
|
||||
"@flowgram.ai/eslint-config": "1.0.12",
|
||||
"@flowgram.ai/ts-config": "1.0.12"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env MODE=app NODE_ENV=production rsbuild build",
|
||||
"build:fast": "exit 0",
|
||||
"build:watch": "exit 0",
|
||||
"build:prod": "cross-env MODE=app NODE_ENV=production rsbuild build",
|
||||
"build:analyze": "BUNDLE_ANALYZE=true rsbuild build",
|
||||
"clean": "rimraf dist",
|
||||
"dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open",
|
||||
"lint": "eslint ./src --cache",
|
||||
"lint:fix": "eslint ./src --fix",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"start": "cross-env NODE_ENV=development rsbuild dev --open",
|
||||
"test": "exit",
|
||||
"test:cov": "exit",
|
||||
"watch": "exit 0"
|
||||
}
|
||||
}
|
||||
10234
flowgram-editor/pnpm-lock.yaml
generated
Normal file
41
flowgram-editor/rsbuild.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { pluginReact } from '@rsbuild/plugin-react';
|
||||
import { pluginLess } from '@rsbuild/plugin-less';
|
||||
import { defineConfig } from '@rsbuild/core';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [pluginReact(), pluginLess()],
|
||||
source: {
|
||||
entry: {
|
||||
index: './src/app.tsx',
|
||||
},
|
||||
/**
|
||||
* support inversify @injectable() and @inject decorators
|
||||
*/
|
||||
decorators: {
|
||||
version: 'legacy',
|
||||
},
|
||||
},
|
||||
html: {
|
||||
title: 'FlowGram Workflow Editor',
|
||||
},
|
||||
output: {
|
||||
assetPrefix: '/flowgram-editor/',
|
||||
distPath: {
|
||||
root: '../web/public/flowgram-editor',
|
||||
},
|
||||
cleanDistPath: true,
|
||||
},
|
||||
tools: {
|
||||
rspack: {
|
||||
/**
|
||||
* ignore warnings from @coze-editor/editor/language-typescript
|
||||
*/
|
||||
ignoreWarnings: [/Critical dependency: the request of a dependency is an expression/],
|
||||
},
|
||||
},
|
||||
});
|
||||
18
flowgram-editor/src/app.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { unstableSetCreateRoot } from '@flowgram.ai/form-materials';
|
||||
|
||||
import { Editor } from './editor';
|
||||
|
||||
/**
|
||||
* React 18/19 polyfill for form-materials
|
||||
*/
|
||||
unstableSetCreateRoot(createRoot);
|
||||
|
||||
const app = createRoot(document.getElementById('root')!);
|
||||
|
||||
app.render(<Editor />);
|
||||
13
flowgram-editor/src/assets/icon-auto-layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export const IconAutoLayout = (
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 2C2.44772 2 2 2.44771 2 3V12C2 12.5523 2.44772 13 3 13H10C10.5523 13 11 12.5523 11 12V3C11 2.44772 10.5523 2 10 2H3zM4 11V4H9V11H4zM21 22C21.5523 22 22 21.5523 22 21V12C22 11.4477 21.5523 11 21 11H14C13.4477 11 13 11.4477 13 12V21C13 21.5523 13.4477 22 14 22H21zM20 13V20H15V13H20zM2 16C2 15.4477 2.44772 15 3 15H10C10.5523 15 11 15.4477 11 16V21C11 21.5523 10.5523 22 10 22H3C2.44772 22 2 21.5523 2 21V16zM4 20V17H9V20H4zM21 9C21.5523 9 22 8.55228 22 8V3C22 2.44772 21.5523 2 21 2H14C13.4477 2 13 2.44772 13 3V8C13 8.55228 13.4477 9 14 9H21zM20 4V7H15V4H20z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
BIN
flowgram-editor/src/assets/icon-break.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
24
flowgram-editor/src/assets/icon-cancel.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const IconCancel = ({ className, style }: Props) => (
|
||||
<svg
|
||||
className={className}
|
||||
style={style}
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M9.5 8C8.67157 8 8 8.67157 8 9.5V14.5C8 15.3284 8.67157 16 9.5 16H14.5C15.3284 16 16 15.3284 16 14.5V9.5C16 8.67157 15.3284 8 14.5 8H9.5Z"></path>
|
||||
<path d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21Z"></path>
|
||||
</svg>
|
||||
);
|
||||
24
flowgram-editor/src/assets/icon-comment.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { CSSProperties, FC } from 'react';
|
||||
|
||||
interface IconCommentProps {
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const IconComment: FC<IconCommentProps> = ({ style }) => (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={style}
|
||||
>
|
||||
<path d="M6.5 9C5.94772 9 5.5 9.44772 5.5 10V11C5.5 11.5523 5.94772 12 6.5 12H7.5C8.05228 12 8.5 11.5523 8.5 11V10C8.5 9.44772 8.05228 9 7.5 9H6.5zM11.5 9C10.9477 9 10.5 9.44772 10.5 10V11C10.5 11.5523 10.9477 12 11.5 12H12.5C13.0523 12 13.5 11.5523 13.5 11V10C13.5 9.44772 13.0523 9 12.5 9H11.5zM15.5 10C15.5 9.44772 15.9477 9 16.5 9H17.5C18.0523 9 18.5 9.44772 18.5 10V11C18.5 11.5523 18.0523 12 17.5 12H16.5C15.9477 12 15.5 11.5523 15.5 11V10z"></path>
|
||||
<path d="M23 4C23 2.9 22.1 2 21 2H3C1.9 2 1 2.9 1 4V17.0111C1 18.0211 1.9 19.0111 3 19.0111H7.7586L10.4774 22C10.9822 22.5017 11.3166 22.6311 12 22.7009C12.414 22.707 13.0502 22.5093 13.5 22L16.2414 19.0111H21C22.1 19.0111 23 18.1111 23 17.0111V4ZM3 4H21V17.0111H15.5L12 20.6714L8.5 17.0111H3V4Z"></path>
|
||||
</svg>
|
||||
);
|
||||
9
flowgram-editor/src/assets/icon-condition.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="44" height="45" viewBox="0 0 44 45" fill="none" class="injected-svg" data-src="https://lf3-static.bytednsdoc.com/obj/eden-cn/uvpahtvabh_lm_zhhwh/ljhwZthlaukjlkulzlp/activity_icons/exclusive-split-0518.svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4705 14.0152C15.299 12.8436 15.299 10.944 16.4705 9.77244L20.7131 5.5297C21.8846 4.3581 23.784 4.3581 24.9556 5.5297L29.1981 9.77244C30.3697 10.944 30.3697 12.8436 29.1981 14.0152L25.1206 18.0929H32.6674C36.5334 18.0929 39.6674 21.2269 39.6674 25.0929V33.154V33.3271V37.154C39.6674 38.2585 38.7719 39.154 37.6674 39.154H33.6674C32.5628 39.154 31.6674 38.2585 31.6674 37.154V33.3271V33.154V26.0929H23.5948H15.6674V33.1327L17.2685 33.1244C18.8397 33.1163 19.6322 35.0156 18.5212 36.1266L12.7374 41.9103C12.0506 42.5971 10.9371 42.5971 10.2503 41.9103L4.52588 36.1859C3.42107 35.0811 4.19797 33.1917 5.76038 33.1837L7.66737 33.1739V25.0929C7.66737 21.227 10.8014 18.0929 14.6674 18.0929H20.5481L16.4705 14.0152Z" fill="url(#paint0_linear_2752_183702-7)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2752_183702-7" x1="38.52" y1="43.3915" x2="8.09686" y2="4.6982" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3370FF"/>
|
||||
<stop offset="0.997908" stop-color="#33A9FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
flowgram-editor/src/assets/icon-continue.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
flowgram-editor/src/assets/icon-end.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
5
flowgram-editor/src/assets/icon-http.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M20.006 4C22.145 4 23.9853 7.39855 24.7651 12.241H15.2469C16.0267 7.39855 17.867 4 20.006 4ZM15.021 20.9119C14.908 19.8023 14.8424 18.6486 14.8424 17.4436C14.8424 16.2421 14.908 15.0848 15.021 13.9752H24.9837C25.0966 15.0848 25.1623 16.2421 25.1623 17.4436C25.1623 18.645 25.0966 19.7987 24.9837 20.9119H15.021ZM23.8044 4.56199C27.6525 5.71199 30.7644 8.55942 32.3022 12.2409H26.4936C26.0199 9.15463 25.1162 6.39537 23.8044 4.56199ZM16.1971 4.56199C12.3563 5.71199 9.23701 8.55942 7.70652 12.2409H13.5151C13.9815 9.15463 14.8852 6.39537 16.1971 4.56199ZM26.7119 13.9752H32.8776C33.1691 15.0848 33.3368 16.2421 33.3368 17.4436C33.3368 18.645 33.1691 19.7987 32.874 20.9119H26.7119C26.8249 19.7766 26.8906 18.6083 26.8906 17.4436C26.8906 16.2789 26.8249 15.1142 26.7119 13.9752ZM13.122 17.4436C13.122 16.2789 13.1876 15.1105 13.3006 13.9752H7.13127C6.83975 15.0885 6.66848 16.2421 6.66848 17.4436C6.66848 18.645 6.83975 19.8023 7.13127 20.912H13.2933C13.1876 19.7767 13.122 18.6119 13.122 17.4436ZM4 25.3373C4 23.8005 5.24582 22.5547 6.78261 22.5547H33.2174C34.7542 22.5547 36 23.8005 36 25.3373V33.2174C36 34.7542 34.7542 36 33.2174 36H6.78261C5.24582 36 4 34.7542 4 33.2174V25.3373ZM10.9109 28.1569H8.48666V25.9161H6.66848V32.6388H8.48666V29.8376H10.9109V32.6388H12.7291V25.9161H10.9109V28.1569ZM13.9412 27.5968H15.7594V32.6388H17.5776V27.5968H19.3958V25.9161H13.9412V27.5968ZM20.6079 27.5968H22.426V32.6388H24.2442V27.5968H26.0625V25.9161H20.6079V27.5968ZM31.5169 25.9161H27.2746V32.6388H29.0927V30.3979H31.5169C32.5472 30.3979 33.3351 29.6696 33.3351 28.7172V27.5968C33.3351 26.6445 32.5472 25.9161 31.5169 25.9161ZM31.5169 28.7172H29.0927V27.5968H31.5169V28.7172Z"
|
||||
fill="#3370FF" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
flowgram-editor/src/assets/icon-llm.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
flowgram-editor/src/assets/icon-loop.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
24
flowgram-editor/src/assets/icon-minimap.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export const IconMinimap = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="g1">
|
||||
<path
|
||||
id="path1"
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
d="M 18.09091 6.883101 L 5.409091 6.883101 L 5.409091 16.746737 L 10.664648 16.746737 C 10.927091 17.116341 11.30353 17.422749 11.792977 17.611004 L 12.664289 17.946156 L 12.744959 18.155828 L 5.409091 18.155828 C 4.630871 18.155828 4 17.524979 4 16.746737 L 4 6.883101 C 4 6.104881 4.630871 5.47401 5.409091 5.47401 L 18.09091 5.47401 C 18.86915 5.47401 19.5 6.104881 19.5 6.883101 L 19.5 12.52348 C 19.247208 11.883823 18.730145 11.365912 18.09091 11.111994 L 18.09091 6.883101 Z M 18.09091 18.155828 L 17.881165 18.155828 L 19.469212 14.368896 C 19.479921 14.343321 19.490206 14.317817 19.5 14.292241 L 19.5 16.746737 C 19.5 17.524979 18.86915 18.155828 18.09091 18.155828 Z"
|
||||
/>
|
||||
<path
|
||||
id="path2"
|
||||
fill="#000000"
|
||||
fillRule="evenodd"
|
||||
stroke="none"
|
||||
d="M 18.494614 13.960189 C 18.982441 12.796985 17.813459 11.628003 16.650255 12.11576 L 12.133272 14.01 C 10.962248 14.501069 10.987188 16.168798 12.172375 16.62464 L 13.482055 17.128389 L 13.985805 18.438068 C 14.441646 19.623184 16.109375 19.648125 16.600443 18.477171 L 18.494614 13.960189 Z M 17.19515 13.415224 L 15.30098 17.932205 L 14.79723 16.622526 C 14.654066 16.250385 14.359989 15.956307 13.987918 15.813213 L 12.678168 15.309464 L 17.19515 13.415224 Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
41
flowgram-editor/src/assets/icon-mouse.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export function IconMouse(props: { width?: number; height?: number }) {
|
||||
const { width, height } = props;
|
||||
return (
|
||||
<svg
|
||||
width={width || 34}
|
||||
height={height || 52}
|
||||
viewBox="0 0 34 52"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M30.9998 16.6666V35.3333C30.9998 37.5748 30.9948 38.4695 30.9 39.1895C30.2108 44.4247 26.0912 48.5443 20.856 49.2335C20.1361 49.3283 19.2413 49.3333 16.9998 49.3333C14.7584 49.3333 13.8636 49.3283 13.1437 49.2335C7.90847 48.5443 3.78888 44.4247 3.09965 39.1895C3.00487 38.4695 2.99984 37.5748 2.99984 35.3333V16.6666C2.99984 14.4252 3.00487 13.5304 3.09965 12.8105C3.78888 7.57528 7.90847 3.45569 13.1437 2.76646C13.7232 2.69017 14.4159 2.67202 15.8332 2.66785V9.86573C14.4738 10.3462 13.4998 11.6426 13.4998 13.1666V17.8332C13.4998 19.3571 14.4738 20.6536 15.8332 21.1341V23.6666C15.8332 24.3109 16.3555 24.8333 16.9998 24.8333C17.6442 24.8333 18.1665 24.3109 18.1665 23.6666V21.1341C19.5259 20.6536 20.4998 19.3572 20.4998 17.8332V13.1666C20.4998 11.6426 19.5259 10.3462 18.1665 9.86571V2.66785C19.5837 2.67202 20.2765 2.69017 20.856 2.76646C26.0912 3.45569 30.2108 7.57528 30.9 12.8105C30.9948 13.5304 30.9998 14.4252 30.9998 16.6666ZM0.666504 16.6666C0.666504 14.4993 0.666504 13.4157 0.786276 12.5059C1.61335 6.22368 6.55687 1.28016 12.8391 0.453085C13.7489 0.333313 14.8325 0.333313 16.9998 0.333313C19.1671 0.333313 20.2508 0.333313 21.1605 0.453085C27.4428 1.28016 32.3863 6.22368 33.2134 12.5059C33.3332 13.4157 33.3332 14.4994 33.3332 16.6666V35.3333C33.3332 37.5006 33.3332 38.5843 33.2134 39.494C32.3863 45.7763 27.4428 50.7198 21.1605 51.5469C20.2508 51.6666 19.1671 51.6666 16.9998 51.6666C14.8325 51.6666 13.7489 51.6666 12.8391 51.5469C6.55687 50.7198 1.61335 45.7763 0.786276 39.494C0.666504 38.5843 0.666504 37.5006 0.666504 35.3333V16.6666ZM15.8332 13.1666C15.8332 13.0011 15.8676 12.8437 15.9297 12.7011C15.9886 12.566 16.0722 12.4443 16.1749 12.3416C16.386 12.1305 16.6777 11.9999 16.9998 11.9999C17.6435 11.9999 18.1654 12.5212 18.1665 13.1646L18.1665 13.1666V17.8332L18.1665 17.8353C18.1665 17.8364 18.1665 17.8376 18.1665 17.8387C18.1661 17.9132 18.1588 17.986 18.1452 18.0565C18.0853 18.3656 17.9033 18.6312 17.6515 18.8011C17.4655 18.9266 17.2412 18.9999 16.9998 18.9999C16.3555 18.9999 15.8332 18.4776 15.8332 17.8332V13.1666Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconMouseTool = () => (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.5 8C4.5 4.13401 7.63401 1 11.5 1H12.5C16.366 1 19.5 4.13401 19.5 8V17C19.5 20.3137 16.8137 23 13.5 23H10.5C7.18629 23 4.5 20.3137 4.5 17V8ZM11.2517 3.00606C8.60561 3.13547 6.5 5.32184 6.5 8V17C6.5 19.2091 8.29086 21 10.5 21H13.5C15.7091 21 17.5 19.2091 17.5 17V8C17.5 5.32297 15.3962 3.13732 12.7517 3.00622V5.28013C13.2606 5.54331 13.6074 6.06549 13.6074 6.66669V8.75759C13.6074 9.35879 13.2606 9.88097 12.7517 10.1441V11.4091C12.7517 11.8233 12.4159 12.1591 12.0017 12.1591C11.5875 12.1591 11.2517 11.8233 11.2517 11.4091V10.1457C10.7411 9.88298 10.3931 9.35994 10.3931 8.75759V6.66669C10.3931 6.06433 10.7411 5.5413 11.2517 5.27862V3.00606ZM12.0017 6.14397C11.7059 6.14397 11.466 6.38381 11.466 6.67968V8.74462C11.466 9.03907 11.7036 9.27804 11.9975 9.28031L12.0002 9.28032C12.0456 9.28032 12.0896 9.27482 12.1316 9.26447C12.3401 9.21256 12.5002 9.0386 12.5318 8.82287C12.5345 8.80149 12.5359 8.7797 12.5359 8.75759V6.66669C12.5359 6.64463 12.5345 6.62288 12.5318 6.60154C12.4999 6.38354 12.3368 6.20817 12.1252 6.15826C12.0856 6.14891 12.0442 6.14397 12.0017 6.14397Z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
56
flowgram-editor/src/assets/icon-pad.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export function IconPad(props: { width?: number; height?: number }) {
|
||||
const { width, height } = props;
|
||||
return (
|
||||
<svg
|
||||
width={width || 48}
|
||||
height={height || 38}
|
||||
viewBox="0 0 48 38"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="1.83317"
|
||||
y="1.49998"
|
||||
width="44.3333"
|
||||
height="35"
|
||||
rx="3.5"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.8"
|
||||
strokeWidth="2.33333"
|
||||
/>
|
||||
<path
|
||||
d="M14.6665 30.6667H33.3332"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.8"
|
||||
strokeWidth="2.33333"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconPadTool = () => (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M20.8549 5H3.1451C3.06496 5 3 5.06496 3 5.1451V18.8549C3 18.935 3.06496 19 3.1451 19H20.8549C20.935 19 21 18.935 21 18.8549V5.1451C21 5.06496 20.935 5 20.8549 5ZM3.1451 3C1.96039 3 1 3.96039 1 5.1451V18.8549C1 20.0396 1.96039 21 3.1451 21H20.8549C22.0396 21 23 20.0396 23 18.8549V5.1451C23 3.96039 22.0396 3 20.8549 3H3.1451Z"
|
||||
></path>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.99991 16C6.99991 15.4477 7.44762 15 7.99991 15H15.9999C16.5522 15 16.9999 15.4477 16.9999 16C16.9999 16.5523 16.5522 17 15.9999 17H7.99991C7.44762 17 6.99991 16.5523 6.99991 16Z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
BIN
flowgram-editor/src/assets/icon-script.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
flowgram-editor/src/assets/icon-start.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
37
flowgram-editor/src/assets/icon-success.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const IconSuccessFill = ({ className, style }: Props) => (
|
||||
<svg
|
||||
className={className}
|
||||
style={style}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g clipPath="url(#icon-workflow-run-success_svg__a)">
|
||||
<path
|
||||
fill="#3EC254"
|
||||
d="M.833 10A9.166 9.166 0 0 0 10 19.168a9.166 9.166 0 0 0 9.167-9.166A9.166 9.166 0 0 0 10 .834a9.166 9.166 0 0 0-9.167 9.167"
|
||||
></path>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M6.077 9.755a.833.833 0 0 0 0 1.179l2.357 2.357a.833.833 0 0 0 1.179 0l4.714-4.714a.833.833 0 1 0-1.178-1.179l-4.125 4.125-1.768-1.768a.833.833 0 0 0-1.179 0"
|
||||
></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="icon-workflow-run-success_svg__a">
|
||||
<path fill="#fff" d="M0 0h20v20H0z"></path>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
15
flowgram-editor/src/assets/icon-switch-line.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export const IconSwitchLine = (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
id="switch-line"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
d="M 12.728118 10.060962 C 13.064282 8.716098 14.272528 7.772551 15.65877 7.772343 L 17.689898 7.772343 C 18.0798 7.772343 18.39588 7.456264 18.39588 7.066362 C 18.39588 6.676458 18.0798 6.36038 17.689898 6.36038 L 15.659616 6.36038 C 13.62515 6.360315 11.851767 7.745007 11.358504 9.718771 C 11.02234 11.063635 9.814095 12.007183 8.427853 12.007389 L 7.101437 12.007389 C 6.711768 12.007389 6.395878 12.323277 6.395878 12.712947 C 6.395878 13.102616 6.711768 13.418506 7.101437 13.418506 L 8.426159 13.418506 C 9.812716 13.418323 11.021417 14.361954 11.357657 15.707124 C 11.850921 17.680887 13.624304 19.065578 15.65877 19.065516 L 17.689049 19.065516 C 18.078953 19.065516 18.395033 18.749435 18.395033 18.359533 C 18.395033 17.969631 18.078953 17.653551 17.689049 17.653551 L 15.65877 17.653551 C 14.272528 17.653345 13.064282 16.709797 12.728118 15.364932 C 12.454905 14.27114 11.774856 13.322707 10.826583 12.712947 C 11.774536 12.10303 12.454268 11.154617 12.727271 10.060962 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
BIN
flowgram-editor/src/assets/icon-variable.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
27
flowgram-editor/src/assets/icon-warning.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const IconWarningFill = ({ className, style }: Props) => (
|
||||
<svg
|
||||
className={className}
|
||||
style={style}
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12ZM11 8C11 7.44772 11.4477 7 12 7C12.5523 7 13 7.44772 13 8V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V8ZM11 16C11 15.4477 11.4477 15 12 15C12.5523 15 13 15.4477 13 16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16Z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
28
flowgram-editor/src/components/add-node/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { IconPlus } from '@douyinfe/semi-icons';
|
||||
|
||||
import { useAddNode } from './use-add-node';
|
||||
|
||||
export const AddNode = (props: { disabled: boolean }) => {
|
||||
const addNode = useAddNode();
|
||||
return (
|
||||
<Button
|
||||
data-testid="demo.free-layout.add-node"
|
||||
icon={<IconPlus />}
|
||||
color="highlight"
|
||||
style={{ backgroundColor: 'rgba(171,181,255,0.3)', borderRadius: '8px' }}
|
||||
disabled={props.disabled}
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
addNode(rect);
|
||||
}}
|
||||
>
|
||||
Add Node
|
||||
</Button>
|
||||
);
|
||||
};
|
||||