chore: import clean Agent Desk snapshot (1/7)
This commit is contained in:
164
docs/embedding.md
Normal file
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
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
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
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
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
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
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
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.
|
||||
Reference in New Issue
Block a user