chore: complete clean Agent Desk source snapshot

This commit is contained in:
2026-10-16 12:03:16 +00:00
parent 756ae33d05
commit e57610a27c
123 changed files with 37513 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
import test from "node:test"
import assert from "node:assert/strict"
import { getAgentActivityLabel, formatOperatorToolName } from "./agent-activity-label.ts"
// Mock translator that mimics t(key, params)
function mockT(key, params = {}) {
const dictionary = {
"agentActivity.thinking": "AI-ассистент думает...",
"agentActivity.searchingKnowledge": "Поиск в базе знаний...",
"agentActivity.callingTool": `Выполнение действия: ${params.name || "..."}...`,
"agentActivity.runningWorkflow": `Выполнение сценария: ${params.name || "..."}...`,
"agentActivity.default": "AI-ассистент обрабатывает запрос...",
"agentActivity.customerThinking": "AI-ассистент думает...",
"agentActivity.customerSearching": "Поиск информации...",
"agentActivity.customerProcessing": "AI-ассистент готовит ответ...",
"agentActivity.customerDefault": "AI-ассистент готовит ответ...",
"agentActivity.toolAnalyzeConversation": "Анализ диалога",
"agentActivity.toolTriageServiceRequest": "Маршрутизация обращения",
"agentActivity.toolCreateTicket": "Создание обращения",
"agentActivity.toolHandoff": "Перевод на оператора",
}
return dictionary[key] ?? key
}
test("formatOperatorToolName formats known graph tools to localized human titles", () => {
assert.equal(formatOperatorToolName("graph_analyze_conversation", mockT), "Анализ диалога")
assert.equal(formatOperatorToolName("graph/analyze_conversation", mockT), "Анализ диалога")
assert.equal(formatOperatorToolName("analyze_conversation", mockT), "Анализ диалога")
assert.equal(formatOperatorToolName("graph_triage_service_request", mockT), "Маршрутизация обращения")
assert.equal(formatOperatorToolName("graph_create_ticket", mockT), "Создание обращения")
assert.equal(formatOperatorToolName("graph_handoff", mockT), "Перевод на оператора")
})
test("formatOperatorToolName formats unknown tools by stripping prefixes and converting underscores", () => {
assert.equal(formatOperatorToolName("mcp_fetch_order_status", mockT), "mcp fetch order status")
assert.equal(formatOperatorToolName("workflow_refund_check", mockT), "refund check")
assert.equal(formatOperatorToolName("skill_order_lookup", mockT), "order lookup")
assert.equal(formatOperatorToolName("", mockT), "...")
assert.equal(formatOperatorToolName(undefined, mockT), "...")
})
test("customer mode (isCustomer=true) never exposes tool names or technical system jargon", () => {
// Calling tool with internal graph_analyze_conversation
const toolLabel = getAgentActivityLabel(
{ stepCode: "calling_tool", toolName: "graph_analyze_conversation", isCustomer: true },
mockT
)
assert.equal(toolLabel, "AI-ассистент готовит ответ...")
assert.ok(!toolLabel.includes("graph_analyze_conversation"))
assert.ok(!toolLabel.includes("Выполнение действия"))
// Running workflow
const workflowLabel = getAgentActivityLabel(
{ stepCode: "running_workflow", toolName: "workflow_triage_request", isCustomer: true },
mockT
)
assert.equal(workflowLabel, "AI-ассистент готовит ответ...")
assert.ok(!workflowLabel.includes("workflow_triage_request"))
// Searching knowledge
const searchLabel = getAgentActivityLabel(
{ stepCode: "searching_knowledge", isCustomer: true },
mockT
)
assert.equal(searchLabel, "Поиск информации...")
assert.ok(!searchLabel.includes("базе знаний"))
// Thinking
const thinkingLabel = getAgentActivityLabel(
{ stepCode: "thinking", isCustomer: true },
mockT
)
assert.equal(thinkingLabel, "AI-ассистент думает...")
// Defaults to customer mode if isCustomer is omitted
const defaultCustomerLabel = getAgentActivityLabel(
{ stepCode: "calling_tool", toolName: "graph_analyze_conversation" },
mockT
)
assert.equal(defaultCustomerLabel, "AI-ассистент готовит ответ...")
})
test("operator mode (isCustomer=false) provides clear formatted context to operators", () => {
const toolLabel = getAgentActivityLabel(
{ stepCode: "calling_tool", toolName: "graph_analyze_conversation", isCustomer: false },
mockT
)
assert.equal(toolLabel, "Выполнение действия: Анализ диалога...")
const workflowLabel = getAgentActivityLabel(
{ stepCode: "running_workflow", toolName: "workflow_sync_data", isCustomer: false },
mockT
)
assert.equal(workflowLabel, "Выполнение сценария: sync data...")
const searchLabel = getAgentActivityLabel(
{ stepCode: "searching_knowledge", isCustomer: false },
mockT
)
assert.equal(searchLabel, "Поиск в базе знаний...")
const thinkingLabel = getAgentActivityLabel(
{ stepCode: "thinking", isCustomer: false },
mockT
)
assert.equal(thinkingLabel, "AI-ассистент думает...")
})

View File

@@ -0,0 +1,88 @@
export type AgentActivityLabelOptions = {
stepCode: string
toolName?: string
isCustomer?: boolean
}
export type TranslateFn = (key: string, values?: Record<string, string>) => string
export function formatOperatorToolName(toolName?: string, t?: TranslateFn): string {
if (!toolName) return "..."
const normalized = toolName.trim()
if (
normalized === "graph_analyze_conversation" ||
normalized === "analyze_conversation" ||
normalized === "graph/analyze_conversation"
) {
return t ? t("agentActivity.toolAnalyzeConversation") : "Analyze conversation"
}
if (
normalized === "graph_triage_service_request" ||
normalized === "triage_service_request" ||
normalized === "graph/triage_service_request"
) {
return t ? t("agentActivity.toolTriageServiceRequest") : "Triage service request"
}
if (
normalized === "graph_create_ticket" ||
normalized === "create_ticket" ||
normalized === "graph/create_ticket"
) {
return t ? t("agentActivity.toolCreateTicket") : "Create ticket"
}
if (
normalized === "graph_handoff" ||
normalized === "handoff" ||
normalized === "graph/handoff"
) {
return t ? t("agentActivity.toolHandoff") : "Human handoff"
}
// Remove common prefixes: graph_, skill_, workflow_, builtin_, or graph/
const clean = normalized
.replace(/^(graph_|skill_|workflow_|builtin_|graph\/)/, "")
.replace(/_/g, " ")
.trim()
return clean || normalized
}
export function getAgentActivityLabel(
options: AgentActivityLabelOptions,
t: TranslateFn
): string {
const { stepCode, toolName, isCustomer = true } = options
// For customers (end-users in support chat):
// Never expose internal system details, technical function names, or tool execution steps.
if (isCustomer) {
if (stepCode === "thinking") {
return t("agentActivity.customerThinking")
}
if (stepCode === "searching_knowledge") {
return t("agentActivity.customerSearching")
}
// For calling_tool, running_workflow, or any other internal action
return t("agentActivity.customerProcessing")
}
// For operators (in dashboard workspace):
// Surface informative step and formatted tool name
if (stepCode === "thinking") {
return t("agentActivity.thinking")
}
if (stepCode === "searching_knowledge") {
return t("agentActivity.searchingKnowledge")
}
if (stepCode === "calling_tool") {
const formatted = formatOperatorToolName(toolName, t)
return t("agentActivity.callingTool", { name: formatted })
}
if (stepCode === "running_workflow") {
const formatted = formatOperatorToolName(toolName, t)
return t("agentActivity.runningWorkflow", { name: formatted })
}
return t("agentActivity.default")
}

View File

@@ -0,0 +1,74 @@
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("clears activity state when status is failed", () => {
const current = { conversationId: 42, stepCode: "thinking" }
const event = {
conversationId: 42,
stepCode: "failed",
status: "failed",
}
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)
})
test("preserves existing activity when event is for another conversation", () => {
const current = { conversationId: 42, stepCode: "thinking" }
const event = {
conversationId: 99,
stepCode: "calling_tool",
status: "running",
}
const next = resolveAgentActivityState(current, 42, event)
assert.deepEqual(next, current)
})
test("preserves toolName when provided", () => {
const current = null
const event = {
conversationId: 42,
stepCode: "calling_tool",
toolName: "web_search",
status: "running",
}
const next = resolveAgentActivityState(current, 42, event)
assert.deepEqual(next, {
conversationId: 42,
stepCode: "calling_tool",
toolName: "web_search",
})
})

View File

@@ -0,0 +1,36 @@
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 ||
payload.conversationId !== activeConversationId ||
!payload.stepCode
) {
return current
}
if (payload.status === "completed" || payload.status === "failed") {
return null
}
return {
conversationId: payload.conversationId,
stepCode: payload.stepCode,
...(payload.toolName ? { toolName: payload.toolName } : {}),
}
}

View File

@@ -0,0 +1,27 @@
import assert from "node:assert/strict"
import test from "node:test"
import { shouldReloadConversationListForRealtimePatch } from "./agent-conversation-realtime.ts"
test("reloads conversation list when realtime patch changes list membership fields", () => {
assert.equal(
shouldReloadConversationListForRealtimePatch({
conversationId: 1,
status: 3,
currentAssigneeId: 101,
}),
true
)
})
test("keeps local patching for message summary and unread-only changes", () => {
assert.equal(
shouldReloadConversationListForRealtimePatch({
conversationId: 1,
lastMessageId: 9,
lastMessageSummary: "hello",
agentUnreadCount: 1,
}),
false
)
})

View File

@@ -0,0 +1,18 @@
import type { RealtimeConversationPatch } from "@/lib/im-realtime-state"
const listMembershipFields = new Set<keyof RealtimeConversationPatch>([
"status",
"currentAssigneeId",
"currentTeamId",
])
export function shouldReloadConversationListForRealtimePatch(
patch: RealtimeConversationPatch | null | undefined
) {
if (!patch) {
return false
}
return Object.keys(patch).some((key) =>
listMembershipFields.has(key as keyof RealtimeConversationPatch)
)
}

2654
web/lib/api/admin.ts Normal file

File diff suppressed because it is too large Load Diff

277
web/lib/api/agent.ts Normal file
View File

@@ -0,0 +1,277 @@
import { request } from "@/lib/api/client"
import type { ConversationSatisfactionResponse } from "@/lib/api/im"
export type Paging = {
page: number
limit: number
total: number
}
export type PageResult<T> = {
results: T[]
page: Paging
}
export type CursorResult<T> = {
results: T[]
cursor: string
hasMore: boolean
}
export type AgentConversationTag = {
id: number
name: string
}
export type AgentConversationParticipant = {
id: number
participantType: string
participantId: number
externalParticipantId?: string
joinedAt?: string
leftAt?: string
status: number
}
export type AgentConversation = {
id: number
aiAgentId?: number
channelId?: number
customerId?: number
customerName: string
status: number
serviceMode: number
priority: number
currentAssigneeId: number
currentAssigneeName?: string
currentTeamId: number
currentTeamName?: string
lastMessageId: number
lastMessageAt?: string
lastActiveAt?: string
lastMessageSummary?: string
customerUnreadCount: number
agentUnreadCount: number
customerLastReadMessageId: number
customerLastReadAt?: string
agentLastReadMessageId: number
agentLastReadAt?: string
customerOnline: boolean
closedAt?: string
tags?: AgentConversationTag[]
participants?: AgentConversationParticipant[]
satisfaction?: ConversationSatisfactionResponse | null
}
export type AgentConversationDetail = AgentConversation & {
participants?: AgentConversationParticipant[]
}
export type AgentMessage = {
id: number
conversationId: number
workflowRunId?: number
clientMsgId?: string
senderType: string
senderId: number
senderName?: string
senderAvatar?: string
messageType: string
content: string
payload?: string
sendStatus: number
sentAt?: string
deliveredAt?: string
readAt?: string
customerRead: boolean
customerReadAt?: string
agentRead: boolean
agentReadAt?: string
recalledAt?: string
quotedMessageId?: number
}
export type AgentAsset = {
id: number
assetId: string
provider: string
storageKey: string
filename: string
fileSize: number
mimeType: string
status: number
url: string
createdAt: string
updatedAt: string
createUserId: number
createUserName: string
updateUserId: number
updateUserName: string
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export function fetchAgentConversations(
query?: Record<string, string | number | undefined>
) {
return request<PageResult<AgentConversation>>(
`/api/dashboard/conversation/conversations${toQueryString(query)}`
)
}
export function fetchAgentConversationDetail(id: number) {
return request<AgentConversationDetail>(`/api/dashboard/conversation/${id}`)
}
export function fetchAgentMessages(
query?: Record<string, string | number | undefined>
) {
return request<CursorResult<AgentMessage>>(
`/api/dashboard/conversation/message_list${toQueryString(query)}`
)
}
export function sendAgentMessage(payload: {
conversationId: number
messageType: string
content: string
payload?: string
clientMsgId?: string
}) {
return request<AgentMessage>("/api/dashboard/conversation/send_message", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function recallAgentMessage(messageId: number) {
return request<AgentMessage>("/api/dashboard/conversation/recall_message", {
method: "POST",
body: JSON.stringify({ messageId }),
})
}
export function markAgentMessageRead(conversationId: number, messageId = 0) {
return request<void>("/api/dashboard/conversation/read", {
method: "POST",
body: JSON.stringify({ conversationId, messageId }),
})
}
export function uploadAgentConversationImage(conversationId: number, file: File) {
const formData = new FormData()
formData.set("conversationId", String(conversationId))
formData.set("file", file)
return request<AgentAsset>("/api/dashboard/conversation/upload_image", {
method: "POST",
body: formData,
})
}
export function uploadAgentConversationAttachment(conversationId: number, file: File) {
const formData = new FormData()
formData.set("conversationId", String(conversationId))
formData.set("file", file)
return request<AgentAsset>("/api/dashboard/conversation/upload_attachment", {
method: "POST",
body: formData,
})
}
export function closeAgentConversation(
conversationId: number,
closeReason: string
) {
return request<void>("/api/dashboard/conversation/close", {
method: "POST",
body: JSON.stringify({ conversationId, closeReason }),
})
}
export function assignAgentConversation(
conversationId: number,
assigneeId: number,
reason: string
) {
return request<void>("/api/dashboard/conversation/assign", {
method: "POST",
body: JSON.stringify({ conversationId, assigneeId, reason }),
})
}
export function transferAgentConversation(
conversationId: number,
toUserId: number,
reason: string
) {
return request<void>("/api/dashboard/conversation/transfer", {
method: "POST",
body: JSON.stringify({ conversationId, toUserId, reason }),
})
}
export function linkConversationToCustomer(payload: {
conversationId: number
customerId: number
}) {
return request<void>("/api/dashboard/conversation/link_customer", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function addConversationTag(payload: {
conversationId: number
tagId: number
}) {
return request<void>("/api/dashboard/conversation/add_tag", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function removeConversationTag(payload: {
conversationId: number
tagId: number
}) {
return request<void>("/api/dashboard/conversation/remove_tag", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function suggestAgentReply(conversationId: number): Promise<string> {
const res = await request<{ suggestion: string }>(
"/api/dashboard/conversation/suggest_reply",
{
method: "POST",
body: JSON.stringify({ conversationId }),
}
)
return res?.suggestion ?? ""
}
export async function summarizeAgentConversation(conversationId: number): Promise<string> {
const res = await request<{ summary: string }>(
"/api/dashboard/conversation/summarize",
{
method: "POST",
body: JSON.stringify({ conversationId }),
}
)
return res?.summary ?? ""
}

75
web/lib/api/auth.ts Normal file
View File

@@ -0,0 +1,75 @@
import { clearSession, readSession, writeSession, type AuthSession } from "@/lib/auth"
import { request } from "@/lib/api/client"
export type LoginRequest = {
username: string
password: string
}
export type UpdateProfileRequest = {
avatar: string
email?: string
nickname: string
}
export type ProfileAvatarAsset = {
url: string
}
export async function loginWithPassword(payload: LoginRequest) {
const data = await request<AuthSession>("/api/auth/login", {
method: "POST",
body: JSON.stringify(payload),
skipAuth: true,
})
writeSession(data)
return data
}
export async function exchangeOIDCTicket(ticket: string) {
const data = await request<AuthSession>("/api/auth/oidc_exchange", {
method: "POST",
body: JSON.stringify({ ticket }),
skipAuth: true,
})
writeSession(data)
return data
}
export async function fetchProfile() {
return request<AuthSession>("/api/auth/profile")
}
export async function updateProfile(payload: UpdateProfileRequest) {
const data = await request<AuthSession>("/api/auth/profile/update", {
method: "POST",
body: JSON.stringify(payload),
})
const stored = readSession()
const nextSession: AuthSession = {
...data,
accessToken: data.accessToken || stored?.accessToken || "",
expiresAt: data.expiresAt || stored?.expiresAt,
}
writeSession(nextSession)
return nextSession
}
export function uploadProfileAvatar(file: File) {
const formData = new FormData()
formData.set("file", file)
return request<ProfileAvatarAsset>("/api/auth/profile/avatar/upload", {
method: "POST",
body: formData,
})
}
export async function logout() {
try {
await request("/api/auth/logout", {
method: "POST",
})
} finally {
clearSession()
}
}

152
web/lib/api/client.ts Normal file
View File

@@ -0,0 +1,152 @@
import { expireSession, readSession } from "@/lib/auth"
import { translateCurrentMessage } from "@/i18n/messages"
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
type JsonResult<T> = {
errorCode: number
message: string
data: T
success: boolean
}
type RequestOptions = RequestInit & {
skipAuth?: boolean
baseUrl?: string
onResponse?: (response: Response) => void
}
export type BlobResponse = {
blob: Blob
filename: string
}
export class ApiRequestError extends Error {
errorCode?: number
data?: unknown
constructor(message: string, errorCode?: number, data?: unknown, options?: ErrorOptions) {
super(message, options)
this.name = "ApiRequestError"
this.errorCode = errorCode
this.data = data
}
}
export function isApiRequestError(error: unknown): error is ApiRequestError {
return error instanceof ApiRequestError
}
function normalizeApiRequestError(error: unknown) {
if (isApiRequestError(error)) {
return error
}
return new ApiRequestError(
translateCurrentMessage("api.requestFailed"),
undefined,
undefined,
{ cause: error }
)
}
async function parseResult<T>(response: Response) {
const payload = (await response.json()) as JsonResult<T>
if (!response.ok || !payload.success) {
if (payload.errorCode === 3000 || payload.errorCode === 3002) {
expireSession()
}
throw new ApiRequestError(
payload.message || translateCurrentMessage("api.requestFailed"),
payload.errorCode,
payload.data
)
}
return payload.data
}
function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolean, body?: BodyInit | null) {
const session = readSession()
const authHeaders = new Headers(headers)
if (!skipAuth && session?.accessToken) {
authHeaders.set("Authorization", `Bearer ${session.accessToken}`)
}
if (
!authHeaders.has("Content-Type") &&
body &&
!(typeof FormData !== "undefined" && body instanceof FormData)
) {
authHeaders.set("Content-Type", "application/json")
}
return authHeaders
}
function parseFilename(contentDisposition: string | null) {
if (!contentDisposition) {
return ""
}
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1])
}
const match = contentDisposition.match(/filename="?([^";]+)"?/i)
return match?.[1] ? decodeURIComponent(match[1]) : ""
}
export async function request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
try {
const response = await fetch(`${requestBaseUrl}${path}`, {
...rest,
headers: authHeaders,
cache: "no-store",
})
onResponse?.(response)
return await parseResult<T>(response)
} catch (error) {
throw normalizeApiRequestError(error)
}
}
export async function requestBlob(
path: string,
options: RequestOptions = {}
): Promise<BlobResponse> {
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
const response = await fetch(`${requestBaseUrl}${path}`, {
...rest,
headers: authHeaders,
cache: "no-store",
})
onResponse?.(response)
const contentType = response.headers.get("Content-Type") ?? ""
if (contentType.includes("application/json")) {
try {
await parseResult<never>(response)
} catch (error) {
throw error
}
throw new ApiRequestError(translateCurrentMessage("api.requestFailed"))
}
if (!response.ok) {
throw new ApiRequestError(response.statusText || translateCurrentMessage("api.requestFailed"))
}
return {
blob: await response.blob(),
filename: parseFilename(response.headers.get("Content-Disposition")),
}
}

77
web/lib/api/company.ts Normal file
View File

@@ -0,0 +1,77 @@
import { request } from "@/lib/api/client"
import type { PageResult } from "@/lib/api/admin"
export type AdminCompany = {
id: number
name: string
code: string
customerCount: number
status: number
remark: string
createdAt: string
updatedAt: string
}
export type CreateAdminCompanyPayload = {
name: string
code: string
remark: string
}
export type UpdateAdminCompanyPayload = CreateAdminCompanyPayload & {
id: number
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export function fetchCompanies(query?: Record<string, string | number | undefined>) {
return request<PageResult<AdminCompany>>(
`/api/dashboard/company/list${toQueryString(query)}`
)
}
export function fetchCompany(id: number) {
return request<AdminCompany>(`/api/dashboard/company/${id}`)
}
export function createCompany(payload: CreateAdminCompanyPayload) {
return request<AdminCompany>("/api/dashboard/company/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateCompany(payload: UpdateAdminCompanyPayload) {
return request<void>("/api/dashboard/company/update", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateCompanyStatus(id: number, status: number) {
return request<void>("/api/dashboard/company/update_status", {
method: "POST",
body: JSON.stringify({ id, status }),
})
}
export function deleteCompany(id: number) {
return request<void>("/api/dashboard/company/delete", {
method: "POST",
body: JSON.stringify({ id }),
})
}

21
web/lib/api/config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { request } from "@/lib/api/client"
export type PublicConfig = {
language: string
companyName?: string
companyLogoUrl?: string
passwordLoginEnabled?: boolean
oidcEnabled: boolean
}
let publicConfigPromise: Promise<PublicConfig> | null = null
export function fetchPublicConfig() {
publicConfigPromise ??= request<PublicConfig>("/api/config", {
skipAuth: true,
}).catch((error) => {
publicConfigPromise = null
throw error
})
return publicConfigPromise
}

View File

@@ -0,0 +1,77 @@
import { request } from "@/lib/api/client"
import type { ContactType } from "@/lib/generated/enums"
export type AdminCustomerContact = {
id: number
customerId: number
contactType: ContactType | string
contactValue: string
isPrimary: boolean
isVerified: boolean
verifiedAt?: string
source: string
status: number
remark: string
createdAt: string
updatedAt: string
}
export type CreateCustomerContactPayload = {
customerId: number
contactType: ContactType | string
contactValue: string
isPrimary: boolean
isVerified: boolean
source: string
status: number
remark: string
}
export type UpdateCustomerContactPayload = Omit<
CreateCustomerContactPayload,
"customerId"
> & {
id: number
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export function fetchCustomerContacts(customerId: number) {
return request<AdminCustomerContact[]>(
`/api/dashboard/customer-contact/list${toQueryString({ customerId })}`
)
}
export function createCustomerContact(payload: CreateCustomerContactPayload) {
return request<AdminCustomerContact>("/api/dashboard/customer-contact/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateCustomerContact(payload: UpdateCustomerContactPayload) {
return request<void>("/api/dashboard/customer-contact/update", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function deleteCustomerContact(id: number) {
return request<void>("/api/dashboard/customer-contact/delete", {
method: "POST",
body: JSON.stringify({ id }),
})
}

108
web/lib/api/customer.ts Normal file
View File

@@ -0,0 +1,108 @@
import { request } from "@/lib/api/client"
import type { PageResult } from "@/lib/api/admin"
import type { ContactType } from "@/lib/generated/enums"
import { AdminCompany } from "./company"
export type AdminCustomer = {
id: number
name: string
gender: number
companyId: number
company?: AdminCompany
lastActiveAt?: string
primaryMobile: string
primaryEmail: string
status: number
remark: string
createdAt: string
updatedAt: string
}
export type CreateAdminCustomerPayload = {
name: string
gender: number
companyId: number
primaryMobile: string
primaryEmail: string
remark: string
}
export type UpdateAdminCustomerPayload = CreateAdminCustomerPayload & {
id: number
}
/** Matches POST /customer/save/profile request body. */
export type SaveCustomerProfileContactLine = {
id?: number
contactType: ContactType | string
contactValue: string
remark: string
isPrimary: boolean
}
export type SaveCustomerProfilePayload = {
id?: number
name: string
gender: number
companyId: number
remark: string
contacts: SaveCustomerProfileContactLine[]
}
/** Matches POST /customer/list JSON body. */
export type CustomerListRequest = {
page: number
limit: number
status?: number
gender?: number
companyId?: number
/** Fuzzy match against customer name, primary phone, primary email, contacts, and company name. */
keyword?: string
}
export function fetchCustomers(body: CustomerListRequest) {
return request<PageResult<AdminCustomer>>("/api/dashboard/customer/list", {
method: "POST",
body: JSON.stringify(body),
})
}
export function fetchCustomer(id: number) {
return request<AdminCustomer | null>(`/api/dashboard/customer/${id}`)
}
export function createCustomer(payload: CreateAdminCustomerPayload) {
return request<AdminCustomer>("/api/dashboard/customer/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
/** Saves the customer profile and the full contact list in one request and transaction. */
export function saveCustomerProfile(payload: SaveCustomerProfilePayload) {
return request<AdminCustomer>("/api/dashboard/customer/save_profile", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateCustomer(payload: UpdateAdminCustomerPayload) {
return request<void>("/api/dashboard/customer/update", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateCustomerStatus(id: number, status: number) {
return request<void>("/api/dashboard/customer/update_status", {
method: "POST",
body: JSON.stringify({ id, status }),
})
}
export function deleteCustomer(id: number) {
return request<void>("/api/dashboard/customer/delete", {
method: "POST",
body: JSON.stringify({ id }),
})
}

81
web/lib/api/dashboard.ts Normal file
View File

@@ -0,0 +1,81 @@
import { request } from "@/lib/api/client"
export type DashboardRange = "today" | "7d" | "30d"
export type DashboardStatusDistributionItem = {
status: number
label: string
count: number
}
export type DashboardTrendItem = {
date: string
newCount: number
closedCount: number
}
export type DashboardTeamLoad = {
teamId: number
teamName: string
totalAgents: number
onlineAgents: number
busyAgents: number
offlineAgents: number
waitingConversations: number
processingConversations: number
maxConcurrentCapacity: number
loadRate: number
hasScheduleNow: boolean
}
export type DashboardAlert = {
id: string
level: "info" | "warning" | "error"
title: string
description: string
count: number
link: string
}
export type DashboardQuickLink = {
title: string
description: string
link: string
}
export type DashboardOverview = {
range: DashboardRange
generatedAt: string
summary: {
todayNewConversations: number
processingConversations: number
pendingDispatchConversations: number
onlineAgents: number
aiServiceRate: number
}
conversationStats: {
statusDistribution: DashboardStatusDistributionItem[]
trend: DashboardTrendItem[]
}
agentStats: {
onlineAgents: number
busyAgents: number
offlineAgents: number
teamLoads: DashboardTeamLoad[]
}
aiStats: {
enabledAiAgents: number
enabledChannels: number
todayKnowledgeRetrieves: number
todayKnowledgeRetrieveFailCount: number
todayKnowledgeRetrieveFailRate: number
todayAgentRunFailCount: number
todayAiHandoffCount: number
}
alerts: DashboardAlert[]
quickLinks: DashboardQuickLink[]
}
export function fetchDashboardOverview(range: DashboardRange) {
return request<DashboardOverview>(`/api/dashboard/dashboard/overview?range=${range}`)
}

595
web/lib/api/im.ts Normal file
View File

@@ -0,0 +1,595 @@
import { request } from "@/lib/api/client"
import { translateCurrentMessage } from "@/i18n/messages"
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
import { generateUUID, parseApiTimestamp } from "@/lib/utils"
export type Paging = {
page: number
limit: number
total: number
}
export type PageResult<T> = {
results?: T[] | null
page: Paging
cursor?: string
hasMore?: boolean
}
export type ImConversationTag = {
id: number
name: string
color: string
}
export type ImConversationParticipant = {
id: number
participantType: string
participantId: number
externalParticipantId?: string
joinedAt?: string
leftAt?: string
status: number
}
export interface SubmitSatisfactionParams {
conversationId: number
score: number
feedback?: string
}
export interface ConversationSatisfactionResponse {
id: number
conversationId: number
score: number
feedback: string
createdAt: string
}
export type ImConversation = {
id: number
channelId: number
customerName: string
subject?: string
status: number
serviceMode: number
priority: number
currentAssigneeId: number
currentAssigneeName?: string
currentTeamId?: number
lastMessageId: number
lastMessageAt?: string
lastActiveAt?: string
lastMessageSummary?: string
customerUnreadCount: number
agentUnreadCount: number
customerLastReadMessageId: number
customerLastReadAt?: string
agentLastReadMessageId: number
agentLastReadAt?: string
closedAt?: string
tags?: ImConversationTag[]
participants?: ImConversationParticipant[]
satisfaction?: ConversationSatisfactionResponse | null
}
export type ImConversationDetail = ImConversation
export type ConversationResponse = ImConversation
export type ImMessage = {
id: number
conversationId: number
workflowRunId?: number
clientMsgId?: string
senderType: string
senderId: number
senderName?: string
senderAvatar?: string
messageType: string
content: string
payload?: string
sendStatus: number
sentAt?: string
deliveredAt?: string
readAt?: string
customerRead: boolean
customerReadAt?: string
agentRead: boolean
agentReadAt?: string
recalledAt?: string
quotedMessageId?: number
}
export type ImAsset = {
id: number
assetId: string
provider: string
storageKey: string
filename: string
fileSize: number
mimeType: string
status: number
url: string
createdAt: string
updatedAt: string
createUserId: number
createUserName: string
updateUserId: number
updateUserName: string
}
export type WidgetConfigResponse = {
channelId?: string
channelType?: string
userToken?: string
title?: string
subtitle?: string
themeColor?: string
position?: "left" | "right"
width?: string
promptStarters?: string[]
}
export type ImWidgetConfig = WidgetConfigResponse
export type ImCustomerSessionCustomer = {
id: number
name: string
}
export type ImCustomerSessionExchangeResponse = {
customerSessionToken: string
expiresAt: string
identityKey: string
customer: ImCustomerSessionCustomer
}
export type ImCustomerSession = ImCustomerSessionExchangeResponse & {
channelId: string
baseUrl: string
}
const GUEST_STORAGE_KEY = "cs_ai_agent_im_guest_id"
const CUSTOMER_SESSION_STORAGE_KEY = "cs_ai_agent_customer_session"
const CUSTOMER_SESSION_TOKEN_HEADER = "X-Customer-Session-Token"
const CUSTOMER_SESSION_EXPIRES_HEADER = "X-Customer-Session-Expires-At"
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
const OPEN_IM_CHANNEL_ID =
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || ""
let entryIdentityExchangeKey = ""
function buildGuestId() {
return `guest_${generateUUID()}`
}
export function getGuestId() {
if (typeof window === "undefined") {
return ""
}
const existing = window.localStorage.getItem(GUEST_STORAGE_KEY)?.trim()
if (existing) {
return existing
}
const guestId = buildGuestId()
window.localStorage.setItem(GUEST_STORAGE_KEY, guestId)
return guestId
}
function getRuntimeImConfig() {
const widgetConfig = readSupportChatRuntimeConfig()
const baseUrl = (widgetConfig.apiBaseUrl || widgetConfig.baseUrl || API_BASE_URL)
.trim()
.replace(/\/$/, "")
return {
baseUrl,
channelId: widgetConfig.channelId || OPEN_IM_CHANNEL_ID,
externalId: (widgetConfig.externalId || "").trim(),
externalIdSignature: (widgetConfig.externalIdSignature || "").trim(),
externalIdSignedAt: Number(widgetConfig.externalIdSignedAt) || 0,
externalName: (widgetConfig.externalName || "").trim(),
userToken: (widgetConfig.userToken || "").trim(),
requireUserToken: widgetConfig.requireUserToken === true,
}
}
type RuntimeImConfig = ReturnType<typeof getRuntimeImConfig>
function identityContextKey(config: RuntimeImConfig) {
return JSON.stringify([
config.baseUrl,
config.channelId,
config.externalId,
config.userToken,
config.externalIdSignature,
config.externalIdSignedAt,
config.requireUserToken,
])
}
function requireEntryToken(config: RuntimeImConfig) {
if (config.requireUserToken && !config.userToken) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
}
}
function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImConfig) {
if (typeof session.identityKey !== "string") return false
if (session.baseUrl !== config.baseUrl || session.channelId !== config.channelId) return false
if (config.userToken || config.requireUserToken) {
return (
!!config.userToken &&
(config.externalId
? session.identityKey === `user:${config.externalId}`
: session.identityKey.startsWith("user:"))
)
}
const externalId = config.externalId || getGuestId()
return (
session.identityKey === `guest:${externalId}` ||
(!!config.externalIdSignature && session.identityKey === `user:${externalId}`)
)
}
function usableCustomerSession(session: ImCustomerSession | null, config: RuntimeImConfig) {
if (!session || !isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) return false
// A prior iframe's session, especially a prior guest, cannot replace this
// entry's JWT verification. Signed HMAC entries likewise re-exchange first.
if ((config.userToken || config.externalIdSignature) && entryIdentityExchangeKey !== identityContextKey(config)) {
return false
}
return true
}
function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string } | null {
try {
const payload = token.split(".")[1]
if (!payload) return null
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/")
const bytes = Uint8Array.from(
atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")),
(c) => c.charCodeAt(0)
)
return JSON.parse(new TextDecoder().decode(bytes))
} catch {
return null
}
}
function parseExpiresAt(value: string) {
return parseApiTimestamp(value)
}
function isCustomerSessionValid(
session: ImCustomerSession | null,
channelId?: string,
identityKey?: string
) {
if (!session?.customerSessionToken || !session.expiresAt) {
return false
}
if (channelId && session.channelId !== channelId) {
return false
}
if (identityKey && session.identityKey !== identityKey) {
return false
}
return parseExpiresAt(session.expiresAt) > Date.now() + 5000
}
export function readCustomerSession(): ImCustomerSession | null {
if (typeof window === "undefined") {
return null
}
const raw = window.sessionStorage.getItem(CUSTOMER_SESSION_STORAGE_KEY)
if (!raw) {
return null
}
try {
return JSON.parse(raw) as ImCustomerSession
} catch {
window.sessionStorage.removeItem(CUSTOMER_SESSION_STORAGE_KEY)
return null
}
}
function writeCustomerSession(session: ImCustomerSession) {
if (typeof window === "undefined") {
return
}
window.sessionStorage.setItem(CUSTOMER_SESSION_STORAGE_KEY, JSON.stringify(session))
}
export function getCustomerSessionToken() {
const config = getRuntimeImConfig()
const session = readCustomerSession()
return usableCustomerSession(session, config)
? session?.customerSessionToken ?? ""
: ""
}
export function applyCustomerSessionRefresh(payload?: {
customerSessionToken?: string
expiresAt?: string
}) {
const token = payload?.customerSessionToken?.trim()
const expiresAt = payload?.expiresAt?.trim()
if (!token || !expiresAt) {
return
}
const current = readCustomerSession()
const config = getRuntimeImConfig()
if (!current || !usableCustomerSession(current, config)) return
// Decode only for cache consistency, never as proof of authenticity. The
// server still verifies signatures. A late old-account websocket/HTTP refresh
// must not overwrite the current account's stored customer session.
const claims = sessionTokenClaims(token)
if (claims?.identityKey !== current.identityKey || claims?.channelCode !== current.channelId) return
writeCustomerSession({
...current,
customerSessionToken: token,
expiresAt,
})
}
function applyCustomerSessionHeaders(response: Response) {
applyCustomerSessionRefresh({
customerSessionToken: response.headers.get(CUSTOMER_SESSION_TOKEN_HEADER) ?? "",
expiresAt: response.headers.get(CUSTOMER_SESSION_EXPIRES_HEADER) ?? "",
})
}
function createChannelHeaders() {
const config = getRuntimeImConfig()
return {
"X-Channel-Id": config.channelId,
}
}
function createExchangeHeaders() {
const config = getRuntimeImConfig()
requireEntryToken(config)
const headers: Record<string, string> = {
"X-Channel-Id": config.channelId,
}
if (config.userToken) {
headers.Authorization = `Bearer ${config.userToken}`
} else {
headers["X-External-Id"] = config.externalId || getGuestId()
// Only meaningful together with a host-supplied externalId: the browser-local
// guest id cannot be signed by the host. Without a valid signature the
// server treats the visitor as anonymous.
if (config.externalId && config.externalIdSignature && config.externalIdSignedAt) {
headers["X-External-Id-Signature"] = config.externalIdSignature
headers["X-External-Id-Signed-At"] = String(config.externalIdSignedAt)
}
if (config.externalName) {
headers["X-External-Name"] = encodeURIComponent(config.externalName)
}
}
return {
...headers,
}
}
function createImHeaders(explicitToken?: string) {
const sessionToken = explicitToken || getCustomerSessionToken()
if (!sessionToken) {
throw new Error(translateCurrentMessage("api.customerSessionNotReady"))
}
return {
...createChannelHeaders(),
Authorization: `Bearer ${sessionToken}`,
...(explicitToken ? { "X-Customer-Session-Token": explicitToken } : {}),
}
}
function createRequestOptions(
init?: RequestInit,
explicitToken?: string
): RequestInit & {
baseUrl?: string
skipAuth?: boolean
onResponse?: (response: Response) => void
} {
const config = getRuntimeImConfig()
requireEntryToken(config)
const contextKey = identityContextKey(config)
return {
...init,
skipAuth: true,
headers: {
...createImHeaders(explicitToken),
...(init?.headers as Record<string, string> | undefined),
},
onResponse: (response) => {
if (contextKey === identityContextKey(getRuntimeImConfig())) {
applyCustomerSessionHeaders(response)
}
},
baseUrl: config.baseUrl,
}
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export async function exchangeCustomerSession() {
const config = getRuntimeImConfig()
requireEntryToken(config)
const contextKey = identityContextKey(config)
const result = await request<ImCustomerSessionExchangeResponse>(
"/api/customer/session_exchange",
{
method: "POST",
skipAuth: true,
baseUrl: config.baseUrl,
headers: createExchangeHeaders(),
}
)
if (contextKey !== identityContextKey(getRuntimeImConfig())) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
}
const session: ImCustomerSession = {
...result,
channelId: config.channelId,
baseUrl: config.baseUrl,
}
if (!isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
}
writeCustomerSession(session)
entryIdentityExchangeKey = contextKey
return session
}
export async function ensureCustomerSession() {
const config = getRuntimeImConfig()
requireEntryToken(config)
const cached = readCustomerSession()
if (usableCustomerSession(cached, config)) return cached!
return exchangeCustomerSession()
}
export function fetchImConversationDetail(id: number) {
return request<ImConversationDetail>(`/api/conversation/${id}`, {
...createRequestOptions(),
})
}
export function fetchImMessages(
query?: Record<string, string | number | undefined>
) {
return request<PageResult<ImMessage>>(
`/api/message/list${toQueryString(query)}`,
createRequestOptions()
)
}
export function createOrMatchImConversation() {
return request<ImConversation>("/api/conversation/create_or_match", {
...createRequestOptions({ method: "POST" }),
})
}
// Starts a new request instead of rejoining the unfinished one. The server caps
// how many open requests one visitor may have and answers with a localized
// message when the cap is reached.
export function createImConversation(subject?: string) {
return request<ImConversation>("/api/conversation/create", {
...createRequestOptions({ method: "POST" }),
body: JSON.stringify({ subject: subject?.trim() ?? "" }),
})
}
// The visitor's own requests, newest activity first. Scoping comes from the
// external identity the request already carries.
export function fetchImConversationList(
query?: Record<string, string | number | undefined>
) {
return request<PageResult<ImConversation>>(
`/api/conversation/list${toQueryString(query)}`,
createRequestOptions()
)
}
export function fetchImWidgetConfig() {
return request<ImWidgetConfig>(
`/api/channel/config${toQueryString({
channelId: getRuntimeImConfig().channelId,
})}`,
{
skipAuth: true,
baseUrl: getRuntimeImConfig().baseUrl,
headers: createChannelHeaders(),
}
)
}
export function closeImConversation(conversationId: number) {
return request<void>("/api/conversation/close", {
...createRequestOptions({
method: "POST",
body: JSON.stringify({ conversationId }),
}),
})
}
export function sendImMessage(payload: {
conversationId: number
messageType: string
content: string
payload?: string
clientMsgId?: string
}) {
return request<ImMessage>("/api/message/send", {
...createRequestOptions({
method: "POST",
body: JSON.stringify(payload),
}),
})
}
export function markImMessageRead(conversationId: number, messageId = 0) {
return request<void>("/api/message/read", {
...createRequestOptions({
method: "POST",
body: JSON.stringify({ conversationId, messageId }),
}),
})
}
export function uploadImImage(conversationId: number, file: File) {
const formData = new FormData()
formData.set("conversationId", String(conversationId))
formData.set("file", file)
return request<ImAsset>("/api/message/upload_image", {
...createRequestOptions({
method: "POST",
body: formData,
}),
})
}
export function uploadImAttachment(conversationId: number, file: File) {
const formData = new FormData()
formData.set("conversationId", String(conversationId))
formData.set("file", file)
return request<ImAsset>("/api/message/upload_attachment", {
...createRequestOptions({
method: "POST",
body: formData,
}),
})
}
export async function submitConversationSatisfaction(
params: SubmitSatisfactionParams,
token?: string
): Promise<ConversationSatisfactionResponse> {
return request<ConversationSatisfactionResponse>(
"/api/conversation/satisfaction",
createRequestOptions(
{
method: "POST",
body: JSON.stringify(params),
},
token
)
)
}

View File

@@ -0,0 +1,82 @@
import { readSession } from "@/lib/auth"
import { request } from "@/lib/api/client"
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
import type { PageResult } from "@/lib/api/admin"
import { translateCurrentMessage } from "@/i18n/messages"
export type NotificationReadStatus = "all" | "unread" | "read"
export type NotificationItem = {
id: number
recipientUserId: number
title: string
content: string
notificationType: string
bizType: string
bizId: number
actionUrl: string
readAt?: string
createdAt?: string
}
export type NotificationUnreadCount = {
unreadCount: number
}
export type NotificationListQuery = {
page?: number
limit?: number
readStatus?: NotificationReadStatus
type?: string
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export function fetchNotifications(query?: NotificationListQuery) {
return request<PageResult<NotificationItem>>(
`/api/dashboard/notification/list${toQueryString(query)}`
)
}
export function fetchNotificationUnreadCount() {
return request<NotificationUnreadCount>("/api/dashboard/notification/unread_count")
}
export function markNotificationRead(id: number) {
return request<void>("/api/dashboard/notification/mark_read", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function markAllNotificationsRead() {
return request<void>("/api/dashboard/notification/mark_all_read", {
method: "POST",
})
}
export function createNotificationWebSocketUrl() {
const session = readSession()
if (!session?.accessToken) {
throw new Error(translateCurrentMessage("api.authExpired"))
}
const params = new URLSearchParams({
accessToken: session.accessToken,
})
return `${createWebSocketBaseUrl()}/api/ws/dashboard/notification?${params.toString()}`
}

View File

@@ -0,0 +1,31 @@
import { request } from "@/lib/api/client"
export type OrganizationItem = {
id: number
code: string
name: string
logo: string
plan: string
status: number
role?: string
isActive: boolean
createdAt: string
}
export type UserOrganizationListResponse = {
currentOrganizationId: number
organizations: OrganizationItem[]
}
export async function listMyOrganizations() {
return request<UserOrganizationListResponse>("/api/dashboard/organization/my_list", {
method: "GET",
})
}
export async function switchOrganization(organizationId: number) {
return request<OrganizationItem>("/api/dashboard/organization/switch", {
method: "POST",
body: JSON.stringify({ organizationId }),
})
}

View File

@@ -0,0 +1,134 @@
import { request } from "@/lib/api/client"
export type SetupStepStatus = "ok" | "warning" | "error"
export type SetupQuickFixAction =
| "quick_setup_all"
| "ensure_team_and_schedule"
| "enable_my_operator_profile"
| "fix_ai_agent_handoff"
| "ensure_default_channel"
export type SetupIssueItem = {
code: string
severity: "warning" | "error"
entity_type: "team" | "schedule" | "operator" | "ai_agent" | "channel" | "system"
entity_id: number
entity_name: string
quick_fix_action?: SetupQuickFixAction
}
export type SetupStepItem = {
key: "teams" | "schedules" | "operators" | "ai_agents" | "channels" | "end_to_end"
step_number: number
status: SetupStepStatus
summary_code: string
total_count: number
ready_count: number
issues: SetupIssueItem[]
quick_fix_actions: SetupQuickFixAction[]
manage_path: string
}
export type CurrentOperatorDiagnostic = {
user_id: number
username: string
nickname: string
has_profile: boolean
profile_status_ok: boolean
team_id: number
team_name: string
team_active: boolean
team_has_schedule_now: boolean
service_status: number
auto_assign_enabled: boolean
active_conversation_count: number
max_concurrent_count: number
can_receive_now: boolean
blocker_codes: string[]
}
export type SetupChannelBrief = {
id: number
name: string
channel_type: string
channel_id: string
ai_agent_id: number
rollout_percent: number
status: number
}
export type SetupReadinessResponse = {
overall_status: SetupStepStatus
ready_steps: number
total_steps: number
can_receive_requests_now: boolean
steps: SetupStepItem[]
current_operator: CurrentOperatorDiagnostic
channels: SetupChannelBrief[]
}
export type SimulatedRouteHop = {
hop_key: "channel" | "ai_agent" | "teams" | "schedules" | "operators"
status: SetupStepStatus
reason_code: string
entity_id?: number
entity_name?: string
quick_fix_action?: SetupQuickFixAction
}
export type SimulatedOperatorCandidate = {
user_id: number
display_name: string
team_id: number
team_name: string
eligible: boolean
skip_reason_code: string
active_count: number
max_concurrent_count: number
service_status: number
auto_assign_enabled: boolean
}
export type SetupSimulateRouteResponse = {
channel_id: number
channel_name: string
channel_type: string
will_reach_operator: boolean
final_outcome_code: string
matched_operator?: SimulatedOperatorCandidate
hops: SimulatedRouteHop[]
candidate_operators: SimulatedOperatorCandidate[]
}
export type SetupQuickFixResponse = {
applied_actions: string[]
readiness: SetupReadinessResponse
}
export function fetchSetupReadiness() {
return request<SetupReadinessResponse>("/api/dashboard/setup/readiness")
}
export function simulateSetupRoute(payload: {
channel_id?: number
bypass_ai?: boolean
}) {
return request<SetupSimulateRouteResponse>(
"/api/dashboard/setup/simulate_route",
{
method: "POST",
body: JSON.stringify(payload),
},
)
}
export function applySetupQuickFix(payload: {
action: SetupQuickFixAction
entity_id?: number
}) {
return request<SetupQuickFixResponse>("/api/dashboard/setup/quick_fix", {
method: "POST",
body: JSON.stringify(payload),
})
}

View File

@@ -0,0 +1,176 @@
import { type PageResult } from "@/lib/api/admin"
import { request } from "@/lib/api/client"
import type { PageData } from "@/lib/api/types"
export type Category = {
id: number
name: string
slug: string
description: string
parentId?: number
sortNo: number
status: number
}
export type Post = {
id: number
categoryId: number
categoryName: string
userId: number
userName: string
userType: string
title: string
contentType: string
content: string
tags: string[]
status: string
acceptedCommentId: number
commentCount: number
reactionCount: number
viewCount: number
createdAt: string
updatedAt: string
}
export type Comment = {
id: number
postId: number
parentId: number
authorType: string
authorId: number
authorName: string
contentType: string
content: string
status: string
reactionCount: number
replyCount: number
reportCount: number
isAccepted: boolean
replies: Comment[]
createdAt: string
updatedAt: string
}
export type PostDetail = {
comments: Comment[]
post: Post
}
export type ReactionTargetType = "post" | "comment"
export type ReactionType = "like"
export function categoryHref(slug: string) {
return `/support/community/categories/${encodeURIComponent(slug)}`
}
export function newPostHref() {
return "/support/community/posts/new"
}
export function postHref(id: number) {
return `/support/community/posts/${id}`
}
export function postsHref() {
return "/support/community/posts"
}
export function fetchCategories() {
return request<Category[]>("/api/support/community/categories/list", { skipAuth: true })
}
export function fetchPosts(query?: Record<string, string | number | undefined>) {
return request<PageData<Post>>(`/api/support/community/posts/list${toQueryString(query)}`, {
skipAuth: true,
})
}
export function fetchPost(id: number) {
return request<PostDetail>(`/api/support/community/posts/${id}`, { skipAuth: true })
}
export function createPost(payload: {
categoryId: number
content: string
contentType: string
tags: string[]
title: string
}) {
return request<Post>("/api/support/community/posts/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function fetchComments(query: {
limit?: number
page?: number
parentId?: number
postId: number
sort?: string
}) {
return request<PageResult<Comment>>(`/api/support/community/comments/list${toQueryString(query)}`, {
skipAuth: true,
})
}
export function createComment(payload: {
content: string
contentType: string
parentId?: number
postId: number
}) {
return request<Comment>("/api/support/community/comments/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateComment(payload: { content: string; contentType: string; id: number }) {
return request<void>("/api/support/community/comments/update", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function deleteComment(id: number) {
return request<void>("/api/support/community/comments/delete", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function reportComment(id: number, reason = "") {
return request<void>("/api/support/community/comments/report", {
method: "POST",
body: JSON.stringify({ id, reason }),
})
}
export function acceptComment(postId: number, commentId: number) {
return request<void>("/api/support/community/posts/accept_comment", {
method: "POST",
body: JSON.stringify({ postId, commentId }),
})
}
export function toggleReaction(payload: { reactionType?: ReactionType; targetId: number; targetType: ReactionTargetType }) {
return request<void>("/api/support/community/reactions/toggle", {
method: "POST",
body: JSON.stringify({ reactionType: payload.reactionType || "like", targetId: payload.targetId, targetType: payload.targetType }),
})
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== "" && value !== "all") {
params.set(key, String(value))
}
})
const raw = params.toString()
return raw ? `?${raw}` : ""
}

View File

@@ -0,0 +1,19 @@
import { request } from "@/lib/api/client"
export type SupportNavigationMenuItem = {
id: string
title: string
url: string
openInNewWindow: boolean
visible: boolean
sortNo: number
children?: SupportNavigationMenuItem[]
}
export type PublicSupportConfig = {
navigationMenu: SupportNavigationMenuItem[]
}
export function fetchSupportConfig() {
return request<PublicSupportConfig>("/api/support/config", { skipAuth: true })
}

90
web/lib/api/support.ts Normal file
View File

@@ -0,0 +1,90 @@
import { type PageResult } from "@/lib/api/admin"
import { loginWithPassword } from "@/lib/api/auth"
import { request } from "@/lib/api/client"
import { type AuthSession, writeSession } from "@/lib/auth"
export type DocPage = {
id: number
parentId: number
title: string
slug: string
summary: string
contentType: string
content: string
coverUrl: string
tags: string[]
status: string
sortNo: number
viewCount: number
helpfulCount: number
unhelpfulCount: number
publishedAt: string
createdAt: string
updatedAt: string
docPath?: string
}
export type DocNavigationNode = Pick<DocPage, "id" | "parentId" | "title" | "slug" | "sortNo"> & {
children: DocNavigationNode[]
}
export type SupportUser = {
id: number
name: string
email: string
userType: string
}
export async function loginSupportCustomer(payload: { email: string; password: string }) {
return loginWithPassword({ username: payload.email, password: payload.password })
}
export async function registerSupportCustomer(payload: { name: string; email: string; password: string }) {
const session = await request<AuthSession>("/api/support/auth/register", {
method: "POST",
skipAuth: true,
body: JSON.stringify(payload),
})
writeSession(session)
return session
}
export function fetchSupportMe() {
return request<SupportUser>("/api/support/me")
}
export function fetchDocPages(query?: Record<string, string | number | undefined>) {
return request<PageResult<DocPage>>(`/api/support/doc-page/list${toQueryString(query)}`, {
skipAuth: true,
})
}
export function fetchDocNavigation() {
return request<DocNavigationNode[]>("/api/support/doc-page/navigation", { skipAuth: true })
}
export function fetchDocPage(id: number) {
return request<DocPage>(`/api/support/doc-page/${id}`, { skipAuth: true })
}
export function submitDocPageFeedback(id: number, helpful: boolean) {
return request<void>("/api/support/doc-page/feedback", {
method: "POST",
skipAuth: true,
body: JSON.stringify({ id, helpful }),
})
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== "" && value !== "all") {
params.set(key, String(value))
}
})
const raw = params.toString()
return raw ? `?${raw}` : ""
}

239
web/lib/api/ticket.ts Normal file
View File

@@ -0,0 +1,239 @@
import { request } from "@/lib/api/client"
import type { Tag } from "@/lib/api/admin"
export type Paging = {
page: number
limit: number
total: number
}
export type PageResult<T> = {
results: T[]
page: Paging
}
export type TicketStatus = "pending" | "in_progress" | "done"
export type TicketSource = "manual" | "conversation"
export type TicketCustomer = {
id: number
name: string
companyId?: number
company?: {
id: number
name: string
code?: string
remark?: string
createdAt?: string
updatedAt?: string
}
primaryMobile?: string
primaryEmail?: string
}
export type TicketProgress = {
id: number
ticketId: number
content: string
authorId: number
authorName?: string
createdAt?: string
}
export type TicketItem = {
id: number
ticketNo: string
title: string
description: string
source: TicketSource
channel: string
customerId: number
conversationId: number
tags?: Tag[]
status: TicketStatus
currentAssigneeId: number
currentAssigneeName?: string
createdBy: number
createdByName?: string
handledAt?: string
createdAt?: string
updatedAt?: string
customer?: TicketCustomer
}
export type TicketDetail = {
ticket: TicketItem
progresses?: TicketProgress[]
}
export type TicketSummary = {
all: number
pending: number
inProgress: number
done: number
unassigned: number
mine: number
stale: number
}
export type TicketSavedView = {
id: number
name: string
filters?: Record<string, unknown>
sortNo: number
}
export type TicketListQuery = {
page?: number
limit?: number
keyword?: string
status?: TicketStatus
tagId?: number
currentAssigneeId?: number
customerId?: number
conversationId?: number
source?: TicketSource
channel?: string
mine?: number | boolean
unassigned?: number | boolean
staleHours?: number
}
export type CreateTicketPayload = {
title: string
description: string
source?: TicketSource
channel?: string
customerId?: number
conversationId?: number
tagIds?: number[]
currentAssigneeId?: number
}
export type CreateTicketFromConversationPayload = {
conversationId: number
title: string
description: string
tagIds?: number[]
currentAssigneeId?: number
}
export type UpdateTicketPayload = {
ticketId: number
title: string
description: string
tagIds?: number[]
currentAssigneeId?: number
}
function toQueryString(query?: Record<string, string | number | boolean | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export function fetchTickets(query?: TicketListQuery) {
return request<PageResult<TicketItem>>(`/api/dashboard/ticket/list${toQueryString(query)}`)
}
export function fetchTicketSummary(query?: { staleHours?: number }) {
return request<TicketSummary>(`/api/dashboard/ticket/summary${toQueryString(query)}`)
}
export function fetchTicketDetail(id: number) {
return request<TicketDetail>(`/api/dashboard/ticket/${id}`)
}
export function createTicket(payload: CreateTicketPayload) {
return request<TicketItem>("/api/dashboard/ticket/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function createTicketFromConversation(payload: CreateTicketFromConversationPayload) {
return request<TicketItem>("/api/dashboard/ticket/create_from_conversation", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateTicket(payload: UpdateTicketPayload) {
return request<void>("/api/dashboard/ticket/update", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function linkTicketToCustomer(payload: {
ticketId: number
customerId: number
}) {
return request<void>("/api/dashboard/ticket/link_customer", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function assignTicket(payload: {
ticketId: number
toUserId: number
reason?: string
}) {
return request<void>("/api/dashboard/ticket/assign", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function changeTicketStatus(payload: {
ticketId: number
status: TicketStatus
}) {
return request<void>("/api/dashboard/ticket/change_status", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function createTicketProgress(payload: {
ticketId: number
content: string
}) {
return request<TicketProgress>("/api/dashboard/ticket/progress/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function fetchTicketViews() {
return request<TicketSavedView[]>("/api/dashboard/ticket/view_list")
}
export function saveTicketView(payload: {
id?: number
name: string
filters: Record<string, unknown>
}) {
return request<TicketSavedView>("/api/dashboard/ticket/save_view", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function deleteTicketView(id: number) {
return request<void>("/api/dashboard/ticket/delete_view", {
method: "POST",
body: JSON.stringify({ id }),
})
}

5
web/lib/api/types.ts Normal file
View File

@@ -0,0 +1,5 @@
export type PageData<T> = {
cursor?: string
hasMore: boolean
results: T[]
}

16
web/lib/api/websocket.ts Normal file
View File

@@ -0,0 +1,16 @@
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ||
(process.env.NODE_ENV === "development" ? "http://127.0.0.1:8083" : "")
export function createWebSocketBaseUrl() {
if (API_BASE_URL) {
return API_BASE_URL.replace(/^http/, "ws").replace(/\/$/, "")
}
if (typeof window === "undefined") {
return ""
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
return `${protocol}//${window.location.host}`
}

68
web/lib/auth.ts Normal file
View File

@@ -0,0 +1,68 @@
export type AuthUser = {
id: number
username: string
nickname: string
avatar: string
email?: string
userType: string
status: number
roles: string[]
}
export type AuthSession = {
accessToken: string
expiresAt?: string
user: AuthUser
permissions: string[]
roles: string[]
}
const SESSION_STORAGE_KEY = "agent-desk-session"
export const AUTH_SESSION_EXPIRED_EVENT = "agent-desk-auth-expired"
export const AUTH_SESSION_CHANGED_EVENT = "agent-desk-auth-changed"
function hasWindow() {
return typeof window !== "undefined"
}
export function readSession(): AuthSession | null {
if (!hasWindow()) {
return null
}
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY)
if (!raw) {
return null
}
try {
return JSON.parse(raw) as AuthSession
} catch {
window.localStorage.removeItem(SESSION_STORAGE_KEY)
return null
}
}
export function writeSession(session: AuthSession) {
if (!hasWindow()) {
return
}
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session))
window.dispatchEvent(new Event(AUTH_SESSION_CHANGED_EVENT))
}
export function clearSession() {
if (!hasWindow()) {
return
}
window.localStorage.removeItem(SESSION_STORAGE_KEY)
window.dispatchEvent(new Event(AUTH_SESSION_CHANGED_EVENT))
}
export function expireSession() {
if (!hasWindow()) {
return
}
clearSession()
window.dispatchEvent(new Event(AUTH_SESSION_EXPIRED_EVENT))
}

15
web/lib/enums.ts Normal file
View File

@@ -0,0 +1,15 @@
export function getEnumLabel<T extends string | number>(
enumLabels: Record<T, string>,
value: T
): string {
return enumLabels[value] || String(value)
}
export function getEnumOptions<T extends string | number>(
enumLabels: Record<T, string>
): Array<{ value: T; label: string }> {
return Object.entries(enumLabels).map(([value, label]) => ({
value: value as T,
label: label as string,
}))
}

16
web/lib/fonts.ts Normal file
View File

@@ -0,0 +1,16 @@
import { IBM_Plex_Mono, Inter } from "next/font/google"
export const appSans = Inter({
variable: "--font-app-sans",
subsets: ["latin", "latin-ext", "cyrillic", "cyrillic-ext"],
display: "swap",
})
export const appMono = IBM_Plex_Mono({
variable: "--font-app-mono",
subsets: ["latin", "latin-ext", "cyrillic", "cyrillic-ext"],
weight: ["400", "500", "600"],
display: "swap",
})
export const appFontVariables = `${appSans.variable} ${appMono.variable}`

391
web/lib/generated/enums.ts Normal file
View File

@@ -0,0 +1,391 @@
// Code generated by cmd/enums/generator.go. DO NOT EDIT.
export enum AIAgentFallbackMode {
NoAnswer = 1,
SuggestRetry = 2,
Handoff = 3,
}
export const AIAgentFallbackModeLabels: Record<AIAgentFallbackMode, string> = {
[AIAgentFallbackMode.NoAnswer]: "直接说明知识不足",
[AIAgentFallbackMode.SuggestRetry]: "引导用户补充信息",
[AIAgentFallbackMode.Handoff]: "转人工客服",
}
export enum AIAgentHandoffMode {
WaitPool = 1,
DefaultTeamPool = 2,
AIHoldAndNotify = 3,
}
export const AIAgentHandoffModeLabels: Record<AIAgentHandoffMode, string> = {
[AIAgentHandoffMode.WaitPool]: "进入待接入池",
[AIAgentHandoffMode.DefaultTeamPool]: "进入默认客服组待接入池",
[AIAgentHandoffMode.AIHoldAndNotify]: "AI继续接待并提醒人工",
}
export enum AIModelType {
LLM = "llm",
Embedding = "embedding",
Rerank = "rerank",
}
export const AIModelTypeLabels: Record<AIModelType, string> = {
[AIModelType.LLM]: "大语言模型",
[AIModelType.Embedding]: "向量模型",
[AIModelType.Rerank]: "重排序模型",
}
export enum AIProvider {
OpenAI = "openai",
}
export const AIProviderLabels: Record<AIProvider, string> = {
[AIProvider.OpenAI]: "OpenAI",
}
export enum AssetProvider {
Local = "local",
OSS = "oss",
}
export const AssetProviderLabels: Record<AssetProvider, string> = {
[AssetProvider.Local]: "本地存储",
[AssetProvider.OSS]: "对象存储",
}
export enum AssetStatus {
Pending = 1,
Success = 2,
Failed = 3,
Deleted = 4,
}
export const AssetStatusLabels: Record<AssetStatus, string> = {
[AssetStatus.Pending]: "处理中",
[AssetStatus.Success]: "成功",
[AssetStatus.Failed]: "失败",
[AssetStatus.Deleted]: "已删除",
}
export enum ContactType {
Mobile = "mobile",
Email = "email",
Other = "other",
}
export const ContactTypeLabels: Record<ContactType, string> = {
[ContactType.Mobile]: "手机号",
[ContactType.Email]: "邮箱",
[ContactType.Other]: "其他",
}
export enum ExternalSource {
Guest = "guest",
User = "user",
Telegram = "telegram",
ZaloOA = "zalo_oa",
Slack = "slack",
Discord = "discord",
Email = "email",
}
export const ExternalSourceLabels: Record<ExternalSource, string> = {
[ExternalSource.Guest]: "访客",
[ExternalSource.User]: "用户",
[ExternalSource.Telegram]: "Telegram",
[ExternalSource.ZaloOA]: "Zalo OA",
[ExternalSource.Slack]: "Slack",
[ExternalSource.Discord]: "Discord",
[ExternalSource.Email]: "Email",
}
export enum Gender {
Unknown = 0,
Male = 1,
Female = 2,
}
export const GenderLabels: Record<Gender, string> = {
[Gender.Unknown]: "未知",
[Gender.Male]: "男",
[Gender.Female]: "女",
}
export enum IMAssignmentStatus {
Active = 0,
Inactive = 1,
}
export const IMAssignmentStatusLabels: Record<IMAssignmentStatus, string> = {
[IMAssignmentStatus.Active]: "进行中",
[IMAssignmentStatus.Inactive]: "已结束",
}
export enum IMAssignmentType {
Assign = "assign",
Transfer = "transfer",
}
export const IMAssignmentTypeLabels: Record<IMAssignmentType, string> = {
[IMAssignmentType.Assign]: "分配",
[IMAssignmentType.Transfer]: "转接",
}
export enum IMConversationServiceMode {
AIOnly = 1,
HumanOnly = 2,
AIFirst = 3,
}
export const IMConversationServiceModeLabels: Record<IMConversationServiceMode, string> = {
[IMConversationServiceMode.AIOnly]: "仅AI",
[IMConversationServiceMode.HumanOnly]: "仅人工",
[IMConversationServiceMode.AIFirst]: "AI优先",
}
export enum IMConversationStatus {
AIServing = 1,
Pending = 2,
Active = 3,
Closed = 4,
}
export const IMConversationStatusLabels: Record<IMConversationStatus, string> = {
[IMConversationStatus.AIServing]: "AI接待中",
[IMConversationStatus.Pending]: "待接入",
[IMConversationStatus.Active]: "处理中",
[IMConversationStatus.Closed]: "已关闭",
}
export enum IMEventType {
Create = "create",
Assign = "assign",
Transfer = "transfer",
Close = "close",
MessageSend = "message_send",
MessageRecall = "message_recall",
}
export const IMEventTypeLabels: Record<IMEventType, string> = {
[IMEventType.Create]: "创建会话",
[IMEventType.Assign]: "分配会话",
[IMEventType.Transfer]: "转接会话",
[IMEventType.Close]: "关闭会话",
[IMEventType.MessageSend]: "发送消息",
[IMEventType.MessageRecall]: "撤回消息",
}
export enum IMMessageStatus {
Sending = 1,
Sent = 2,
Delivered = 3,
Read = 4,
Failed = 5,
Recalled = 6,
}
export const IMMessageStatusLabels: Record<IMMessageStatus, string> = {
[IMMessageStatus.Sending]: "发送中",
[IMMessageStatus.Sent]: "已发送",
[IMMessageStatus.Delivered]: "已送达",
[IMMessageStatus.Read]: "已读",
[IMMessageStatus.Failed]: "发送失败",
[IMMessageStatus.Recalled]: "已撤回",
}
export enum IMMessageType {
Text = "text",
Image = "image",
Attachment = "attachment",
HTML = "html",
}
export const IMMessageTypeLabels: Record<IMMessageType, string> = {
[IMMessageType.Text]: "文本",
[IMMessageType.Image]: "图片",
[IMMessageType.Attachment]: "附件",
[IMMessageType.HTML]: "富文本",
}
export enum IMParticipantType {
Customer = "customer",
Agent = "agent",
AI = "ai",
System = "system",
}
export const IMParticipantTypeLabels: Record<IMParticipantType, string> = {
[IMParticipantType.Customer]: "客户",
[IMParticipantType.Agent]: "客服",
[IMParticipantType.AI]: "AI",
[IMParticipantType.System]: "系统",
}
export enum IMSenderType {
Agent = "agent",
Customer = "customer",
AI = "ai",
System = "system",
}
export const IMSenderTypeLabels: Record<IMSenderType, string> = {
[IMSenderType.Agent]: "客服",
[IMSenderType.Customer]: "客户",
[IMSenderType.AI]: "AI",
[IMSenderType.System]: "系统",
}
export enum KnowledgeAnswerMode {
Strict = 1,
Assist = 2,
}
export const KnowledgeAnswerModeLabels: Record<KnowledgeAnswerMode, string> = {
[KnowledgeAnswerMode.Strict]: "严格模式",
[KnowledgeAnswerMode.Assist]: "辅助模式",
}
export enum KnowledgeAnswerStatus {
Normal = 1,
NoAnswer = 2,
Fallback = 3,
Blocked = 4,
}
export const KnowledgeAnswerStatusLabels: Record<KnowledgeAnswerStatus, string> = {
[KnowledgeAnswerStatus.Normal]: "正常",
[KnowledgeAnswerStatus.NoAnswer]: "无答案",
[KnowledgeAnswerStatus.Fallback]: "兜底回复",
[KnowledgeAnswerStatus.Blocked]: "已屏蔽",
}
export enum KnowledgeBaseType {
Document = "document",
FAQ = "faq",
}
export const KnowledgeBaseTypeLabels: Record<KnowledgeBaseType, string> = {
[KnowledgeBaseType.Document]: "文档知识库",
[KnowledgeBaseType.FAQ]: "FAQ知识库",
}
export enum KnowledgeChunkProvider {
Fixed = "fixed",
Structured = "structured",
FAQ = "faq",
Semantic = "semantic",
}
export const KnowledgeChunkProviderLabels: Record<KnowledgeChunkProvider, string> = {
[KnowledgeChunkProvider.Fixed]: "固定长度",
[KnowledgeChunkProvider.Structured]: "结构化分块",
[KnowledgeChunkProvider.FAQ]: "问答式分块",
[KnowledgeChunkProvider.Semantic]: "语义分块",
}
export enum KnowledgeChunkType {
Text = "text",
FAQ = "faq",
Table = "table",
Code = "code",
}
export const KnowledgeChunkTypeLabels: Record<KnowledgeChunkType, string> = {
[KnowledgeChunkType.Text]: "文本",
[KnowledgeChunkType.FAQ]: "问答",
[KnowledgeChunkType.Table]: "表格",
[KnowledgeChunkType.Code]: "代码",
}
export enum KnowledgeDocumentContentType {
HTML = "html",
Markdown = "markdown",
}
export const KnowledgeDocumentContentTypeLabels: Record<KnowledgeDocumentContentType, string> = {
[KnowledgeDocumentContentType.HTML]: "HTML",
[KnowledgeDocumentContentType.Markdown]: "Markdown",
}
export enum KnowledgeDocumentIndexStatus {
Pending = "pending",
Indexed = "indexed",
Failed = "failed",
}
export const KnowledgeDocumentIndexStatusLabels: Record<KnowledgeDocumentIndexStatus, string> = {
[KnowledgeDocumentIndexStatus.Pending]: "待索引",
[KnowledgeDocumentIndexStatus.Indexed]: "已索引",
[KnowledgeDocumentIndexStatus.Failed]: "索引失败",
}
export enum KnowledgeFeedbackType {
Like = 1,
Dislike = 2,
NotHelpful = 3,
WrongCitation = 4,
Other = 5,
}
export const KnowledgeFeedbackTypeLabels: Record<KnowledgeFeedbackType, string> = {
[KnowledgeFeedbackType.Like]: "点赞",
[KnowledgeFeedbackType.Dislike]: "点踩",
[KnowledgeFeedbackType.NotHelpful]: "无帮助",
[KnowledgeFeedbackType.WrongCitation]: "引用错误",
[KnowledgeFeedbackType.Other]: "其他",
}
export enum KnowledgeRetrieveChannel {
IM = "im",
AgentAssist = "agent_assist",
API = "api",
Debug = "debug",
}
export const KnowledgeRetrieveChannelLabels: Record<KnowledgeRetrieveChannel, string> = {
[KnowledgeRetrieveChannel.IM]: "客服会话",
[KnowledgeRetrieveChannel.AgentAssist]: "客服助手",
[KnowledgeRetrieveChannel.API]: "API接口",
[KnowledgeRetrieveChannel.Debug]: "调试",
}
export enum KnowledgeRetrieveScene {
FirstResponse = "first_response",
Assist = "assist",
QA = "qa",
}
export const KnowledgeRetrieveSceneLabels: Record<KnowledgeRetrieveScene, string> = {
[KnowledgeRetrieveScene.FirstResponse]: "首次回复",
[KnowledgeRetrieveScene.Assist]: "辅助回复",
[KnowledgeRetrieveScene.QA]: "问答",
}
export enum ServiceStatus {
Idle = 0,
Busy = 1,
}
export const ServiceStatusLabels: Record<ServiceStatus, string> = {
[ServiceStatus.Idle]: "空闲",
[ServiceStatus.Busy]: "忙碌",
}
export enum Status {
Ok = 0,
Disabled = 1,
Deleted = 2,
}
export const StatusLabels: Record<Status, string> = {
[Status.Ok]: "启用",
[Status.Disabled]: "禁用",
[Status.Deleted]: "已删除",
}
export enum ThirdProvider {
OIDC = "oidc",
}
export const ThirdProviderLabels: Record<ThirdProvider, string> = {
[ThirdProvider.OIDC]: "OIDC",
}
export enum TicketStatus {
Pending = "pending",
InProgress = "in_progress",
Done = "done",
}
export const TicketStatusLabels: Record<TicketStatus, string> = {
[TicketStatus.Pending]: "待处理",
[TicketStatus.InProgress]: "处理中",
[TicketStatus.Done]: "已处理",
}
export enum UserType {
User = "user",
Employee = "employee",
}
export const UserTypeLabels: Record<UserType, string> = {
[UserType.User]: "访客",
[UserType.Employee]: "员工",
}
export enum VectorDBType {
Qdrant = "qdrant",
LanceDB = "lancedb",
}
export const VectorDBTypeLabels: Record<VectorDBType, string> = {
[VectorDBType.Qdrant]: "Qdrant",
[VectorDBType.LanceDB]: "LanceDB",
}

232
web/lib/im-editor-image.ts Normal file
View File

@@ -0,0 +1,232 @@
import type { Editor } from "@tiptap/react"
import Image from "@tiptap/extension-image"
export const MessageImageExtension = Image.extend({
addAttributes() {
return {
...this.parent?.(),
dataAssetId: {
default: null,
parseHTML: (element) => element.getAttribute("data-asset-id"),
renderHTML: (attributes) =>
attributes.dataAssetId ? { "data-asset-id": attributes.dataAssetId } : {},
},
dataProvider: {
default: null,
parseHTML: (element) => element.getAttribute("data-provider"),
renderHTML: (attributes) =>
attributes.dataProvider ? { "data-provider": attributes.dataProvider } : {},
},
dataStorageKey: {
default: null,
parseHTML: (element) => element.getAttribute("data-storage-key"),
renderHTML: (attributes) =>
attributes.dataStorageKey ? { "data-storage-key": attributes.dataStorageKey } : {},
},
}
},
addNodeView() {
return ({ node }) => {
const wrapper = document.createElement("span")
wrapper.className =
"agent-desk-editor-image-wrap relative inline-block max-w-full align-middle"
const image = document.createElement("img")
image.className = "agent-desk-editor-image"
image.draggable = true
const overlay = document.createElement("span")
overlay.className =
"agent-desk-editor-image-loading pointer-events-none absolute inset-0 hidden items-center justify-center rounded-lg bg-background/55 backdrop-blur-[1px]"
const spinner = document.createElement("span")
spinner.className =
"size-6 animate-spin rounded-full border-2 border-primary/25 border-t-primary shadow-sm"
overlay.appendChild(spinner)
wrapper.appendChild(image)
wrapper.appendChild(overlay)
const applyAttrs = (attrs: Record<string, unknown>) => {
setImageAttr(image, "src", attrs.src)
setImageAttr(image, "alt", attrs.alt)
setImageAttr(image, "title", attrs.title)
setImageAttr(image, "data-asset-id", attrs.dataAssetId)
setImageAttr(image, "data-provider", attrs.dataProvider)
setImageAttr(image, "data-storage-key", attrs.dataStorageKey)
setImageUploading(wrapper, Boolean(String(attrs.title ?? "").startsWith("uploading-")))
}
applyAttrs(node.attrs)
return {
dom: wrapper,
update: (updatedNode) => {
if (updatedNode.type.name !== node.type.name) {
return false
}
applyAttrs(updatedNode.attrs)
return true
},
ignoreMutation: () => true,
}
}
},
})
export type UploadedEditorImage = {
assetId: string
provider: string
storageKey: string
filename?: string
}
export type UploadedEditorImageMap = Map<string, UploadedEditorImage>
export function removeEditorImageByTitle(editor: Editor, title: string) {
const { state } = editor
let targetPos: number | null = null
state.doc.descendants((node, pos) => {
if (node.type.name === "image" && node.attrs.title === title) {
targetPos = pos
return false
}
return true
})
if (targetPos === null) {
return
}
editor.chain().focus().deleteRange({ from: targetPos, to: targetPos + 1 }).run()
}
export function markEditorImageUploadedByTitle(
editor: Editor,
title: string,
uploaded: UploadedEditorImage,
uploadedImages: UploadedEditorImageMap
) {
uploadedImages.set(title, uploaded)
const image = findEditorImageElementByTitle(editor, title)
if (!image) {
return
}
image.setAttribute("data-asset-id", uploaded.assetId)
image.setAttribute("data-provider", uploaded.provider)
image.setAttribute("data-storage-key", uploaded.storageKey)
image.setAttribute("alt", uploaded.filename || image.getAttribute("alt") || "image")
image.classList.remove("agent-desk-editor-image-uploading")
image.removeAttribute("data-uploading")
image.removeAttribute("title")
setImageUploading(image.closest(".agent-desk-editor-image-wrap"), false)
}
export function setEditorImageUploadingByTitle(editor: Editor, title: string) {
const image = findEditorImageElementByTitle(editor, title)
if (!image) {
return
}
image.classList.add("agent-desk-editor-image-uploading")
image.setAttribute("data-uploading", "true")
setImageUploading(image.closest(".agent-desk-editor-image-wrap"), true)
}
export function buildSendableEditorHTML(
html: string,
uploadedImages?: UploadedEditorImageMap
) {
if (typeof document === "undefined" || !html.includes("<img")) {
return html
}
const template = document.createElement("template")
template.innerHTML = html
for (const image of Array.from(template.content.querySelectorAll("img"))) {
const title = image.getAttribute("title") ?? ""
const uploaded = title ? uploadedImages?.get(title) : undefined
if (uploaded) {
image.setAttribute("data-asset-id", uploaded.assetId)
image.setAttribute("data-provider", uploaded.provider)
image.setAttribute("data-storage-key", uploaded.storageKey)
image.setAttribute("alt", uploaded.filename || image.getAttribute("alt") || "image")
image.removeAttribute("title")
}
if (
image.getAttribute("data-asset-id") &&
image.getAttribute("src")?.startsWith("blob:")
) {
image.removeAttribute("src")
}
}
return template.innerHTML
}
export function hasUploadingEditorImages(
html: string,
uploadedImages?: UploadedEditorImageMap
) {
if (typeof document === "undefined" || !html.includes("<img")) {
return /<img\b[^>]*\btitle=(["'])uploading-[^"']+\1/i.test(html)
}
const template = document.createElement("template")
template.innerHTML = html
return Array.from(template.content.querySelectorAll("img")).some((image) =>
isUnfinishedUploadingImage(image, uploadedImages)
)
}
export function revokeEditorObjectUrl(urls: Set<string>, objectUrl: string) {
if (!urls.delete(objectUrl)) {
return
}
URL.revokeObjectURL(objectUrl)
}
export function revokeEditorObjectUrls(urls: Set<string>) {
for (const objectUrl of urls) {
URL.revokeObjectURL(objectUrl)
}
urls.clear()
}
function findEditorImageElementByTitle(editor: Editor, title: string) {
const escapedTitle =
typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(title)
: title.replace(/["\\]/g, "\\$&")
return editor.view.dom.querySelector<HTMLImageElement>(
`img[title="${escapedTitle}"]`
)
}
function isUnfinishedUploadingImage(
image: HTMLImageElement,
uploadedImages?: UploadedEditorImageMap
) {
const title = image.getAttribute("title") ?? ""
return title.startsWith("uploading-") && !uploadedImages?.has(title)
}
function setImageAttr(image: HTMLImageElement, name: string, value: unknown) {
const normalized = typeof value === "string" ? value : ""
if (!normalized) {
image.removeAttribute(name)
return
}
if (image.getAttribute(name) !== normalized) {
image.setAttribute(name, normalized)
}
}
function setImageUploading(wrapper: Element | null, uploading: boolean) {
if (!(wrapper instanceof HTMLElement)) {
return
}
wrapper.classList.toggle("agent-desk-editor-image-wrap-uploading", uploading)
const overlay = wrapper.querySelector<HTMLElement>(".agent-desk-editor-image-loading")
if (overlay) {
overlay.classList.toggle("hidden", !uploading)
overlay.classList.toggle("flex", uploading)
}
}

177
web/lib/im-message-merge.ts Normal file
View File

@@ -0,0 +1,177 @@
import { parseMessageAssetPayload } from "@/lib/im-message"
export type MergeableImMessage = {
id: number
conversationId: number
clientMsgId?: string
senderType: string
senderId: number
senderName?: string
senderAvatar?: string
messageType: string
content: string
payload?: string
sendStatus: number
sentAt?: string
deliveredAt?: string
readAt?: string
customerRead: boolean
customerReadAt?: string
agentRead: boolean
agentReadAt?: string
recalledAt?: string
quotedMessageId?: number
}
export function mergeImMessagesByIdAsc<T extends MergeableImMessage>(
a: T[],
b: T[]
): T[] {
const byId = new Map<number, T>()
for (const message of a) {
byId.set(message.id, message)
}
for (const message of b) {
const existing = byId.get(message.id)
byId.set(message.id, existing ? mergeImMessage(existing, message) : message)
}
return Array.from(byId.values()).sort((x, y) => x.id - y.id)
}
export function parseImMessageCursorId(cursor: string): number {
const value = Number.parseInt(cursor, 10)
return Number.isFinite(value) && value > 0 ? value : 0
}
export function cursorFromLoadedImMessages<T extends Pick<MergeableImMessage, "id">>(
messages: T[]
): string {
if (messages.length === 0) {
return ""
}
return String(Math.min(...messages.map((message) => message.id)))
}
export function hasMoreAfterLatestImMessageMerge<
T extends Pick<MergeableImMessage, "id">,
>(args: {
previousMessages: T[]
previousHasMore: boolean
merged: T[]
apiHasMore: boolean
}): boolean {
const prevMin = minImMessageId(args.previousMessages)
const mergedMin = minImMessageId(args.merged)
if (mergedMin === null) {
return Boolean(args.apiHasMore)
}
if (!args.previousHasMore && prevMin !== null && mergedMin >= prevMin) {
return false
}
return args.previousHasMore || Boolean(args.apiHasMore)
}
function minImMessageId<T extends Pick<MergeableImMessage, "id">>(
messages: T[]
): number | null {
if (messages.length === 0) {
return null
}
return Math.min(...messages.map((message) => message.id))
}
export function mergeImMessage<T extends MergeableImMessage>(
existing: T,
incoming: T
): T {
const normalizedIncoming = normalizeDynamicImageContent(
existing,
normalizeDynamicImagePayload(existing, incoming)
)
return isSameImMessage(existing, normalizedIncoming) ? existing : normalizedIncoming
}
function normalizeDynamicImagePayload<T extends MergeableImMessage>(
existing: T,
incoming: T
): T {
if (
existing.messageType !== "image" ||
incoming.messageType !== "image" ||
!existing.payload ||
!incoming.payload
) {
return incoming
}
const existingAsset = parseMessageAssetPayload(existing.payload)
const incomingAsset = parseMessageAssetPayload(incoming.payload)
if (
!existingAsset?.assetId ||
existingAsset.assetId !== incomingAsset?.assetId ||
existing.payload === incoming.payload
) {
return incoming
}
return {
...incoming,
payload: existing.payload,
}
}
function normalizeDynamicImageContent<T extends MergeableImMessage>(
existing: T,
incoming: T
): T {
if (
existing.content === incoming.content ||
!existing.content.includes("data-asset-id") ||
!incoming.content.includes("data-asset-id")
) {
return incoming
}
if (getStableHTMLContentKey(existing.content) !== getStableHTMLContentKey(incoming.content)) {
return incoming
}
return {
...incoming,
content: existing.content,
}
}
function getStableHTMLContentKey(html: string): string {
if (typeof document === "undefined") {
return html.replace(
/(<img\b[^>]*\bdata-asset-id=(["'])[^"']+\2[^>]*?)\s+(?:src|srcset)=(["'])[^"']*\3/gi,
"$1"
)
}
const template = document.createElement("template")
template.innerHTML = html
for (const image of Array.from(template.content.querySelectorAll("img"))) {
if (image.getAttribute("data-asset-id")) {
image.removeAttribute("src")
image.removeAttribute("srcset")
}
}
return template.innerHTML
}
function isSameImMessage(a: MergeableImMessage, b: MergeableImMessage): boolean {
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
if (aKeys.length !== bKeys.length) {
return false
}
return aKeys.every((key) => {
const field = key as keyof MergeableImMessage
return Object.prototype.hasOwnProperty.call(b, key) && a[field] === b[field]
})
}

153
web/lib/im-message.ts Normal file
View File

@@ -0,0 +1,153 @@
import MarkdownIt from "markdown-it"
import { translateCurrentMessage } from "@/i18n/messages"
export type MessageAssetPayload = {
assetId: string
filename?: string
fileSize?: number
mimeType?: string
url?: string
}
const messageMarkdown = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
})
function t(key: string) {
return translateCurrentMessage(key)
}
export function parseMessageAssetPayload(payload?: string): MessageAssetPayload | null {
if (!payload?.trim()) {
return null
}
try {
const parsed = JSON.parse(payload) as MessageAssetPayload
if (!parsed?.assetId?.trim()) {
return null
}
return parsed
} catch {
return null
}
}
export function renderIMMessageHTML(message: {
messageType: string
content: string
payload?: string
}) {
if (message.messageType === "html") {
return message.content
}
const asset = parseMessageAssetPayload(message.payload)
if (message.messageType === "image") {
if (asset?.url) {
return `<p><img src="${escapeHTMLAttr(asset.url)}" alt="${escapeHTMLAttr(
asset.filename || "image"
)}"></p>`
}
return `<p>${escapeHTML(t("supportChat.imageSummary"))}</p>`
}
if (message.messageType === "attachment") {
if (asset?.url) {
const title = escapeHTML(asset.filename || message.content || t("supportChat.attachmentSummary"))
const meta = formatFileSize(asset.fileSize ?? 0)
const metaHTML = meta ? `<div class="im-attachment-meta">${escapeHTML(meta)}</div>` : ""
return `<div class="im-attachment"><a href="${escapeHTMLAttr(
asset.url
)}" target="_blank" rel="noreferrer" download="${escapeHTMLAttr(
asset.filename || ""
)}" class="im-attachment-link"><span class="im-attachment-icon" aria-hidden="true">${getAttachmentIconSVG()}</span><span class="im-attachment-content"><span class="im-attachment-title">${title}</span>${metaHTML}</span></a></div>`
}
return `<p>${escapeHTML(message.content || t("supportChat.attachmentSummary"))}</p>`
}
return renderTextMessageHTML(message.content || "")
}
export function summarizeIMMessage(message: {
messageType: string
content: string
payload?: string
}) {
if (message.messageType === "image") {
return t("supportChat.imageSummary")
}
if (message.messageType === "attachment") {
const asset = parseMessageAssetPayload(message.payload)
return asset?.filename?.trim()
? `${t("supportChat.attachmentSummary")} ${asset.filename.trim()}`
: t("supportChat.attachmentSummary")
}
if (message.messageType === "html") {
const text = extractTextFromHTML(message.content)
if (text.trim()) {
return text.substring(0, 100)
}
if (message.content.includes("<img")) {
return t("supportChat.imageSummary")
}
return t("supportChat.messageSummary")
}
return message.content?.substring(0, 100) || t("supportChat.messageSummary")
}
export function formatFileSize(size: number) {
if (!Number.isFinite(size) || size <= 0) {
return ""
}
const units = ["B", "KB", "MB", "GB"]
let value = size
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index += 1
}
const digits = value >= 10 || index === 0 ? 0 : 1
return `${value.toFixed(digits)} ${units[index]}`
}
function extractTextFromHTML(html: string): string {
if (typeof document === "undefined") {
return ""
}
const div = document.createElement("div")
div.innerHTML = html
return div.textContent || div.innerText || ""
}
function renderTextMessageHTML(content: string) {
const value = content.trim()
if (!value) {
return "<p></p>"
}
return messageMarkdown.render(value)
}
function escapeHTML(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
.replaceAll("\n", "<br>")
}
function escapeHTMLAttr(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
}
function getAttachmentIconSVG() {
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><path d="M14 2v6h6"></path><path d="M9 15h6"></path><path d="M9 11h2"></path></svg>`
}

View File

@@ -0,0 +1,172 @@
import type { AgentConversation, AgentMessage } from "@/lib/api/agent"
import type { ImConversation, ImMessage } from "@/lib/api/im"
import { mergeImMessagesByIdAsc } from "@/lib/im-message-merge"
import { summarizeIMMessage } from "@/lib/im-message"
export type RealtimeMessage = AgentMessage | ImMessage
export type RealtimeConversation = AgentConversation | ImConversation
export type RealtimeMessageCreatedPayload<TMessage extends RealtimeMessage> = {
conversationId?: number
messageId?: number
message?: TMessage
senderType?: string
senderId?: number
senderName?: string
senderAvatar?: string
messageType?: string
content?: string
payload?: string
sendStatus?: number
sentAt?: string
}
export type RealtimeConversationPatch = Partial<RealtimeConversation> & {
conversationId?: number
}
export function normalizeRealtimeMessage<TMessage extends RealtimeMessage>(
payload: RealtimeMessageCreatedPayload<TMessage> | null | undefined
): TMessage | null {
if (!payload) {
return null
}
if (payload.message?.id) {
return payload.message
}
const id = payload.messageId ?? 0
const conversationId = payload.conversationId ?? 0
if (id <= 0 || conversationId <= 0) {
return null
}
return {
id,
conversationId,
senderType: payload.senderType ?? "",
senderId: payload.senderId ?? 0,
senderName: payload.senderName,
senderAvatar: payload.senderAvatar,
messageType: payload.messageType ?? "",
content: payload.content ?? "",
payload: payload.payload,
sendStatus: payload.sendStatus ?? 0,
sentAt: payload.sentAt,
customerRead: false,
agentRead: false,
} as TMessage
}
export function mergeRealtimeMessage<TMessage extends RealtimeMessage>(
messages: TMessage[],
message: TMessage | null | undefined
): TMessage[] {
if (!message) {
return messages
}
return mergeImMessagesByIdAsc(messages, [message])
}
export function patchConversation<TConversation extends RealtimeConversation>(
conversation: TConversation | null,
patch: RealtimeConversationPatch | null | undefined
): TConversation | null {
if (!conversation || !patch) {
return conversation
}
const id = patch.id ?? patch.conversationId
if (!id || conversation.id !== id) {
return conversation
}
const fields = { ...patch }
delete fields.conversationId
return {
...conversation,
...fields,
} as TConversation
}
export function patchConversationList<TConversation extends RealtimeConversation>(
conversations: TConversation[],
patch: RealtimeConversationPatch | null | undefined
): TConversation[] {
if (!patch) {
return conversations
}
const id = patch.id ?? patch.conversationId
if (!id) {
return conversations
}
let changed = false
const next = conversations.map((item) => {
const patched = patchConversation(item, patch)
if (patched !== item) {
changed = true
}
return patched ?? item
})
return changed ? next : conversations
}
export function patchConversationWithMessage<
TConversation extends RealtimeConversation,
TMessage extends RealtimeMessage,
>(conversation: TConversation | null, message: TMessage | null | undefined) {
if (!conversation || !message || conversation.id !== message.conversationId) {
return conversation
}
return {
...conversation,
lastMessageId: message.id,
lastMessageAt: message.sentAt ?? conversation.lastMessageAt,
lastActiveAt: message.sentAt ?? conversation.lastActiveAt,
lastMessageSummary: summarizeIMMessage(message),
} as TConversation
}
export function patchConversationListWithMessage<
TConversation extends RealtimeConversation,
TMessage extends RealtimeMessage,
>(conversations: TConversation[], message: TMessage | null | undefined) {
if (!message) {
return conversations
}
let changed = false
const next = conversations.map((item) => {
const patched = patchConversationWithMessage(item, message)
if (patched !== item) {
changed = true
}
return patched ?? item
})
return changed ? next : conversations
}
export function markMessagesReadToMessageId<TMessage extends RealtimeMessage>(
messages: TMessage[],
messageId: number,
reader: "agent" | "customer",
readAt?: string
): TMessage[] {
if (messageId <= 0) {
return messages
}
let changed = false
const next = messages.map((message) => {
if (message.id > messageId) {
return message
}
if (reader === "agent") {
if (message.agentRead && message.agentReadAt === readAt) {
return message
}
changed = true
return { ...message, agentRead: true, agentReadAt: readAt } as TMessage
}
if (message.customerRead && message.customerReadAt === readAt) {
return message
}
changed = true
return { ...message, customerRead: true, customerReadAt: readAt } as TMessage
})
return changed ? next : messages
}

40
web/lib/im-realtime.ts Normal file
View File

@@ -0,0 +1,40 @@
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
import { getCustomerSessionToken, type ImMessage } from "@/lib/api/im"
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
import type {
RealtimeConversationPatch,
RealtimeMessageCreatedPayload,
} from "@/lib/im-realtime-state"
import type { AgentActivityEventPayload } from "@/lib/agent-activity-state"
export type RealtimeAgentActivityPayload = AgentActivityEventPayload
export type ImRealtimeEnvelope = {
type: string
topic?: string
data?: RealtimeMessageCreatedPayload<ImMessage> &
RealtimeConversationPatch &
RealtimeAgentActivityPayload & {
customerSessionToken?: string
expiresAt?: string
}
payload?: RealtimeMessageCreatedPayload<ImMessage> &
RealtimeConversationPatch &
RealtimeAgentActivityPayload & {
customerSessionToken?: string
expiresAt?: string
}
}
export function createImRealtimeConnection() {
const config = readSupportChatRuntimeConfig()
const apiBaseUrl = (config.apiBaseUrl || config.baseUrl || "").trim()
const baseUrl = apiBaseUrl
? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "")
: createWebSocketBaseUrl()
const channelId = encodeURIComponent(config.channelId || "")
const customerSessionToken = getCustomerSessionToken()
return new WebSocket(
`${baseUrl}/api/ws/open?channelId=${channelId}&customerSessionToken=${encodeURIComponent(customerSessionToken)}`
)
}

View File

@@ -0,0 +1,56 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadModule() {
const source = await readFile(new URL("./knowledge-i18n.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "knowledge-i18n.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
const t = (key) =>
({
"knowledge.channelIM": "Conversations",
"knowledge.channelAgentAssist": "Agent Assist",
"knowledge.channelAPI": "API",
"knowledge.channelDebug": "Debug",
"knowledge.sceneFirstResponse": "First response",
"knowledge.sceneAssist": "Assisted reply",
"knowledge.sceneQA": "Q&A",
"knowledge.answerNormal": "Answered",
"knowledge.answerNoAnswer": "No answer",
"knowledge.answerFallback": "Fallback",
"knowledge.answerBlocked": "Blocked",
"knowledge.chunkFixed": "Fixed length",
"knowledge.chunkStructured": "Structured chunks",
"knowledge.chunkFAQ": "Q&A chunks",
"knowledge.chunkSemantic": "Semantic chunks",
})[key] ?? key
test("localizes knowledge retrieve enum labels from stable values", async () => {
const {
getKnowledgeAnswerStatusLabel,
getKnowledgeChunkProviderLabel,
getKnowledgeRetrieveChannelLabel,
getKnowledgeRetrieveSceneLabel,
} = await loadModule()
assert.equal(getKnowledgeRetrieveChannelLabel("im", "\u5ba2\u670d\u4f1a\u8bdd", t), "Conversations")
assert.equal(getKnowledgeRetrieveSceneLabel("first_response", "\u9996\u6b21\u56de\u590d", t), "First response")
assert.equal(getKnowledgeAnswerStatusLabel(2, "\u65e0\u7b54\u6848", t), "No answer")
assert.equal(getKnowledgeChunkProviderLabel("semantic", t), "Semantic chunks")
})

48
web/lib/knowledge-i18n.ts Normal file
View File

@@ -0,0 +1,48 @@
type TFunction = (key: string, values?: Record<string, string | number>) => string
const CHANNEL_LABEL_KEYS: Record<string, string> = {
im: "knowledge.channelIM",
agent_assist: "knowledge.channelAgentAssist",
api: "knowledge.channelAPI",
debug: "knowledge.channelDebug",
}
const SCENE_LABEL_KEYS: Record<string, string> = {
first_response: "knowledge.sceneFirstResponse",
assist: "knowledge.sceneAssist",
qa: "knowledge.sceneQA",
}
const ANSWER_STATUS_LABEL_KEYS: Record<number, string> = {
1: "knowledge.answerNormal",
2: "knowledge.answerNoAnswer",
3: "knowledge.answerFallback",
4: "knowledge.answerBlocked",
}
const PROVIDER_LABEL_KEYS: Record<string, string> = {
fixed: "knowledge.chunkFixed",
structured: "knowledge.chunkStructured",
faq: "knowledge.chunkFAQ",
semantic: "knowledge.chunkSemantic",
}
export function getKnowledgeRetrieveChannelLabel(value: string, fallback: string, t: TFunction) {
return translateByKey(CHANNEL_LABEL_KEYS[value], fallback, t)
}
export function getKnowledgeRetrieveSceneLabel(value: string, fallback: string, t: TFunction) {
return translateByKey(SCENE_LABEL_KEYS[value], fallback, t)
}
export function getKnowledgeAnswerStatusLabel(value: number, fallback: string, t: TFunction) {
return translateByKey(ANSWER_STATUS_LABEL_KEYS[value], fallback, t)
}
export function getKnowledgeChunkProviderLabel(value: string, t: TFunction) {
return translateByKey(PROVIDER_LABEL_KEYS[value], value, t)
}
function translateByKey(key: string | undefined, fallback: string, t: TFunction) {
return key ? t(key) : fallback
}

View File

@@ -0,0 +1,92 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadModule() {
const source = await readFile(
new URL("./navigation-active.ts", import.meta.url),
"utf8"
)
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "navigation-active.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
describe("isDashboardNavItemActive", () => {
it("matches exact dashboard paths and nested child routes", async () => {
const { isDashboardNavItemActive } = await loadModule()
assert.equal(isDashboardNavItemActive("/dashboard/tickets", "/dashboard/tickets"), true)
assert.equal(
isDashboardNavItemActive("/dashboard/tickets/123", "/dashboard/tickets"),
true
)
assert.equal(
isDashboardNavItemActive("/dashboard/tickets-extra", "/dashboard/tickets"),
false
)
assert.equal(
isDashboardNavItemActive(
"/dashboard/channels-archive",
"/dashboard/channels"
),
false
)
})
it("only marks the dashboard home item active on the exact dashboard path", async () => {
const { isDashboardNavItemActive } = await loadModule()
assert.equal(isDashboardNavItemActive("/dashboard", "/dashboard"), true)
assert.equal(isDashboardNavItemActive("/dashboard/tickets", "/dashboard"), false)
})
})
describe("dashboardNavSectionHasActiveItem", () => {
it("detects whether a sidebar group contains the current route", async () => {
const { dashboardNavSectionHasActiveItem } = await loadModule()
const items = [
{ url: "/dashboard/tags" },
{ url: "/dashboard/quick-replies" },
]
assert.equal(
dashboardNavSectionHasActiveItem(items, "/dashboard/quick-replies/create"),
true
)
assert.equal(dashboardNavSectionHasActiveItem(items, "/dashboard/users"), false)
})
})
describe("dashboard nav section storage helpers", () => {
it("builds stable localStorage keys from section identifiers", async () => {
const { getDashboardNavSectionStorageKey } = await loadModule()
assert.equal(
getDashboardNavSectionStorageKey("nav.receptionCenter"),
"dashboard.sidebar.navSection.nav.receptionCenter.open"
)
})
it("parses stored open states and ignores unknown values", async () => {
const { parseDashboardNavSectionOpenState } = await loadModule()
assert.equal(parseDashboardNavSectionOpenState("true"), true)
assert.equal(parseDashboardNavSectionOpenState("false"), false)
assert.equal(parseDashboardNavSectionOpenState(null), undefined)
assert.equal(parseDashboardNavSectionOpenState("bad"), undefined)
})
})

View File

@@ -0,0 +1,40 @@
export type DashboardNavActiveItem = {
url: string
}
const DASHBOARD_NAV_SECTION_STORAGE_PREFIX = "dashboard.sidebar.navSection"
function normalizePath(path: string) {
return path !== "/" ? path.replace(/\/+$/, "") : path
}
export function isDashboardNavItemActive(pathname: string, itemUrl: string) {
const currentPath = normalizePath(pathname)
const targetPath = normalizePath(itemUrl)
if (targetPath === "/" || targetPath === "/dashboard") {
return currentPath === targetPath
}
return currentPath === targetPath || currentPath.startsWith(targetPath + "/")
}
export function dashboardNavSectionHasActiveItem(
items: ReadonlyArray<DashboardNavActiveItem>,
pathname: string
) {
return items.some((item) => isDashboardNavItemActive(pathname, item.url))
}
export function getDashboardNavSectionStorageKey(sectionKey: string) {
return `${DASHBOARD_NAV_SECTION_STORAGE_PREFIX}.${sectionKey}.open`
}
export function parseDashboardNavSectionOpenState(value: string | null) {
if (value === "true") {
return true
}
if (value === "false") {
return false
}
return undefined
}

352
web/lib/navigation.tsx Normal file
View File

@@ -0,0 +1,352 @@
import {
BotMessageSquareIcon,
BookOpenIcon,
BrainCircuitIcon,
Building2Icon,
CalendarClockIcon,
FileTextIcon,
CircleHelpIcon,
GlobeIcon,
KeyRoundIcon,
LayoutDashboardIcon,
ListChecksIcon,
MessageSquareCodeIcon,
MessageSquareMoreIcon,
SettingsIcon,
ShieldCheckIcon,
TagsIcon,
UserCogIcon,
UsersIcon,
WorkflowIcon,
} from "lucide-react";
import type { ReactNode } from "react";
/** Keep in sync with backend internal/pkg/constants/auth.go RoleCodeSuperAdmin. */
export const DASHBOARD_ROLE_SUPER_ADMIN = "super_admin";
export type DashboardNavMenuItem = {
title: string;
titleKey: string;
url: string;
icon: ReactNode;
};
export type DashboardNavItemConfig = Omit<DashboardNavMenuItem, "title"> & {
/**
* Keep in sync with backend Permission.Code. Missing value means any signed-in
* admin can see the module.
*/
requiredPermission?: string;
};
export type DashboardNavSectionConfig = {
titleKey: string;
icon: ReactNode;
items: DashboardNavItemConfig[];
};
function navItemVisible(
item: DashboardNavItemConfig,
superAdmin: boolean,
permissionSet: Set<string>,
): boolean {
if (superAdmin) {
return true;
}
if (!item.requiredPermission) {
return true;
}
return permissionSet.has(item.requiredPermission);
}
export function filterDashboardNavForSession(
permissions: readonly string[] | undefined,
roles: readonly string[] | undefined,
): { titleKey: string; icon: ReactNode; items: DashboardNavMenuItem[] }[] {
const superAdmin = roles?.includes(DASHBOARD_ROLE_SUPER_ADMIN) ?? false;
const permissionSet = new Set(permissions ?? []);
return dashboardNavSections
.map((section) => ({
titleKey: section.titleKey,
icon: section.icon,
items: section.items
.filter((item) => navItemVisible(item, superAdmin, permissionSet))
.map(({ titleKey, url, icon }) => ({ title: titleKey, titleKey, url, icon })),
}))
.filter((section) => section.items.length > 0);
}
export function filterDashboardSecondaryNavForSession(
permissions: readonly string[] | undefined,
roles: readonly string[] | undefined,
): DashboardNavMenuItem[] {
const superAdmin = roles?.includes(DASHBOARD_ROLE_SUPER_ADMIN) ?? false;
const permissionSet = new Set(permissions ?? []);
return dashboardSecondaryNav
.filter((item) => navItemVisible(item, superAdmin, permissionSet))
.map(({ titleKey, url, icon }) => ({ title: titleKey, titleKey, url, icon }));
}
export const dashboardNavSections: DashboardNavSectionConfig[] = [
// {
// title: "Overview",
// items: [
// {
// title: "Overview",
// url: "/",
// icon: <LayoutDashboardIcon />,
// },
// ],
// },
{
titleKey: "nav.receptionCenter",
icon: <BotMessageSquareIcon />,
items: [
{
titleKey: "nav.overview",
url: "/dashboard",
icon: <LayoutDashboardIcon />,
},
{
titleKey: "nav.setupWizard",
url: "/dashboard/setup",
icon: <ListChecksIcon />,
},
{
titleKey: "nav.conversations",
url: "/dashboard/conversations",
icon: <BotMessageSquareIcon />,
requiredPermission: "conversation.view",
},
{
titleKey: "nav.tickets",
url: "/dashboard/tickets",
icon: <FileTextIcon />,
requiredPermission: "ticket.view",
},
{
titleKey: "nav.conversationMonitor",
url: "/dashboard/conversation-monitor",
icon: <BotMessageSquareIcon />,
requiredPermission: "conversation.view",
},
{
titleKey: "nav.customers",
url: "/dashboard/customers",
icon: <UsersIcon />,
requiredPermission: "customer.view",
},
{
titleKey: "nav.companies",
url: "/dashboard/companies",
icon: <Building2Icon />,
requiredPermission: "company.view",
},
],
},
{
titleKey: "nav.supportCenter",
icon: <CircleHelpIcon />,
items: [
{
titleKey: "nav.supportDocs",
url: "/dashboard/support/docs",
icon: <BookOpenIcon />,
requiredPermission: "docPage.view",
},
{
titleKey: "nav.supportCommunity",
url: "/dashboard/support/community",
icon: <CircleHelpIcon />,
requiredPermission: "supportCommunity.view",
},
{
titleKey: "nav.supportCommunityCategories",
url: "/dashboard/support/community-categories",
icon: <TagsIcon />,
requiredPermission: "supportCommunity.view",
},
{
titleKey: "nav.supportConfig",
url: "/dashboard/support/config",
icon: <SettingsIcon />,
requiredPermission: "supportConfig.view",
},
],
},
{
titleKey: "nav.agentConfig",
icon: <UserCogIcon />,
items: [
{
titleKey: "nav.tags",
url: "/dashboard/tags",
icon: <TagsIcon />,
requiredPermission: "tag.view",
},
{
titleKey: "nav.quickReplies",
url: "/dashboard/quick-replies",
icon: <MessageSquareMoreIcon />,
requiredPermission: "quickReply.view",
},
{
titleKey: "nav.agents",
url: "/dashboard/agents",
icon: <UserCogIcon />,
requiredPermission: "agent.view",
},
{
titleKey: "nav.agentTeamSchedules",
url: "/dashboard/agent-team-schedules",
icon: <CalendarClockIcon />,
requiredPermission: "agentTeamSchedule.view",
},
{
titleKey: "nav.channels",
url: "/dashboard/channels",
icon: <GlobeIcon />,
requiredPermission: "channel.view",
},
],
},
{
titleKey: "nav.aiCapabilities",
icon: <BrainCircuitIcon />,
items: [
{
titleKey: "nav.knowledge",
url: "/dashboard/knowledge",
icon: <FileTextIcon />,
requiredPermission: "knowledgeBase.view",
},
{
titleKey: "nav.aiConfigs",
url: "/dashboard/ai-configs",
icon: <BrainCircuitIcon />,
requiredPermission: "aiConfig.view",
},
{
titleKey: "nav.aiAgents",
url: "/dashboard/ai-agents",
icon: <MessageSquareMoreIcon />,
requiredPermission: "aiAgent.view",
},
{
titleKey: "nav.workflows",
url: "/dashboard/ai-workflows",
icon: <WorkflowIcon />,
requiredPermission: "aiAgent.view",
},
{
titleKey: "nav.skillDefinition",
url: "/dashboard/skill-definition",
icon: <MessageSquareCodeIcon />,
requiredPermission: "skillDefinition.view",
},
{
titleKey: "nav.mcp",
url: "/dashboard/mcp",
icon: <MessageSquareCodeIcon />,
requiredPermission: "mcp.view",
},
{
titleKey: "nav.workflowRuns",
url: "/dashboard/ai-workflow-runs",
icon: <WorkflowIcon />,
requiredPermission: "aiAgent.view",
},
{
titleKey: "nav.agentRuns",
url: "/dashboard/agent-runs",
icon: <BotMessageSquareIcon />,
requiredPermission: "aiAgent.view",
},
],
},
{
titleKey: "nav.system",
icon: <ShieldCheckIcon />,
items: [
{
titleKey: "nav.users",
url: "/dashboard/users",
icon: <UsersIcon />,
requiredPermission: "user.view",
},
{
titleKey: "nav.roles",
url: "/dashboard/roles",
icon: <ShieldCheckIcon />,
requiredPermission: "role.view",
},
{
titleKey: "nav.permissions",
url: "/dashboard/permissions",
icon: <KeyRoundIcon />,
requiredPermission: "permission.view",
},
],
},
];
export const dashboardSecondaryNav: DashboardNavItemConfig[] = [
// {
// title: "System Settings",
// url: "/settings",
// icon: <Settings2Icon />,
// },
// {
// title: "Documentation Center",
// url: "/help",
// icon: <LifeBuoyIcon />,
// },
];
export const dashboardQuickActions = [
{
title: "View Conversations",
icon: <BotMessageSquareIcon />,
},
{
title: "Invite Members",
icon: <UserCogIcon />,
},
{
title: "Connect Bot",
icon: <MessageSquareCodeIcon />,
},
] as const;
export function getPageTitle(pathname: string): string {
return getPageTitleKey(pathname);
}
export function getPageTitleKey(pathname: string): string {
let matchedTitle = "nav.dashboardHome";
let longestMatch = 0;
for (const section of dashboardNavSections) {
for (const item of section.items) {
if (pathname === item.url || pathname.startsWith(item.url + "/")) {
const matchLength = item.url.length;
if (matchLength > longestMatch) {
longestMatch = matchLength;
matchedTitle = item.titleKey;
}
}
}
}
for (const item of dashboardSecondaryNav) {
if (pathname === item.url || pathname.startsWith(item.url + "/")) {
const matchLength = item.url.length;
if (matchLength > longestMatch) {
longestMatch = matchLength;
matchedTitle = item.titleKey;
}
}
}
return matchedTitle;
}

View File

@@ -0,0 +1,118 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
const messageBundles = {}
async function loadBundle(locale) {
if (!messageBundles[locale]) {
const source = await readFile(new URL(`../messages/${locale}.json`, import.meta.url), "utf8")
messageBundles[locale] = JSON.parse(source)
}
return messageBundles[locale]
}
function lookupMessage(bundle, key) {
return key.split(".").reduce((value, segment) => (value == null ? undefined : value[segment]), bundle)
}
function translateMessage(locale, key, values) {
const message = lookupMessage(messageBundles[locale], key)
if (typeof message !== "string") {
return key
}
if (!values) {
return message
}
return message.replace(/\{(\w+)\}/g, (match, name) => String(values[name] ?? match))
}
async function loadModule() {
await loadBundle("en-US")
await loadBundle("ru-RU")
const source = await readFile(new URL("./notification-i18n.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "notification-i18n.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
require: (id) => {
if (id === "@/i18n/config") {
return { DEFAULT_LOCALE: "zh-CN", normalizeLocale: normalizeLocaleStub }
}
if (id === "@/i18n/messages") {
return { translateMessage }
}
throw new Error(`unexpected import ${id}`)
},
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
function normalizeLocaleStub(value) {
const key = String(value ?? "").trim().toLowerCase()
if (key === "zh-cn" || key === "zh_cn" || key === "zh") {
return "zh-CN"
}
if (key === "en-us" || key === "en_us" || key === "en") {
return "en-US"
}
if (key === "ru-ru" || key === "ru_ru" || key === "ru") {
return "ru-RU"
}
return "zh-CN"
}
const ticketAssignedNotification = {
id: 1,
recipientUserId: 2,
title: "工单指派提醒",
content: "工单 TK-100 已指派给你\nCannot sign in\n指派原因: urgent",
notificationType: "ticket_assigned",
bizType: "ticket",
bizId: 100,
actionUrl: "/dashboard/tickets?ticketId=100",
}
test("localizes realtime ticket assignment notification to English", async () => {
const { localizeNotificationItem } = await loadModule()
const result = localizeNotificationItem(ticketAssignedNotification, "en-US")
assert.equal(result.title, "Ticket assigned")
assert.equal(
result.content,
"Ticket TK-100 has been assigned to you.\nCannot sign in\nAssignment reason: urgent"
)
})
test("localizes realtime ticket assignment notification to Russian", async () => {
await loadBundle("ru-RU")
const { localizeNotificationItem } = await loadModule()
const result = localizeNotificationItem(ticketAssignedNotification, "ru-RU")
assert.equal(result.title, "Обращение назначено")
assert.equal(
result.content,
"Обращение TK-100 назначено на вас.\nCannot sign in\nПричина назначения: urgent"
)
})
test("leaves Chinese notifications untouched for the Chinese locale", async () => {
const { localizeNotificationItem } = await loadModule()
const result = localizeNotificationItem(ticketAssignedNotification, "zh-CN")
assert.equal(result.title, ticketAssignedNotification.title)
assert.equal(result.content, ticketAssignedNotification.content)
})

View File

@@ -0,0 +1,117 @@
import { normalizeLocale } from "@/i18n/config"
import { translateMessage } from "@/i18n/messages"
type LocalizableNotification = {
title: string
content: string
notificationType: string
}
const TICKET_ASSIGNED_TITLE = "\u5de5\u5355\u6307\u6d3e\u63d0\u9192"
const CONVERSATION_TRANSFERRED_TITLE = "\u4f1a\u8bdd\u8f6c\u63a5\u63d0\u9192"
const CONVERSATION_AUTO_ASSIGNED_TITLE = "\u4f1a\u8bdd\u81ea\u52a8\u5206\u914d\u63d0\u9192"
const CONVERSATION_ASSIGNED_TITLE = "\u4f1a\u8bdd\u5206\u914d\u63d0\u9192"
const TICKET_ASSIGNED_PATTERN = /^\u5de5\u5355 (.+) \u5df2\u6307\u6d3e\u7ed9\u4f60$/
const CONVERSATION_ASSIGNED_PATTERN = /^\u4f1a\u8bdd #([0-9]+) \u5df2\u5206\u914d\u7ed9\u4f60$/
const ASSIGNMENT_REASON_PREFIX = "\u6307\u6d3e\u539f\u56e0: "
const CONVERSATION_ASSIGNMENT_REASON_PREFIX = "\u5206\u914d\u539f\u56e0: "
const TRANSFER_REASON_PREFIX = "\u8f6c\u63a5\u539f\u56e0: "
export function localizeNotificationItem<T extends LocalizableNotification>(
notification: T,
locale: string
): T {
const normalizedLocale = normalizeLocale(locale)
if (normalizedLocale === "zh-CN") {
return notification
}
if (notification.notificationType === "ticket_assigned") {
return {
...notification,
title: localizeNotificationTitle(notification.title, normalizedLocale),
content: localizeTicketAssignedContent(notification.content, normalizedLocale),
}
}
if (notification.notificationType === "conversation_assigned") {
return {
...notification,
title: localizeNotificationTitle(notification.title, normalizedLocale),
content: localizeConversationAssignedContent(notification.content, normalizedLocale),
}
}
return notification
}
function localizeTicketAssignedContent(content: string, locale: ReturnType<typeof normalizeLocale>) {
const lines = splitNotificationLines(content)
if (lines.length === 0) {
return content
}
const match = lines[0].match(TICKET_ASSIGNED_PATTERN)
if (match?.[1]) {
lines[0] = translateMessage(locale, "notification.ticketAssignedLine", {
ticketNo: match[1],
})
}
return lines
.map((line) =>
line.startsWith(ASSIGNMENT_REASON_PREFIX)
? translateMessage(locale, "notification.assignmentReason", {
reason: line.slice(ASSIGNMENT_REASON_PREFIX.length),
})
: line
)
.join("\n")
}
function localizeConversationAssignedContent(content: string, locale: ReturnType<typeof normalizeLocale>) {
const lines = splitNotificationLines(content)
if (lines.length === 0) {
return content
}
const match = lines[0].match(CONVERSATION_ASSIGNED_PATTERN)
if (match?.[1]) {
lines[0] = translateMessage(locale, "notification.conversationAssignedLine", {
conversationId: match[1],
})
}
return lines
.map((line) => {
if (line.startsWith(CONVERSATION_ASSIGNMENT_REASON_PREFIX)) {
return translateMessage(locale, "notification.assignmentReason", {
reason: line.slice(CONVERSATION_ASSIGNMENT_REASON_PREFIX.length),
})
}
if (line.startsWith(TRANSFER_REASON_PREFIX)) {
return translateMessage(locale, "notification.transferReason", {
reason: line.slice(TRANSFER_REASON_PREFIX.length),
})
}
return line
})
.join("\n")
}
function localizeNotificationTitle(title: string, locale: ReturnType<typeof normalizeLocale>) {
switch (title.trim()) {
case TICKET_ASSIGNED_TITLE:
return translateMessage(locale, "notification.ticketAssignedTitle")
case CONVERSATION_TRANSFERRED_TITLE:
return translateMessage(locale, "notification.conversationTransferredTitle")
case CONVERSATION_AUTO_ASSIGNED_TITLE:
return translateMessage(locale, "notification.conversationAutoAssignedTitle")
case CONVERSATION_ASSIGNED_TITLE:
return translateMessage(locale, "notification.conversationAssignedTitle")
default:
return title
}
}
function splitNotificationLines(content: string) {
const normalized = content.trim()
if (!normalized) {
return []
}
return normalized.split("\n")
}

View File

@@ -0,0 +1,187 @@
import test from "node:test"
import assert from "node:assert/strict"
import {
updateOperatorPresenceList,
purgeExpiredOperatorPresences,
resolveOperatorPresence,
} from "./operator-presence-state.ts"
test("adds a new peer operator record with current timestamp", () => {
const currentList = []
const incoming = {
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "viewing",
}
const now = 100_000
const result = updateOperatorPresenceList(currentList, incoming, 1, now)
assert.equal(result.length, 1)
assert.deepEqual(result[0], {
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "viewing",
at: now,
})
})
test("ignores incoming events from the current user (self)", () => {
const currentList = [
{
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "viewing",
at: 100_000,
},
]
const incoming = {
conversationId: 101,
userId: 1, // current user ID
username: "myself",
nickname: "Me",
action: "typing",
}
const result = updateOperatorPresenceList(currentList, incoming, 1, 101_000)
assert.equal(result, currentList)
})
test("updates action from viewing to typing and updates timestamp", () => {
const currentList = [
{
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "viewing",
at: 100_000,
},
]
const incoming = {
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "typing",
}
const now = 103_000
const result = updateOperatorPresenceList(currentList, incoming, 1, now)
assert.equal(result.length, 1)
assert.equal(result[0].action, "typing")
assert.equal(result[0].at, now)
})
test("removes operator record on action left", () => {
const currentList = [
{
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "viewing",
at: 100_000,
},
{
conversationId: 101,
userId: 3,
username: "bob",
nickname: "Bob Operator",
action: "viewing",
at: 101_000,
},
]
const incoming = {
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "left",
}
const now = 102_000
const result = updateOperatorPresenceList(currentList, incoming, 1, now)
assert.equal(result.length, 1)
assert.equal(result[0].userId, 3)
})
test("automatically filters out records older than 15 seconds during update", () => {
const currentList = [
{
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice",
action: "viewing",
at: 80_000, // 25s old at now = 105_000 (> 15s)
},
{
conversationId: 101,
userId: 3,
username: "bob",
nickname: "Bob",
action: "viewing",
at: 95_000, // 10s old at now = 105_000 (valid)
},
]
const incoming = {
conversationId: 101,
userId: 4,
username: "charlie",
nickname: "Charlie",
action: "typing",
}
const now = 105_000
const result = updateOperatorPresenceList(currentList, incoming, 1, now)
assert.equal(result.length, 2)
const userIds = result.map((r) => r.userId)
assert.ok(!userIds.includes(2), "User 2 should be expired")
assert.ok(userIds.includes(3), "User 3 should be kept")
assert.ok(userIds.includes(4), "User 4 should be added")
})
test("purgeExpiredOperatorPresences filters out expired entries based on maxAgeMs", () => {
const list = [
{
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice",
action: "viewing",
at: 84_000, // 16s ago at 100_000 -> expired
},
{
conversationId: 101,
userId: 3,
username: "bob",
nickname: "Bob",
action: "viewing",
at: 86_000, // 14s ago at 100_000 -> kept
},
]
const purged = purgeExpiredOperatorPresences(list, 100_000, 15_000)
assert.equal(purged.length, 1)
assert.equal(purged[0].userId, 3)
})
test("resolveOperatorPresence alias works identically to updateOperatorPresenceList", () => {
const currentList = []
const incoming = {
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice",
action: "viewing",
}
const now = 50_000
const result = resolveOperatorPresence(currentList, incoming, 1, now)
assert.equal(result.length, 1)
assert.equal(result[0].userId, 2)
})

View File

@@ -0,0 +1,83 @@
export type OperatorPresenceAction = "viewing" | "typing" | "left"
export type OperatorPresenceRecord = {
conversationId: number
userId: number
username: string
nickname: string
action: OperatorPresenceAction
at: number
}
export type OperatorPresencePayload = {
conversationId?: number
userId?: number
username?: string
nickname?: string
action?: string
at?: string | number
}
const DEFAULT_PRESENCE_TTL_MS = 15_000
export function purgeExpiredOperatorPresences(
currentList: OperatorPresenceRecord[],
nowMs?: number,
maxAgeMs = DEFAULT_PRESENCE_TTL_MS
): OperatorPresenceRecord[] {
const now = nowMs ?? Date.now()
return (currentList || []).filter((record) => now - record.at <= maxAgeMs)
}
export function updateOperatorPresenceList(
currentList: OperatorPresenceRecord[],
incoming: OperatorPresencePayload,
currentUserId: number,
nowMs?: number
): OperatorPresenceRecord[] {
if (!incoming || !incoming.conversationId || !incoming.userId) {
return currentList
}
if (incoming.userId === currentUserId) {
return currentList
}
const now = nowMs ?? Date.now()
const list = currentList || []
if (incoming.action === "left") {
const nextList = list.filter(
(item) =>
!(item.conversationId === incoming.conversationId && item.userId === incoming.userId)
)
return purgeExpiredOperatorPresences(nextList, now)
}
if (incoming.action === "viewing" || incoming.action === "typing") {
const existingIndex = list.findIndex(
(item) =>
item.conversationId === incoming.conversationId && item.userId === incoming.userId
)
const record: OperatorPresenceRecord = {
conversationId: incoming.conversationId,
userId: incoming.userId,
username: incoming.username ?? "",
nickname: incoming.nickname ?? "",
action: incoming.action,
at: now,
}
let nextList: OperatorPresenceRecord[]
if (existingIndex >= 0) {
nextList = [...list]
nextList[existingIndex] = record
} else {
nextList = [...list, record]
}
return purgeExpiredOperatorPresences(nextList, now)
}
return purgeExpiredOperatorPresences(list, now)
}
export const resolveOperatorPresence = updateOperatorPresenceList

View File

@@ -0,0 +1,71 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadModule() {
const source = await readFile(new URL("./permission-i18n.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "permission-i18n.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
test("localizes seeded permission display names to English", async () => {
const { getPermissionDisplayName, getPermissionGroupName } = await loadModule()
assert.equal(getPermissionDisplayName("user.assignRole", "\u5206\u914d\u7528\u6237\u89d2\u8272", "en-US"), "Assign user roles")
assert.equal(getPermissionDisplayName("agentTeamSchedule.batchGenerate", "批量生成客服组排班", "en-US"), "Batch generate agent team schedules")
assert.equal(getPermissionDisplayName("branding.view", "查看平台品牌配置", "en-US"), "View branding settings")
assert.equal(getPermissionDisplayName("docPage.view", "查看文档中心页面", "en-US"), "View support docs")
assert.equal(getPermissionGroupName("agentTeamSchedule", "en-US"), "Agent team schedules")
assert.equal(getPermissionGroupName("branding", "en-US"), "Branding settings")
})
test("keeps original permission names for Chinese locale", async () => {
const { getPermissionDisplayName, getPermissionGroupName } = await loadModule()
assert.equal(getPermissionDisplayName("user.view", "查看用户", "zh-CN"), "查看用户")
assert.equal(getPermissionDisplayName("branding.view", "查看平台品牌配置", "zh-CN"), "查看平台品牌配置")
assert.equal(getPermissionGroupName("agentTeam", "zh-CN"), "agentTeam")
})
test("localizes seeded permission display names to Russian", async () => {
const { getPermissionDisplayName, getPermissionGroupName } = await loadModule()
assert.equal(
getPermissionDisplayName("user.assignRole", "分配用户角色", "ru-RU"),
"Назначение ролей пользователям"
)
assert.equal(getPermissionDisplayName("user.view", "查看用户", "ru-RU"), "Просмотр пользователей")
assert.equal(getPermissionDisplayName("branding.view", "查看平台品牌配置", "ru-RU"), "Просмотр настроек брендинга")
assert.equal(getPermissionDisplayName("docPage.view", "查看文档中心页面", "ru-RU"), "Просмотр страниц документации")
assert.equal(getPermissionDisplayName("supportConfig.update", "更新支持中心配置", "ru-RU"), "Изменение настроек центра поддержки")
assert.equal(
getPermissionDisplayName("agentTeamSchedule.batchGenerate", "批量生成客服组排班", "ru-RU"),
"Массовое создание расписаний команд операторов"
)
assert.equal(getPermissionGroupName("agentTeamSchedule", "ru-RU"), "Расписаний команд операторов")
assert.equal(getPermissionGroupName("aiAgent", "ru-RU"), "ИИ-агентов")
assert.equal(getPermissionGroupName("branding", "ru-RU"), "Настроек брендинга")
})
test("falls back for unknown codes and unsupported locales", async () => {
const { getPermissionDisplayName, getPermissionGroupName } = await loadModule()
assert.equal(getPermissionDisplayName("unknown.thing", "\u539f\u59cb\u540d\u79f0", "ru-RU"), "\u539f\u59cb\u540d\u79f0")
assert.equal(getPermissionDisplayName("user.view", "\u539f\u59cb\u540d\u79f0", "fr-FR"), "\u539f\u59cb\u540d\u79f0")
assert.equal(getPermissionGroupName("unknownGroup", "ru-RU"), "unknownGroup")
assert.equal(getPermissionGroupName("user", "fr-FR"), "user")
})

223
web/lib/permission-i18n.ts Normal file
View File

@@ -0,0 +1,223 @@
type PermissionVocabulary = {
actions: Record<string, string>
resources: Record<string, { singular: string; plural: string }>
nameOverrides: Record<string, string>
/** Labels that must not be re-capitalized, e.g. acronyms. */
preserveCase?: readonly string[]
}
const EN_PERMISSION_VOCABULARY: PermissionVocabulary = {
actions: {
view: "View",
create: "Create",
update: "Update",
delete: "Delete",
assignRole: "Assign roles to",
assignPermission: "Assign permissions to",
sync: "Sync",
revoke: "Revoke",
assign: "Assign",
transfer: "Transfer",
close: "Close",
send: "Send",
tag: "Manage tags for",
handover: "Handle handoffs for",
recycle: "Recycle",
linkCustomer: "Link customers to",
changeStatus: "Change status for",
progress: "Update progress for",
resetUserTokenSecret: "Reset user token secret for",
updateStatus: "Update status for",
config: "Configure service rules for",
batchGenerate: "Batch generate",
call: "Call",
},
resources: {
user: { singular: "user", plural: "users" },
role: { singular: "role", plural: "roles" },
permission: { singular: "permission", plural: "permissions" },
session: { singular: "session", plural: "sessions" },
conversation: { singular: "conversation", plural: "conversations" },
ticket: { singular: "ticket", plural: "tickets" },
notification: { singular: "notification", plural: "notifications" },
quickReply: { singular: "quick reply", plural: "quick replies" },
tag: { singular: "tag", plural: "tags" },
company: { singular: "company", plural: "companies" },
channel: { singular: "channel", plural: "channels" },
customer: { singular: "customer", plural: "customers" },
agent: { singular: "agent", plural: "agents" },
agentTeam: { singular: "agent team", plural: "agent teams" },
agentTeamSchedule: { singular: "agent team schedule", plural: "agent team schedules" },
asset: { singular: "file asset", plural: "file assets" },
aiAgent: { singular: "AI Agent", plural: "AI Agents" },
aiConfig: { singular: "AI configuration", plural: "AI configurations" },
knowledgeBase: { singular: "knowledge base", plural: "knowledge bases" },
knowledgeDocument: { singular: "knowledge document", plural: "knowledge documents" },
knowledgeFAQ: { singular: "knowledge FAQ", plural: "knowledge FAQs" },
skillDefinition: { singular: "Skill definition", plural: "Skill definitions" },
mcp: { singular: "MCP tool", plural: "MCP tools" },
docPage: { singular: "support doc", plural: "support docs" },
supportCommunity: { singular: "support community post", plural: "support community posts" },
supportConfig: { singular: "support center configuration", plural: "support center configurations" },
branding: { singular: "branding setting", plural: "branding settings" },
},
nameOverrides: {
"user.assignRole": "Assign user roles",
"role.assignPermission": "Assign role permissions",
"session.revoke": "Revoke sessions",
"conversation.linkCustomer": "Link conversation customer",
"ticket.changeStatus": "Change ticket status",
"ticket.progress": "Update ticket progress",
"channel.resetUserTokenSecret": "Reset channel user token secret",
"agent.config": "Configure agent service rules",
"agentTeamSchedule.batchGenerate": "Batch generate agent team schedules",
"mcp.view": "View MCP debug information",
"mcp.call": "Call MCP tools",
"docPage.view": "View support docs",
"docPage.create": "Create support docs",
"docPage.update": "Update support docs",
"docPage.delete": "Delete support docs",
"supportCommunity.view": "View support community",
"supportCommunity.update": "Moderate support community",
"supportConfig.view": "View support center configuration",
"supportConfig.update": "Update support center configuration",
"branding.view": "View branding settings",
"branding.update": "Update branding settings",
},
preserveCase: ["AI Agents", "MCP tools"],
}
/**
* Russian permission labels are built from genitive-plural resource names so
* the action noun and the resource agree grammatically.
*/
const RU_PERMISSION_VOCABULARY: PermissionVocabulary = {
actions: {
view: "Просмотр",
create: "Создание",
update: "Изменение",
delete: "Удаление",
assignRole: "Назначение ролей",
assignPermission: "Назначение разрешений",
sync: "Синхронизация",
revoke: "Отзыв",
assign: "Назначение",
transfer: "Передача",
close: "Закрытие",
send: "Отправка",
tag: "Управление тегами",
handover: "Обработка передач",
recycle: "Корзина",
linkCustomer: "Связывание клиентов",
changeStatus: "Изменение статуса",
progress: "Обновление хода работ",
resetUserTokenSecret: "Сброс секрета токена",
updateStatus: "Изменение статуса",
config: "Настройка правил обслуживания",
batchGenerate: "Массовое создание",
call: "Вызов",
},
resources: {
user: { singular: "пользователь", plural: "пользователей" },
role: { singular: "роль", plural: "ролей" },
permission: { singular: "разрешение", plural: "разрешений" },
session: { singular: "сессия", plural: "сессий" },
conversation: { singular: "диалог", plural: "диалогов" },
ticket: { singular: "обращение", plural: "обращений" },
notification: { singular: "уведомление", plural: "уведомлений" },
quickReply: { singular: "быстрый ответ", plural: "быстрых ответов" },
tag: { singular: "тег", plural: "тегов" },
company: { singular: "компания", plural: "компаний" },
channel: { singular: "канал", plural: "каналов" },
customer: { singular: "клиент", plural: "клиентов" },
agent: { singular: "оператор", plural: "операторов" },
agentTeam: { singular: "команда операторов", plural: "команд операторов" },
agentTeamSchedule: { singular: "расписание команды операторов", plural: "расписаний команд операторов" },
asset: { singular: "файл", plural: "файлов" },
aiAgent: { singular: "ИИ-агент", plural: "ИИ-агентов" },
aiConfig: { singular: "конфигурация ИИ", plural: "конфигураций ИИ" },
knowledgeBase: { singular: "база знаний", plural: "баз знаний" },
knowledgeDocument: { singular: "документ базы знаний", plural: "документов базы знаний" },
knowledgeFAQ: { singular: "вопрос FAQ", plural: "вопросов FAQ" },
skillDefinition: { singular: "определение навыка", plural: "определений навыков" },
mcp: { singular: "инструмент MCP", plural: "инструментов MCP" },
docPage: { singular: "страница документации", plural: "страниц документации" },
supportCommunity: { singular: "сообщество поддержки", plural: "сообществ поддержки" },
supportConfig: { singular: "настройка центра поддержки", plural: "настроек центра поддержки" },
branding: { singular: "настройка брендинга", plural: "настроек брендинга" },
},
nameOverrides: {
"user.assignRole": "Назначение ролей пользователям",
"role.assignPermission": "Назначение разрешений роли",
"session.revoke": "Отзыв сессий",
"conversation.linkCustomer": "Связывание клиента с диалогом",
"ticket.changeStatus": "Изменение статуса обращения",
"ticket.progress": "Обновление хода работ по обращению",
"channel.resetUserTokenSecret": "Сброс секрета токена пользователя канала",
"agent.config": "Настройка правил обслуживания оператора",
"agentTeamSchedule.batchGenerate": "Массовое создание расписаний команд операторов",
"mcp.view": "Просмотр отладочной информации MCP",
"mcp.call": "Вызов инструментов MCP",
"docPage.view": "Просмотр страниц документации",
"docPage.create": "Создание страниц документации",
"docPage.update": "Изменение страниц документации",
"docPage.delete": "Удаление страниц документации",
"supportCommunity.view": "Просмотр сообщества поддержки",
"supportCommunity.update": "Модерация сообщества поддержки",
"supportConfig.view": "Просмотр настроек центра поддержки",
"supportConfig.update": "Изменение настроек центра поддержки",
"branding.view": "Просмотр настроек брендинга",
"branding.update": "Изменение настроек брендинга",
},
}
const PERMISSION_VOCABULARIES: Record<string, PermissionVocabulary> = {
"en-US": EN_PERMISSION_VOCABULARY,
"ru-RU": RU_PERMISSION_VOCABULARY,
}
export function getPermissionDisplayName(
code: string | undefined,
fallbackName: string,
locale: string
) {
const vocabulary = PERMISSION_VOCABULARIES[locale]
if (!vocabulary) {
return fallbackName
}
const normalizedCode = code?.trim() ?? ""
if (!normalizedCode) {
return fallbackName
}
const override = vocabulary.nameOverrides[normalizedCode]
if (override) {
return override
}
const [resourceKey, actionKey] = normalizedCode.split(".")
const resource = vocabulary.resources[resourceKey]
const action = vocabulary.actions[actionKey]
if (!resource || !action) {
return fallbackName
}
return `${action} ${resource.plural}`
}
export function getPermissionGroupName(groupName: string | undefined, locale: string) {
const normalizedGroupName = groupName?.trim() ?? ""
const vocabulary = PERMISSION_VOCABULARIES[locale]
if (!vocabulary) {
return normalizedGroupName
}
const resource = vocabulary.resources[normalizedGroupName]
if (!resource) {
return normalizedGroupName
}
return sentenceCase(resource.plural, vocabulary.preserveCase)
}
function sentenceCase(value: string, preserveCase?: readonly string[]) {
if (preserveCase?.includes(value)) {
return value
}
return value.charAt(0).toUpperCase() + value.slice(1)
}

View File

@@ -0,0 +1,99 @@
import assert from "node:assert/strict"
import test from "node:test"
import { createRealtimeConnectionManager } from "./realtime-connection.ts"
// Minimal WebSocket mock for testing connection manager lifecycle
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
constructor() {
this.readyState = MockWebSocket.CONNECTING
this.listeners = new Map()
}
addEventListener(type, listener) {
if (!this.listeners.has(type)) {
this.listeners.set(type, [])
}
this.listeners.get(type).push(listener)
}
emit(type, event = {}) {
const list = this.listeners.get(type) ?? []
for (const listener of list) {
listener(event)
}
}
open() {
this.readyState = MockWebSocket.OPEN
this.emit("open")
}
close() {
this.readyState = MockWebSocket.CLOSED
this.emit("close", { code: 1000, reason: "Normal" })
}
send() {}
}
test("beforeConnect is called and awaited before createSocket", async () => {
let beforeConnectCalled = false
let socketCreated = false
let mockSocket = null
const manager = createRealtimeConnectionManager({
beforeConnect: async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
beforeConnectCalled = true
},
createSocket: () => {
assert.ok(beforeConnectCalled, "expected beforeConnect to run before createSocket")
socketCreated = true
mockSocket = new MockWebSocket()
return mockSocket
},
})
manager.connect()
// Wait for beforeConnect promise to resolve
await new Promise((resolve) => setTimeout(resolve, 30))
assert.ok(beforeConnectCalled, "beforeConnect should be called")
assert.ok(socketCreated, "createSocket should be called")
assert.ok(mockSocket, "socket should be created")
manager.disconnect()
})
test("beforeConnect failure triggers onConnectError and disconnect status", async () => {
let connectError = null
let statusList = []
const manager = createRealtimeConnectionManager({
beforeConnect: async () => {
throw new Error("token expired")
},
createSocket: () => {
assert.fail("createSocket must not be called when beforeConnect fails")
},
onConnectError: (err) => {
connectError = err
},
onStatusChange: (status) => {
statusList.push(status)
},
})
manager.connect()
await new Promise((resolve) => setTimeout(resolve, 20))
assert.ok(connectError, "onConnectError should be called")
assert.equal(connectError.message, "token expired")
assert.ok(statusList.includes("disconnected"), "status should include disconnected")
manager.disconnect()
})

View File

@@ -0,0 +1,216 @@
"use client"
export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected"
type RealtimeConnectionManagerOptions = {
createSocket: () => WebSocket
beforeConnect?: () => Promise<unknown> | unknown
canReconnect?: () => boolean
onStatusChange?: (status: RealtimeConnectionStatus) => void
onSocketChange?: (socket: WebSocket | null) => void
onOpen?: (socket: WebSocket) => void
onMessage?: (event: MessageEvent, socket: WebSocket) => void
onClose?: (event: CloseEvent, socket: WebSocket) => void
onError?: (event: Event, socket: WebSocket) => void
onConnectError?: (error: unknown) => void
buildPingMessage?: () => string
pingIntervalMs?: number
reconnectBaseDelayMs?: number
reconnectMaxDelayMs?: number
}
type DisconnectOptions = {
reconnect?: boolean
updateStatus?: boolean
}
const DEFAULT_PING_INTERVAL_MS = 20000
const DEFAULT_RECONNECT_BASE_DELAY_MS = 2000
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000
export function createRealtimeConnectionManager(
options: RealtimeConnectionManagerOptions
) {
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let pingTimer: ReturnType<typeof setInterval> | null = null
let reconnectAttempt = 0
let reconnectEnabled = false
let connectEpoch = 0
const clearReconnectTimer = () => {
if (reconnectTimer !== null) {
globalThis.clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
const clearPingTimer = () => {
if (pingTimer !== null) {
globalThis.clearInterval(pingTimer)
pingTimer = null
}
}
const clearTimers = () => {
clearReconnectTimer()
clearPingTimer()
}
const setSocket = (nextSocket: WebSocket | null) => {
socket = nextSocket
options.onSocketChange?.(nextSocket)
}
const canReconnect = () => {
return reconnectEnabled && (options.canReconnect?.() ?? true)
}
const scheduleReconnect = () => {
if (!canReconnect() || reconnectTimer !== null) {
return
}
const delay = Math.min(
(options.reconnectBaseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) *
2 ** reconnectAttempt,
options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS
)
options.onStatusChange?.("connecting")
reconnectTimer = globalThis.setTimeout(() => {
reconnectTimer = null
reconnectAttempt += 1
if (canReconnect()) {
void connect()
}
}, delay)
}
const connect = async () => {
reconnectEnabled = true
if (!(options.canReconnect?.() ?? true)) {
return
}
disconnect({ reconnect: true, updateStatus: false })
reconnectEnabled = true
options.onStatusChange?.("connecting")
const currentEpoch = ++connectEpoch
if (options.beforeConnect) {
try {
await options.beforeConnect()
} catch (error) {
if (currentEpoch !== connectEpoch || !reconnectEnabled) {
return
}
options.onStatusChange?.("disconnected")
options.onConnectError?.(error)
scheduleReconnect()
return
}
}
if (
currentEpoch !== connectEpoch ||
!reconnectEnabled ||
!(options.canReconnect?.() ?? true)
) {
return
}
let nextSocket: WebSocket
try {
nextSocket = options.createSocket()
} catch (error) {
options.onStatusChange?.("disconnected")
options.onConnectError?.(error)
scheduleReconnect()
return
}
setSocket(nextSocket)
nextSocket.addEventListener("open", () => {
if (socket !== nextSocket) {
return
}
clearReconnectTimer()
clearPingTimer()
reconnectAttempt = 0
options.onStatusChange?.("connected")
const buildPingMessage =
options.buildPingMessage ?? (() => JSON.stringify({ type: "ping" }))
pingTimer = globalThis.setInterval(() => {
if (nextSocket.readyState === WebSocket.OPEN) {
nextSocket.send(buildPingMessage())
}
}, options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS)
options.onOpen?.(nextSocket)
})
nextSocket.addEventListener("message", (event) => {
if (socket === nextSocket) {
options.onMessage?.(event, nextSocket)
}
})
nextSocket.addEventListener("close", (event) => {
clearPingTimer()
const isCurrentSocket = socket === nextSocket
if (isCurrentSocket) {
setSocket(null)
}
options.onClose?.(event, nextSocket)
if (!isCurrentSocket) {
return
}
if (canReconnect()) {
scheduleReconnect()
} else {
options.onStatusChange?.("disconnected")
}
})
nextSocket.addEventListener("error", (event) => {
if (socket !== nextSocket) {
return
}
options.onError?.(event, nextSocket)
options.onStatusChange?.("disconnected")
scheduleReconnect()
})
}
const disconnect = (disconnectOptions?: DisconnectOptions) => {
reconnectEnabled = disconnectOptions?.reconnect ?? false
clearTimers()
if (!reconnectEnabled) {
reconnectAttempt = 0
}
const currentSocket = socket
setSocket(null)
if (
currentSocket &&
(currentSocket.readyState === WebSocket.OPEN ||
currentSocket.readyState === WebSocket.CONNECTING)
) {
currentSocket.close()
}
if (disconnectOptions?.updateStatus ?? !reconnectEnabled) {
options.onStatusChange?.("disconnected")
}
}
return {
connect,
disconnect,
reconnect: () => {
reconnectEnabled = true
connect()
},
getSocket: () => socket,
}
}

View File

@@ -0,0 +1,54 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadModule() {
const source = await readFile(new URL("./role-i18n.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "role-i18n.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
test("localizes seeded role names to English by role code", async () => {
const { getRoleDisplayName } = await loadModule()
assert.equal(getRoleDisplayName("super_admin", "\u8d85\u7ea7\u7ba1\u7406\u5458", "en-US"), "Super admin")
assert.equal(getRoleDisplayName("cs_team_leader", "\u5ba2\u670d\u7ec4\u957f", "en-US"), "Support team lead")
assert.equal(getRoleDisplayName("cs_user", "\u5ba2\u670d", "en-US"), "Support agent")
})
test("keeps custom or Chinese role names unchanged", async () => {
const { getRoleDisplayName } = await loadModule()
assert.equal(getRoleDisplayName("custom", "Ops reviewer", "en-US"), "Ops reviewer")
assert.equal(getRoleDisplayName("super_admin", "\u8d85\u7ea7\u7ba1\u7406\u5458", "zh-CN"), "\u8d85\u7ea7\u7ba1\u7406\u5458")
})
test("localizes seeded role names to Russian by role code", async () => {
const { getRoleDisplayName } = await loadModule()
assert.equal(
getRoleDisplayName("super_admin", "\u8d85\u7ea7\u7ba1\u7406\u5458", "ru-RU"),
"\u0421\u0443\u043f\u0435\u0440\u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440"
)
assert.equal(
getRoleDisplayName("cs_team_leader", "\u5ba2\u670d\u7ec4\u957f", "ru-RU"),
"\u0420\u0443\u043a\u043e\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044c \u043a\u043e\u043c\u0430\u043d\u0434\u044b \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0438"
)
assert.equal(getRoleDisplayName("cs_user", "\u5ba2\u670d", "ru-RU"), "\u041e\u043f\u0435\u0440\u0430\u0442\u043e\u0440 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0438")
assert.equal(getRoleDisplayName("custom", "Ops reviewer", "ru-RU"), "Ops reviewer")
assert.equal(getRoleDisplayName("super_admin", "\u8d85\u7ea7\u7ba1\u7406\u5458", "fr-FR"), "\u8d85\u7ea7\u7ba1\u7406\u5458")
})

27
web/lib/role-i18n.ts Normal file
View File

@@ -0,0 +1,27 @@
const SEEDED_ROLE_LABELS: Record<string, Record<string, string>> = {
"en-US": {
super_admin: "Super admin",
admin: "Admin",
cs_team_leader: "Support team lead",
cs_user: "Support agent",
},
"ru-RU": {
super_admin: "Суперадминистратор",
admin: "Администратор",
cs_team_leader: "Руководитель команды поддержки",
cs_user: "Оператор поддержки",
},
}
export function getRoleDisplayName(
code: string | undefined,
fallbackName: string,
locale: string
) {
const labels = SEEDED_ROLE_LABELS[locale]
if (!labels) {
return fallbackName
}
const label = labels[code?.trim() ?? ""]
return label || fallbackName
}

View File

@@ -0,0 +1,305 @@
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import test from "node:test"
import ts from "typescript"
import vm from "node:vm"
function createElement(tagName) {
return {
tagName,
children: [],
dataset: {},
style: {},
attributes: {},
listeners: {},
parentNode: null,
contentWindow: {},
setAttribute(name, value) {
this.attributes[name] = String(value)
},
appendChild(child) {
child.parentNode = this
this.children.push(child)
return child
},
removeChild(child) {
this.children = this.children.filter((item) => item !== child)
child.parentNode = null
return child
},
addEventListener(type, handler) {
this.listeners[type] = handler
},
click() {
this.listeners.click?.()
},
}
}
async function loadSdk(config) {
const source = await readFile(new URL("./agent-desk-sdk.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.ESNext,
importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove,
},
fileName: "agent-desk-sdk.ts",
})
const body = createElement("body")
const sandbox = {
URL,
console,
fetch: async (url) => ({
json: async () =>
String(url).endsWith("/api/config")
? {
success: true,
data: {
language: "en-US",
},
}
: {
success: true,
data: {
themeColor: "#2563eb",
},
},
}),
document: {
body,
currentScript: {
src: "https://chat.example/sdk/agent-desk-sdk.min.js",
},
createElement,
createElementNS: (_namespace, tagName) => createElement(tagName),
},
window: {
AgentDeskConfig: config,
location: {
origin: "https://host.example",
},
addEventListener() {},
removeEventListener() {},
clearTimeout() {},
setTimeout(handler, delay) {
if (!delay || delay < 1000) {
handler()
}
return 1
},
},
}
sandbox.window.window = sandbox.window
sandbox.window.document = sandbox.document
sandbox.window.fetch = sandbox.fetch
sandbox.window.console = console
sandbox.window.URL = URL
sandbox.window.setTimeout = sandbox.window.setTimeout
sandbox.window.clearTimeout = sandbox.window.clearTimeout
const compiledCode = compiled.outputText.replace(/\nexport\s*\{\};?\s*$/, "")
vm.runInNewContext(compiledCode, sandbox)
await Promise.resolve()
await Promise.resolve()
return sandbox
}
async function flushPromises(count = 30) {
for (let i = 0; i < count; i += 1) {
await Promise.resolve()
}
}
test("getChatUrl resolves a fresh userToken for each call", async () => {
let calls = 0
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
getUserToken: async () => `token_${++calls}`,
})
assert.equal(typeof sandbox.window.AgentDeskWidget.getChatUrl, "function")
const first = await sandbox.window.AgentDeskWidget.getChatUrl()
const second = await sandbox.window.AgentDeskWidget.getChatUrl()
assert.equal(new URL(first).pathname, "/support/chat/")
assert.equal(new URL(second).pathname, "/support/chat/")
assert.equal(new URL(first).searchParams.get("userToken"), "token_1")
assert.equal(new URL(second).searchParams.get("userToken"), "token_2")
assert.equal(new URL(first).searchParams.get("parentOrigin"), "https://host.example")
assert.equal(calls, 2)
})
test("getChatUrl forwards a signed externalId with its timestamp", async () => {
const seen = []
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
externalId: "u_10001",
externalName: "Ivan",
signExternalId: async (externalId) => {
seen.push(externalId)
return { signature: `sig_${seen.length}`, issuedAt: 1700000000 + seen.length }
},
})
const first = await sandbox.window.AgentDeskWidget.getChatUrl()
const second = await sandbox.window.AgentDeskWidget.getChatUrl()
assert.equal(new URL(first).searchParams.get("externalId"), "u_10001")
assert.equal(new URL(first).searchParams.get("externalIdSignature"), "sig_1")
assert.equal(new URL(first).searchParams.get("externalIdSignedAt"), "1700000001")
assert.equal(new URL(second).searchParams.get("externalIdSignature"), "sig_2")
assert.equal(new URL(second).searchParams.get("externalIdSignedAt"), "1700000002")
assert.deepEqual(seen, ["u_10001", "u_10001"])
})
test("no signer means no identity parameters", async () => {
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
externalId: "u_10001",
})
const url = await sandbox.window.AgentDeskWidget.getChatUrl()
assert.equal(new URL(url).searchParams.get("externalId"), "u_10001")
assert.equal(new URL(url).searchParams.has("externalIdSignature"), false)
assert.equal(new URL(url).searchParams.has("externalIdSignedAt"), false)
})
test("signer without an externalId is not called", async () => {
let calls = 0
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
signExternalId: async () => {
calls += 1
return { signature: "sig", issuedAt: 1700000000 }
},
})
const url = await sandbox.window.AgentDeskWidget.getChatUrl()
assert.equal(calls, 0)
assert.equal(new URL(url).searchParams.has("externalIdSignature"), false)
})
// A signature expires, so reopening on a long-lived host page must hand the frame
// a fresh one instead of leaving the stale pair in its URL.
test("reopening reloads the frame when a fresh signature was issued", async () => {
let issued = 0
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
externalId: "u_10001",
signExternalId: async () => {
issued += 1
return { signature: `sig_${issued}`, issuedAt: 1700000000 + issued }
},
})
await flushPromises()
const widget = sandbox.window.AgentDeskWidget
await widget.open()
const frame = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "frame"
)
assert.ok(frame, "expected the launcher click to create the frame")
const firstSrc = frame.src
assert.ok(firstSrc.includes("externalIdSignature=sig_1"), `frame src = ${firstSrc}`)
widget.close()
await widget.open()
const reloaded = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "frame"
)
assert.ok(reloaded.src.includes("externalIdSignature=sig_2"), `frame src after reopen = ${reloaded.src}`)
assert.notEqual(reloaded.src, firstSrc)
})
test("launcher click creates chat iframe with a freshly resolved userToken", async () => {
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
getUserToken: async () => "click_token",
})
await flushPromises()
const launcher = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "launcher"
)
assert.ok(launcher)
assert.equal(launcher.children.at(-1)?.textContent, "Support")
launcher.click()
await flushPromises()
const frame = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "frame"
)
assert.ok(frame)
assert.equal(new URL(frame.src).pathname, "/support/chat/")
assert.equal(new URL(frame.src).searchParams.get("userToken"), "click_token")
})
test("requireUserToken rejects opening when getUserToken is missing or empty", async () => {
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
requireUserToken: true,
getUserToken: async () => " ",
})
await flushPromises()
await assert.rejects(
() => sandbox.window.AgentDeskWidget.open(),
/Support user token is required/
)
const frame = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "frame"
)
assert.equal(frame, undefined)
})
test("ru-RU language configures Russian launcher and default title", async () => {
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
language: "ru-RU",
})
await flushPromises()
const launcher = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "launcher"
)
assert.ok(launcher)
assert.equal(launcher.attributes["aria-label"], "Поддержка")
assert.equal(launcher.children.at(-1)?.textContent, "Чат")
})
test("chat iframe layout uses dynamic viewport height for iOS browser chrome", async () => {
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
})
await flushPromises()
const launcher = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "launcher"
)
launcher.click()
await flushPromises()
const frame = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "frame"
)
assert.ok(frame)
assert.match(frame.style.height, /100dvh/)
assert.match(frame.style.maxWidth, /100vw/)
assert.equal(frame.style.borderRadius, "16px")
})

View File

@@ -0,0 +1,772 @@
import type {
AgentDeskConfig,
AgentDeskWidget,
SupportChatRuntimeConfig,
} from "./config-types"
type NormalizedAgentDeskConfig = AgentDeskConfig & {
baseUrl: string
channelId: string
language: string
position: "left" | "right"
themeColor: string
width: string
}
/** A host-signed visitor identity, resolved fresh on every open. */
type ResolvedExternalIdentity = {
signature: string
issuedAt: number
}
type WidgetState = {
button: HTMLButtonElement | null
frame: HTMLIFrameElement | null
frameLoaded: boolean
frameReady: boolean
initSent: boolean
isOpen: boolean
opening: boolean
isMaximized: boolean
mounted: boolean
mountGeneration: number
openGeneration: number
cancelOpen: (() => void) | null
configLoading: boolean
configPromise: Promise<void> | null
frameHideTimer: number | null
config: NormalizedAgentDeskConfig | null
frameConfig: SupportChatRuntimeConfig | null
frameUrl: URL | null
animationDuration: number
messageListener: ((event: MessageEvent) => void) | null
resizeListener: (() => void) | null
}
type WidgetConfigResponse = {
success?: boolean
data?: Partial<Pick<
AgentDeskConfig,
"title" | "subtitle" | "themeColor" | "position" | "width"
>>
}
type PublicConfigResponse = {
success?: boolean
data?: {
language?: string
}
}
function normalizeWidgetLanguage(language: string | undefined) {
const lang = String(language || "").toLowerCase()
if (lang.startsWith("ru")) return "ru-RU"
if (lang.startsWith("en")) return "en-US"
return "zh-CN"
}
function getDefaultWidgetTitle(config?: NormalizedAgentDeskConfig | null) {
const lang = normalizeWidgetLanguage(config?.language)
if (lang === "ru-RU") return "Поддержка"
if (lang === "en-US") return "Support"
return "\u5728\u7ebf\u5ba2\u670d"
}
function getLauncherText(config?: NormalizedAgentDeskConfig | null) {
const lang = normalizeWidgetLanguage(config?.language)
if (lang === "ru-RU") return "Чат"
if (lang === "en-US") return "Support"
return "\u5ba2\u670d"
}
type FrameMessage =
| { type: "agent-desk:init"; payload: SupportChatRuntimeConfig }
| { type: "agent-desk:open" }
| { type: "agent-desk:minimize" }
| { type: "agent-desk:maximized"; payload: { isMaximized: boolean } }
;(function () {
const DEFAULT_CONFIG: Pick<
NormalizedAgentDeskConfig,
"language" | "position" | "themeColor" | "width"
> = {
language: "zh-CN",
position: "right",
themeColor: "#0f6cbd",
width: "380px",
}
// Capture the SDK origin while currentScript still refers to this script.
const currentScript = document.currentScript as HTMLScriptElement | null
const scriptBaseUrl = currentScript?.src
? currentScript.src.replace(/\/sdk\/agent-desk-sdk\.min\.js(?:\?.*)?$/, "")
: ""
const existingState = window.__CS_AI_AGENT_WIDGET_STATE__ as WidgetState | undefined
// Re-evaluating the script must not leave callbacks from an older instance.
if (existingState) window.AgentDeskWidget?.destroy()
const state: WidgetState =
existingState || {
button: null,
frame: null,
frameLoaded: false,
frameReady: false,
initSent: false,
isOpen: false,
opening: false,
isMaximized: false,
mounted: false,
mountGeneration: 0,
openGeneration: 0,
cancelOpen: null,
configLoading: false,
configPromise: null,
frameHideTimer: null,
config: null,
frameConfig: null,
frameUrl: null,
animationDuration: 260,
messageListener: null,
resizeListener: null,
}
if (!existingState) {
window.__CS_AI_AGENT_WIDGET_STATE__ = state
}
function normalizeConfig(config?: AgentDeskConfig): NormalizedAgentDeskConfig {
const merged: Record<string, unknown> = { ...DEFAULT_CONFIG, ...(config || {}) }
merged.baseUrl = String(merged.baseUrl || window.location.origin).replace(/\/$/, "")
if (merged.apiBaseUrl) {
merged.apiBaseUrl = String(merged.apiBaseUrl).replace(/\/$/, "")
} else {
delete merged.apiBaseUrl
}
merged.channelId = String(merged.channelId || "")
merged.language = normalizeWidgetLanguage(String(merged.language || "zh-CN"))
if (merged.externalId) {
merged.externalId = String(merged.externalId)
}
if (typeof merged.getUserToken !== "function") {
delete merged.getUserToken
}
return merged as NormalizedAgentDeskConfig
}
function resolveWidgetBaseUrl(config: NormalizedAgentDeskConfig) {
return String(config.widgetBaseUrl || scriptBaseUrl || config.baseUrl || window.location.origin).replace(/\/$/, "")
}
function resolveConfig(config: AgentDeskConfig) {
const normalized = normalizeConfig(config)
normalized.widgetBaseUrl = resolveWidgetBaseUrl(normalized)
if (!config.baseUrl) normalized.baseUrl = normalized.widgetBaseUrl
return normalized
}
function createFrameUrl(
config: NormalizedAgentDeskConfig,
userToken: string,
identity: ResolvedExternalIdentity
) {
const widgetBaseUrl = resolveWidgetBaseUrl(config)
const frameUrl = new URL(`${widgetBaseUrl}/support/chat/`)
frameUrl.searchParams.set("channelId", config.channelId)
frameUrl.searchParams.set("baseUrl", config.baseUrl)
frameUrl.searchParams.set("parentOrigin", window.location.origin)
if (config.requireUserToken) frameUrl.searchParams.set("requireUserToken", "true")
if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl)
if (config.externalId) frameUrl.searchParams.set("externalId", config.externalId)
if (identity.signature) frameUrl.searchParams.set("externalIdSignature", identity.signature)
if (identity.issuedAt) frameUrl.searchParams.set("externalIdSignedAt", String(identity.issuedAt))
if (config.externalName) frameUrl.searchParams.set("externalName", config.externalName)
if (userToken) frameUrl.searchParams.set("userToken", userToken)
return frameUrl
}
function createFrameConfig(
config: NormalizedAgentDeskConfig,
userToken: string,
identity: ResolvedExternalIdentity
): SupportChatRuntimeConfig {
const payload = { ...config } as Record<string, unknown>
// Functions cannot cross the postMessage boundary; their resolved values travel instead.
delete payload.getUserToken
delete payload.signExternalId
if (userToken) {
return { ...payload, userToken } as SupportChatRuntimeConfig
}
if (identity.signature) {
return {
...payload,
externalIdSignature: identity.signature,
externalIdSignedAt: identity.issuedAt,
} as SupportChatRuntimeConfig
}
return payload as SupportChatRuntimeConfig
}
function resolveUserToken(config: NormalizedAgentDeskConfig) {
if (typeof config?.getUserToken !== "function") {
if (config.requireUserToken) return Promise.reject(new Error("Support user token is required"))
return Promise.resolve("")
}
try {
return Promise.resolve(config.getUserToken()).then((token) => {
const value = String(token || "").trim()
if (config.requireUserToken && !value) throw new Error("Support user token is required")
return value
})
} catch (error) {
return Promise.reject(error)
}
}
// Resolved per open(), like getUserToken, so the pair is always fresh and a
// long-lived host page does not keep replaying an expired signature. A missing
// signer simply means the visitor stays an anonymous guest.
function resolveExternalIdentity(config: NormalizedAgentDeskConfig): Promise<ResolvedExternalIdentity> {
if (!config?.externalId || typeof config.signExternalId !== "function") {
return Promise.resolve({ signature: "", issuedAt: 0 })
}
try {
return Promise.resolve(config.signExternalId(config.externalId)).then((signed) => ({
signature: String(signed?.signature || "").trim(),
issuedAt: Number(signed?.issuedAt) || 0,
}))
} catch (error) {
return Promise.reject(error)
}
}
function prepareFrame(config: NormalizedAgentDeskConfig) {
// Resolve against one snapshot. A later mount must never receive a token or
// signature requested for the previous channel or visitor.
return Promise.all([resolveUserToken(config), resolveExternalIdentity(config)]).then(
([userToken, identity]) => ({
frameUrl: createFrameUrl(config, userToken, identity),
frameConfig: createFrameConfig(config, userToken, identity),
})
)
}
function mergeWidgetConfig(
config: NormalizedAgentDeskConfig,
remoteConfig?: WidgetConfigResponse["data"]
) {
if (!remoteConfig) {
return config
}
const merged: NormalizedAgentDeskConfig = { ...config }
const remoteKeys = ["title", "subtitle", "themeColor", "position", "width"] as const
remoteKeys.forEach((key) => {
const value = remoteConfig[key]
if (value !== undefined && value !== null) {
;(merged[key] as typeof value) = value
}
})
return merged
}
function fetchConfigPayload<T>(url: string, options: RequestInit): Promise<T> {
const controller = typeof AbortController === "function" ? new AbortController() : null
// Remote defaults are optional. A slow config service must not prevent the
// host from opening with its own configuration indefinitely.
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => {
controller?.abort()
reject(new Error("Widget configuration request timed out"))
}, 5000)
Promise.resolve()
.then(() => fetch(url, { ...options, signal: controller?.signal }))
.then((response) => response.json() as Promise<T>)
.then(
(payload) => {
window.clearTimeout(timer)
resolve(payload)
},
(error) => {
window.clearTimeout(timer)
reject(error)
}
)
})
}
function fetchWidgetConfig(config: NormalizedAgentDeskConfig) {
const baseUrl = String(config.apiBaseUrl || config.baseUrl || "").replace(/\/$/, "")
if (!baseUrl || !config.channelId || typeof fetch !== "function") {
return Promise.resolve(config)
}
const url = `${baseUrl}/api/channel/config?channelId=${encodeURIComponent(config.channelId)}`
return fetchConfigPayload<WidgetConfigResponse>(url, {
method: "GET",
cache: "no-store",
headers: {
"X-Channel-Id": config.channelId,
},
})
.then((payload) => {
if (!payload || payload.success === false) {
return config
}
return mergeWidgetConfig(config, payload.data || {})
})
.catch(() => config)
}
function fetchPublicConfig(config: NormalizedAgentDeskConfig) {
const baseUrl = String(config.apiBaseUrl || config.baseUrl || "").replace(/\/$/, "")
if (!baseUrl || typeof fetch !== "function") {
return Promise.resolve(config)
}
return fetchConfigPayload<PublicConfigResponse>(`${baseUrl}/api/config`, {
method: "GET",
cache: "no-store",
})
.then((payload) => {
if (!payload || payload.success === false) {
return config
}
return normalizeConfig({
...config,
language: payload.data?.language || config.language,
})
})
.catch(() => config)
}
function clearFrameTimers() {
if (state.frameHideTimer !== null) {
window.clearTimeout(state.frameHideTimer)
state.frameHideTimer = null
}
}
function applyFrameLayout() {
const frame = state.frame
const config = state.config
if (!frame || !config) {
return
}
frame.style.position = "fixed"
frame.style.border = "0"
frame.style.overflow = "hidden"
frame.style.background = "#fff"
frame.style.zIndex = "2147483000"
frame.style.boxShadow =
"0 0 0 1px rgba(15, 35, 65, 0.06), 0 4px 12px rgba(15, 35, 65, 0.08), 0 24px 64px rgba(15, 35, 65, 0.22)"
frame.style.willChange = "top,right,bottom,left,width,height,opacity,transform,border-radius"
frame.style.transition =
"top 260ms cubic-bezier(0.22, 1, 0.36, 1), right 260ms cubic-bezier(0.22, 1, 0.36, 1), bottom 260ms cubic-bezier(0.22, 1, 0.36, 1), left 260ms cubic-bezier(0.22, 1, 0.36, 1), width 260ms cubic-bezier(0.22, 1, 0.36, 1), height 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease, transform 260ms cubic-bezier(0.22, 1, 0.36, 1), border-radius 260ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 260ms ease"
frame.style.transformOrigin =
config.position === "left" ? "left bottom" : "right bottom"
// Phones get the full viewport instead of a floating card that squeezes the chat.
const isCompact =
typeof window.innerWidth === "number" && window.innerWidth > 0 && window.innerWidth <= 480
if (state.isMaximized || isCompact) {
frame.style.top = "max(12px, env(safe-area-inset-top))"
frame.style.right = "max(12px, env(safe-area-inset-right))"
frame.style.bottom = "max(12px, env(safe-area-inset-bottom))"
frame.style.left = "max(12px, env(safe-area-inset-left))"
frame.style.width =
"calc(100vw - max(12px, env(safe-area-inset-left)) - max(12px, env(safe-area-inset-right)))"
frame.style.maxWidth = "none"
frame.style.height =
"calc(100dvh - max(12px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom)))"
frame.style.borderRadius = "16px"
return
}
frame.style.top = ""
frame.style.bottom = "max(88px, calc(84px + env(safe-area-inset-bottom)))"
frame.style.right = config.position === "left" ? "" : "max(12px, env(safe-area-inset-right))"
frame.style.left = config.position === "left" ? "max(12px, env(safe-area-inset-left))" : ""
frame.style.width = config.width || "380px"
frame.style.maxWidth =
"calc(100vw - max(12px, env(safe-area-inset-left)) - max(12px, env(safe-area-inset-right)))"
frame.style.height =
"min(760px, calc(100dvh - max(104px, calc(88px + env(safe-area-inset-bottom))) - max(12px, env(safe-area-inset-top))))"
frame.style.borderRadius = "16px"
}
function postToFrame(message: FrameMessage) {
if (!state.frame?.contentWindow || !state.frameUrl) {
return
}
try {
state.frame.contentWindow.postMessage(message, state.frameUrl.origin)
} catch (error) {
console.error("[agent-desk-widget] postMessage failed", error)
}
}
function flushFrameState() {
if (!state.frame || !state.frameLoaded || !state.frameReady || !state.frameConfig) {
return
}
if (!state.initSent) {
state.initSent = true
postToFrame({
type: "agent-desk:init",
payload: state.frameConfig,
})
}
postToFrame({ type: state.isOpen ? "agent-desk:open" : "agent-desk:minimize" })
postToFrame({
type: "agent-desk:maximized",
payload: { isMaximized: state.isMaximized },
})
}
function syncLauncherState() {
state.button?.setAttribute("aria-expanded", String(state.isOpen || state.opening))
state.button?.setAttribute("aria-busy", String(state.opening))
}
function syncFrameVisibility() {
syncLauncherState()
const frame = state.frame
if (!frame) {
return
}
clearFrameTimers()
applyFrameLayout()
frame.style.display = "block"
if (state.isOpen) {
frame.style.visibility = "visible"
frame.style.pointerEvents = "auto"
state.frameHideTimer = window.setTimeout(() => {
if (state.frame !== frame || !state.isOpen || !state.mounted) {
return
}
frame.style.opacity = "1"
frame.style.transform = "translate3d(0, 0, 0) scale(1)"
}, 16)
flushFrameState()
return
}
frame.style.pointerEvents = "none"
frame.style.opacity = "0"
frame.style.transform = state.isMaximized
? "translate3d(0, 10px, 0) scale(0.985)"
: "translate3d(0, 16px, 0) scale(0.96)"
state.frameHideTimer = window.setTimeout(() => {
if (state.frame !== frame || state.isOpen || !state.mounted) {
return
}
frame.style.visibility = "hidden"
}, state.animationDuration)
flushFrameState()
}
function removeFrame() {
clearFrameTimers()
if (state.frame?.parentNode) state.frame.parentNode.removeChild(state.frame)
state.frame = null
state.frameLoaded = false
state.frameReady = false
state.initSent = false
}
function createFrame() {
if (state.frame) {
return state.frame
}
if (!state.frameUrl || !state.config || !state.mounted) {
return null
}
const frame = document.createElement("iframe")
state.frame = frame
frame.dataset.agentDeskWidget = "frame"
frame.title = state.config.title || getDefaultWidgetTitle(state.config)
frame.src = state.frameUrl.toString()
applyFrameLayout()
frame.style.display = "block"
frame.style.visibility = "hidden"
frame.style.pointerEvents = "none"
frame.style.opacity = "0"
frame.style.transform = "translate3d(0, 18px, 0) scale(0.96)"
frame.addEventListener("load", () => {
if (state.frame !== frame || !state.mounted) return
state.frameLoaded = true
syncFrameVisibility()
})
document.body.appendChild(frame)
return frame
}
function handleWindowMessage(event: MessageEvent) {
if (
!state.mounted ||
!state.frame ||
!state.frameUrl ||
event.source !== state.frame.contentWindow ||
event.origin !== state.frameUrl.origin
) {
return
}
if (!event.data || typeof event.data !== "object") return
const data = event.data as { type?: string }
if (data.type === "agent-desk:ready") {
state.frameReady = true
flushFrameState()
return
}
if (data.type === "agent-desk:request-minimize" || data.type === "agent-desk:request-close") {
closeWidget()
return
}
if (data.type === "agent-desk:request-toggle-maximize") {
state.isMaximized = !state.isMaximized
syncFrameVisibility()
}
}
function createLauncher() {
if (state.button) {
return state.button
}
const config = state.config
if (!config) {
return null
}
const button = document.createElement("button")
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg")
const iconPaths = ["M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"]
const text = document.createElement("span")
const themeColor = config.themeColor || "#0f6cbd"
const reduceMotion =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
const restShadow = "0 1px 2px rgba(15, 35, 65, 0.16), 0 8px 24px rgba(15, 35, 65, 0.22)"
const hoverShadow = "0 2px 4px rgba(15, 35, 65, 0.18), 0 12px 32px rgba(15, 35, 65, 0.28)"
button.type = "button"
button.dataset.agentDeskWidget = "launcher"
button.setAttribute("aria-label", config.title || getDefaultWidgetTitle(config))
icon.setAttribute("viewBox", "0 0 24 24")
icon.setAttribute("fill", "none")
icon.setAttribute("stroke", "currentColor")
icon.setAttribute("stroke-width", "2")
icon.setAttribute("stroke-linecap", "round")
icon.setAttribute("stroke-linejoin", "round")
icon.setAttribute("aria-hidden", "true")
icon.style.width = "26px"
icon.style.height = "26px"
icon.style.flex = "0 0 auto"
iconPaths.forEach((pathData) => {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path")
path.setAttribute("d", pathData)
icon.appendChild(path)
})
// Visually hidden: the icon carries the meaning, the label serves assistive tech.
text.textContent = getLauncherText(config)
text.style.position = "absolute"
text.style.width = "1px"
text.style.height = "1px"
text.style.overflow = "hidden"
text.style.clip = "rect(0 0 0 0)"
text.style.whiteSpace = "nowrap"
button.style.position = "fixed"
button.style.bottom = "max(20px, env(safe-area-inset-bottom))"
button.style.right = config.position === "left" ? "" : "max(20px, env(safe-area-inset-right))"
button.style.left = config.position === "left" ? "max(20px, env(safe-area-inset-left))" : ""
button.style.zIndex = "2147483000"
button.style.display = "inline-flex"
button.style.alignItems = "center"
button.style.justifyContent = "center"
button.style.width = "56px"
button.style.height = "56px"
button.style.border = "0"
button.style.borderRadius = "999px"
button.style.padding = "0"
button.style.background = themeColor
button.style.color = "#fff"
button.style.boxShadow = restShadow
button.style.cursor = "pointer"
button.style.outline = "none"
button.style.transition = reduceMotion
? "none"
: "transform 180ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 180ms ease"
button.appendChild(icon)
button.appendChild(text)
const lift = () => {
button.style.transform = "translateY(-2px)"
button.style.boxShadow = hoverShadow
}
const rest = () => {
button.style.transform = "none"
button.style.boxShadow = restShadow
}
button.addEventListener("mouseenter", lift)
button.addEventListener("mouseleave", rest)
button.addEventListener("focus", () => {
button.style.boxShadow = `${hoverShadow}, 0 0 0 3px ${themeColor}55`
})
button.addEventListener("blur", rest)
button.addEventListener("click", () => {
if (state.isOpen || state.opening) {
closeWidget()
return
}
// openWidget logs active failures; native clicks have no Promise caller.
void openWidget().catch(() => {})
})
document.body.appendChild(button)
state.button = button
syncLauncherState()
return button
}
function mount(config?: AgentDeskConfig) {
const rawConfig = { ...(config || window.AgentDeskConfig || { channelId: "" }) }
// A remount replaces the previous UI and invalidates every pending request.
// Conversation history belongs to the chat, not this disposable host UI.
destroy()
state.config = resolveConfig(rawConfig)
if (!state.config.channelId) {
console.error("[agent-desk-widget] channelId is required")
return
}
state.mounted = true
state.messageListener = handleWindowMessage
state.resizeListener = applyFrameLayout
window.addEventListener("message", state.messageListener)
window.addEventListener("resize", state.resizeListener)
const mountGeneration = state.mountGeneration
state.configLoading = true
const publicConfig = rawConfig.language
? Promise.resolve(state.config)
: fetchPublicConfig(state.config)
state.configPromise = publicConfig
.then((nextConfig) => {
if (!state.mounted || state.mountGeneration !== mountGeneration) return null
return fetchWidgetConfig(nextConfig)
})
.then((nextConfig) => {
if (!nextConfig || !state.mounted || state.mountGeneration !== mountGeneration) return
state.configLoading = false
state.config = normalizeConfig(nextConfig)
createLauncher()
})
}
function destroy() {
state.mounted = false
state.mountGeneration = (state.mountGeneration || 0) + 1
cancelPendingOpen()
if (state.messageListener) window.removeEventListener("message", state.messageListener)
if (state.resizeListener) window.removeEventListener("resize", state.resizeListener)
state.messageListener = null
state.resizeListener = null
removeFrame()
if (state.button?.parentNode) {
state.button.parentNode.removeChild(state.button)
}
state.button = null
state.isOpen = false
state.isMaximized = false
state.configLoading = false
state.configPromise = null
state.frameConfig = null
state.frameUrl = null
}
function cancelPendingOpen() {
state.openGeneration = (state.openGeneration || 0) + 1
state.cancelOpen?.()
state.cancelOpen = null
state.opening = false
}
function closeWidget() {
cancelPendingOpen()
state.isOpen = false
syncFrameVisibility()
}
async function openWidget() {
if (!state.mounted) mount(state.config || window.AgentDeskConfig)
if (!state.config?.channelId || !state.mounted) return
cancelPendingOpen()
const mountGeneration = state.mountGeneration
const openGeneration = state.openGeneration
const canceled = new Promise<null>((resolve) => {
state.cancelOpen = () => resolve(null)
})
const isCurrentOpen = () =>
state.mounted &&
state.mountGeneration === mountGeneration &&
state.openGeneration === openGeneration
state.opening = true
syncLauncherState()
try {
await Promise.race([state.configPromise, canceled])
if (!isCurrentOpen() || !state.config) return
const prepared = await Promise.race([prepareFrame({ ...state.config }), canceled])
// close(), destroy(), and a newer open()/mount() all cancel this open.
// Do not assign identity, create a frame or flash stale UI afterwards.
if (!prepared || !isCurrentOpen()) return
state.frameUrl = prepared.frameUrl
state.frameConfig = prepared.frameConfig
if (state.frame && state.frame.src !== prepared.frameUrl.toString()) {
// A fresh identity needs a new document. Replacing the element also
// fences delayed load/ready events from its previous document.
removeFrame()
}
if (!state.frame) createFrame()
if (!state.frame) return
state.opening = false
state.isOpen = true
syncFrameVisibility()
} catch (error) {
if (isCurrentOpen()) {
console.error("[agent-desk-widget] open failed", error)
throw error
}
} finally {
if (isCurrentOpen()) {
state.cancelOpen = null
state.opening = false
syncLauncherState()
}
}
}
window.AgentDeskWidget = {
mount,
destroy,
open: () => openWidget(),
close: closeWidget,
getChatUrl: () => {
const config = state.config
? { ...state.config }
: resolveConfig(window.AgentDeskConfig || { channelId: "" })
if (!config.channelId) {
return Promise.reject(new Error("channelId is required"))
}
// A standalone link must not change the active frame's URL or identity.
return prepareFrame(config).then(({ frameUrl }) => frameUrl.toString())
},
} satisfies AgentDeskWidget
if (window.AgentDeskConfig) {
mount(window.AgentDeskConfig)
}
})()

View File

@@ -0,0 +1,60 @@
export type AgentDeskConfig = {
channelId: string
baseUrl?: string
apiBaseUrl?: string
widgetBaseUrl?: string
/** Stable external visitor ID. Uses the browser-local visitor ID when omitted. */
externalId?: string
/**
* Signs `externalId` with the deployment-wide shared identity secret so the
* visitor counts as identified instead of anonymous:
*
* signature = hex(HMAC-SHA256(identitySecret, channelId + "\n" + externalId + "\n" + issuedAt))
*
* `issuedAt` is unix seconds and must be returned alongside the signature so
* the server can expire it. The server only accepts the pair while it is
* within identity.maxAgeMinutes, which bounds how long a captured signature
* stays replayable.
*
* The host owns the secret and should compute this in its own trusted code.
* Without a valid pair the visitor is only ever an anonymous guest, so an
* unsigned embed keeps working unchanged.
*/
signExternalId?: (externalId: string) => { signature: string; issuedAt: number } | Promise<{ signature: string; issuedAt: number }>
/** Visitor display name, only used when first exchanging for a chat token. */
externalName?: string
/** Gets the user JWT issued by the host system before opening support. */
getUserToken?: () => string | Promise<string>
/** Reject missing tokens/session downgrades instead of opening as a guest. */
requireUserToken?: boolean
title?: string
subtitle?: string
language?: string
position?: "left" | "right"
themeColor?: string
width?: string
}
export type SupportChatRuntimeConfig = Omit<AgentDeskConfig, "getUserToken" | "signExternalId"> & {
/** Used only by /support/chat to exchange for a chat token; not part of AgentDeskConfig. */
userToken?: string
externalIdSignature?: string
externalIdSignedAt?: number
}
export type AgentDeskWidget = {
mount: (config?: AgentDeskConfig) => void
destroy: () => void
open: () => Promise<void>
close: () => void
getChatUrl: () => Promise<string>
}
declare global {
interface Window {
AgentDeskConfig?: AgentDeskConfig
AgentDeskWidget?: AgentDeskWidget
__CS_AI_AGENT_WIDGET_CONFIG__?: SupportChatRuntimeConfig
__CS_AI_AGENT_WIDGET_STATE__?: unknown
}
}

View File

@@ -0,0 +1,61 @@
import type { SupportChatRuntimeConfig } from "@/lib/sdk/config-types"
export function readSupportChatRuntimeConfig(): SupportChatRuntimeConfig {
if (typeof window === "undefined") {
return {
channelId: "",
baseUrl: "",
apiBaseUrl: "",
}
}
const query = new URLSearchParams(window.location.search)
const fallback: SupportChatRuntimeConfig = {
channelId:
query.get("channelId") ??
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() ??
"",
baseUrl:
query.get("baseUrl") ??
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ??
window.location.origin,
apiBaseUrl:
query.get("apiBaseUrl") ??
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ??
undefined,
externalId: query.get("externalId") ?? undefined,
externalIdSignature: query.get("externalIdSignature") ?? undefined,
externalIdSignedAt: Number(query.get("externalIdSignedAt")) || undefined,
externalName: query.get("externalName") ?? undefined,
userToken: query.get("userToken") ?? undefined,
requireUserToken: query.get("requireUserToken") === "true",
title: query.get("title") ?? undefined,
subtitle: query.get("subtitle") ?? undefined,
position: (query.get("position") as "left" | "right" | null) ?? undefined,
themeColor: query.get("themeColor") ?? undefined,
width: query.get("width") ?? undefined,
}
if (window.__CS_AI_AGENT_WIDGET_CONFIG__) {
return window.__CS_AI_AGENT_WIDGET_CONFIG__
}
if (window.AgentDeskConfig) {
// The signer functions cannot cross into the iframe; their resolved values
// arrive in the query string and in the init payload instead.
const hostConfig = { ...window.AgentDeskConfig } as Record<string, unknown>
delete hostConfig.getUserToken
delete hostConfig.signExternalId
return {
...fallback,
...hostConfig,
} as SupportChatRuntimeConfig
}
return fallback
}
export function setSupportChatRuntimeConfig(config: SupportChatRuntimeConfig) {
if (typeof window === "undefined") {
return
}
window.__CS_AI_AGENT_WIDGET_CONFIG__ = config
}

View File

@@ -0,0 +1,42 @@
import { summarizeIMMessage } from "@/lib/im-message"
type NotificationMessage = {
messageType: string
content: string
payload?: string
}
export function getNotificationBody(message: NotificationMessage): string {
return summarizeIMMessage(message)
}
export function showNotification(title: string, body: string, onClick?: () => void) {
if (typeof Notification === "undefined") {
return
}
if (Notification.permission === "granted") {
const notification = new Notification(title, {
body,
icon: "/favicon.ico",
badge: "/favicon.ico",
})
if (onClick) {
notification.onclick = () => {
onClick()
notification.close()
}
}
setTimeout(() => {
notification.close()
}, 5000)
} else if (Notification.permission === "default") {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
showNotification(title, body, onClick)
}
})
}
}

View File

@@ -0,0 +1,193 @@
import test from "node:test"
import assert from "node:assert/strict"
import {
playIncomingMessageSound,
resetAudioContextForTesting,
} from "./sound-alert.ts"
class MockAudioParam {
constructor(defaultValue = 0) {
this.value = defaultValue
this.calls = []
}
setValueAtTime(val, time) {
this.calls.push({ method: "setValueAtTime", val, time })
}
exponentialRampToValueAtTime(val, time) {
this.calls.push({ method: "exponentialRampToValueAtTime", val, time })
}
}
class MockAudioNode {
constructor() {
this.connectedTo = null
}
connect(target) {
this.connectedTo = target
}
}
class MockOscillatorNode extends MockAudioNode {
constructor() {
super()
this.type = "sine"
this.frequency = new MockAudioParam(440)
this.startCalls = []
this.stopCalls = []
}
start(time) {
this.startCalls.push(time)
}
stop(time) {
this.stopCalls.push(time)
}
}
class MockGainNode extends MockAudioNode {
constructor() {
super()
this.gain = new MockAudioParam(1)
}
}
class MockAudioContext {
constructor() {
this.state = "running"
this.currentTime = 5.0
this.destination = new MockAudioNode()
this.oscillators = []
this.gains = []
this.resumeCalled = false
this.resumePromise = Promise.resolve()
}
createOscillator() {
const osc = new MockOscillatorNode()
this.oscillators.push(osc)
return osc
}
createGain() {
const gain = new MockGainNode()
this.gains.push(gain)
return gain
}
resume() {
this.resumeCalled = true
return this.resumePromise
}
}
test("returns false safely in Node.js / SSR environment without window", () => {
const originalWindow = globalThis.window
try {
delete globalThis.window
resetAudioContextForTesting()
const result = playIncomingMessageSound()
assert.equal(result, false)
} finally {
if (originalWindow !== undefined) {
globalThis.window = originalWindow
}
}
})
test("returns false safely when window exists but AudioContext is unsupported", () => {
const originalWindow = globalThis.window
try {
globalThis.window = {}
resetAudioContextForTesting()
const result = playIncomingMessageSound()
assert.equal(result, false)
} finally {
if (originalWindow !== undefined) {
globalThis.window = originalWindow
} else {
delete globalThis.window
}
}
})
test("synthesizes dual-tone chime (587.33Hz and 880.00Hz) with gain ramps", () => {
const originalWindow = globalThis.window
try {
let mockCtx = null
globalThis.window = {
AudioContext: class {
constructor() {
mockCtx = new MockAudioContext()
return mockCtx
}
},
}
resetAudioContextForTesting()
const result = playIncomingMessageSound()
assert.equal(result, true)
assert.ok(mockCtx, "AudioContext should be created")
// Verify 2 oscillators created
assert.equal(mockCtx.oscillators.length, 2)
const [osc1, osc2] = mockCtx.oscillators
// Note 1: 587.33 Hz (D5) for 0.08s
assert.equal(osc1.type, "sine")
assert.equal(osc1.frequency.calls[0].val, 587.33)
assert.equal(osc1.frequency.calls[0].time, 5.0)
assert.equal(osc1.startCalls[0], 5.0)
assert.equal(osc1.stopCalls[0], 5.08)
// Note 2: 880.00 Hz (A5) for 0.16s
assert.equal(osc2.type, "sine")
assert.equal(osc2.frequency.calls[0].val, 880.00)
assert.equal(osc2.frequency.calls[0].time, 5.08)
assert.equal(osc2.startCalls[0], 5.08)
assert.equal(osc2.stopCalls[0], 5.08 + 0.16)
// Verify 2 gains with exponentialRampToValueAtTime
assert.equal(mockCtx.gains.length, 2)
const [gain1, gain2] = mockCtx.gains
const ramp1 = gain1.gain.calls.find(c => c.method === "exponentialRampToValueAtTime")
const ramp2 = gain2.gain.calls.find(c => c.method === "exponentialRampToValueAtTime")
assert.ok(ramp1, "gain1 should have exponential ramp")
assert.ok(ramp2, "gain2 should have exponential ramp")
assert.equal(ramp1.time, 5.08)
assert.equal(ramp2.time, 5.24)
} finally {
if (originalWindow !== undefined) {
globalThis.window = originalWindow
} else {
delete globalThis.window
}
}
})
test("handles suspended AudioContext and catches rejected resume() safely", async () => {
const originalWindow = globalThis.window
try {
let mockCtx = null
globalThis.window = {
AudioContext: class {
constructor() {
mockCtx = new MockAudioContext()
mockCtx.state = "suspended"
mockCtx.resumePromise = Promise.reject(new Error("Autoplay not allowed"))
return mockCtx
}
},
}
resetAudioContextForTesting()
// Should not throw or crash on autoplay rejection
const result = playIncomingMessageSound()
assert.equal(result, true)
assert.equal(mockCtx.resumeCalled, true)
// Wait a tick for promise rejection to settle
await new Promise((resolve) => setTimeout(resolve, 10))
} finally {
if (originalWindow !== undefined) {
globalThis.window = originalWindow
} else {
delete globalThis.window
}
}
})

91
web/lib/sound-alert.ts Normal file
View File

@@ -0,0 +1,91 @@
let sharedAudioContext: AudioContext | null = null
/**
* Resets the cached AudioContext instance (used for testing).
*/
export function resetAudioContextForTesting(): void {
sharedAudioContext = null
}
function getOrCreateAudioContext(): AudioContext | null {
if (typeof window === "undefined") {
return null
}
const AudioContextClass =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!AudioContextClass) {
return null
}
if (!sharedAudioContext || sharedAudioContext.state === "closed") {
try {
sharedAudioContext = new AudioContextClass()
} catch {
return null
}
}
return sharedAudioContext
}
function scheduleTone(
ctx: AudioContext,
freq: number,
startTime: number,
duration: number,
peakGain = 0.15
): void {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = "sine"
osc.frequency.setValueAtTime(freq, startTime)
gain.gain.setValueAtTime(peakGain, startTime)
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start(startTime)
osc.stop(startTime + duration)
}
/**
* Plays a gentle dual-tone chime (587.33 Hz D5 -> 880.00 Hz A5) using the Web Audio API.
* Safely handles SSR/Node environments, unsupported browsers, suspended contexts, and
* browser autoplay policy rejections without throwing errors.
*
* @returns boolean indicating whether the sound playback was successfully initiated.
*/
export function playIncomingMessageSound(): boolean {
try {
const ctx = getOrCreateAudioContext()
if (!ctx) {
return false
}
if (ctx.state === "suspended") {
ctx.resume().catch(() => {
// Traps browser autoplay rejection without crashing
})
}
const now = ctx.currentTime
const note1Duration = 0.08
const note2Duration = 0.16
// Note 1: 587.33 Hz (D5) for 0.08s
scheduleTone(ctx, 587.33, now, note1Duration)
// Note 2: 880.00 Hz (A5) for 0.16s
scheduleTone(ctx, 880.00, now + note1Duration, note2Duration)
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,672 @@
"use client"
import { create } from "zustand"
import {
fetchAgentConversations,
fetchAgentMessages,
markAgentMessageRead,
recallAgentMessage,
sendAgentMessage,
uploadAgentConversationAttachment,
uploadAgentConversationImage,
type AgentAsset,
type AgentConversation,
type AgentMessage,
} from "@/lib/api/agent"
import type { RealtimeConnectionStatusValue } from "@/components/realtime-connection-status"
import {
cursorFromLoadedImMessages,
hasMoreAfterLatestImMessageMerge,
mergeImMessagesByIdAsc,
parseImMessageCursorId,
} from "@/lib/im-message-merge"
import {
markMessagesReadToMessageId,
patchConversationList,
patchConversationListWithMessage,
type RealtimeConversationPatch,
} from "@/lib/im-realtime-state"
import { summarizeIMMessage } from "@/lib/im-message"
import { generateUUID } from "@/lib/utils"
import { readSession } from "@/lib/auth"
import {
resolveAgentActivityState,
type AgentActivityInfo,
type AgentActivityEventPayload,
} from "@/lib/agent-activity-state"
import {
purgeExpiredOperatorPresences as purgePresences,
updateOperatorPresenceList,
type OperatorPresencePayload,
type OperatorPresenceRecord,
} from "@/lib/operator-presence-state"
export const agentConversationFilterOptions = [
{ value: "active", labelKey: "conversation.filterActive" },
{ value: "pending", labelKey: "conversation.filterPending" },
{ value: "ai_serving", labelKey: "conversation.filterAiServing" },
{ value: "closed", labelKey: "conversation.filterClosed" },
] as const
export type AgentConversationFilterKey =
(typeof agentConversationFilterOptions)[number]["value"]
function buildConversationQuery(filter: AgentConversationFilterKey, keyword: string) {
const query: Record<string, string | number | undefined> = {
filter,
keyword: keyword.trim() || undefined,
limit: 100,
}
return query
}
type LoadMessagesOptions = {
forceLoading?: boolean
reset?: boolean
}
function ensureArray<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : []
}
type AgentConversationsStore = {
searchKeyword: string
conversationFilter: AgentConversationFilterKey
conversations: AgentConversation[]
conversationsLoading: boolean
conversationsLoaded: boolean
selectedConversationId: number | null
activeAgentActivity: AgentActivityInfo | null
messages: AgentMessage[]
messagesLoading: boolean
messagesLoadingMore: boolean
messagesCursor: string
messagesHasMore: boolean
messagesLoadedConversationId: number | null
sending: boolean
uploadingAsset: boolean
recallingMessageId: number
readingMessageId: number
realtimeStatus: RealtimeConnectionStatusValue
operatorPresences: OperatorPresenceRecord[]
setSearchKeyword: (keyword: string) => void
setConversationFilter: (filter: AgentConversationFilterKey) => void
setRealtimeStatus: (status: RealtimeConnectionStatusValue) => void
setConversationTags: (
conversationId: number,
tags: AgentConversation["tags"]
) => void
loadConversations: () => Promise<void>
selectConversation: (conversationId: number) => Promise<void>
loadMessages: (conversationId: number, options?: LoadMessagesOptions) => Promise<void>
loadOlderMessages: () => Promise<void>
syncLatestMessages: (conversationId: number) => Promise<void>
markSelectedConversationRead: () => Promise<void>
sendMessage: (html: string) => Promise<AgentMessage | null>
uploadImage: (file: File) => Promise<AgentAsset | null>
sendAttachment: (file: File) => Promise<AgentMessage | null>
recallMessage: (messageId: number) => Promise<AgentMessage | null>
applyRealtimeMessageCreated: (message: AgentMessage) => void
applyRealtimeAgentActivity: (payload: AgentActivityEventPayload) => void
applyRealtimeOperatorPresence: (payload: OperatorPresencePayload, currentUserId?: number) => void
purgeExpiredOperatorPresences: () => void
applyRealtimeConversationChanged: (patch: RealtimeConversationPatch) => void
applyRealtimeMessageRecalled: (messageId: number, patch: Partial<AgentMessage>) => void
resyncRealtimeData: (conversationId?: number) => Promise<void>
}
let conversationsRequestSeq = 0
let messagesRequestSeq = 0
export const useAgentConversationsStore = create<AgentConversationsStore>((set, get) => ({
searchKeyword: "",
conversationFilter: "active",
conversations: [],
conversationsLoading: false,
conversationsLoaded: false,
selectedConversationId: null,
activeAgentActivity: null,
messages: [],
messagesLoading: false,
messagesLoadingMore: false,
messagesCursor: "",
messagesHasMore: false,
messagesLoadedConversationId: null,
sending: false,
uploadingAsset: false,
recallingMessageId: 0,
readingMessageId: 0,
realtimeStatus: "connecting",
operatorPresences: [],
setSearchKeyword: (keyword) => {
set({ searchKeyword: keyword })
},
setConversationFilter: (filter) => {
set({ conversationFilter: filter })
},
setRealtimeStatus: (status) => {
set({ realtimeStatus: status })
},
setConversationTags: (conversationId, tags) => {
set((state) => ({
conversations: state.conversations.map((item) =>
item.id === conversationId
? {
...item,
tags: tags && tags.length > 0 ? tags : [],
}
: item
),
}))
},
loadConversations: async () => {
const requestSeq = ++conversationsRequestSeq
const store = get()
if (!store.conversationsLoaded) {
set({ conversationsLoading: true })
}
try {
const data = await fetchAgentConversations(
buildConversationQuery(store.conversationFilter, store.searchKeyword)
)
const conversations = ensureArray(data.results)
if (requestSeq !== conversationsRequestSeq) {
return
}
const currentSelectedId = get().selectedConversationId
const hasCurrentSelection =
currentSelectedId !== null && conversations.some((item) => item.id === currentSelectedId)
const nextSelectedId = hasCurrentSelection ? currentSelectedId : (conversations[0]?.id ?? null)
const selectionChanged = nextSelectedId !== currentSelectedId
set({
conversations,
conversationsLoaded: true,
conversationsLoading: false,
selectedConversationId: nextSelectedId,
})
if (nextSelectedId === null) {
set({
messages: [],
messagesLoading: false,
messagesLoadingMore: false,
messagesCursor: "",
messagesHasMore: false,
messagesLoadedConversationId: null,
})
return
}
if (selectionChanged || get().messagesLoadedConversationId === null) {
await get().loadMessages(nextSelectedId, {
forceLoading: true,
reset: true,
})
}
} catch (error) {
if (requestSeq === conversationsRequestSeq) {
set({ conversationsLoading: false })
}
throw error
}
},
selectConversation: async (conversationId) => {
if (get().selectedConversationId === conversationId) {
return
}
set({
selectedConversationId: conversationId,
messages: [],
messagesLoading: true,
messagesLoadingMore: false,
messagesCursor: "",
messagesHasMore: false,
messagesLoadedConversationId: null,
activeAgentActivity: null,
})
await get().loadMessages(conversationId, {
forceLoading: true,
reset: true,
})
},
loadMessages: async (conversationId, options = {}) => {
const requestSeq = ++messagesRequestSeq
const store = get()
const shouldShowLoading =
options.forceLoading || store.messagesLoadedConversationId !== conversationId
if (shouldShowLoading) {
set({
messagesLoading: true,
...(options.reset
? {
messages: [],
messagesCursor: "",
messagesHasMore: false,
}
: {}),
})
}
try {
const data = await fetchAgentMessages({
conversationId,
limit: 50,
})
if (requestSeq !== messagesRequestSeq) {
return
}
if (get().selectedConversationId !== conversationId) {
return
}
const list = ensureArray(data.results)
set({
messages: list,
messagesLoading: false,
messagesLoadedConversationId: conversationId,
messagesCursor:
cursorFromLoadedImMessages(list) || (data.cursor ?? ""),
messagesHasMore: Boolean(data.hasMore),
})
} catch (error) {
if (requestSeq === messagesRequestSeq) {
set({ messagesLoading: false })
}
throw error
}
},
loadOlderMessages: async () => {
const conversationId = get().selectedConversationId
if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) {
return
}
const cursorId = parseImMessageCursorId(get().messagesCursor)
if (cursorId <= 0) {
return
}
set({ messagesLoadingMore: true })
try {
const data = await fetchAgentMessages({
conversationId,
cursor: cursorId,
limit: 50,
})
if (get().selectedConversationId !== conversationId) {
return
}
const incoming = ensureArray(data.results)
set((state) => {
const merged = mergeImMessagesByIdAsc(state.messages, incoming)
return {
messages: merged,
messagesCursor:
cursorFromLoadedImMessages(merged) ||
(data.cursor ?? state.messagesCursor),
messagesHasMore: Boolean(data.hasMore),
messagesLoadingMore: false,
}
})
} catch (error) {
set({ messagesLoadingMore: false })
throw error
}
},
syncLatestMessages: async (conversationId) => {
if (conversationId <= 0) {
return
}
try {
const data = await fetchAgentMessages({
conversationId,
limit: 50,
})
if (get().selectedConversationId !== conversationId) {
return
}
const batch = ensureArray(data.results)
if (batch.length === 0) {
return
}
set((state) => {
const merged = mergeImMessagesByIdAsc(state.messages, batch)
return {
messages: merged,
messagesCursor:
cursorFromLoadedImMessages(merged) ||
(data.cursor ?? state.messagesCursor),
messagesHasMore: hasMoreAfterLatestImMessageMerge({
previousMessages: state.messages,
previousHasMore: state.messagesHasMore,
merged,
apiHasMore: Boolean(data.hasMore),
}),
}
})
} catch {
// Keep realtime callback errors contained in the store.
}
},
markSelectedConversationRead: async () => {
const store = get()
const conversationId = store.selectedConversationId
const conversation = store.conversations.find((item) => item.id === conversationId)
const lastMessage = store.messages.at(-1)
if (!conversationId || !conversation || !lastMessage) {
return
}
if (
conversation.agentUnreadCount <= 0 &&
(conversation.agentLastReadMessageId ?? 0) >= lastMessage.id
) {
return
}
if (store.readingMessageId === lastMessage.id) {
return
}
set({ readingMessageId: lastMessage.id })
try {
await markAgentMessageRead(conversationId, lastMessage.id)
set((current) => {
if (current.selectedConversationId !== conversationId) {
return { readingMessageId: 0 }
}
return {
readingMessageId: 0,
messages: current.messages.map((item) => {
if (item.id > lastMessage.id) {
return item
}
return item.agentRead ? item : { ...item, agentRead: true }
}),
conversations: current.conversations.map((item) =>
item.id === conversationId
? {
...item,
agentUnreadCount: 0,
agentLastReadMessageId: lastMessage.id,
}
: item
),
}
})
} catch (error) {
set({ readingMessageId: 0 })
throw error
}
},
applyRealtimeMessageCreated: (message) => {
set((state) => {
const isSelected = state.selectedConversationId === message.conversationId
const nextMessages = isSelected
? mergeImMessagesByIdAsc(state.messages, [message])
: state.messages
return {
messages: nextMessages,
activeAgentActivity: isSelected ? null : state.activeAgentActivity,
conversations: patchConversationListWithMessage(
state.conversations,
message
),
}
})
},
applyRealtimeAgentActivity: (payload) => {
set((state) => ({
activeAgentActivity: resolveAgentActivityState(
state.activeAgentActivity,
state.selectedConversationId ?? undefined,
payload
),
}))
},
applyRealtimeOperatorPresence: (payload, currentUserId) => {
const userId = currentUserId || readSession()?.user?.id || 0
set((state) => ({
operatorPresences: updateOperatorPresenceList(
state.operatorPresences,
payload,
userId
),
}))
},
purgeExpiredOperatorPresences: () => {
set((state) => {
const purged = purgePresences(state.operatorPresences)
if (purged.length === state.operatorPresences.length) {
return state
}
return { operatorPresences: purged }
})
},
applyRealtimeConversationChanged: (patch) => {
set((state) => {
const conversationId = patch.id ?? patch.conversationId ?? 0
const isSelected =
conversationId > 0 && state.selectedConversationId === conversationId
let nextMessages = state.messages
if (isSelected) {
if ((patch.agentLastReadMessageId ?? 0) > 0) {
nextMessages = markMessagesReadToMessageId(
nextMessages,
patch.agentLastReadMessageId ?? 0,
"agent",
patch.agentLastReadAt
)
}
if ((patch.customerLastReadMessageId ?? 0) > 0) {
nextMessages = markMessagesReadToMessageId(
nextMessages,
patch.customerLastReadMessageId ?? 0,
"customer",
patch.customerLastReadAt
)
}
}
return {
messages: nextMessages,
conversations: patchConversationList(state.conversations, patch),
...(isSelected && (patch.status === 4 || patch.closedAt)
? { activeAgentActivity: null }
: {}),
}
})
},
applyRealtimeMessageRecalled: (messageId, patch) => {
if (messageId <= 0) {
return
}
set((state) => ({
messages: state.messages.map((item) =>
item.id === messageId ? { ...item, ...patch, id: item.id } : item
),
}))
},
resyncRealtimeData: async (conversationId) => {
await get().loadConversations()
const selectedConversationId = get().selectedConversationId
const targetConversationId = conversationId ?? selectedConversationId
if (targetConversationId && selectedConversationId === targetConversationId) {
await get().loadMessages(targetConversationId, {
forceLoading: false,
reset: false,
})
}
},
sendMessage: async (html) => {
const trimmedContent = html.trim()
const { selectedConversationId, sending } = get()
if (!selectedConversationId || !trimmedContent || sending) {
return null
}
set({ sending: true })
try {
const message = await sendAgentMessage({
conversationId: selectedConversationId,
messageType: "html",
content: trimmedContent,
clientMsgId: `agent_${generateUUID()}`,
})
if (get().selectedConversationId === selectedConversationId) {
set((current) => ({
messages: current.messages.some((m) => m.id === message.id)
? current.messages.map((m) => (m.id === message.id ? message : m))
: [...current.messages, message],
conversations: patchConversationList(
patchConversationListWithMessage(current.conversations, message),
{
conversationId: selectedConversationId,
agentUnreadCount: 0,
customerUnreadCount:
(current.conversations.find((item) => item.id === selectedConversationId)
?.customerUnreadCount ?? 0) + 1,
agentLastReadMessageId: message.id,
}
),
}))
}
return message
} finally {
set({ sending: false })
}
},
uploadImage: async (file) => {
const { selectedConversationId, sending, uploadingAsset } = get()
if (!selectedConversationId || sending || uploadingAsset) {
return null
}
set({ uploadingAsset: true })
try {
return await uploadAgentConversationImage(selectedConversationId, file)
} finally {
set({ uploadingAsset: false })
}
},
sendAttachment: async (file) => {
const { selectedConversationId, sending, uploadingAsset } = get()
if (!selectedConversationId || sending || uploadingAsset) {
return null
}
set({ uploadingAsset: true })
try {
const asset = await uploadAgentConversationAttachment(selectedConversationId, file)
const message = await sendAgentMessage({
conversationId: selectedConversationId,
messageType: "attachment",
content: asset.filename,
payload: JSON.stringify({ assetId: asset.assetId }),
clientMsgId: `agent_attachment_${generateUUID()}`,
})
if (get().selectedConversationId === selectedConversationId) {
set((current) => ({
messages: current.messages.some((m) => m.id === message.id)
? current.messages.map((m) => (m.id === message.id ? message : m))
: [...current.messages, message],
conversations: patchConversationList(
patchConversationListWithMessage(current.conversations, message),
{
conversationId: selectedConversationId,
agentUnreadCount: 0,
customerUnreadCount:
(current.conversations.find((item) => item.id === selectedConversationId)
?.customerUnreadCount ?? 0) + 1,
agentLastReadMessageId: message.id,
}
),
}))
}
return message
} finally {
set({ uploadingAsset: false })
}
},
recallMessage: async (messageId) => {
const { selectedConversationId, recallingMessageId } = get()
if (!selectedConversationId || messageId <= 0 || recallingMessageId === messageId) {
return null
}
set({ recallingMessageId: messageId })
try {
const message = await recallAgentMessage(messageId)
if (get().selectedConversationId === selectedConversationId) {
set((current) => {
const nextMessages = current.messages.map((item) =>
item.id === message.id ? message : item
)
const lastActiveMessage = [...nextMessages]
.reverse()
.find((item) => !item.recalledAt && item.sendStatus !== 6)
return {
recallingMessageId: 0,
messages: nextMessages,
conversations: current.conversations.map((item) =>
item.id === selectedConversationId
? {
...item,
lastMessageId: lastActiveMessage?.id ?? 0,
lastMessageAt: lastActiveMessage?.sentAt ?? "",
lastMessageSummary: lastActiveMessage
? summarizeIMMessage(lastActiveMessage)
: "",
}
: item
),
}
})
} else {
set({ recallingMessageId: 0 })
}
return message
} catch (error) {
set({ recallingMessageId: 0 })
throw error
}
},
}))
export const agentConversationSelectors = {
selectedConversation: (state: AgentConversationsStore) =>
state.conversations.find((item) => item.id === state.selectedConversationId) ?? null,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
export function articleHeadingId(title: string, index: number) {
const normalized = title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "")
return `section-${index + 1}-${normalized || "heading"}`
}
export function markdownHeadingText(value: string) {
return value
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/<[^>]+>/g, "")
.replace(/[*_~`]/g, "")
.trim()
}

View File

@@ -0,0 +1,127 @@
import assert from "node:assert/strict"
import test from "node:test"
import { readFile } from "node:fs/promises"
import ts from "typescript"
import vm from "node:vm"
async function loadModule() {
const source = await readFile(
new URL("./support-conversation-i18n.ts", import.meta.url),
"utf8"
)
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "support-conversation-i18n.ts",
})
const messages = {}
const sandbox = {
exports: {},
module: { exports: {} },
require: (name) => {
if (name === "@/i18n/messages") {
return {
translateCurrentMessage: (key) => messages[key] ?? key,
translateMessage: (_locale, key) => messages[key] ?? key,
}
}
throw new Error(`unexpected require: ${name}`)
},
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return { module: sandbox.module.exports, messages }
}
const LOCALES = ["zh-CN", "en-US", "ru-RU"]
async function readMessages(locale) {
const raw = await readFile(
new URL(`../messages/${locale}.json`, import.meta.url),
"utf8"
)
return JSON.parse(raw)
}
test("every conversation status maps to a key that exists in all locales", async () => {
const { module, messages } = await loadModule()
const catalogues = Object.fromEntries(
await Promise.all(
LOCALES.map(async (locale) => [locale, await readMessages(locale)])
)
)
for (const locale of LOCALES) {
for (const status of [1, 2, 3, 4]) {
const key = module.getConversationStatusMessageKey(status)
const expected = lookup(catalogues[locale], key)
messages[key] = expected
assert.ok(expected, `missing ${key} in ${locale}`)
assert.equal(
module.getConversationStatusLabel(status),
expected,
`status ${status} is not localized for ${locale}`
)
}
}
})
test("no two statuses share a label in any locale", async () => {
const catalogues = Object.fromEntries(
await Promise.all(LOCALES.map(async (locale) => [locale, await readMessages(locale)]))
)
for (const locale of LOCALES) {
const labels = [1, 2, 3, 4].map((status) =>
lookup(catalogues[locale], statusKey(status))
)
assert.equal(
new Set(labels).size,
labels.length,
`duplicate status labels in ${locale}: ${labels.join(" / ")}`
)
}
})
// The helper deals in fully qualified "supportChat.x" keys while the catalogues
// are already scoped to the supportChat namespace.
function lookup(catalogue, key) {
return key.split(".").reduce((node, part) => node?.[part], catalogue)
}
function statusKey(status) {
return {
1: "supportChat.statusAiServing",
2: "supportChat.statusPending",
3: "supportChat.statusActive",
4: "supportChat.statusClosed",
}[status]
}
test("an unknown status still resolves to a defined label", async () => {
const { module, messages } = await loadModule()
const en = await readMessages("en-US")
messages[module.getConversationStatusMessageKey(99)] =
en.supportChat.statusClosed
assert.equal(
module.getConversationStatusLabel(99),
en.supportChat.statusClosed
)
assert.equal(
module.getConversationStatusMessageKey(0),
module.getConversationStatusMessageKey(4)
)
})
test("the fallback request title is localized everywhere", async () => {
const { module, messages } = await loadModule()
messages["supportChat.requestFallbackTitle"] = "Support request"
assert.equal(
module.getConversationFallbackTitle("ru-RU"),
"Support request"
)
})

View File

@@ -0,0 +1,46 @@
import { translateCurrentMessage, translateMessage } from "@/i18n/messages"
import type { AppLocale } from "@/i18n/config"
/**
* Localized display names for IMConversationStatus.
*
* The generated enum labels are Chinese-only, so they cannot be shown to an
* English or Russian visitor. Keeping the number-to-key mapping here - rather
* than switching on locale inside a component - means the widget, the demo and
* any later surface all read the same vocabulary.
*/
const CONVERSATION_STATUS_MESSAGE_KEYS: Record<number, string> = {
1: "supportChat.statusAiServing",
2: "supportChat.statusPending",
3: "supportChat.statusActive",
4: "supportChat.statusClosed",
}
/**
* Falls back to the closed label for an unknown status. A request is either
* finished or not, and mislabelling it as finished is the safer error: it
* invites the visitor to reopen it rather than hiding that it is still live.
*/
const UNKNOWN_STATUS_MESSAGE_KEY = "supportChat.statusClosed"
export function getConversationStatusMessageKey(status: number): string {
return CONVERSATION_STATUS_MESSAGE_KEYS[status] ?? UNKNOWN_STATUS_MESSAGE_KEY
}
export function getConversationStatusLabel(
status: number,
locale?: AppLocale
): string {
const key = getConversationStatusMessageKey(status)
return locale ? translateMessage(locale, key) : translateCurrentMessage(key)
}
/**
* The generic title for a request the visitor did not name. Subject wins when
* present; this is what a list row shows instead.
*/
export function getConversationFallbackTitle(locale?: AppLocale): string {
return locale
? translateMessage(locale, "supportChat.requestFallbackTitle")
: translateCurrentMessage("supportChat.requestFallbackTitle")
}

View File

@@ -0,0 +1,269 @@
import assert from "node:assert/strict"
import test from "node:test"
import { readFile } from "node:fs/promises"
import {
buildSelectConversationPatch,
findConversationById,
isOpenImConversation,
pickConversationToResume,
resolveConversationTitle,
sortConversationsByRecency,
totalUnreadConversations,
upsertConversation,
CLOSED_IM_CONVERSATION_STATUS,
} from "./support-conversation-list.ts"
const AISERVING = 1
const PENDING = 2
const ACTIVE = 3
const CLOSED = 4
function makeConversation(overrides = {}) {
return {
id: 1,
channelId: 1,
customerName: "Alice",
status: ACTIVE,
serviceMode: 3,
priority: 0,
currentAssigneeId: 0,
lastMessageId: 0,
customerUnreadCount: 0,
agentUnreadCount: 0,
customerLastReadMessageId: 0,
agentLastReadMessageId: 0,
closedBy: 0,
...overrides,
}
}
test("the mirrored closed status still matches the generated backend enum", async () => {
const source = await readFile(
new URL("./generated/enums.ts", import.meta.url),
"utf8"
)
const block = source.match(/export enum IMConversationStatus \{[^}]*\}/)
assert.ok(block, "expected an IMConversationStatus enum in the generated file")
const values = Object.fromEntries(
[...block[0].matchAll(/(\w+)\s*=\s*(\d+)/g)].map((match) => [
match[1],
Number(match[2]),
])
)
assert.equal(values.Closed, CLOSED_IM_CONVERSATION_STATUS)
// The server counts a conversation open while it is any of these three.
assert.deepEqual(
{ serving: values.AIServing, pending: values.Pending, active: values.Active },
{ serving: AISERVING, pending: PENDING, active: ACTIVE }
)
})
test("anything not closed is still an open request", () => {
assert.equal(isOpenImConversation(makeConversation({ status: AISERVING })), true)
assert.equal(isOpenImConversation(makeConversation({ status: PENDING })), true)
assert.equal(isOpenImConversation(makeConversation({ status: ACTIVE })), true)
assert.equal(isOpenImConversation(makeConversation({ status: CLOSED })), false)
// A status the server adds later defaults to open rather than being mistaken
// for a finished request.
assert.equal(isOpenImConversation(makeConversation({ status: 99 })), true)
})
test("a closed request is not open, and neither is nothing", () => {
assert.equal(isOpenImConversation(null), false)
assert.equal(isOpenImConversation(undefined), false)
})
test("sorting puts the most recently active request first", () => {
const list = [
makeConversation({ id: 1, lastActiveAt: "2026-10-01 10:00:00" }),
makeConversation({ id: 2, lastActiveAt: "2026-10-03 10:00:00" }),
makeConversation({ id: 3, lastActiveAt: "2026-10-02 10:00:00" }),
]
assert.deepEqual(
sortConversationsByRecency(list).map((item) => item.id),
[2, 3, 1]
)
})
// The server writes time.DateTime, which has no timezone marker. Parsing it as
// a bare local timestamp keeps it consistent with formatDateTime elsewhere.
test("server timestamps without a zone still order correctly", () => {
const list = [
makeConversation({ id: 1, lastActiveAt: "2026-10-01 09:00:00" }),
makeConversation({ id: 2, lastActiveAt: "2026-10-01 11:00:00" }),
]
assert.deepEqual(
sortConversationsByRecency(list).map((item) => item.id),
[2, 1]
)
})
test("a brand new request with no timestamps sorts ahead by id", () => {
const list = [
makeConversation({ id: 9 }),
makeConversation({ id: 12 }),
makeConversation({ id: 3, lastActiveAt: "2026-10-01 09:00:00" }),
]
assert.deepEqual(
sortConversationsByRecency(list).map((item) => item.id),
[12, 9, 3]
)
})
test("an unparsable timestamp does not become NaN and scramble the order", () => {
const list = [
makeConversation({ id: 1, lastActiveAt: "not-a-date" }),
makeConversation({ id: 2, lastActiveAt: "2026-10-01 09:00:00" }),
]
assert.deepEqual(
sortConversationsByRecency(list).map((item) => item.id),
[2, 1]
)
})
test("lastMessageAt stands in when lastActiveAt is missing", () => {
const list = [
makeConversation({ id: 1, lastMessageAt: "2026-10-01 08:00:00" }),
makeConversation({ id: 2, lastMessageAt: "2026-10-01 10:00:00" }),
]
assert.deepEqual(
sortConversationsByRecency(list).map((item) => item.id),
[2, 1]
)
})
test("upsert merges an existing request instead of duplicating it", () => {
const list = [makeConversation({ id: 1, subject: "old" })]
const next = upsertConversation(list, makeConversation({ id: 1, subject: "new" }))
assert.equal(next.length, 1)
assert.equal(next[0].subject, "new")
})
test("upsert keeps fields the patch does not carry", () => {
const list = [makeConversation({ id: 1, subject: "kept", customerUnreadCount: 4 })]
const next = upsertConversation(list, { id: 1, status: CLOSED })
assert.equal(next[0].subject, "kept")
assert.equal(next[0].customerUnreadCount, 4)
assert.equal(next[0].status, CLOSED)
})
test("upserting a new request puts it at the top", () => {
const list = [makeConversation({ id: 1, lastActiveAt: "2026-10-01 09:00:00" })]
const next = upsertConversation(list, makeConversation({ id: 2 }))
assert.deepEqual(next.map((item) => item.id), [2, 1])
})
test("upsert does not mutate the input", () => {
const list = [makeConversation({ id: 1 })]
upsertConversation(list, makeConversation({ id: 2 }))
assert.equal(list.length, 1)
})
test("findConversationById returns null rather than throwing", () => {
const list = [makeConversation({ id: 7 })]
assert.equal(findConversationById(list, 7).id, 7)
assert.equal(findConversationById(list, 8), null)
assert.equal(findConversationById(list, 0), null)
})
test("resume picks the newest open request, skipping closed ones", () => {
const list = [
makeConversation({ id: 1, status: CLOSED, lastActiveAt: "2026-10-05 10:00:00" }),
makeConversation({ id: 2, status: ACTIVE, lastActiveAt: "2026-10-01 10:00:00" }),
]
assert.equal(pickConversationToResume(list).id, 2)
})
test("resume returns null when everything is closed so a new one gets started", () => {
const list = [
makeConversation({ id: 1, status: CLOSED }),
makeConversation({ id: 2, status: CLOSED }),
]
assert.equal(pickConversationToResume(list), null)
})
test("resume returns null for a visitor who never wrote to us", () => {
assert.equal(pickConversationToResume([]), null)
})
test("title prefers the visitor's subject", () => {
const conversation = makeConversation({
subject: "Проблема с оплатой",
lastMessageSummary: "Здравствуйте",
})
assert.equal(resolveConversationTitle(conversation, "Запрос"), "Проблема с оплатой")
})
test("title falls back to the last message, then to the localized label", () => {
assert.equal(
resolveConversationTitle(makeConversation({ lastMessageSummary: "Здравствуйте" }), "Запрос"),
"Здравствуйте"
)
assert.equal(resolveConversationTitle(makeConversation(), "Запрос"), "Запрос")
assert.equal(resolveConversationTitle(null, "Запрос"), "Запрос")
})
test("a whitespace-only subject is treated as absent", () => {
assert.equal(
resolveConversationTitle(makeConversation({ subject: " ", lastMessageSummary: "Привет" }), "Запрос"),
"Привет"
)
})
test("unread totals across requests include the ones not being viewed", () => {
const list = [
makeConversation({ id: 1, customerUnreadCount: 2 }),
makeConversation({ id: 2, customerUnreadCount: 3 }),
makeConversation({ id: 3, customerUnreadCount: 0 }),
]
assert.equal(totalUnreadConversations(list), 5)
})
test("a missing unread count counts as zero", () => {
assert.equal(
totalUnreadConversations([
makeConversation({ id: 1 }),
{ ...makeConversation({ id: 2 }), customerUnreadCount: undefined },
]),
0
)
})
test("switching requests re-derives the conversation along with the active id", () => {
const a = makeConversation({ id: 1 })
const b = makeConversation({ id: 2 })
const patch = buildSelectConversationPatch(
{ conversations: [a, b], activeConversationId: 1 },
2
)
assert.equal(patch.activeConversationId, 2)
assert.equal(patch.conversation.id, 2)
assert.deepEqual(patch.messages, [])
assert.equal(patch.messagesHasMore, false)
})
test("switching to an unknown or already active request is a no-op", () => {
const a = makeConversation({ id: 1 })
const state = { conversations: [a], activeConversationId: 1 }
assert.equal(buildSelectConversationPatch(state, 99), null)
assert.equal(buildSelectConversationPatch(state, 1), null)
})

View File

@@ -0,0 +1,199 @@
import type { ImConversation } from "./api/im"
/**
* Mirrors IMConversationStatus.Closed from the generated backend enum.
*
* It is spelled out as a literal rather than imported because Node's type
* stripping cannot load a TypeScript `enum`, and these helpers have to stay
* unit-testable outside the bundler. support-conversation-list.test.mjs reads
* the generated file and fails if this ever drifts, so this is a guarded mirror
* of the generated enum, not a second source of truth.
*/
export const CLOSED_IM_CONVERSATION_STATUS = 4
/**
* Parses an API timestamp string into a millisecond timestamp.
* If the string does not specify a timezone offset, it is interpreted as UTC.
*/
export function parseApiTimestamp(
value?: string | number | Date | null
): number {
if (value === null || value === undefined) {
return 0
}
if (value instanceof Date) {
const time = value.getTime()
return Number.isFinite(time) ? time : 0
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : 0
}
if (typeof value !== "string") {
return 0
}
const trimmed = value.trim()
if (!trimmed) {
return 0
}
let normalized = trimmed.replace(" ", "T")
if (!/(Z|[+-]\d{2}(?::?\d{2})?)$/i.test(normalized)) {
normalized += "Z"
}
const timestamp = Date.parse(normalized)
return Number.isFinite(timestamp) ? timestamp : 0
}
/**
* A request counts as open until it is closed, rather than by listing the open
* statuses. The server's own definition is "status is one of ai_serving,
* pending, active", so any new status it introduces is open by default here
* instead of being silently treated as finished.
*/
export function isOpenImConversation(
conversation: ImConversation | null | undefined
): boolean {
if (!conversation) {
return false
}
return conversation.status !== CLOSED_IM_CONVERSATION_STATUS
}
function recencyTimestamp(conversation: ImConversation): number {
const candidates = [conversation.lastActiveAt, conversation.lastMessageAt]
for (const value of candidates) {
if (!value) {
continue
}
const parsed = parseApiTimestamp(value)
if (parsed > 0) {
return parsed
}
}
return 0
}
/**
* Most recently active first, matching the server's list ordering.
*
* A conversation that was just created has no timestamps yet, so treating an
* unknown time as "oldest" would drop the visitor's brand new request to the
* bottom of the switcher. Whenever either side has no usable timestamp we fall
* back to the descending id instead: ids are monotonic, so that still puts the
* newest request first. Array.prototype.sort is stable, so equal keys keep
* their existing relative order instead of shuffling between renders.
*/
export function sortConversationsByRecency(
conversations: readonly ImConversation[]
): ImConversation[] {
return [...conversations].sort((left, right) => {
const leftTime = recencyTimestamp(left)
const rightTime = recencyTimestamp(right)
if (leftTime > 0 && rightTime > 0 && leftTime !== rightTime) {
return rightTime - leftTime
}
return right.id - left.id
})
}
export function upsertConversation(
conversations: readonly ImConversation[],
conversation: ImConversation
): ImConversation[] {
const existing = conversations.some((item) => item.id === conversation.id)
const next = existing
? conversations.map((item) =>
item.id === conversation.id ? { ...item, ...conversation } : item
)
: [conversation, ...conversations]
return sortConversationsByRecency(next)
}
export function findConversationById(
conversations: readonly ImConversation[],
conversationId: number
): ImConversation | null {
if (!conversationId) {
return null
}
return conversations.find((item) => item.id === conversationId) ?? null
}
/**
* The request to rejoin on load: the newest still-open one.
*
* Returns null when every request is closed, which is the signal to start a
* fresh one. Selecting a closed request here would leave the visitor staring at
* a read-only thread with the composer disabled and no way forward.
*/
export function pickConversationToResume(
conversations: readonly ImConversation[]
): ImConversation | null {
return (
sortConversationsByRecency(conversations).find((item) =>
isOpenImConversation(item)
) ?? null
)
}
/**
* What to show as a request's title. The visitor's own subject wins; otherwise
* the latest message stands in, and only then do we fall back to the caller's
* localized generic label.
*/
export function resolveConversationTitle(
conversation: ImConversation | null | undefined,
fallbackLabel: string
): string {
const subject = conversation?.subject?.trim()
if (subject) {
return subject
}
const summary = conversation?.lastMessageSummary?.trim()
if (summary) {
return summary
}
return fallbackLabel
}
/**
* Total unread across every request, for the switcher's badge. A visitor needs
* to notice activity in a request they are not currently looking at.
*/
export function totalUnreadConversations(
conversations: readonly ImConversation[]
): number {
return conversations.reduce(
(total, item) => total + (Number(item.customerUnreadCount) || 0),
0
)
}
/**
* Builds the state patch for switching the visible request. `conversation` is
* re-derived from the list together with the active id, so the header, the
* composer and the message loader can never point at different requests.
* Returns null when there is nothing to switch to.
*/
export function buildSelectConversationPatch(
state: {
conversations: readonly ImConversation[]
activeConversationId: number
},
conversationId: number
) {
const target = findConversationById(state.conversations, conversationId)
if (!target || target.id === state.activeConversationId) {
return null
}
return {
conversations: state.conversations,
activeConversationId: target.id,
conversation: target,
// Messages belong to the request they were loaded for.
messages: [] as never[],
messagesCursor: "",
messagesHasMore: false,
messagesLoadingMore: false,
readingMessageId: 0,
error: "",
}
}

View File

@@ -0,0 +1,110 @@
import { setSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
import type { SupportChatRuntimeConfig } from "@/lib/sdk/config-types"
type HostBridgeOptions = {
onInit?: () => void
onOpen?: () => void
onMinimize?: () => void
onMaximizedChange?: (isMaximized: boolean) => void
}
const INIT_MESSAGE_TYPE = "agent-desk:init"
const OPEN_MESSAGE_TYPE = "agent-desk:open"
const MINIMIZE_MESSAGE_TYPE = "agent-desk:minimize"
const MAXIMIZED_MESSAGE_TYPE = "agent-desk:maximized"
const READY_MESSAGE_TYPE = "agent-desk:ready"
const REQUEST_MINIMIZE_MESSAGE_TYPE = "agent-desk:request-minimize"
const REQUEST_CLOSE_MESSAGE_TYPE = "agent-desk:request-close"
const REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE = "agent-desk:request-toggle-maximize"
function trustedParentOrigin() {
try {
const value =
new URLSearchParams(window.location.search).get("parentOrigin") ||
document.referrer ||
window.location.origin
const origin = value ? new URL(value).origin : ""
return origin && origin !== "null" ? origin : ""
} catch {
return ""
}
}
export function bindSupportHostBridge(options: HostBridgeOptions = {}) {
if (typeof window === "undefined") {
return () => undefined
}
const parentOrigin = trustedParentOrigin()
if (parentOrigin && window.parent && window.parent !== window) {
window.parent.postMessage({ type: READY_MESSAGE_TYPE }, parentOrigin)
}
const handleMessage = (event: MessageEvent) => {
if (
!parentOrigin ||
window.parent === window ||
event.source !== window.parent ||
event.origin !== parentOrigin
) {
return
}
const data = event.data as
| {
type?: string
payload?: SupportChatRuntimeConfig | { isMaximized?: boolean }
}
| undefined
if (!data?.type) {
return
}
if (data.type === INIT_MESSAGE_TYPE && data.payload) {
setSupportChatRuntimeConfig(data.payload as SupportChatRuntimeConfig)
options.onInit?.()
return
}
if (data.type === OPEN_MESSAGE_TYPE) {
options.onOpen?.()
return
}
if (data.type === MINIMIZE_MESSAGE_TYPE) {
options.onMinimize?.()
return
}
if (data.type === MAXIMIZED_MESSAGE_TYPE) {
const payload = data.payload as { isMaximized?: boolean } | undefined
options.onMaximizedChange?.(Boolean(payload?.isMaximized))
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}
function postToParent(type: string) {
if (typeof window === "undefined") {
return
}
if (window.parent && window.parent !== window) {
const parentOrigin = trustedParentOrigin()
if (parentOrigin) {
window.parent.postMessage({ type }, parentOrigin)
}
}
}
export function requestSupportHostMinimize() {
postToParent(REQUEST_MINIMIZE_MESSAGE_TYPE)
}
export function requestSupportHostClose() {
postToParent(REQUEST_CLOSE_MESSAGE_TYPE)
}
export function requestSupportHostToggleMaximize() {
postToParent(REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE)
}

5
web/lib/support-slug.ts Normal file
View File

@@ -0,0 +1,5 @@
export const supportSlugPattern = /^[a-z0-9-]+$/
export function normalizeSupportSlug(value: string) {
return value.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")
}

115
web/lib/tag-tree.test.mjs Normal file
View File

@@ -0,0 +1,115 @@
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import test from "node:test"
import ts from "typescript"
import vm from "node:vm"
async function loadModule() {
const source = await readFile(new URL("./tag-tree.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "tag-tree.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
const tags = [
{
id: 1,
parentId: 0,
name: "产品",
remark: "",
sortNo: 1,
status: 0,
createdAt: "",
updatedAt: "",
children: [
{
id: 2,
parentId: 1,
name: "退款",
remark: "售后",
sortNo: 1,
status: 0,
createdAt: "",
updatedAt: "",
children: [],
},
],
},
{
id: 3,
parentId: 0,
name: "技术",
remark: "",
sortNo: 2,
status: 0,
createdAt: "",
updatedAt: "",
children: [],
},
]
test("flattens tag tree with depth and full path", async () => {
const { flattenTagTree } = await loadModule()
assert.equal(
JSON.stringify(flattenTagTree(tags).map((item) => ({
id: item.id,
depth: item.depth,
path: item.path,
searchableText: item.searchableText,
}))),
JSON.stringify([
{ id: 1, depth: 0, path: "产品", searchableText: "产品 1 " },
{ id: 2, depth: 1, path: "产品 / 退款", searchableText: "产品 / 退款 2 售后" },
{ id: 3, depth: 0, path: "技术", searchableText: "技术 3 " },
])
)
})
test("excludes a tag and its descendants for parent selection", async () => {
const { flattenTagTree } = await loadModule()
assert.equal(
JSON.stringify(flattenTagTree(tags, { excludeIds: [1] }).map((item) => item.id)),
JSON.stringify([3])
)
})
test("builds full-path map for selected tag badges", async () => {
const { buildTagPathMap } = await loadModule()
assert.equal(buildTagPathMap(tags).get(2), "产品 / 退款")
})
test("flattens only visible branches when a parent is collapsed", async () => {
const { flattenVisibleTagTree } = await loadModule()
assert.equal(
JSON.stringify(
flattenVisibleTagTree(tags, { collapsedIds: [1] }).map((item) => item.id)
),
JSON.stringify([1, 3])
)
})
test("updates a tag status in tree data without mutating the original tree", async () => {
const { updateTagTreeStatus } = await loadModule()
const nextTags = updateTagTreeStatus(tags, 2, 1)
assert.equal(tags[0].children[0].status, 0)
assert.equal(nextTags[0].children[0].status, 1)
assert.equal(nextTags[0].status, 0)
assert.equal(nextTags[1], tags[1])
})

126
web/lib/tag-tree.ts Normal file
View File

@@ -0,0 +1,126 @@
import type { TagTree } from "@/lib/api/admin"
export type FlatTagNode = TagTree & {
depth: number
path: string
searchableText: string
}
type FlattenTagTreeOptions = {
excludeIds?: number[]
}
type FlattenVisibleTagTreeOptions = FlattenTagTreeOptions & {
collapsedIds?: number[]
}
export function flattenTagTree(
nodes: TagTree[] | null | undefined,
options: FlattenTagTreeOptions = {}
): FlatTagNode[] {
const excluded = new Set(options.excludeIds ?? [])
const result: FlatTagNode[] = []
function walk(items: TagTree[] | null | undefined, depth: number, parentPath: string) {
const safeItems = Array.isArray(items) ? items : []
safeItems.forEach((item) => {
if (excluded.has(item.id)) {
return
}
const path = parentPath ? `${parentPath} / ${item.name}` : item.name
result.push({
...item,
depth,
path,
searchableText: `${path} ${item.id} ${item.remark ?? ""}`,
})
walk(item.children, depth + 1, path)
})
}
walk(nodes, 0, "")
return result
}
export function buildTagPathMap(
nodes: TagTree[] | null | undefined
): Map<number, string> {
const result = new Map<number, string>()
flattenTagTree(nodes).forEach((item) => {
result.set(item.id, item.path)
})
return result
}
export function flattenVisibleTagTree(
nodes: TagTree[] | null | undefined,
options: FlattenVisibleTagTreeOptions = {}
): FlatTagNode[] {
const excluded = new Set(options.excludeIds ?? [])
const collapsed = new Set(options.collapsedIds ?? [])
const result: FlatTagNode[] = []
function walk(items: TagTree[] | null | undefined, depth: number, parentPath: string) {
const safeItems = Array.isArray(items) ? items : []
safeItems.forEach((item) => {
if (excluded.has(item.id)) {
return
}
const path = parentPath ? `${parentPath} / ${item.name}` : item.name
result.push({
...item,
depth,
path,
searchableText: `${path} ${item.id} ${item.remark ?? ""}`,
})
if (!collapsed.has(item.id)) {
walk(item.children, depth + 1, path)
}
})
}
walk(nodes, 0, "")
return result
}
type MutableTagTreeLike<T> = {
id: number
status: number
children: T[]
}
export function updateTagTreeStatus<T extends MutableTagTreeLike<T>>(
nodes: T[] | null | undefined,
id: number,
status: number
): T[] {
const safeNodes = Array.isArray(nodes) ? nodes : []
function walk(items: T[]): { nodes: T[]; changed: boolean } {
let changed = false
const nextNodes = items.map((item) => {
const nextChildren = walk(item.children)
const statusChanged = item.id === id && item.status !== status
if (!statusChanged && !nextChildren.changed) {
return item
}
changed = true
return {
...item,
status: statusChanged ? status : item.status,
children: nextChildren.nodes,
}
})
return { nodes: changed ? nextNodes : items, changed }
}
return walk(safeNodes).nodes
}

31
web/lib/utils.test.mjs Normal file
View File

@@ -0,0 +1,31 @@
import assert from "node:assert/strict"
import test from "node:test"
import { parseApiTimestamp } from "./utils.ts"
test("parseApiTimestamp parses date without timezone as UTC", () => {
const result = parseApiTimestamp("2026-10-11 14:00:00")
assert.equal(result, Date.parse("2026-10-11T14:00:00Z"))
})
test("parseApiTimestamp preserves explicit UTC Z", () => {
const result = parseApiTimestamp("2026-10-11T14:00:00Z")
assert.equal(result, Date.parse("2026-10-11T14:00:00Z"))
})
test("parseApiTimestamp preserves explicit offset", () => {
const result = parseApiTimestamp("2026-10-11T17:00:00+03:00")
assert.equal(result, Date.parse("2026-10-11T17:00:00+03:00"))
assert.equal(result, Date.parse("2026-10-11T14:00:00Z"))
})
test("parseApiTimestamp handles Date, number, empty and invalid values", () => {
assert.equal(parseApiTimestamp(null), 0)
assert.equal(parseApiTimestamp(""), 0)
assert.equal(parseApiTimestamp(" "), 0)
assert.equal(parseApiTimestamp("invalid-date"), 0)
const now = Date.now()
assert.equal(parseApiTimestamp(now), now)
const d = new Date()
assert.equal(parseApiTimestamp(d), d.getTime())
})

78
web/lib/utils.ts Normal file
View File

@@ -0,0 +1,78 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function generateUUID() {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID()
}
const bytes = new Uint8Array(16)
if (typeof globalThis.crypto?.getRandomValues === "function") {
globalThis.crypto.getRandomValues(bytes)
} else {
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256)
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"))
return [
hex.slice(0, 4).join(""),
hex.slice(4, 6).join(""),
hex.slice(6, 8).join(""),
hex.slice(8, 10).join(""),
hex.slice(10, 16).join(""),
].join("-")
}
function pad(value: number) {
return value.toString().padStart(2, "0")
}
export function formatDateTime(value?: string | number | Date | null) {
if (!value) {
return "-"
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return "-"
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
export function parseApiTimestamp(
value?: string | number | Date | null
): number {
if (value === null || value === undefined) {
return 0
}
if (value instanceof Date) {
const time = value.getTime()
return Number.isFinite(time) ? time : 0
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : 0
}
if (typeof value !== "string") {
return 0
}
const trimmed = value.trim()
if (!trimmed) {
return 0
}
let normalized = trimmed.replace(" ", "T")
if (!/(Z|[+-]\d{2}(?::?\d{2})?)$/i.test(normalized)) {
normalized += "Z"
}
const timestamp = Date.parse(normalized)
return Number.isFinite(timestamp) ? timestamp : 0
}