feat: harden support, localize knowledge and add operator availability [skip ci]
Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.
Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.
Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.
Expected source tree: 0a4a7528e9.
This commit is contained in:
@@ -1,74 +1,54 @@
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
Loader2Icon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import {
|
||||
notifySetupReadinessUpdated,
|
||||
SETUP_READINESS_UPDATED_EVENT,
|
||||
} from "@/components/operator-readiness-indicator"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
applySetupQuickFix,
|
||||
fetchSetupReadiness,
|
||||
type SetupReadinessResponse,
|
||||
} from "@/lib/api/setup-readiness"
|
||||
|
||||
export function SetupReadinessBanner({
|
||||
onUpdated,
|
||||
}: {
|
||||
export function SetupReadinessBanner({ onUpdated }: {
|
||||
onUpdated?: () => void
|
||||
}) {
|
||||
const t = useI18n()
|
||||
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
|
||||
const [fixing, setFixing] = useState(false)
|
||||
|
||||
const loadReadiness = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchSetupReadiness()
|
||||
setReadiness(data)
|
||||
} catch {
|
||||
// Ignore in banner if failed
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadReadiness()
|
||||
let active = true
|
||||
let latestRequest = 0
|
||||
const loadReadiness = () => {
|
||||
const request = ++latestRequest
|
||||
void fetchSetupReadiness().then((data) => {
|
||||
if (active && request === latestRequest) setReadiness(data)
|
||||
}).catch(() => {
|
||||
// Keep this optional background banner quiet on failure.
|
||||
})
|
||||
}
|
||||
loadReadiness()
|
||||
const handleUpdated = () => {
|
||||
void loadReadiness()
|
||||
loadReadiness()
|
||||
onUpdated?.()
|
||||
}
|
||||
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
return () => {
|
||||
active = false
|
||||
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
}
|
||||
}, [loadReadiness])
|
||||
|
||||
async function handleQuickSetupAll() {
|
||||
setFixing(true)
|
||||
try {
|
||||
const res = await applySetupQuickFix({ action: "quick_setup_all" })
|
||||
setReadiness(res.readiness)
|
||||
notifySetupReadinessUpdated()
|
||||
onUpdated?.()
|
||||
toast.success(t("setupWizard.quickFixSuccess"))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||
)
|
||||
} finally {
|
||||
setFixing(false)
|
||||
}
|
||||
}
|
||||
}, [onUpdated])
|
||||
|
||||
if (!readiness) {
|
||||
return null
|
||||
@@ -137,24 +117,12 @@ export function SetupReadinessBanner({
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{canQuickSetupAll ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5 rounded-md"
|
||||
disabled={fixing}
|
||||
onClick={() => void handleQuickSetupAll()}
|
||||
>
|
||||
{fixing ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-4" />
|
||||
)}
|
||||
<span>
|
||||
{fixing
|
||||
? t("setupWizard.quickSetupRunning")
|
||||
: t("setupWizard.quickSetupAll")}
|
||||
</span>
|
||||
<Button size="sm" className="gap-1.5 rounded-md" render={<Link href="/dashboard/setup" />}>
|
||||
<SparklesIcon className="size-4" />
|
||||
<span>{t("setupWizard.preview.review")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Modified in this independent fork for explicit dated/recurring calendar modes; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
@@ -286,6 +288,7 @@ export default function DashboardAgentTeamSchedulesPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[calc(100vh-var(--header-height))] min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4 lg:p-6">
|
||||
<p className="shrink-0 rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">{t("supportAvailability.datedShiftsNotice")} <Link href="/dashboard/support-availability" className="underline underline-offset-2">{t("supportAvailability.title")}</Link></p>
|
||||
<div className="shrink-0 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ButtonGroup>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
type CreateAdminAgentTeamPayload,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { availabilityStatusOptions } from "@/lib/support-availability-i18n";
|
||||
import { ServiceStatus } from "@/lib/generated/enums";
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
@@ -65,7 +67,7 @@ const emptyForm: EditForm = {
|
||||
agentCode: "",
|
||||
displayName: "",
|
||||
avatar: "",
|
||||
serviceStatus: String(ServiceStatus.Idle) as "0" | "1",
|
||||
serviceStatus: String(ServiceStatus.Idle) as "0" | "1" | "2" | "3" | "4",
|
||||
maxConcurrentCount: "0",
|
||||
priorityLevel: "0",
|
||||
autoAssignEnabled: true,
|
||||
@@ -79,7 +81,7 @@ type EditForm = {
|
||||
agentCode: string;
|
||||
displayName: string;
|
||||
avatar: string;
|
||||
serviceStatus: "0" | "1";
|
||||
serviceStatus: "0" | "1" | "2" | "3" | "4";
|
||||
maxConcurrentCount: string;
|
||||
priorityLevel: string;
|
||||
autoAssignEnabled: boolean;
|
||||
@@ -94,7 +96,7 @@ function createEditFormSchema(t: TFunction) {
|
||||
agentCode: z.string().trim().min(1, t("agentProfile.agentCodeRequired")),
|
||||
displayName: z.string().trim().min(1, t("agentProfile.displayNameRequired")),
|
||||
avatar: z.string().trim(),
|
||||
serviceStatus: z.enum(["0", "1"], {
|
||||
serviceStatus: z.enum(["0", "1", "2", "3", "4"], {
|
||||
message: t("agentProfile.statusRequired"),
|
||||
}),
|
||||
maxConcurrentCount: z
|
||||
@@ -112,10 +114,7 @@ function createEditFormSchema(t: TFunction) {
|
||||
}
|
||||
|
||||
function getServiceStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
|
||||
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
|
||||
];
|
||||
return availabilityStatusOptions(t, false);
|
||||
}
|
||||
|
||||
function buildForm(item: AdminAgentProfile | null): EditForm {
|
||||
@@ -529,7 +528,7 @@ function AgentEditDialogBody({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.serviceStatus}>
|
||||
{!itemId ? <Field data-invalid={!!errors.serviceStatus}>
|
||||
<FieldLabel>{t("agentProfile.serviceStatus")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
@@ -548,7 +547,7 @@ function AgentEditDialogBody({
|
||||
/>
|
||||
<FieldError errors={[errors.serviceStatus]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</Field> : <p className="text-sm text-muted-foreground">{t("supportAvailability.statusSeparate")}</p>}
|
||||
|
||||
<Field className="min-h-32">
|
||||
<FieldLabel>{t("agentProfile.avatar")}</FieldLabel>
|
||||
@@ -625,10 +624,12 @@ function AgentEditDialogBody({
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
disabled
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("supportAvailability.offlineDeprecated")}</p>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -5,13 +6,19 @@ import {
|
||||
PanelLeftOpenIcon,
|
||||
UserCogIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
DashboardCrudPage,
|
||||
type DashboardCrudColumn,
|
||||
type DashboardCrudFilter,
|
||||
} from "@/components/dashboard/crud";
|
||||
import { isSameSession } from "@/lib/session-request-guard";
|
||||
import type { AuthSession } from "@/lib/auth";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { AVAILABILITY_UPDATED_EVENT, notifyAvailabilityUpdated, OperatorStatusDialog } from "@/components/support-availability/operator-status";
|
||||
import { fetchAvailabilityAgents, type OperatorAvailability } from "@/lib/api/support-availability";
|
||||
import { availabilityReasonLabel, availabilityStatusLabel, availabilityStatusOptions } from "@/lib/support-availability-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -24,7 +31,6 @@ import {
|
||||
type CreateAdminAgentProfilePayload,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { ServiceStatus } from "@/lib/generated/enums";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { EditDialog } from "./_components/edit";
|
||||
import { AgentTeamSidebar } from "./_components/team-sidebar";
|
||||
@@ -34,20 +40,33 @@ type TFunction = (key: string, values?: Record<string, string | number>) => stri
|
||||
function getServiceStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("agentProfile.allStatuses") },
|
||||
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
|
||||
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
|
||||
...availabilityStatusOptions(t),
|
||||
];
|
||||
}
|
||||
|
||||
function getStatusLabel(value: number, t: TFunction) {
|
||||
return (
|
||||
getServiceStatusOptions(t).find((item) => item.value === String(value))
|
||||
?.label ?? String(value)
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardAgentsPage() {
|
||||
const t = useI18n();
|
||||
const { session } = useAuth();
|
||||
const accountId = session?.user.id;
|
||||
const canUpdateAvailability = Boolean(session?.permissions.includes("agent.update"));
|
||||
const [availability, setAvailability] = useState<{ accountId?: number; items: OperatorAvailability[] }>({ items: [] });
|
||||
const [statusDialog, setStatusDialog] = useState<{ session: AuthSession; value: OperatorAvailability } | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let pending = false;
|
||||
async function refresh() {
|
||||
if (pending || !accountId) return;
|
||||
pending = true;
|
||||
try { const items = await fetchAvailabilityAgents(); if (active) setAvailability({ accountId, items }); }
|
||||
catch { if (active) setAvailability({ accountId, items: [] }); }
|
||||
finally { pending = false; }
|
||||
}
|
||||
void refresh();
|
||||
const onRefresh = () => { if (document.visibilityState === "visible") void refresh(); };
|
||||
window.addEventListener(AVAILABILITY_UPDATED_EVENT, onRefresh);
|
||||
const timer = window.setInterval(onRefresh, 30000);
|
||||
return () => { active = false; window.clearInterval(timer); window.removeEventListener(AVAILABILITY_UPDATED_EVENT, onRefresh); };
|
||||
}, [accountId]);
|
||||
const serviceStatusOptions = useMemo(() => getServiceStatusOptions(t), [t]);
|
||||
const [selectedTeam, setSelectedTeam] = useState<AdminAgentTeam | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
@@ -130,19 +149,21 @@ export default function DashboardAgentsPage() {
|
||||
{
|
||||
key: "rules",
|
||||
label: t("agentProfile.columnRules"),
|
||||
render: (item) => (
|
||||
<>
|
||||
<Badge variant="outline">
|
||||
{getStatusLabel(item.serviceStatus, t)}
|
||||
</Badge>
|
||||
render: (item) => {
|
||||
const current = availability.accountId === accountId ? availability.items.find(value => value.userId === item.userId) : undefined;
|
||||
return <>
|
||||
<div className="flex flex-wrap gap-1.5"><Badge variant="outline">{current ? availabilityStatusLabel(current.effectiveServiceStatus, t) : t("supportAvailability.status.unknown")}</Badge>
|
||||
{current ? <Badge variant="outline">{t(current.online ? "supportAvailability.online" : "supportAvailability.offline")}</Badge> : null}</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{current ? availabilityReasonLabel(current.reason, t) : t("supportAvailability.reason.unknown")}</p>
|
||||
{current && canUpdateAvailability ? <Button type="button" variant="link" size="sm" className="h-auto px-0" onClick={() => { if (session) setStatusDialog({ session, value: current }); }}>{t("supportAvailability.manageStatus")}</Button> : null}
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{t("agentProfile.capacityPriority", {
|
||||
capacity: item.maxConcurrentCount,
|
||||
priority: item.priorityLevel,
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
</>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "dispatch",
|
||||
@@ -154,13 +175,7 @@ export default function DashboardAgentsPage() {
|
||||
? t("agentProfile.autoAssign")
|
||||
: t("agentProfile.noAutoAssign")}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={item.receiveOfflineMessage ? "secondary" : "outline"}
|
||||
>
|
||||
{item.receiveOfflineMessage
|
||||
? t("agentProfile.offlineReceive")
|
||||
: t("agentProfile.noOfflineReceive")}
|
||||
</Badge>
|
||||
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -183,11 +198,12 @@ export default function DashboardAgentsPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
[t, availability, accountId, canUpdateAvailability, session],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
{statusDialog && isSameSession(statusDialog.session, session) ? <OperatorStatusDialog value={statusDialog.value} admin onClose={() => setStatusDialog(null)} onSaved={() => notifyAvailabilityUpdated()} /> : null}
|
||||
<div
|
||||
className={`shrink-0 overflow-hidden transition-[width] duration-200 ${
|
||||
sidebarCollapsed ? "w-0" : "w-80"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Controller, Resolver, useForm, useWatch } from "react-hook-form"
|
||||
import { z } from "zod/v4"
|
||||
@@ -1375,13 +1376,15 @@ function ChannelFormBody({
|
||||
)
|
||||
}
|
||||
|
||||
// The origin cannot change within a document. An explicit server snapshot keeps
|
||||
// the generated access links hydration-safe without a state-setting effect.
|
||||
const subscribeToOrigin = () => () => {}
|
||||
const readBrowserOrigin = () => window.location.origin
|
||||
const readServerOrigin = () => ""
|
||||
|
||||
function WebAccessGuide({ channelId }: { channelId: string }) {
|
||||
const t = useI18n()
|
||||
const [origin, setOrigin] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
setOrigin(window.location.origin)
|
||||
}, [])
|
||||
const origin = useSyncExternalStore(subscribeToOrigin, readBrowserOrigin, readServerOrigin)
|
||||
|
||||
const accessUrl = useMemo(() => {
|
||||
if (!origin || !channelId) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: retain operator drafts and forward pending-send state.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
@@ -11,11 +13,14 @@ import {
|
||||
sendOperatorPresence,
|
||||
useAgentConversationsStore,
|
||||
} from "@/hooks/use-agent-conversation-realtime"
|
||||
import type { MessageEditorDraftStore } from "@/lib/message-editor-drafts"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
|
||||
import { suggestAgentReply } from "@/lib/api/agent"
|
||||
|
||||
type AgentMessageEditorProps = {
|
||||
draftStore: MessageEditorDraftStore
|
||||
sending?: boolean
|
||||
disabled?: boolean
|
||||
uploadingAsset?: boolean
|
||||
onSend: (html: string) => Promise<void>
|
||||
@@ -28,6 +33,8 @@ type AgentMessageEditorProps = {
|
||||
* Supports quick replies picker and slash command (/) autocomplete.
|
||||
*/
|
||||
export function AgentMessageEditor({
|
||||
draftStore,
|
||||
sending = false,
|
||||
disabled = false,
|
||||
uploadingAsset = false,
|
||||
onSend,
|
||||
@@ -79,6 +86,8 @@ export function AgentMessageEditor({
|
||||
<SharedMessageEditor
|
||||
variant="agent"
|
||||
conversationId={selectedConversationId}
|
||||
draftStore={draftStore}
|
||||
sending={sending}
|
||||
disabled={disabled}
|
||||
uploadingAsset={uploadingAsset}
|
||||
quickReplies={{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: capture operator recipients and preserve failed message drafts.
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -61,6 +63,7 @@ import {
|
||||
type AIWorkflowRun,
|
||||
} from "@/lib/api/admin";
|
||||
import { readSession } from "@/lib/auth";
|
||||
import { createMessageEditorDraftStore } from "@/lib/message-editor-drafts";
|
||||
import { renderIMMessageHTML } from "@/lib/im-message";
|
||||
import {
|
||||
agentConversationSelectors,
|
||||
@@ -177,6 +180,8 @@ export function ChatPanel() {
|
||||
null,
|
||||
);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const [draftStore] = useState(createMessageEditorDraftStore);
|
||||
useEffect(() => () => draftStore.clear(), [draftStore]);
|
||||
const [claiming, setClaiming] = useState(false);
|
||||
const [claimDialogOpen, setClaimDialogOpen] = useState(false);
|
||||
const [transferDialogOpen, setTransferDialogOpen] = useState(false);
|
||||
@@ -314,12 +319,19 @@ export function ChatPanel() {
|
||||
};
|
||||
|
||||
const handleSend = async (html: string) => {
|
||||
if (!conversation || sending || isClosedConversation) return;
|
||||
// Bind the displayed draft to its recipient before the store reads selection.
|
||||
const recipientId = conversation?.id;
|
||||
try {
|
||||
if (!recipientId || sending || isClosedConversation ||
|
||||
useAgentConversationsStore.getState().selectedConversationId !== recipientId) {
|
||||
throw new Error(t("conversation.sendMessageFailed"));
|
||||
}
|
||||
shouldStickToBottomRef.current = true;
|
||||
await sendMessage(html);
|
||||
const message = await sendMessage(html, recipientId);
|
||||
if (!message) throw new Error(t("conversation.sendMessageFailed"));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversation.sendMessageFailed"));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -369,7 +381,7 @@ export function ChatPanel() {
|
||||
setWorkflowRunLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!conversation) {
|
||||
@@ -495,20 +507,28 @@ export function ChatPanel() {
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
<AgentMessageEditor
|
||||
disabled={!conversation || sending}
|
||||
draftStore={draftStore}
|
||||
disabled={!conversation}
|
||||
sending={sending}
|
||||
uploadingAsset={uploadingAsset}
|
||||
onSend={handleSend}
|
||||
onUploadImage={async (file) => {
|
||||
shouldStickToBottomRef.current = true;
|
||||
const uploaded = await uploadImage(file);
|
||||
return uploaded;
|
||||
try {
|
||||
return await uploadImage(file, conversation.id);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("upload.imageUploadFailed"));
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
onSendAttachment={async (file) => {
|
||||
shouldStickToBottomRef.current = true;
|
||||
try {
|
||||
await sendAttachment(file);
|
||||
const message = await sendAttachment(file, conversation.id);
|
||||
if (!message) throw new Error(t("conversation.sendAttachmentFailed"));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversation.sendAttachmentFailed"));
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
@@ -145,7 +146,7 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
|
||||
{getKnowledgeAnswerStatusLabel(answerResult.answerStatus, answerResult.answerStatusName, t)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{answerResult.latencyMs}ms · {answerResult.modelName || "fallback"}
|
||||
{answerResult.latencyMs}ms · {answerResult.modelName || t("knowledge.answerFallback")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background p-3 whitespace-pre-wrap">
|
||||
@@ -161,12 +162,12 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-xs font-medium">
|
||||
{getSearchResultLabel(citation)}
|
||||
{getSearchResultLabel(citation, t)}
|
||||
</div>
|
||||
<Badge variant="outline">{citation.score.toFixed(4)}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{citation.sectionPath || citation.title || `Chunk #${citation.chunkNo}`}
|
||||
{citation.sectionPath || citation.title || t("knowledge.chunkNumber", { number: citation.chunkNo })}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||
{citation.snippet}
|
||||
@@ -192,12 +193,12 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
|
||||
<div key={`${item.chunkId}-${item.documentId}`} className="rounded-md border bg-background p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{getSearchResultLabel(item)}
|
||||
{getSearchResultLabel(item, t)}
|
||||
</div>
|
||||
<Badge variant="outline">{item.score.toFixed(4)}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.sectionPath || item.title || `Chunk #${item.chunkNo}`}
|
||||
{item.sectionPath || item.title || t("knowledge.chunkNumber", { number: item.chunkNo })}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||
{item.content}
|
||||
@@ -218,7 +219,7 @@ function getSearchResultLabel(item: {
|
||||
faqId?: number
|
||||
documentTitle?: string
|
||||
documentId?: number
|
||||
}) {
|
||||
}, t: (key: string, values?: Record<string, string | number>) => string) {
|
||||
if (item.faqQuestion) {
|
||||
return item.faqQuestion
|
||||
}
|
||||
@@ -226,7 +227,7 @@ function getSearchResultLabel(item: {
|
||||
return item.documentTitle
|
||||
}
|
||||
if (item.faqId && item.faqId > 0) {
|
||||
return `FAQ ${item.faqId}`
|
||||
return t("knowledge.faqNumber", { number: item.faqId })
|
||||
}
|
||||
return `Document ${item.documentId ?? 0}`
|
||||
return t("knowledge.documentNumber", { number: item.documentId ?? 0 })
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
KnowledgeDocumentIndexStatus,
|
||||
Status,
|
||||
} from "@/lib/generated/enums";
|
||||
import { getKnowledgeIndexErrorLabel } from "@/lib/knowledge-i18n";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import { DocumentEditDialog } from "./document-edit";
|
||||
import { KnowledgeContentDetailDialog } from "./knowledge-content-detail";
|
||||
@@ -139,7 +141,7 @@ function renderIndexStatusBadge(item: KnowledgeDocumentListItem, t: TFunction) {
|
||||
<span className="inline-flex">{badge}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" className="max-w-sm whitespace-normal">
|
||||
{item.indexError}
|
||||
{getKnowledgeIndexErrorLabel(item.indexError, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -637,7 +639,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
|
||||
? t("knowledge.indexTime", { time: formatDateTime(item.indexedAt) })
|
||||
: item.indexError || getIndexStatusLabel(item.indexStatus, t)}
|
||||
: getKnowledgeIndexErrorLabel(item.indexError, t) || getIndexStatusLabel(item.indexStatus, t)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
} from "@/lib/api/admin";
|
||||
import { KnowledgeDocumentIndexStatus } from "@/lib/generated/enums";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { getKnowledgeIndexErrorLabel } from "@/lib/knowledge-i18n";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import { FAQEditDialog } from "./faq-edit";
|
||||
import { FAQImportDialog } from "./faq-import-dialog";
|
||||
@@ -127,7 +129,7 @@ function renderIndexStatusBadge(item: KnowledgeFAQ, t: TFunction) {
|
||||
<span className="inline-flex">{badge}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" className="max-w-sm whitespace-normal">
|
||||
{item.indexError}
|
||||
{getKnowledgeIndexErrorLabel(item.indexError, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -42,26 +43,41 @@ export function KnowledgeBulkMoveDialog({
|
||||
onSubmit,
|
||||
}: KnowledgeBulkMoveDialogProps) {
|
||||
const t = useI18n();
|
||||
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
|
||||
const [directoryResult, setDirectoryResult] = useState<{
|
||||
knowledgeBaseId: number;
|
||||
directories: KnowledgeDirectory[];
|
||||
}>({ knowledgeBaseId, directories: [] });
|
||||
const [targetDirectoryId, setTargetDirectoryId] = useState("0");
|
||||
const [selectionScope, setSelectionScope] = useState({ knowledgeBaseId, open });
|
||||
|
||||
// A new base or dialog opening is a new selection session. Reset before the
|
||||
// children render, rather than exposing the previous target for one frame.
|
||||
if (selectionScope.knowledgeBaseId !== knowledgeBaseId || selectionScope.open !== open) {
|
||||
setSelectionScope({ knowledgeBaseId, open });
|
||||
setTargetDirectoryId("0");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setTargetDirectoryId("0");
|
||||
let active = true;
|
||||
fetchKnowledgeDirectories(knowledgeBaseId)
|
||||
.then(setDirectories)
|
||||
.then((result) => {
|
||||
if (active) setDirectoryResult({ knowledgeBaseId, directories: result });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
console.error(error);
|
||||
setDirectories([]);
|
||||
setDirectoryResult({ knowledgeBaseId, directories: [] });
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [knowledgeBaseId, open]);
|
||||
|
||||
const directoryOptions = useMemo(() => [
|
||||
{ value: "0", label: t("knowledge.rootContent") },
|
||||
...flattenDirectoryOptions(directories),
|
||||
], [directories, t]);
|
||||
...flattenDirectoryOptions(directoryResult.knowledgeBaseId === knowledgeBaseId ? directoryResult.directories : []),
|
||||
], [directoryResult, knowledgeBaseId, t]);
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { markdownToHtml } from "@/components/content-editor/convert";
|
||||
import { KnowledgeDocumentContentType } from "@/lib/generated/enums";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { getKnowledgeStatusLabel, getKnowledgeIndexStatusLabel, getKnowledgeIndexErrorLabel } from "@/lib/knowledge-i18n";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
|
||||
type KnowledgeContentDetailDialogProps = {
|
||||
@@ -128,11 +130,11 @@ export function KnowledgeContentDetailDialog({
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.status")}</div>
|
||||
<Badge variant="outline">{item.statusName || item.status}</Badge>
|
||||
<Badge variant="outline">{getKnowledgeStatusLabel(item.status, t)}</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.indexStatus")}</div>
|
||||
<Badge variant="secondary">{item.indexStatusName || item.indexStatus}</Badge>
|
||||
<Badge variant="secondary">{getKnowledgeIndexStatusLabel(item.indexStatus, t)}</Badge>
|
||||
</div>
|
||||
<DetailField
|
||||
label={t("knowledge.createdAt")}
|
||||
@@ -158,7 +160,7 @@ export function KnowledgeContentDetailDialog({
|
||||
|
||||
{item.indexError ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{item.indexError}
|
||||
{getKnowledgeIndexErrorLabel(item.indexError, t)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
@@ -57,7 +58,7 @@ function CitationList({ hits, t }: { hits: KnowledgeRetrieveHit[]; t: TFunction
|
||||
<div key={item.id} className="rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{getHitSourceLabel(item, t)}</span>
|
||||
<Badge variant="outline">Chunk #{item.chunkNo}</Badge>
|
||||
<Badge variant="outline">{t("knowledge.chunkNumber", { number: item.chunkNo })}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.sectionPath || item.title || t("knowledge.unrecordedSection")}
|
||||
@@ -124,6 +125,7 @@ export function RetrieveLogDetailDrawer({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 pb-6">
|
||||
<p className="text-xs text-muted-foreground">{t("knowledge.historyRecordedNotice")}</p>
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.requestInfo")}</h3>
|
||||
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-2">
|
||||
@@ -143,7 +145,7 @@ export function RetrieveLogDetailDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Request ID</div>
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.requestId")}</div>
|
||||
<div className="mt-1 break-all font-mono text-xs">{detail.log.requestId || "-"}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
@@ -165,15 +167,15 @@ export function RetrieveLogDetailDrawer({
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.retrieveStrategy")}</h3>
|
||||
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-3">
|
||||
<Metric
|
||||
label="Chunk Provider"
|
||||
label={t("knowledge.chunkProvider")}
|
||||
value={detail.log.chunkProvider ? getKnowledgeChunkProviderLabel(detail.log.chunkProvider, t) : "-"}
|
||||
mono
|
||||
/>
|
||||
<Metric label="Target Tokens" value={detail.log.chunkTargetTokens} />
|
||||
<Metric label="Max Tokens" value={detail.log.chunkMaxTokens} />
|
||||
<Metric label="Overlap Tokens" value={detail.log.chunkOverlapTokens} />
|
||||
<Metric label="Rerank" value={detail.log.rerankEnabled ? t("knowledge.rerankEnabled") : t("knowledge.rerankDisabled")} />
|
||||
<Metric label="Rerank Limit" value={detail.log.rerankLimit} />
|
||||
<Metric label={t("knowledge.targetToken")} value={detail.log.chunkTargetTokens} />
|
||||
<Metric label={t("knowledge.maxToken")} value={detail.log.chunkMaxTokens} />
|
||||
<Metric label={t("knowledge.overlapToken")} value={detail.log.chunkOverlapTokens} />
|
||||
<Metric label={t("knowledge.rerank")} value={detail.log.rerankEnabled ? t("knowledge.rerankEnabled") : t("knowledge.rerankDisabled")} />
|
||||
<Metric label={t("knowledge.rerankLimitLabel")} value={detail.log.rerankLimit} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -187,12 +189,12 @@ export function RetrieveLogDetailDrawer({
|
||||
<Metric label={t("knowledge.hitCount")} value={detail.log.hitCount} />
|
||||
<Metric label={t("knowledge.citations")} value={detail.log.citationCount} />
|
||||
<Metric label={t("knowledge.contextChunks")} value={detail.log.usedChunkCount} />
|
||||
<Metric label="Top Score" value={detail.log.topScore.toFixed(4)} mono />
|
||||
<Metric label={t("knowledge.topScore")} value={detail.log.topScore.toFixed(4)} mono />
|
||||
<Metric label={t("knowledge.retrieveMs")} value={`${detail.log.retrieveMs} ms`} />
|
||||
<Metric label={t("knowledge.generateMs")} value={`${detail.log.generateMs} ms`} />
|
||||
<Metric label={t("knowledge.totalMs")} value={`${detail.log.latencyMs} ms`} />
|
||||
<Metric label="Prompt Tokens" value={detail.log.promptTokens} />
|
||||
<Metric label="Completion Tokens" value={detail.log.completionTokens} />
|
||||
<Metric label={t("knowledge.promptTokens")} value={detail.log.promptTokens} />
|
||||
<Metric label={t("knowledge.completionTokens")} value={detail.log.completionTokens} />
|
||||
<Metric label={t("knowledge.model")} value={detail.log.modelName || "-"} mono />
|
||||
<Metric label={t("knowledge.sessionId")} value={detail.log.sessionId || "-"} mono />
|
||||
</div>
|
||||
@@ -221,10 +223,10 @@ export function RetrieveLogDetailDrawer({
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t("knowledge.section", { section: item.sectionPath || item.title || "-" })}</span>
|
||||
<span>Chunk #{item.chunkNo}</span>
|
||||
<span>Provider:{item.provider || "-"}</span>
|
||||
<span>Score:{item.score.toFixed(4)}</span>
|
||||
<span>Rerank:{item.rerankScore ? item.rerankScore.toFixed(4) : "-"}</span>
|
||||
<span>{t("knowledge.chunkNumber", { number: item.chunkNo })}</span>
|
||||
<span>{t("knowledge.providerValue", { value: item.provider ? getKnowledgeChunkProviderLabel(item.provider, t) : "-" })}</span>
|
||||
<span>{t("knowledge.scoreValue", { value: item.score.toFixed(4) })}</span>
|
||||
<span>{t("knowledge.rerankValue", { value: item.rerankScore ? item.rerankScore.toFixed(4) : "-" })}</span>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="text-sm leading-6 whitespace-pre-wrap text-foreground/90">
|
||||
@@ -236,7 +238,7 @@ export function RetrieveLogDetailDrawer({
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">TraceData</h3>
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.traceData")}</h3>
|
||||
<div className="rounded-lg border bg-muted/20 p-4">
|
||||
<pre className="overflow-x-auto text-xs leading-6 text-muted-foreground">
|
||||
{traceData ? JSON.stringify(traceData, null, 2) : detail.log.traceData || "-"}
|
||||
@@ -259,9 +261,9 @@ function getHitSourceLabel(item: KnowledgeRetrieveHit, t: TFunction) {
|
||||
return item.documentTitle
|
||||
}
|
||||
if (item.faqId > 0) {
|
||||
return `FAQ #${item.faqId}`
|
||||
return t("knowledge.faqNumber", { number: item.faqId })
|
||||
}
|
||||
return `${t("knowledge.document")} #${item.documentId}`
|
||||
return t("knowledge.documentNumber", { number: item.documentId })
|
||||
}
|
||||
|
||||
function Metric({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
@@ -190,13 +191,13 @@ export function RetrieveLogList({
|
||||
},
|
||||
{
|
||||
name: "rerankEnabled",
|
||||
label: "Rerank",
|
||||
label: t("knowledge.rerank"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
valueType: "number",
|
||||
options: rerankOptions,
|
||||
placeholder: "Rerank",
|
||||
placeholder: t("knowledge.rerank"),
|
||||
},
|
||||
]}
|
||||
fetchList={(query) => fetchKnowledgeRetrieveLogs({ knowledgeBaseId, ...query })}
|
||||
@@ -243,20 +244,20 @@ export function RetrieveLogList({
|
||||
},
|
||||
{
|
||||
key: "topScore",
|
||||
label: "TopScore",
|
||||
label: t("knowledge.topScore"),
|
||||
className: "w-24 text-right font-mono text-xs",
|
||||
render: (item) => item.topScore.toFixed(4),
|
||||
},
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
label: t("knowledge.chunkProvider"),
|
||||
className: "w-28",
|
||||
render: (item) =>
|
||||
item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-",
|
||||
},
|
||||
{
|
||||
key: "rerank",
|
||||
label: "Rerank",
|
||||
label: t("knowledge.rerank"),
|
||||
className: "w-24",
|
||||
render: (item) =>
|
||||
item.rerankEnabled
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client"
|
||||
|
||||
import { CircleHelpIcon, FileTextIcon, RefreshCwIcon } from "lucide-react"
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "@/lib/api/admin"
|
||||
import { KnowledgeBaseType, Status } from "@/lib/generated/enums"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { getKnowledgeTypeLabel, getKnowledgeStatusLabel } from "@/lib/knowledge-i18n"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
|
||||
export default function DashboardKnowledgePage() {
|
||||
@@ -83,7 +85,7 @@ export default function DashboardKnowledgePage() {
|
||||
{
|
||||
key: "type",
|
||||
label: t("knowledge.columnType"),
|
||||
render: (item) => <Badge variant="outline">{item.knowledgeTypeName}</Badge>,
|
||||
render: (item) => <Badge variant="outline">{getKnowledgeTypeLabel(item.knowledgeType, t)}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "contentCount",
|
||||
@@ -101,7 +103,7 @@ export default function DashboardKnowledgePage() {
|
||||
label: t("knowledge.columnStatus"),
|
||||
render: (item) => (
|
||||
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
|
||||
{item.statusName}
|
||||
{getKnowledgeStatusLabel(item.status, t)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -131,7 +133,7 @@ export default function DashboardKnowledgePage() {
|
||||
rowActions={[
|
||||
{
|
||||
key: "rebuild-index",
|
||||
label: (item) => t("knowledge.rebuildIndex"),
|
||||
label: () => t("knowledge.rebuildIndex"),
|
||||
icon: <RefreshCwIcon />,
|
||||
run: async ({ item }) => {
|
||||
await rebuildKnowledgeBaseIndex(item.id)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
XCircleIcon,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { DashboardPage } from "@/components/dashboard-page"
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -33,12 +35,14 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { availabilityStatusLabel } from "@/lib/support-availability-i18n"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
applySetupQuickFix,
|
||||
fetchSetupReadiness,
|
||||
simulateSetupRoute,
|
||||
type SetupQuickFixAction,
|
||||
type SetupQuickFixResponse,
|
||||
type SetupReadinessResponse,
|
||||
type SetupSimulateRouteResponse,
|
||||
type SetupStepStatus,
|
||||
@@ -98,6 +102,15 @@ export function SetupWizardView() {
|
||||
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fixingAction, setFixingAction] = useState<string | null>(null)
|
||||
const fixingRef = useRef(false)
|
||||
const selectedChannelRef = useRef("")
|
||||
const simulationRequestRef = useRef(0)
|
||||
const readinessRequestRef = useRef(0)
|
||||
const [pendingFix, setPendingFix] = useState<{
|
||||
action: SetupQuickFixAction
|
||||
entityId?: number
|
||||
preview: SetupQuickFixResponse
|
||||
} | null>(null)
|
||||
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>("")
|
||||
const [simulation, setSimulation] =
|
||||
@@ -105,31 +118,38 @@ export function SetupWizardView() {
|
||||
const [simulating, setSimulating] = useState(false)
|
||||
|
||||
const runSimulation = useCallback(async (channelIdNum?: number) => {
|
||||
const requestId = ++simulationRequestRef.current
|
||||
setSimulating(true)
|
||||
setSimulation(null)
|
||||
try {
|
||||
const sim = await simulateSetupRoute({
|
||||
channel_id: channelIdNum && channelIdNum > 0 ? channelIdNum : undefined,
|
||||
bypass_ai: true,
|
||||
bypass_ai: false,
|
||||
})
|
||||
setSimulation(sim)
|
||||
if (requestId !== simulationRequestRef.current) return
|
||||
setSimulation({ ...sim, candidate_operators: sim.candidate_operators ?? [] })
|
||||
if (sim.channel_id > 0) {
|
||||
selectedChannelRef.current = String(sim.channel_id)
|
||||
setSelectedChannelId(String(sim.channel_id))
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== simulationRequestRef.current) return
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||
)
|
||||
} finally {
|
||||
setSimulating(false)
|
||||
if (requestId === simulationRequestRef.current) setSimulating(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
const requestId = ++readinessRequestRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchSetupReadiness()
|
||||
if (requestId !== readinessRequestRef.current) return
|
||||
setReadiness(data)
|
||||
const currentNum = Number(selectedChannelId)
|
||||
const currentNum = Number(selectedChannelRef.current)
|
||||
const targetChannelId =
|
||||
currentNum > 0
|
||||
? currentNum
|
||||
@@ -141,13 +161,19 @@ export function SetupWizardView() {
|
||||
}
|
||||
await runSimulation(targetChannelId)
|
||||
} catch (error) {
|
||||
if (requestId !== readinessRequestRef.current) return
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.loadFailed"),
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (requestId === readinessRequestRef.current) setLoading(false)
|
||||
}
|
||||
}, [runSimulation, selectedChannelId, t])
|
||||
}, [runSimulation, t])
|
||||
|
||||
const invalidateRequests = useCallback(() => {
|
||||
simulationRequestRef.current++
|
||||
readinessRequestRef.current++
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll()
|
||||
@@ -157,30 +183,51 @@ export function SetupWizardView() {
|
||||
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
return () => {
|
||||
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
invalidateRequests()
|
||||
}
|
||||
}, [loadAll])
|
||||
}, [invalidateRequests, loadAll])
|
||||
|
||||
async function handleQuickFix(
|
||||
action: SetupQuickFixAction,
|
||||
entityId?: number,
|
||||
) {
|
||||
const key = entityId ? `${action}:${entityId}` : action
|
||||
setFixingAction(key)
|
||||
async function handleQuickFix(action: SetupQuickFixAction, entityId?: number) {
|
||||
if (fixingRef.current) return
|
||||
fixingRef.current = true
|
||||
setFixingAction(entityId ? `${action}:${entityId}` : action)
|
||||
try {
|
||||
const res = await applySetupQuickFix({ action, entity_id: entityId })
|
||||
setReadiness(res.readiness)
|
||||
notifySetupReadinessUpdated()
|
||||
toast.success(t("setupWizard.quickFixSuccess"))
|
||||
const targetChannelId =
|
||||
Number(selectedChannelId) ||
|
||||
res.readiness.channels.find((ch) => ch.status === 0)?.id ||
|
||||
0
|
||||
await runSimulation(targetChannelId)
|
||||
const preview = await applySetupQuickFix({ action, entity_id: entityId })
|
||||
setPendingFix({ action, entityId, preview })
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||
)
|
||||
toast.error(error instanceof Error ? error.message : t("common.operationFailed"))
|
||||
} finally {
|
||||
fixingRef.current = false
|
||||
setFixingAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmQuickFix() {
|
||||
if (!pendingFix || fixingRef.current) return
|
||||
fixingRef.current = true
|
||||
setFixingAction(pendingFix.action)
|
||||
try {
|
||||
const result = await applySetupQuickFix({
|
||||
action: pendingFix.action,
|
||||
entity_id: pendingFix.entityId,
|
||||
apply: true,
|
||||
preview_token: pendingFix.preview.preview_token,
|
||||
})
|
||||
if (!result.applied) throw new Error(t("setupWizard.preview.notApplied"))
|
||||
if (result.readiness) setReadiness(result.readiness)
|
||||
setPendingFix(null)
|
||||
notifySetupReadinessUpdated()
|
||||
if (result.warning_codes.length) {
|
||||
toast.warning(result.warning_codes.map((code) => t(`setupWizard.repairWarnings.${code}`)).join(" "))
|
||||
} else {
|
||||
toast.success(t("setupWizard.quickFixSuccess"))
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("common.operationFailed"))
|
||||
// A failed/stale preview must be reviewed again, not silently reapplied.
|
||||
setPendingFix(null)
|
||||
} finally {
|
||||
fixingRef.current = false
|
||||
setFixingAction(null)
|
||||
}
|
||||
}
|
||||
@@ -418,7 +465,10 @@ export function SetupWizardView() {
|
||||
</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{readiness.steps.map((step) => {
|
||||
const primaryQuickFix = step.quick_fix_actions[0]
|
||||
const primaryQuickFix =
|
||||
(step.key === "ai_agents" || step.key === "channels") && step.total_count > 0
|
||||
? undefined
|
||||
: step.quick_fix_actions[0]
|
||||
return (
|
||||
<div
|
||||
key={step.key}
|
||||
@@ -469,6 +519,11 @@ export function SetupWizardView() {
|
||||
{t(`setupWizard.issues.${issue.code}`)}
|
||||
</span>
|
||||
</div>
|
||||
{issue.quick_fix_action && issue.entity_id > 0 && issue.entity_type !== "operator" ? (
|
||||
<Button size="sm" variant="outline" className="shrink-0 rounded-md" disabled={fixingAction !== null} onClick={() => void handleQuickFix(issue.quick_fix_action!, issue.entity_id)}>
|
||||
{t("setupWizard.preview.review")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -542,6 +597,7 @@ export function SetupWizardView() {
|
||||
options={channelOptions}
|
||||
value={selectedChannelId}
|
||||
onChange={(val) => {
|
||||
selectedChannelRef.current = val
|
||||
setSelectedChannelId(val)
|
||||
void runSimulation(Number(val))
|
||||
}}
|
||||
@@ -705,9 +761,7 @@ export function SetupWizardView() {
|
||||
variant="outline"
|
||||
className="rounded-md text-xs"
|
||||
>
|
||||
{cand.service_status === 0
|
||||
? t("setupWizard.simulator.statusIdle")
|
||||
: t("setupWizard.simulator.statusBusy")}
|
||||
{availabilityStatusLabel(cand.service_status, t)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -744,6 +798,39 @@ export function SetupWizardView() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Dialog open={pendingFix !== null} onOpenChange={(open) => { if (!open && !fixingRef.current) setPendingFix(null) }}>
|
||||
<DialogContent className="grid max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto] rounded-md sm:max-w-2xl" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("setupWizard.preview.title")}</DialogTitle>
|
||||
<DialogDescription>{t("setupWizard.preview.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 overflow-y-auto break-words">
|
||||
{pendingFix?.preview.changes.length === 0 ? <p>{t("setupWizard.preview.noChanges")}</p> : null}
|
||||
{pendingFix?.preview.changes.map((change, index) => (
|
||||
<div key={`${change.entity_type}-${change.entity_id}-${index}`} className="space-y-2 rounded-md border p-3">
|
||||
<p className="font-medium">{t(`setupWizard.preview.${change.operation}`)}: {change.entity_name}{change.entity_id > 0 ? ` (#${change.entity_id})` : ""}</p>
|
||||
<dl className="space-y-1 text-xs">
|
||||
{Object.entries(change.after).map(([field, value]) => (
|
||||
<div key={field} className="grid gap-1 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
|
||||
<dt className="text-muted-foreground">{t(`setupWizard.repairFields.${field}`)}</dt>
|
||||
<dd>{change.before && field in change.before ? `${String(change.before[field])} → ` : ""}{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
{pendingFix?.preview.warning_codes.map((code) => <p key={code} className="text-sm text-amber-700 dark:text-amber-300">{t(`setupWizard.repairWarnings.${code}`)}</p>)}
|
||||
<Link className="text-sm underline" href="/dashboard/agent-team-schedules">{t("setupWizard.preview.manageSchedules")}</Link>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={fixingAction !== null} onClick={() => setPendingFix(null)}>{t("common.cancel")}</Button>
|
||||
<Button disabled={fixingAction !== null || !pendingFix?.preview.changes.length} onClick={() => void confirmQuickFix()}>
|
||||
{fixingAction !== null ? <Loader2Icon className="size-4 animate-spin" /> : null}
|
||||
{t("setupWizard.preview.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client"
|
||||
|
||||
import { Loader2Icon, PlusIcon, Trash2Icon } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { isSameSession } from "@/lib/session-request-guard"
|
||||
import { readSession } from "@/lib/auth"
|
||||
import { useConfirm } from "@/components/confirm-provider"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { notifyAvailabilityUpdated } from "@/components/support-availability/operator-status"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { previewSupportCalendar, updateSupportCalendar, type CalendarPreview, type SupportCalendar, type SupportPeriod } from "@/lib/api/support-availability"
|
||||
import { calendarValidationKey, formatAvailabilityTime, localDateTimeCandidates, selectedLocalDateTimeISO } from "@/lib/support-availability"
|
||||
|
||||
export function CalendarEditor({ initial, writable, onDirtyChange, onBusyChange, onSaved }: { initial: SupportCalendar; writable: boolean; onDirtyChange: (dirty: boolean) => void; onBusyChange?: (busy: boolean) => void; onSaved: (value: SupportCalendar) => void }) {
|
||||
const t = useI18n()
|
||||
const { locale } = useAppLocale()
|
||||
const confirm = useConfirm()
|
||||
const { session } = useAuth()
|
||||
const capturedSession = useRef(session)
|
||||
const leaving = useRef(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [preview, setPreview] = useState<CalendarPreview | null>(null)
|
||||
const [previewAt, setPreviewAt] = useState("")
|
||||
const [previewOccurrence, setPreviewOccurrence] = useState("")
|
||||
const [previewError, setPreviewError] = useState("")
|
||||
const mutation = useRef(false)
|
||||
const mounted = useRef(true)
|
||||
const previewRequest = useRef(0)
|
||||
const form = useForm<SupportCalendar>({ defaultValues: initial })
|
||||
const { control, register, watch, handleSubmit, reset, setError, clearErrors, formState: { isDirty, errors } } = form
|
||||
const weekly = useFieldArray({ control, name: "weekly" })
|
||||
const exceptions = useFieldArray({ control, name: "exceptions" })
|
||||
const draft = watch()
|
||||
const fingerprint = JSON.stringify(draft)
|
||||
const previewFingerprint = useRef("")
|
||||
const effectiveTimezone = draft.mode === "legacy" ? initial.effectiveTimezone : draft.timezone || initial.deploymentTimezone
|
||||
const locked = !editing || saving
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const weekdays = [1, 2, 3, 4, 5, 6, 7].map(day => ({ value: String(day), label: t(`supportAvailability.weekday.${day}`) }))
|
||||
useEffect(() => { onDirtyChange(isDirty) }, [isDirty, onDirtyChange])
|
||||
useEffect(() => { onBusyChange?.(saving); return () => onBusyChange?.(false) }, [saving, onBusyChange])
|
||||
useEffect(() => {
|
||||
const warn = (event: BeforeUnloadEvent) => { if (isDirty && !leaving.current) { event.preventDefault(); event.returnValue = "" } }
|
||||
const guardLink = (event: MouseEvent) => {
|
||||
if (!isDirty || event.defaultPrevented || !(event.target instanceof Element)) return
|
||||
const anchor = event.target.closest("a[href]") as HTMLAnchorElement | null
|
||||
if (!anchor || anchor.target === "_blank" || anchor.origin !== window.location.origin || anchor.href === window.location.href) return
|
||||
event.preventDefault()
|
||||
void confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }).then(approved => { if (approved && mounted.current) { leaving.current = true; reset(initial); window.location.assign(anchor.href) } })
|
||||
}
|
||||
const sourceUrl = window.location.href
|
||||
const sourceState = window.history.state
|
||||
const guardBack = (event: PopStateEvent) => {
|
||||
if (!isDirty || leaving.current || window.location.href === sourceUrl) return
|
||||
const destination = window.location.href
|
||||
// Keep the current form mounted while the user reviews browser Back/Forward.
|
||||
// Capture runs before App Router's normal popstate listener.
|
||||
event.stopImmediatePropagation()
|
||||
window.history.pushState(sourceState, "", sourceUrl)
|
||||
void confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }).then(approved => {
|
||||
if (approved && mounted.current) { leaving.current = true; window.location.assign(destination) }
|
||||
})
|
||||
}
|
||||
window.addEventListener("popstate", guardBack, true)
|
||||
window.addEventListener("beforeunload", warn)
|
||||
document.addEventListener("click", guardLink, true)
|
||||
return () => { window.removeEventListener("popstate", guardBack, true); window.removeEventListener("beforeunload", warn); document.removeEventListener("click", guardLink, true) }
|
||||
}, [isDirty, confirm, initial, reset, t])
|
||||
const invalidatePreview = useCallback(() => { previewRequest.current++ }, [])
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; invalidatePreview() } }, [invalidatePreview])
|
||||
async function cancel() {
|
||||
if (isDirty && !(await confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }))) return
|
||||
reset(initial); setEditing(false); setPreview(null); clearErrors()
|
||||
}
|
||||
async function save(calendar: SupportCalendar) {
|
||||
if (mutation.current || !writable || !editing || !isDirty || !mounted.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
const validation = calendarValidationKey(calendar)
|
||||
if (validation) { setError("root", { message: t(validation) }); return }
|
||||
if (!(await confirm({ title: t("supportAvailability.confirmCalendarTitle"), description: initial.mode === "legacy" && calendar.mode === "recurring" ? t("supportAvailability.confirmRecurring") : t("supportAvailability.confirmCalendar"), confirmText: t("supportAvailability.saveCalendar") }))) return
|
||||
if (!mounted.current || mutation.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
mutation.current = true; setSaving(true); clearErrors()
|
||||
try {
|
||||
const result = await updateSupportCalendar(calendar)
|
||||
if (!mounted.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
// Re-read on the page to obtain server-normalized effective timezone and arrays.
|
||||
reset({ ...result, weekly: result.weekly ?? [], exceptions: (result.exceptions ?? []).map(day => ({ ...day, periods: day.periods ?? [] })) })
|
||||
setEditing(false); setPreview(null); toast.success(t("supportAvailability.calendarSaved")); notifyAvailabilityUpdated(); onSaved(result)
|
||||
} catch (error) { if (mounted.current) setError("root", { message: error instanceof Error ? error.message : t("supportAvailability.saveFailed") }) }
|
||||
finally { mutation.current = false; if (mounted.current) setSaving(false) }
|
||||
}
|
||||
async function runPreview() {
|
||||
const validation = calendarValidationKey(draft)
|
||||
if (validation) { setPreviewError(t(validation)); return }
|
||||
const at = previewAt ? selectedLocalDateTimeISO(previewAt, previewOccurrence) : undefined
|
||||
if (previewAt && !at) { setPreviewError(t("supportAvailability.invalidPreviewTime")); return }
|
||||
const requestId = ++previewRequest.current
|
||||
setPreviewing(true); setPreviewError("")
|
||||
try {
|
||||
const result = await previewSupportCalendar(draft, at ?? undefined)
|
||||
if (mounted.current && previewRequest.current === requestId) { previewFingerprint.current = `${fingerprint}:${previewAt}:${previewOccurrence}`; setPreview(result) }
|
||||
} catch (error) { if (mounted.current && previewRequest.current === requestId) { setPreview(null); setPreviewError(error instanceof Error ? error.message : t("supportAvailability.previewFailed")) } }
|
||||
finally { if (mounted.current && previewRequest.current === requestId) setPreviewing(false) }
|
||||
}
|
||||
const previewCurrent = preview && previewFingerprint.current === `${fingerprint}:${previewAt}:${previewOccurrence}`
|
||||
return <form onSubmit={handleSubmit(save)} className="space-y-6" noValidate>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b pb-3">
|
||||
<h2 className="text-base font-semibold">{t("supportAvailability.calendar")}</h2>
|
||||
{writable ? editing ? <div className="flex gap-2"><Button variant="outline" type="button" disabled={saving} onClick={() => void cancel()}>{t("supportAvailability.cancel")}</Button><Button type="submit" disabled={saving || !isDirty}>{saving ? <Loader2Icon className="size-4 animate-spin" /> : null}{t(saving ? "supportAvailability.saving" : "supportAvailability.saveCalendar")}</Button></div> : <Button variant="outline" type="button" onClick={() => setEditing(true)}>{t("supportAvailability.editCalendar")}</Button> : <p className="text-sm text-muted-foreground">{t("supportAvailability.readOnly")}</p>}
|
||||
</div>
|
||||
{errors.root?.message ? <p role="alert" className="text-sm text-destructive">{errors.root.message}</p> : null}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field><FieldLabel htmlFor="availability-timezone">{t(initial.teamId === 0 ? "supportAvailability.deploymentTimezone" : "supportAvailability.teamTimezone")}</FieldLabel><Input id="availability-timezone" placeholder={initial.teamId ? initial.deploymentTimezone : "Europe/Moscow"} disabled={locked} {...register("timezone")} /><p className="text-xs text-muted-foreground">{t(initial.teamId ? "supportAvailability.inheritTimezoneHelp" : "supportAvailability.timezoneHelp")}</p><p className="text-sm">{t("supportAvailability.effectiveTimezone", { timezone: effectiveTimezone })}</p></Field>
|
||||
{initial.teamId !== 0 ? <Field><FieldLabel>{t("supportAvailability.scheduleMode")}</FieldLabel><Controller control={control} name="mode" render={({ field }) => <OptionCombobox value={field.value} options={[{ value: "legacy", label: t("supportAvailability.legacy") }, { value: "recurring", label: t("supportAvailability.recurring") }]} onChange={field.onChange} disabled={locked} ariaLabel={t("supportAvailability.scheduleMode")} placeholder={t("supportAvailability.scheduleMode")} />} /></Field> : null}
|
||||
</div>
|
||||
{initial.teamId !== 0 ? <><p className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">{t("supportAvailability.legacyHelp")} <Link href="/dashboard/agent-team-schedules" className="underline underline-offset-2">{t("supportAvailability.viewDatedShifts")}</Link></p>
|
||||
{draft.mode === "recurring" ? <>
|
||||
<section className="space-y-3" aria-labelledby="weekly-hours-title"><div className="flex flex-wrap items-center justify-between gap-2"><h3 id="weekly-hours-title" className="text-sm font-semibold">{t("supportAvailability.weeklyHours")}</h3>{editing ? <Button type="button" variant="outline" size="sm" disabled={saving} onClick={() => weekly.append({ weekday: 1, start: "09:00", end: "17:00" })}><PlusIcon className="size-4" />{t("supportAvailability.addPeriod")}</Button> : null}</div><p className="text-xs text-muted-foreground">{t("supportAvailability.hoursHelp")}</p>
|
||||
{weekly.fields.length === 0 ? <p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">{t("supportAvailability.noWeeklyHours")}</p> : weekly.fields.map((period, index) => <div key={period.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-2 rounded-md border p-3 sm:grid-cols-[minmax(8rem,1fr)_1fr_1fr_auto]">
|
||||
<Field className="col-span-2 sm:col-span-1"><FieldLabel>{t("supportAvailability.day")}</FieldLabel><Controller control={control} name={`weekly.${index}.weekday`} render={({ field }) => <OptionCombobox value={String(field.value)} onChange={value => field.onChange(Number(value))} options={weekdays} disabled={locked} ariaLabel={t("supportAvailability.day")} placeholder={t("supportAvailability.day")} />} /></Field>
|
||||
<Field><FieldLabel htmlFor={`weekly-start-${period.id}`}>{t("supportAvailability.start")}</FieldLabel><Input id={`weekly-start-${period.id}`} type="time" disabled={locked} {...register(`weekly.${index}.start`)} /></Field><Field><FieldLabel htmlFor={`weekly-end-${period.id}`}>{t("supportAvailability.end")}</FieldLabel><Input id={`weekly-end-${period.id}`} type="text" inputMode="text" placeholder="HH:mm" disabled={locked} {...register(`weekly.${index}.end`)} /></Field>
|
||||
{editing ? <Button className="justify-self-end" type="button" variant="ghost" size="icon" aria-label={t("supportAvailability.removePeriod")} disabled={saving} onClick={() => weekly.remove(index)}><Trash2Icon className="size-4" /></Button> : <span />}
|
||||
</div>)}
|
||||
</section>
|
||||
<section className="space-y-3" aria-labelledby="date-exceptions-title"><div className="flex flex-wrap items-center justify-between gap-2"><h3 id="date-exceptions-title" className="text-sm font-semibold">{t("supportAvailability.exceptions")}</h3>{editing ? <Button type="button" variant="outline" size="sm" disabled={saving} onClick={() => exceptions.append({ date: "", closed: true, periods: [] })}><PlusIcon className="size-4" />{t("supportAvailability.addException")}</Button> : null}</div><p className="text-xs text-muted-foreground">{t("supportAvailability.exceptionsHelp")}</p>
|
||||
{!exceptions.fields.length ? <p className="text-sm text-muted-foreground">{t("supportAvailability.noExceptions")}</p> : exceptions.fields.map((day, index) => <div key={day.id} className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-end gap-3"><Field className="w-auto min-w-44 flex-1"><FieldLabel htmlFor={`exception-date-${day.id}`}>{t("supportAvailability.date")}</FieldLabel><Input id={`exception-date-${day.id}`} type="date" disabled={locked} {...register(`exceptions.${index}.date`)} /></Field><Field className="w-auto pb-2"><FieldLabel htmlFor={`exception-closed-${day.id}`}>{t("supportAvailability.closedDay")}</FieldLabel><Controller control={control} name={`exceptions.${index}.closed`} render={({ field }) => <Switch id={`exception-closed-${day.id}`} checked={field.value} onCheckedChange={field.onChange} disabled={locked} />} /></Field>{editing ? <Button variant="ghost" type="button" size="icon" disabled={saving} aria-label={t("supportAvailability.removeException")} onClick={() => exceptions.remove(index)}><Trash2Icon className="size-4" /></Button> : null}</div>
|
||||
{!draft.exceptions[index]?.closed ? <Controller control={control} name={`exceptions.${index}.periods`} render={({ field }) => <ExceptionPeriods value={field.value ?? []} onChange={field.onChange} disabled={locked} id={day.id} />} /> : null}
|
||||
</div>)}
|
||||
</section>
|
||||
</> : <p className="text-sm text-muted-foreground">{t("supportAvailability.legacyActive")}</p>}
|
||||
<section className="space-y-3 rounded-md border p-4" aria-labelledby="calendar-preview-title"><h3 id="calendar-preview-title" className="text-sm font-semibold">{t("supportAvailability.preview")}</h3><p className="text-xs text-muted-foreground">{t("supportAvailability.previewHelp", { timezone: browserTimezone })}</p><div className="flex flex-wrap items-end gap-3"><Field className="min-w-48 flex-1"><FieldLabel htmlFor="preview-at">{t("supportAvailability.previewAt")}</FieldLabel><Input id="preview-at" type="datetime-local" value={previewAt} onChange={event => setPreviewAt(event.target.value)} disabled={previewing || saving} /></Field><Button type="button" variant="outline" disabled={previewing || saving} onClick={() => void runPreview()}>{previewing ? <Loader2Icon className="size-4 animate-spin" /> : null}{t(previewing ? "supportAvailability.previewing" : "supportAvailability.runPreview")}</Button></div>
|
||||
{localDateTimeCandidates(previewAt).length > 1 ? <Field><FieldLabel>{t("supportAvailability.dstOccurrence", { field: t("supportAvailability.previewAt") })}</FieldLabel><OptionCombobox value={selectedLocalDateTimeISO(previewAt, previewOccurrence) ?? ""} options={localDateTimeCandidates(previewAt).map(value => ({ value, label: value }))} onChange={setPreviewOccurrence} disabled={previewing} ariaLabel={t("supportAvailability.chooseOccurrence")} placeholder={t("supportAvailability.chooseOccurrence")} /></Field> : null}
|
||||
{previewError ? <p role="alert" className="text-sm text-destructive">{previewError}</p> : null}
|
||||
{previewCurrent ? <div className="space-y-1 text-sm" role="status"><p>{t(preview.open ? "supportAvailability.scheduleOpen" : "supportAvailability.scheduleClosed")}</p><p>{t("supportAvailability.effectiveTimezone", { timezone: preview.timezone })}</p>{preview.nextOpeningAt ? <p>{t("supportAvailability.nextOpening", { time: formatAvailabilityTime(preview.nextOpeningAt, preview.timezone, locale) })}</p> : !preview.open ? <p>{t("supportAvailability.noNextOpening")}</p> : null}<p className="text-xs text-muted-foreground">{t("supportAvailability.notPresencePromise")}</p></div> : preview ? <p className="text-xs text-muted-foreground">{t("supportAvailability.previewStale")}</p> : null}
|
||||
</section></> : null}
|
||||
</form>
|
||||
}
|
||||
|
||||
function ExceptionPeriods({ value, onChange, disabled, id }: { value: SupportPeriod[]; onChange: (value: SupportPeriod[]) => void; disabled: boolean; id: string }) {
|
||||
const t = useI18n()
|
||||
return <div className="space-y-2">{value.map((period, index) => <div key={index} className="flex items-end gap-2"><Field><FieldLabel htmlFor={`${id}-start-${index}`}>{t("supportAvailability.start")}</FieldLabel><Input id={`${id}-start-${index}`} type="time" value={period.start} disabled={disabled} onChange={event => onChange(value.map((p, i) => i === index ? { ...p, start: event.target.value } : p))} /></Field><Field><FieldLabel htmlFor={`${id}-end-${index}`}>{t("supportAvailability.end")}</FieldLabel><Input id={`${id}-end-${index}`} type="text" inputMode="text" placeholder="HH:mm" value={period.end} disabled={disabled} onChange={event => onChange(value.map((p, i) => i === index ? { ...p, end: event.target.value } : p))} /></Field>{!disabled ? <Button type="button" variant="ghost" size="icon" aria-label={t("supportAvailability.removePeriod")} onClick={() => onChange(value.filter((_, i) => i !== index))}><Trash2Icon className="size-4" /></Button> : null}</div>)}{!disabled ? <Button type="button" variant="outline" size="sm" onClick={() => onChange([...value, { start: "09:00", end: "17:00" }])}><PlusIcon className="size-4" />{t("supportAvailability.addPeriod")}</Button> : null}</div>
|
||||
}
|
||||
63
web/app/(dashboard)/dashboard/support-availability/page.tsx
Normal file
63
web/app/(dashboard)/dashboard/support-availability/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import { RefreshCwIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useConfirm } from "@/components/confirm-provider"
|
||||
import { DashboardPage, DashboardToolbar } from "@/components/dashboard-page"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { OperatorStatusControl } from "@/components/support-availability/operator-status"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { fetchAgentTeamsAll, type AdminAgentTeam } from "@/lib/api/admin"
|
||||
import { fetchSupportCalendar, type SupportCalendar } from "@/lib/api/support-availability"
|
||||
import { CalendarEditor } from "./_components/calendar-editor"
|
||||
|
||||
// A scoped configuration editor, not a paginated CRUD resource.
|
||||
export default function SupportAvailabilityPage() {
|
||||
const { session } = useAuth()
|
||||
return <SupportAvailabilityContent key={session?.user.id ?? "signed-out"} />
|
||||
}
|
||||
function SupportAvailabilityContent() {
|
||||
const t = useI18n()
|
||||
const { session } = useAuth()
|
||||
const confirm = useConfirm()
|
||||
const can = (permission: string) => Boolean(session?.permissions.includes(permission))
|
||||
const canDefault = can("supportConfig.view")
|
||||
const canTeams = can("agentTeam.view") && can("agentTeamSchedule.view")
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||
const [teamId, setTeamId] = useState<number | null>(canDefault ? 0 : null)
|
||||
const [calendar, setCalendar] = useState<SupportCalendar | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const [teamError, setTeamError] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [revision, setRevision] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!canTeams) return
|
||||
let active = true
|
||||
fetchAgentTeamsAll().then(data => { if (active) { setTeams(data); setTeamError(""); setTeamId(current => current ?? data[0]?.id ?? null); if (!data.length && !canDefault) setLoading(false) } }).catch(error => { if (active) { setTeamError(error instanceof Error ? error.message : t("supportAvailability.loadFailed")); if (!canDefault) setLoading(false) } })
|
||||
return () => { active = false }
|
||||
}, [canTeams, canDefault, revision, t])
|
||||
useEffect(() => {
|
||||
if (teamId === null) return
|
||||
let active = true
|
||||
fetchSupportCalendar(teamId).then(data => { if (active) { setCalendar(data); setError("") } }).catch(error => { if (active) { setCalendar(null); setError(error instanceof Error ? error.message : t("supportAvailability.loadFailed")) } }).finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [teamId, revision, t])
|
||||
async function changeScope(next: number) {
|
||||
if (busy) return
|
||||
if (dirty && !(await confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }))) return
|
||||
setDirty(false); setCalendar(null); setLoading(true); setTeamId(next)
|
||||
}
|
||||
const options = [...(canDefault ? [{ value: "0", label: t("supportAvailability.deploymentDefault") }] : []), ...teams.map(team => ({ value: String(team.id), label: team.name }))]
|
||||
return <DashboardPage>
|
||||
<DashboardToolbar actions={<OperatorStatusControl />}><div><h1 className="text-lg font-semibold">{t("supportAvailability.title")}</h1><p className="mt-1 max-w-3xl text-sm text-muted-foreground">{t("supportAvailability.description")}</p></div></DashboardToolbar>
|
||||
{!canDefault && !canTeams ? <p className="text-sm text-muted-foreground">{t("supportAvailability.noPermission")}</p> : <>
|
||||
<div className="flex max-w-xl items-center gap-2"><div className="min-w-0 flex-1"><OptionCombobox value={teamId === null ? "" : String(teamId)} disabled={busy} options={options} onChange={value => void changeScope(Number(value))} ariaLabel={t("supportAvailability.selectScope")} placeholder={t("supportAvailability.selectScope")} /></div><Button type="button" variant="outline" size="icon" aria-label={t("supportAvailability.refresh")} disabled={dirty || busy} onClick={() => { setLoading(true); setRevision(value => value + 1) }}><RefreshCwIcon className="size-4" /></Button></div>
|
||||
{teamError ? <p role="alert" className="text-sm text-destructive">{teamError}</p> : null}
|
||||
{error ? <div role="alert" className="space-y-2 rounded-md border p-4"><p className="text-sm text-destructive">{error}</p><Button variant="outline" onClick={() => { setLoading(true); setRevision(value => value + 1) }}>{t("supportAvailability.retry")}</Button></div> : calendar && calendar.teamId === teamId ? <CalendarEditor key={`${teamId}:${revision}`} initial={calendar} writable={can(teamId === 0 ? "supportConfig.update" : "agentTeamSchedule.update")} onDirtyChange={setDirty} onBusyChange={setBusy} onSaved={value => { setCalendar(value); setDirty(false); setRevision(revision => revision + 1) }} /> : <p className="p-4 text-sm text-muted-foreground" role="status">{t(teamId === null && !loading ? "supportAvailability.noTeams" : "supportAvailability.loading")}</p>}
|
||||
</>}
|
||||
</DashboardPage>
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import { CheckCircle2Icon } from "lucide-react"
|
||||
import { MessageSquareIcon } from "lucide-react"
|
||||
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { getStandaloneChatUrl } from "@/components/support-chat/close-navigation"
|
||||
|
||||
export default function Page() {
|
||||
const t = useI18n()
|
||||
@@ -10,15 +13,18 @@ export default function Page() {
|
||||
return (
|
||||
<main className="flex min-h-svh items-center justify-center bg-muted px-6 text-foreground">
|
||||
<section className="grid max-w-sm justify-items-center gap-4 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900">
|
||||
<CheckCircle2Icon className="size-7" />
|
||||
<div className="flex size-14 items-center justify-center rounded-md border bg-card text-muted-foreground">
|
||||
<MessageSquareIcon className="size-7" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<h1 className="text-lg font-semibold text-foreground">{t("supportChat.closedTitle")}</h1>
|
||||
<h1 className="text-lg font-semibold text-foreground">{t("supportChat.dismissedTitle")}</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{t("supportChat.closedDescription")}
|
||||
{t("supportChat.dismissedDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => window.location.replace(getStandaloneChatUrl(window.location.search))}>
|
||||
{t("supportChat.returnToChat")}
|
||||
</Button>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
@@ -8,15 +9,15 @@ const providerSource = await readFile(
|
||||
"utf8",
|
||||
).catch(() => "");
|
||||
const workbenchLayoutSource = await readFile(
|
||||
new URL("../app/workbench/layout.tsx", import.meta.url),
|
||||
new URL("../app/(dashboard)/workbench/layout.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const dashboardConversationsPageSource = await readFile(
|
||||
new URL("../app/dashboard/conversations/page.tsx", import.meta.url),
|
||||
new URL("../app/(dashboard)/dashboard/conversations/page.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const conversationWorkbenchSource = await readFile(
|
||||
new URL("../app/dashboard/conversations/_components/conversation-workbench.tsx", import.meta.url),
|
||||
new URL("../app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: prevent a late logout from redirecting a newer login.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
@@ -11,7 +13,7 @@ import {
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
|
||||
import { SessionProvider, useSession } from "@/components/session-provider"
|
||||
import { type AuthSession } from "@/lib/auth"
|
||||
import { readSession, type AuthSession } from "@/lib/auth"
|
||||
|
||||
type AuthContextValue = {
|
||||
session: AuthSession | null
|
||||
@@ -46,17 +48,23 @@ function DashboardAuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
await endSession()
|
||||
} finally {
|
||||
startTransition(() => {
|
||||
router.replace("/dashboard/login")
|
||||
})
|
||||
// A different tab or login flow may have established a new session meanwhile.
|
||||
if (!readSession()) {
|
||||
startTransition(() => {
|
||||
router.replace("/dashboard/login")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && !session && requiresAuth) {
|
||||
startTransition(() => {
|
||||
router.replace("/dashboard/login")
|
||||
})
|
||||
// A different tab or login flow may have established a new session meanwhile.
|
||||
if (!readSession()) {
|
||||
startTransition(() => {
|
||||
router.replace("/dashboard/login")
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [ready, requiresAuth, router, session])
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: preserve scoped drafts and guard asynchronous editor operations.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from "react"
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ChangeEvent } from "react"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import { EditorContent, useEditor } from "@tiptap/react"
|
||||
import StarterKit from "@tiptap/starter-kit"
|
||||
@@ -32,10 +34,14 @@ import {
|
||||
MessageImageExtension,
|
||||
removeEditorImageByTitle,
|
||||
revokeEditorObjectUrl,
|
||||
revokeEditorObjectUrls,
|
||||
setEditorImageUploadingByTitle,
|
||||
type UploadedEditorImage,
|
||||
} from "@/lib/im-editor-image"
|
||||
import {
|
||||
createMessageEditorDraftStore,
|
||||
type MessageEditorDraft,
|
||||
type MessageEditorDraftStore,
|
||||
} from "@/lib/message-editor-drafts"
|
||||
import { generateUUID } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
@@ -60,6 +66,9 @@ export type SharedMessageEditorAICopilot = {
|
||||
export type SharedMessageEditorProps = {
|
||||
variant: SharedMessageEditorVariant
|
||||
conversationId?: number | null
|
||||
draftScope?: string
|
||||
draftStore?: MessageEditorDraftStore
|
||||
sending?: boolean
|
||||
disabled?: boolean
|
||||
uploadingAsset?: boolean
|
||||
manageLocalUploading?: boolean
|
||||
@@ -79,6 +88,9 @@ export type SharedMessageEditorProps = {
|
||||
export function SharedMessageEditor({
|
||||
variant,
|
||||
conversationId,
|
||||
draftScope = "default",
|
||||
draftStore,
|
||||
sending = false,
|
||||
disabled = false,
|
||||
uploadingAsset = false,
|
||||
manageLocalUploading = false,
|
||||
@@ -91,6 +103,18 @@ export function SharedMessageEditor({
|
||||
}: SharedMessageEditorProps) {
|
||||
const t = useI18n()
|
||||
const [localUploading, setLocalUploading] = useState(false)
|
||||
const [localDraftStore] = useState(createMessageEditorDraftStore)
|
||||
const drafts = draftStore ?? localDraftStore
|
||||
const draftSending = useSyncExternalStore(drafts.subscribeSending, drafts.isSending, () => false)
|
||||
const draftKey = String(conversationId ?? 0)
|
||||
const displayedDraftRef = useRef<{ key: string; draft: MessageEditorDraft } | null>(null)
|
||||
const contextRef = useRef({ draftKey, draftScope })
|
||||
contextRef.current = { draftKey, draftScope }
|
||||
const mountedRef = useRef(false)
|
||||
const suggestionRequestRef = useRef(0)
|
||||
const suggestionPendingRef = useRef(false)
|
||||
const uploadingRef = useRef(false)
|
||||
const mutationGenerationRef = useRef(0)
|
||||
const [quickReplySearch, setQuickReplySearch] = useState("")
|
||||
const [suggestedReply, setSuggestedReply] = useState<string | null>(null)
|
||||
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false)
|
||||
@@ -105,11 +129,10 @@ export function SharedMessageEditor({
|
||||
const onSendAttachmentRef = useRef(onSendAttachment)
|
||||
const onTypingRef = useRef(onTyping)
|
||||
const shouldRestoreFocusRef = useRef(false)
|
||||
const objectUrlsRef = useRef<Set<string>>(new Set())
|
||||
const uploadedImagesRef = useRef(new Map<string, UploadedMessageEditorImage>())
|
||||
const placeholderRef = useRef(t("conversation.editorPlaceholder"))
|
||||
const isCustomer = variant === "customer"
|
||||
const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
|
||||
const isSending = sending || draftSending
|
||||
|
||||
placeholderRef.current = t("conversation.editorPlaceholder")
|
||||
|
||||
@@ -118,31 +141,21 @@ export function SharedMessageEditor({
|
||||
}, [quickReplies])
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrls = objectUrlsRef.current
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
revokeEditorObjectUrls(objectUrls)
|
||||
mountedRef.current = false
|
||||
suggestionRequestRef.current += 1
|
||||
// A supplied store belongs to the parent and survives editor unmounts.
|
||||
if (!draftStore) localDraftStore.clear()
|
||||
}
|
||||
}, [])
|
||||
}, [draftStore, localDraftStore])
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
onSendRef.current = onSend
|
||||
}, [onSend])
|
||||
|
||||
useEffect(() => {
|
||||
onUploadImageRef.current = onUploadImage
|
||||
}, [onUploadImage])
|
||||
|
||||
useEffect(() => {
|
||||
onSendAttachmentRef.current = onSendAttachment
|
||||
}, [onSendAttachment])
|
||||
|
||||
useEffect(() => {
|
||||
onTypingRef.current = onTyping
|
||||
}, [onTyping])
|
||||
|
||||
useEffect(() => {
|
||||
setSuggestedReply(null)
|
||||
}, [conversationId])
|
||||
}, [onSend, onUploadImage, onSendAttachment, onTyping])
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
@@ -162,6 +175,8 @@ export function SharedMessageEditor({
|
||||
],
|
||||
content: "",
|
||||
onUpdate: ({ editor }) => {
|
||||
const displayed = displayedDraftRef.current
|
||||
if (displayed) drafts.update(displayed.draft, editor.getHTML())
|
||||
if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) {
|
||||
onTypingRef.current?.()
|
||||
}
|
||||
@@ -227,7 +242,7 @@ export function SharedMessageEditor({
|
||||
return false
|
||||
},
|
||||
handlePaste: (_view, event) => {
|
||||
if (disabled || isUploading) {
|
||||
if (disabled || isUploading || isSending) {
|
||||
return false
|
||||
}
|
||||
const imageFile = getClipboardImageFile(event.clipboardData)
|
||||
@@ -241,6 +256,49 @@ export function SharedMessageEditor({
|
||||
},
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
mutationGenerationRef.current += 1
|
||||
uploadingRef.current = false
|
||||
setLocalUploading(false)
|
||||
drafts.setScope(draftScope)
|
||||
}, [drafts, draftScope])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!editor) return
|
||||
const draft = drafts.get(draftKey)
|
||||
displayedDraftRef.current = { key: draftKey, draft }
|
||||
editor.commands.setContent(draft.html, { emitUpdate: false })
|
||||
// Image metadata is retained separately while a placeholder upload settles.
|
||||
for (const [title, uploaded] of draft.uploadedImages) {
|
||||
markEditorImageUploadedByTitle(editor, title, uploaded, draft.uploadedImages)
|
||||
}
|
||||
suggestionRequestRef.current += 1
|
||||
suggestionPendingRef.current = false
|
||||
setSuggestedReply(null)
|
||||
setIsGeneratingSuggestion(false)
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
}, [draftKey, draftScope, drafts, editor])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!editor) return
|
||||
// The parent-owned draft may finish sending after this editor was remounted.
|
||||
return drafts.subscribe((draft) => {
|
||||
if (editor.isDestroyed || displayedDraftRef.current?.draft !== draft) return
|
||||
if (editor.getHTML() !== draft.html) editor.commands.setContent(draft.html, { emitUpdate: false })
|
||||
for (const [title, uploaded] of draft.uploadedImages) {
|
||||
markEditorImageUploadedByTitle(editor, title, uploaded, draft.uploadedImages)
|
||||
}
|
||||
if (!draft.html) {
|
||||
setSuggestedReply(null)
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
}
|
||||
})
|
||||
}, [drafts, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
@@ -252,42 +310,45 @@ export function SharedMessageEditor({
|
||||
if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) {
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus()
|
||||
const draft = displayedDraftRef.current?.draft
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus()
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [disabled, editor, isUploading])
|
||||
|
||||
async function handleSend() {
|
||||
if (!editor || disabled || isUploading) {
|
||||
return
|
||||
}
|
||||
const displayed = displayedDraftRef.current
|
||||
if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return
|
||||
if (displayed.key !== contextRef.current.draftKey) return
|
||||
const rawHTML = editor.getHTML()
|
||||
if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) {
|
||||
return
|
||||
}
|
||||
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
|
||||
if (!isMeaningfulHTML(html)) {
|
||||
return
|
||||
}
|
||||
await onSendRef.current(html)
|
||||
editor.commands.clearContent(true)
|
||||
revokeEditorObjectUrls(objectUrlsRef.current)
|
||||
uploadedImagesRef.current.clear()
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
setSuggestedReply(null)
|
||||
if (!isCustomer) {
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus("end")
|
||||
})
|
||||
if (hasUploadingEditorImages(rawHTML, displayed.draft.uploadedImages)) return
|
||||
const html = buildSendableEditorHTML(rawHTML, displayed.draft.uploadedImages)
|
||||
if (!isMeaningfulHTML(html)) return
|
||||
|
||||
const sent = drafts.beginSend(displayed.key)
|
||||
if (!sent) return
|
||||
const send = onSendRef.current
|
||||
try {
|
||||
await send(html)
|
||||
const cleared = drafts.finishSend(sent, true)
|
||||
if (!cleared || !mountedRef.current || editor.isDestroyed || displayedDraftRef.current?.draft !== sent.draft) return
|
||||
editor.commands.setContent("", { emitUpdate: false })
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
setSuggestedReply(null)
|
||||
if (!isCustomer) focusEditorEnd()
|
||||
} catch {
|
||||
// The caller displays the error. Keep the entire draft, including images, for retry.
|
||||
drafts.finishSend(sent, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ""
|
||||
if (!file || !editor || disabled || isUploading) {
|
||||
if (!file || !editor || disabled || isUploading || isSending) {
|
||||
restoreFocusIfNeeded()
|
||||
return
|
||||
}
|
||||
@@ -295,68 +356,86 @@ export function SharedMessageEditor({
|
||||
}
|
||||
|
||||
async function insertUploadedImage(file: File) {
|
||||
if (!editor || disabled || isUploading) {
|
||||
return
|
||||
}
|
||||
const displayed = displayedDraftRef.current
|
||||
if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return
|
||||
const { key, draft } = displayed
|
||||
const upload = onUploadImageRef.current
|
||||
const generation = mutationGenerationRef.current
|
||||
const isVisible = () => mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft
|
||||
|
||||
shouldRestoreFocusRef.current = true
|
||||
uploadingRef.current = true
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
objectUrlsRef.current.add(objectUrl)
|
||||
draft.objectUrls.add(objectUrl)
|
||||
const placeholderId = `uploading-${generateUUID()}`
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: objectUrl,
|
||||
alt: file.name || "uploading-image",
|
||||
title: placeholderId,
|
||||
})
|
||||
.run()
|
||||
editor.chain().focus().setImage({
|
||||
src: objectUrl,
|
||||
alt: file.name || "uploading-image",
|
||||
title: placeholderId,
|
||||
}).run()
|
||||
setEditorImageUploadingByTitle(editor, placeholderId)
|
||||
|
||||
const removePlaceholder = () => {
|
||||
if (!drafts.contains(key, draft)) return
|
||||
if (isVisible()) {
|
||||
removeEditorImageByTitle(editor, placeholderId)
|
||||
} else {
|
||||
const template = document.createElement("template")
|
||||
template.innerHTML = draft.html
|
||||
for (const image of template.content.querySelectorAll("img")) {
|
||||
if (image.getAttribute("title") === placeholderId) image.remove()
|
||||
}
|
||||
drafts.update(draft, template.innerHTML)
|
||||
drafts.notify(draft)
|
||||
}
|
||||
revokeEditorObjectUrl(draft.objectUrls, objectUrl)
|
||||
}
|
||||
|
||||
try {
|
||||
setLocalUploading(true)
|
||||
const uploaded = await onUploadImageRef.current(file)
|
||||
const uploaded = await upload(file)
|
||||
if (!drafts.contains(key, draft)) return
|
||||
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
|
||||
removeEditorImageByTitle(editor, placeholderId)
|
||||
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
|
||||
removePlaceholder()
|
||||
return
|
||||
}
|
||||
markEditorImageUploadedByTitle(
|
||||
editor,
|
||||
placeholderId,
|
||||
uploaded,
|
||||
uploadedImagesRef.current
|
||||
)
|
||||
draft.uploadedImages.set(placeholderId, uploaded)
|
||||
drafts.notify(draft)
|
||||
} catch {
|
||||
removePlaceholder()
|
||||
} finally {
|
||||
setLocalUploading(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (!disabled && shouldRestoreFocusRef.current) {
|
||||
editor.commands.focus()
|
||||
}
|
||||
})
|
||||
if (generation === mutationGenerationRef.current) {
|
||||
uploadingRef.current = false
|
||||
if (mountedRef.current) setLocalUploading(false)
|
||||
}
|
||||
if (isVisible()) restoreFocusIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectAttachment(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ""
|
||||
if (!file || disabled || isUploading) {
|
||||
if (!file || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) {
|
||||
restoreFocusIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
const draft = displayedDraftRef.current?.draft
|
||||
const sendAttachment = onSendAttachmentRef.current
|
||||
const generation = mutationGenerationRef.current
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
||||
uploadingRef.current = true
|
||||
try {
|
||||
setLocalUploading(true)
|
||||
await onSendAttachmentRef.current(file)
|
||||
await sendAttachment(file)
|
||||
} catch {
|
||||
// The caller reports failed attachment sends; no text draft is discarded.
|
||||
} finally {
|
||||
setLocalUploading(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (editor && !disabled && shouldRestoreFocusRef.current) {
|
||||
editor.commands.focus()
|
||||
}
|
||||
})
|
||||
if (generation === mutationGenerationRef.current) {
|
||||
uploadingRef.current = false
|
||||
if (mountedRef.current) setLocalUploading(false)
|
||||
}
|
||||
if (displayedDraftRef.current?.draft === draft) restoreFocusIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,15 +475,23 @@ export function SharedMessageEditor({
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
focusEditorEnd()
|
||||
}
|
||||
|
||||
function focusEditorEnd() {
|
||||
const draft = displayedDraftRef.current?.draft
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus("end")
|
||||
if (editor && mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) {
|
||||
editor.commands.focus("end")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function restoreFocusIfNeeded() {
|
||||
if (editor && shouldRestoreFocusRef.current) {
|
||||
const draft = displayedDraftRef.current?.draft
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus()
|
||||
if (mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -412,17 +499,23 @@ export function SharedMessageEditor({
|
||||
const isGenerating = isGeneratingSuggestion || Boolean(aiCopilot?.loading)
|
||||
|
||||
async function handleTriggerSuggest() {
|
||||
if (!aiCopilot || disabled || isUploading || isGenerating) {
|
||||
return
|
||||
}
|
||||
if (!aiCopilot || disabled || isUploading || isGenerating || suggestionPendingRef.current) return
|
||||
const requestId = ++suggestionRequestRef.current
|
||||
suggestionPendingRef.current = true
|
||||
const context = contextRef.current
|
||||
setIsGeneratingSuggestion(true)
|
||||
try {
|
||||
const text = await aiCopilot.onSuggest()
|
||||
if (text && text.trim()) {
|
||||
if (mountedRef.current && requestId === suggestionRequestRef.current && context.draftKey === contextRef.current.draftKey && context.draftScope === contextRef.current.draftScope && text?.trim()) {
|
||||
setSuggestedReply(text.trim())
|
||||
}
|
||||
} catch {
|
||||
// The caller displays the suggestion error. Ignore any late failure here.
|
||||
} finally {
|
||||
setIsGeneratingSuggestion(false)
|
||||
if (mountedRef.current && requestId === suggestionRequestRef.current) {
|
||||
suggestionPendingRef.current = false
|
||||
setIsGeneratingSuggestion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,9 +525,7 @@ export function SharedMessageEditor({
|
||||
}
|
||||
editor.chain().focus().insertContent(suggestedReply).run()
|
||||
setSuggestedReply(null)
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus("end")
|
||||
})
|
||||
focusEditorEnd()
|
||||
}
|
||||
|
||||
function handleDismissSuggestedReply() {
|
||||
@@ -520,7 +611,7 @@ export function SharedMessageEditor({
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
||||
imageInputRef.current?.click()
|
||||
}}
|
||||
disabled={disabled || isUploading}
|
||||
disabled={disabled || isUploading || isSending}
|
||||
aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
|
||||
title={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
|
||||
>
|
||||
@@ -536,7 +627,7 @@ export function SharedMessageEditor({
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
||||
attachmentInputRef.current?.click()
|
||||
}}
|
||||
disabled={disabled || isUploading}
|
||||
disabled={disabled || isUploading || isSending}
|
||||
aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
|
||||
title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
|
||||
>
|
||||
@@ -640,21 +731,23 @@ export function SharedMessageEditor({
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={disabled || isUploading}
|
||||
aria-busy={isSending}
|
||||
disabled={disabled || isUploading || isSending}
|
||||
aria-label={t("conversation.send")}
|
||||
title={t("conversation.send")}
|
||||
className="bg-primary text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_24%,transparent)] hover:bg-primary hover:brightness-105"
|
||||
>
|
||||
<SendHorizonalIcon />
|
||||
{isSending ? <Loader2Icon className="animate-spin" /> : <SendHorizonalIcon />}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={disabled || isUploading}
|
||||
aria-busy={isSending}
|
||||
disabled={disabled || isUploading || isSending}
|
||||
>
|
||||
<SendIcon className="mr-1 size-4" />
|
||||
{isSending ? <Loader2Icon className="mr-1 size-4 animate-spin" /> : <SendIcon className="mr-1 size-4" />}
|
||||
{isUploading ? t("conversation.uploading") : t("conversation.send")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
@@ -196,6 +197,9 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(allowedModes.length > 1
|
||||
? [{ key: "separator-mode", type: "separator" } as const]
|
||||
: []),
|
||||
{
|
||||
key: "bold",
|
||||
label: t("editor.bold"),
|
||||
@@ -337,10 +341,6 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
|
||||
},
|
||||
]
|
||||
|
||||
if (allowedModes.length > 1) {
|
||||
actions.splice(1, 0, { key: "separator-mode", type: "separator" })
|
||||
}
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
@@ -65,17 +66,12 @@ export function ContentEditor({
|
||||
const confirm = useConfirm()
|
||||
const editorHeight = normalizeHeight(height)
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const effectiveScrollMode = fullscreen ? "editor" : scrollMode
|
||||
const normalizedAllowedModes = allowedModes.length > 0 ? allowedModes : CONTENT_MODE_OPTIONS
|
||||
const activeMode = normalizedAllowedModes.includes(value.mode)
|
||||
? value.mode
|
||||
: normalizedAllowedModes[0]
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen) {
|
||||
return
|
||||
@@ -185,7 +181,8 @@ export function ContentEditor({
|
||||
</div>
|
||||
)
|
||||
|
||||
if (fullscreen && mounted) {
|
||||
// Fullscreen starts false and can only be enabled by a browser event.
|
||||
if (fullscreen) {
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
@@ -26,9 +27,8 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
assignConversation,
|
||||
transferConversation,
|
||||
fetchAgentProfilesAll,
|
||||
type AdminAgentProfile,
|
||||
} from "@/lib/api/admin"
|
||||
import { fetchAvailabilityAgents, type OperatorAvailability } from "@/lib/api/support-availability"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type ConversationTransferDialogProps = {
|
||||
@@ -87,10 +87,10 @@ function ConversationTransferDialogBody({
|
||||
const t = useI18n()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loadingAgents, setLoadingAgents] = useState(false)
|
||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||
const [agents, setAgents] = useState<OperatorAvailability[]>([])
|
||||
const userOptions = agents.map((agent) => ({
|
||||
value: String(agent.userId),
|
||||
label: agent.displayName || agent.nickname || agent.username || t("conversationAction.agentFallback", { id: agent.userId }),
|
||||
label: agent.displayName || t("conversationAction.agentFallback", { id: agent.userId }),
|
||||
}))
|
||||
|
||||
const transferSchema = useMemo(
|
||||
@@ -123,18 +123,25 @@ function ConversationTransferDialogBody({
|
||||
}, [conversationId, reset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setAgents([])
|
||||
setLoadingAgents(false)
|
||||
return
|
||||
}
|
||||
setLoadingAgents(true)
|
||||
fetchAgentProfilesAll()
|
||||
let active = true
|
||||
fetchAvailabilityAgents(conversationId)
|
||||
.then((data) => {
|
||||
setAgents(data.filter((item) => item.serviceStatus === 0))
|
||||
if (active) setAgents(data.filter((item) => item.eligible))
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("conversationAction.loadAgentsFailed"))
|
||||
if (active) toast.error(error instanceof Error ? error.message : t("conversationAction.loadAgentsFailed"))
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingAgents(false)
|
||||
if (active) setLoadingAgents(false)
|
||||
})
|
||||
}, [t])
|
||||
return () => { active = false }
|
||||
}, [t, conversationId])
|
||||
|
||||
async function onFormSubmit(values: TransferForm) {
|
||||
if (!conversationId) {
|
||||
@@ -194,13 +201,15 @@ function ConversationTransferDialogBody({
|
||||
value={field.value}
|
||||
options={userOptions}
|
||||
placeholder={
|
||||
loadingAgents
|
||||
!conversationId
|
||||
? t("conversationAction.conversationMissing")
|
||||
: loadingAgents
|
||||
? t("conversationAction.loading")
|
||||
: t("conversationAction.selectTargetAgent")
|
||||
}
|
||||
searchPlaceholder={t("conversationAction.searchAgent")}
|
||||
emptyText={t("conversationAction.emptyAgents")}
|
||||
disabled={saving || loadingAgents}
|
||||
disabled={saving || loadingAgents || !conversationId}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
@@ -237,7 +246,7 @@ function ConversationTransferDialogBody({
|
||||
>
|
||||
{t("conversationAction.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Button type="submit" disabled={saving || loadingAgents || !conversationId || agents.length === 0}>
|
||||
<ArrowRightLeftIcon />
|
||||
{saving
|
||||
? isAssign
|
||||
|
||||
164
web/components/lifecycle-regressions.test.mjs
Normal file
164
web/components/lifecycle-regressions.test.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import test from "node:test"
|
||||
|
||||
import { deferred, loadTypeScriptModule } from "../lib/test-support/load-typescript-module.mjs"
|
||||
|
||||
const jsx = (type, props) => ({ type, props })
|
||||
const jsxRuntime = { jsx, jsxs: jsx, Fragment: "Fragment" }
|
||||
const i18n = { useI18n: () => (key) => key }
|
||||
|
||||
// Minimal deterministic hook driver: render-phase resets rerender before effects,
|
||||
// and dependency changes/unmount run prior effect cleanup. No browser or network.
|
||||
function hooks() {
|
||||
const states = [], effects = []
|
||||
let stateIndex, effectIndex, dirty, pending
|
||||
const react = {
|
||||
useState(initial) {
|
||||
const index = stateIndex++
|
||||
if (!(index in states)) states[index] = typeof initial === "function" ? initial() : initial
|
||||
return [states[index], (next) => {
|
||||
const value = typeof next === "function" ? next(states[index]) : next
|
||||
if (!Object.is(value, states[index])) { states[index] = value; dirty = true }
|
||||
}]
|
||||
},
|
||||
useEffect(run, deps) {
|
||||
const index = effectIndex++
|
||||
if (!effects[index] || deps.some((dep, i) => !Object.is(dep, effects[index].deps[i]))) {
|
||||
pending.set(index, { run, deps })
|
||||
}
|
||||
},
|
||||
useMemo: (fn) => fn(),
|
||||
useCallback: (fn) => fn,
|
||||
useRef: (value) => ({ current: value }),
|
||||
createContext: () => ({ Provider: "Provider" }),
|
||||
}
|
||||
return { react,
|
||||
render(component, props) {
|
||||
let result, attempts = 0
|
||||
do {
|
||||
assert.ok(attempts++ < 10, "render-phase state must settle")
|
||||
stateIndex = 0; effectIndex = 0; dirty = false; pending = new Map()
|
||||
result = component(props)
|
||||
} while (dirty)
|
||||
for (const [index, effect] of pending) {
|
||||
effects[index]?.cleanup?.()
|
||||
effects[index] = { deps: effect.deps, cleanup: effect.run() }
|
||||
}
|
||||
return result
|
||||
},
|
||||
unmount() { effects.forEach((effect) => effect.cleanup?.()) },
|
||||
}
|
||||
}
|
||||
function find(node, type) {
|
||||
if (!node || typeof node !== "object") return undefined
|
||||
if (node?.type === type) return node
|
||||
const children = node?.props?.children
|
||||
for (const child of Array.isArray(children) ? children : [children]) {
|
||||
const result = find(child, type)
|
||||
if (result) return result
|
||||
}
|
||||
}
|
||||
const flush = async () => { await Promise.resolve(); await Promise.resolve() }
|
||||
|
||||
test("bulk move resets selection on reopen/base switch and ignores obsolete directory responses", async () => {
|
||||
const h = hooks(), requests = []
|
||||
const { KnowledgeBulkMoveDialog } = loadTypeScriptModule(new URL("../app/(dashboard)/dashboard/knowledge/_components/knowledge-bulk-move-dialog.tsx", import.meta.url), {
|
||||
react: h.react, "react/jsx-runtime": jsxRuntime,
|
||||
"@/i18n/provider": i18n,
|
||||
"@/components/option-combobox": { OptionCombobox: "Combobox" },
|
||||
"@/components/project-dialog": { ProjectDialog: "Dialog" },
|
||||
"@/components/ui/button": { Button: "Button" },
|
||||
"@/lib/api/admin": { fetchKnowledgeDirectories: (id) => { const request = deferred(); requests.push({ id, ...request }); return request.promise } },
|
||||
})
|
||||
let props = { open: true, knowledgeBaseId: 1, moving: false, selectedCount: 2, onOpenChange() {}, onSubmit() {} }
|
||||
let tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
find(tree, "Combobox").props.onChange("42")
|
||||
tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
assert.equal(find(tree, "Combobox").props.value, "42")
|
||||
props = { ...props, open: false }
|
||||
h.render(KnowledgeBulkMoveDialog, props)
|
||||
props = { ...props, open: true }
|
||||
tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
assert.equal(find(tree, "Combobox").props.value, "0")
|
||||
requests[0].resolve([{ id: 99, name: "obsolete" }]); await flush()
|
||||
tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
assert.equal(find(tree, "Combobox").props.options.length, 1)
|
||||
requests[1].resolve([{ id: 11, name: "previous base" }]); await flush()
|
||||
tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
assert.equal(find(tree, "Combobox").props.options[1].label, "previous base")
|
||||
find(tree, "Combobox").props.onChange("42")
|
||||
props = { ...props, knowledgeBaseId: 2 }
|
||||
tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
assert.equal(find(tree, "Combobox").props.value, "0")
|
||||
assert.equal(find(tree, "Combobox").props.options.length, 1)
|
||||
assert.equal(requests.at(-1).id, 2)
|
||||
requests.at(-1).resolve([{ id: 22, name: "current" }]); await flush()
|
||||
tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
assert.equal(find(tree, "Combobox").props.options[1].label, "current")
|
||||
h.unmount()
|
||||
requests[1].resolve([{ id: 98, name: "old reopening" }]); await flush()
|
||||
})
|
||||
|
||||
test("palette snapshots hydrate consistently and notify same-tab and storage changes", () => {
|
||||
const events = new EventTarget(), values = new Map([["dashboard_palette", "green"]])
|
||||
const document = { documentElement: { dataset: {} } }
|
||||
let store, effect
|
||||
const { PaletteToggle } = loadTypeScriptModule(new URL("./palette-toggle.tsx", import.meta.url), {
|
||||
react: {
|
||||
useSyncExternalStore: (subscribe, getSnapshot, getServerSnapshot) => { store = { subscribe, getSnapshot, getServerSnapshot }; return getSnapshot() },
|
||||
useEffect: (run) => { effect = run },
|
||||
},
|
||||
"react/jsx-runtime": jsxRuntime, "@/i18n/provider": i18n,
|
||||
"lucide-react": { CircleIcon: "Circle", DropletsIcon: "Droplets", PaletteIcon: "Palette" },
|
||||
"@/components/ui/button": { Button: "Button" },
|
||||
"@/components/ui/dropdown-menu": Object.fromEntries(["DropdownMenu", "DropdownMenuContent", "DropdownMenuRadioGroup", "DropdownMenuRadioItem", "DropdownMenuTrigger"].map((name) => [name, name])),
|
||||
}, { window: { addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events), dispatchEvent: events.dispatchEvent.bind(events), localStorage: { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value) } }, document, Event })
|
||||
const tree = PaletteToggle()
|
||||
assert.equal(store.getServerSnapshot(), "plain")
|
||||
assert.equal(store.getSnapshot(), "green")
|
||||
effect(); assert.equal(document.documentElement.dataset.palette, "green")
|
||||
let changes = 0
|
||||
const unsubscribe = store.subscribe(() => { changes++ })
|
||||
find(tree, "DropdownMenuRadioGroup").props.onValueChange("blue")
|
||||
assert.equal(changes, 1)
|
||||
assert.equal(store.getSnapshot(), "blue")
|
||||
assert.equal(document.documentElement.dataset.palette, "blue")
|
||||
const storageEvent = (key) => { const event = new Event("storage"); Object.defineProperty(event, "key", { value: key }); events.dispatchEvent(event) }
|
||||
storageEvent("unrelated"); assert.equal(changes, 1)
|
||||
values.set("dashboard_palette", "gray"); storageEvent("dashboard_palette")
|
||||
assert.equal(changes, 2); assert.equal(store.getSnapshot(), "gray")
|
||||
unsubscribe(); storageEvent(null); assert.equal(changes, 2)
|
||||
})
|
||||
|
||||
test("initial notification count ignores completion after provider unmount", async () => {
|
||||
const h = hooks(), response = deferred()
|
||||
const { NotificationProvider } = loadTypeScriptModule(new URL("./notification-provider.tsx", import.meta.url), {
|
||||
react: h.react, "react/jsx-runtime": jsxRuntime,
|
||||
"@/i18n/provider": { ...i18n, useAppLocale: () => ({ locale: "en-US" }) },
|
||||
"next/navigation": { useRouter: () => ({ push() {} }) },
|
||||
sonner: { toast() {} },
|
||||
"@/lib/auth": { readSession: () => ({ user: { id: 1 }, accessToken: "isolated-fixture" }) },
|
||||
"@/lib/api/notification": { fetchNotificationUnreadCount: () => response.promise },
|
||||
"@/lib/realtime-connection": { createRealtimeConnectionManager: () => ({ connect() {}, disconnect() {} }) },
|
||||
"@/lib/notification-i18n": { localizeNotificationItem: (item) => item },
|
||||
})
|
||||
let tree = h.render(NotificationProvider, { children: null })
|
||||
assert.equal(tree.props.value.unreadCount, 0)
|
||||
h.unmount(); response.resolve({ unreadCount: 999 }); await flush()
|
||||
tree = h.render(NotificationProvider, { children: null })
|
||||
assert.equal(tree.props.value.unreadCount, 0)
|
||||
})
|
||||
|
||||
test("editor fullscreen and channel origin do not require mount-state effects", async () => {
|
||||
const editor = await readFile(new URL("./content-editor/index.tsx", import.meta.url), "utf8")
|
||||
assert.match(editor, /const \[fullscreen, setFullscreen\] = useState\(false\)/)
|
||||
assert.match(editor, /if \(fullscreen\) \{\s*return createPortal\(content, document.body\)/)
|
||||
assert.doesNotMatch(editor, /setMounted/)
|
||||
const html = await readFile(new URL("./content-editor/html-editor.tsx", import.meta.url), "utf8")
|
||||
assert.match(html, /allowedModes.length > 1\s*\? \[\{ key: "separator-mode", type: "separator" \} as const\]/)
|
||||
assert.doesNotMatch(html, /actions\.splice/)
|
||||
const channel = await readFile(new URL("../app/(dashboard)/dashboard/channels/_components/edit.tsx", import.meta.url), "utf8")
|
||||
assert.match(channel, /useSyncExternalStore\(subscribeToOrigin, readBrowserOrigin, readServerOrigin\)/)
|
||||
assert.match(channel, /const readServerOrigin = \(\) => ""/)
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import test from "node:test"
|
||||
@@ -5,9 +6,9 @@ import test from "node:test"
|
||||
const source = await readFile(new URL("./nav-main.tsx", import.meta.url), "utf8")
|
||||
|
||||
test("dashboard nav items expose full labels when visual text is truncated", () => {
|
||||
assert.match(source, /<span\s+title=\{title\}>\{title\}<\/span>/)
|
||||
assert.match(source, /<span\b[^>]*\btitle=\{title\}[^>]*>\s*\{title\}\s*<\/span>/)
|
||||
assert.match(source, /tooltip=\{item\.title\}/)
|
||||
assert.match(source, /<span\s+title=\{item\.title\}>\{item\.title\}<\/span>/)
|
||||
assert.match(source, /<span\b[^>]*\btitle=\{item\.title\}[^>]*>\s*\{item\.title\}\s*<\/span>/)
|
||||
})
|
||||
|
||||
test("collapsed dashboard nav sections expose submenu flyouts on hover", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
@@ -73,10 +74,14 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
currentUserIdRef.current = readSession()?.user.id ?? 0
|
||||
void refreshUnreadCount().catch(() => {
|
||||
setUnreadCount(0)
|
||||
let active = true
|
||||
void fetchNotificationUnreadCount().then((result) => {
|
||||
if (active) setUnreadCount(result.unreadCount)
|
||||
}).catch(() => {
|
||||
if (active) setUnreadCount(0)
|
||||
})
|
||||
}, [refreshUnreadCount])
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const realtime = createRealtimeConnectionManager({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
ShieldAlertIcon,
|
||||
SparklesIcon,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -22,9 +21,7 @@ import {
|
||||
} from "@/components/ui/popover"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
applySetupQuickFix,
|
||||
fetchSetupReadiness,
|
||||
type SetupQuickFixAction,
|
||||
type SetupReadinessResponse,
|
||||
} from "@/lib/api/setup-readiness"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -45,7 +42,6 @@ export function OperatorReadinessIndicator({
|
||||
const t = useI18n()
|
||||
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fixing, setFixing] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const loadReadiness = useCallback(async () => {
|
||||
@@ -66,30 +62,14 @@ export function OperatorReadinessIndicator({
|
||||
}
|
||||
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
const interval = window.setInterval(() => {
|
||||
void loadReadiness()
|
||||
}, 45000)
|
||||
if (document.visibilityState === "visible") void loadReadiness()
|
||||
}, 60000)
|
||||
return () => {
|
||||
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
}, [loadReadiness])
|
||||
|
||||
async function handleQuickFix(action: SetupQuickFixAction) {
|
||||
setFixing(true)
|
||||
try {
|
||||
const res = await applySetupQuickFix({ action })
|
||||
setReadiness(res.readiness)
|
||||
notifySetupReadinessUpdated()
|
||||
toast.success(t("setupWizard.quickFixSuccess"))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||
)
|
||||
} finally {
|
||||
setFixing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !readiness) {
|
||||
return null
|
||||
}
|
||||
@@ -225,43 +205,10 @@ export function OperatorReadinessIndicator({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{!operatorOk && canEnableMyProfile ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full gap-1.5 rounded-md text-xs"
|
||||
disabled={fixing}
|
||||
onClick={() =>
|
||||
void handleQuickFix(
|
||||
chainOk || !canQuickSetupAll
|
||||
? "enable_my_operator_profile"
|
||||
: "quick_setup_all",
|
||||
)
|
||||
}
|
||||
>
|
||||
{fixing ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{chainOk || !canQuickSetupAll
|
||||
? t("setupWizard.enableMyReceptionBtn")
|
||||
: t("setupWizard.quickSetupAll")}
|
||||
</span>
|
||||
</Button>
|
||||
) : !chainOk && canQuickSetupAll ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full gap-1.5 rounded-md text-xs"
|
||||
disabled={fixing}
|
||||
onClick={() => void handleQuickFix("quick_setup_all")}
|
||||
>
|
||||
{fixing ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-3.5" />
|
||||
)}
|
||||
<span>{t("setupWizard.quickSetupAll")}</span>
|
||||
{(!operatorOk && canEnableMyProfile) || (!chainOk && canQuickSetupAll) ? (
|
||||
<Button size="sm" className="w-full gap-1.5 rounded-md text-xs" render={<Link href="/dashboard/setup" />}>
|
||||
<SparklesIcon className="size-3.5" />
|
||||
<span>{t("setupWizard.preview.review")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import { useState, type ReactNode } from "react"
|
||||
@@ -31,6 +32,7 @@ export type ComboboxOption = {
|
||||
}
|
||||
|
||||
type CommonOptionComboboxProps = {
|
||||
ariaLabel?: string
|
||||
options: ComboboxOption[]
|
||||
placeholder: string
|
||||
searchPlaceholder?: string
|
||||
@@ -62,6 +64,7 @@ type OptionComboboxProps = CommonOptionComboboxProps &
|
||||
export function OptionCombobox(props: OptionComboboxProps) {
|
||||
const {
|
||||
options,
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyText,
|
||||
@@ -114,6 +117,7 @@ export function OptionCombobox(props: OptionComboboxProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-label={ariaLabel}
|
||||
className={cn("m-0 w-full justify-between font-normal", triggerClassName)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
import { CircleIcon, DropletsIcon, PaletteIcon } from "lucide-react"
|
||||
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
@@ -17,6 +18,7 @@ type PaletteMode = "plain" | "blue" | "green" | "gray"
|
||||
|
||||
const PALETTE_STORAGE_KEY = "dashboard_palette"
|
||||
const DEFAULT_PALETTE: PaletteMode = "plain"
|
||||
const PALETTE_CHANGE_EVENT = "dashboard-palette-change"
|
||||
|
||||
const paletteOptions: Array<{
|
||||
value: PaletteMode
|
||||
@@ -59,27 +61,39 @@ function readPalette(): PaletteMode {
|
||||
: DEFAULT_PALETTE
|
||||
}
|
||||
|
||||
function subscribeToPalette(onChange: () => void) {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === null || event.key === PALETTE_STORAGE_KEY) onChange()
|
||||
}
|
||||
window.addEventListener("storage", onStorage)
|
||||
window.addEventListener(PALETTE_CHANGE_EVENT, onChange)
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage)
|
||||
window.removeEventListener(PALETTE_CHANGE_EVENT, onChange)
|
||||
}
|
||||
}
|
||||
|
||||
const readServerPalette = () => DEFAULT_PALETTE
|
||||
|
||||
function applyPalette(value: PaletteMode) {
|
||||
document.documentElement.dataset.palette = value
|
||||
window.localStorage.setItem(PALETTE_STORAGE_KEY, value)
|
||||
window.dispatchEvent(new Event(PALETTE_CHANGE_EVENT))
|
||||
}
|
||||
|
||||
export function PaletteToggle() {
|
||||
const t = useI18n()
|
||||
const [palette, setPalette] = useState<PaletteMode>(DEFAULT_PALETTE)
|
||||
const palette = useSyncExternalStore(subscribeToPalette, readPalette, readServerPalette)
|
||||
|
||||
useEffect(() => {
|
||||
const storedPalette = readPalette()
|
||||
setPalette(storedPalette)
|
||||
applyPalette(storedPalette)
|
||||
}, [])
|
||||
document.documentElement.dataset.palette = palette
|
||||
}, [palette])
|
||||
|
||||
function handleChange(value: string) {
|
||||
const nextPalette: PaletteMode =
|
||||
value === "blue" || value === "green" || value === "gray"
|
||||
? value
|
||||
: "plain"
|
||||
setPalette(nextPalette)
|
||||
applyPalette(nextPalette)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: invalidate stale profile/sign-out work and clear private account state.
|
||||
"use client"
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"
|
||||
import { Fragment, createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"
|
||||
|
||||
import { fetchProfile, logout } from "@/lib/api/auth"
|
||||
import {
|
||||
@@ -11,6 +13,8 @@ import {
|
||||
writeSession,
|
||||
type AuthSession,
|
||||
} from "@/lib/auth"
|
||||
import { createSessionRequestGuard } from "@/lib/session-request-guard"
|
||||
import { resetAgentConversations } from "@/lib/stores/agent-conversations"
|
||||
|
||||
type SessionContextValue = {
|
||||
session: AuthSession | null
|
||||
@@ -22,21 +26,41 @@ type SessionContextValue = {
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null)
|
||||
|
||||
function accountKey(session: AuthSession | null) {
|
||||
return session ? `${session.user.userType}:${session.user.id}` : "anonymous"
|
||||
}
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<AuthSession | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [signingOut, setSigningOut] = useState(false)
|
||||
const [requestGuard] = useState(() => createSessionRequestGuard(readSession))
|
||||
const identityRef = useRef<string | null>(null)
|
||||
const signOutPendingRef = useRef(false)
|
||||
const signOutGenerationRef = useRef(0)
|
||||
|
||||
const applySession = useCallback((next: AuthSession | null) => {
|
||||
const nextIdentity = accountKey(next)
|
||||
if (identityRef.current !== nextIdentity) {
|
||||
resetAgentConversations()
|
||||
identityRef.current = nextIdentity
|
||||
}
|
||||
setSession(next)
|
||||
setReady(true)
|
||||
}, [])
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
const stored = readSession()
|
||||
if (!stored) {
|
||||
setSession(null)
|
||||
setReady(true)
|
||||
requestGuard.invalidate()
|
||||
applySession(null)
|
||||
return
|
||||
}
|
||||
const isCurrent = requestGuard.begin(stored)
|
||||
|
||||
try {
|
||||
const profile = await fetchProfile()
|
||||
if (!isCurrent()) return
|
||||
const nextSession: AuthSession = {
|
||||
...stored,
|
||||
user: profile.user,
|
||||
@@ -45,60 +69,76 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
accessToken: profile.accessToken || stored.accessToken,
|
||||
expiresAt: profile.expiresAt || stored.expiresAt,
|
||||
}
|
||||
applySession(nextSession)
|
||||
writeSession(nextSession)
|
||||
setSession(nextSession)
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return
|
||||
const errorCode = (error as Error & { errorCode?: number }).errorCode
|
||||
if (errorCode === 3000 || errorCode === 3002) {
|
||||
clearSession()
|
||||
setSession(null)
|
||||
applySession(null)
|
||||
} else {
|
||||
setSession(stored)
|
||||
applySession(stored)
|
||||
}
|
||||
} finally {
|
||||
setReady(true)
|
||||
if (isCurrent()) setReady(true)
|
||||
}
|
||||
}, [])
|
||||
}, [applySession, requestGuard])
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
if (signingOut) return
|
||||
if (signOutPendingRef.current) return
|
||||
signOutPendingRef.current = true
|
||||
const generation = ++signOutGenerationRef.current
|
||||
setSigningOut(true)
|
||||
requestGuard.invalidate()
|
||||
// Start the request with the old token, then immediately remove local access.
|
||||
const pending = logout()
|
||||
clearSession()
|
||||
applySession(null)
|
||||
try {
|
||||
await logout()
|
||||
await pending
|
||||
} catch {
|
||||
clearSession()
|
||||
// Local sign-out already completed; a late error cannot clear a new login.
|
||||
} finally {
|
||||
setSession(null)
|
||||
setSigningOut(false)
|
||||
if (generation === signOutGenerationRef.current) {
|
||||
signOutPendingRef.current = false
|
||||
setSigningOut(false)
|
||||
}
|
||||
}
|
||||
}, [signingOut])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSession()
|
||||
}, [refreshSession])
|
||||
}, [applySession, requestGuard])
|
||||
|
||||
useEffect(() => {
|
||||
const syncSession = () => {
|
||||
setSession(readSession())
|
||||
setReady(true)
|
||||
requestGuard.invalidate()
|
||||
const current = readSession()
|
||||
if (current && signOutPendingRef.current) {
|
||||
signOutGenerationRef.current += 1
|
||||
signOutPendingRef.current = false
|
||||
setSigningOut(false)
|
||||
}
|
||||
applySession(current)
|
||||
}
|
||||
const syncOtherTab = (event: StorageEvent) => {
|
||||
if (event.key === "agent-desk-session") syncSession()
|
||||
if (event.key === "agent-desk-session" || event.key === null) syncSession()
|
||||
}
|
||||
|
||||
window.addEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
|
||||
window.addEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession)
|
||||
window.addEventListener("storage", syncOtherTab)
|
||||
applySession(readSession())
|
||||
void refreshSession()
|
||||
return () => {
|
||||
requestGuard.invalidate()
|
||||
signOutGenerationRef.current += 1
|
||||
window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
|
||||
window.removeEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession)
|
||||
window.removeEventListener("storage", syncOtherTab)
|
||||
}
|
||||
}, [])
|
||||
}, [applySession, refreshSession, requestGuard])
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={{ session, ready, signingOut, refreshSession, signOut }}>
|
||||
{children}
|
||||
<Fragment key={accountKey(session)}>{children}</Fragment>
|
||||
</SessionContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { OperatorStatusControl } from "@/components/support-availability/operator-status"
|
||||
import { OperatorReadinessIndicator } from "@/components/operator-readiness-indicator"
|
||||
import { PaletteToggle } from "@/components/palette-toggle"
|
||||
import { RealtimeConnectionStatus } from "@/components/realtime-connection-status"
|
||||
@@ -78,6 +80,7 @@ export function SiteHeader() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
<OperatorStatusControl compact />
|
||||
<OperatorReadinessIndicator compact />
|
||||
<PaletteToggle />
|
||||
<ThemeToggle />
|
||||
|
||||
112
web/components/support-availability/lifecycle.test.mjs
Normal file
112
web/components/support-availability/lifecycle.test.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import test from "node:test"
|
||||
import vm from "node:vm"
|
||||
import ts from "typescript"
|
||||
|
||||
async function compile(path, imports) {
|
||||
const source = await readFile(new URL(path, import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX } })
|
||||
const loaded = { exports: {} }
|
||||
vm.runInNewContext(compiled.outputText, { exports: loaded.exports, module: loaded, Intl, Date, Set, AbortController, setTimeout, clearTimeout, require: name => { assert.ok(name in imports, `unexpected import ${name}`); return imports[name] } })
|
||||
return loaded.exports
|
||||
}
|
||||
const helpers = await compile("../../lib/support-availability.ts", {})
|
||||
const enums = await compile("../../lib/generated/enums.ts", {})
|
||||
const mappings = await compile("../../lib/support-availability-i18n.ts", { "@/lib/generated/enums": enums })
|
||||
const guard = await compile("../../lib/session-request-guard.ts", {})
|
||||
const sessionA = { user: { id: 1 }, accessToken: "fixture-a" }
|
||||
const sessionB = { user: { id: 2 }, accessToken: "fixture-b" }
|
||||
const calendar = { teamId: 5, mode: "recurring", timezone: "UTC", effectiveTimezone: "UTC", deploymentTimezone: "UTC", weekly: [], exceptions: [] }
|
||||
const operator = { userId: 1, serviceStatus: 1, effectiveServiceStatus: 1, returnServiceStatus: 0, reason: "busy", online: true, calendar }
|
||||
function deferred() { let resolve; const promise = new Promise(r => { resolve = r }); return { promise, resolve } }
|
||||
function find(node, predicate) {
|
||||
if (!node || typeof node !== "object") return null
|
||||
if (predicate(node)) return node
|
||||
for (const value of Object.values(node.props ?? {})) {
|
||||
for (const child of Array.isArray(value) ? value.flat(Infinity) : [value]) {
|
||||
const found = find(child, predicate)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
async function harness(kind, options = {}) {
|
||||
let session = sessionA, stateIndex = 0
|
||||
const effects = [], calls = [], saved = []
|
||||
const apiResult = options.apiResult ?? Promise.resolve(operator)
|
||||
const formValues = options.formValues ?? { serviceStatus: "1", start: "", end: "", startOccurrence: "", endOccurrence: "" }
|
||||
const jsx = (type, props, key) => ({ type, props, key })
|
||||
const controls = new Proxy({}, { get: (_target, name) => name })
|
||||
const imports = {
|
||||
"react/jsx-runtime": { jsx, jsxs: jsx, Fragment: "Fragment" },
|
||||
react: { useRef: value => ({ current: value }), useState: value => { const current = stateIndex++; return [current === 0 && options.editing ? true : typeof value === "function" ? value() : value, () => {}] }, useEffect: fn => { effects.push(fn) }, useCallback: fn => fn },
|
||||
"react-hook-form": { Controller: "Controller", useFieldArray: () => ({ fields: [] }), useForm: () => ({ control: {}, register: () => ({}), handleSubmit: fn => fn, watch: key => key ? formValues[key] : calendar, setError: () => {}, clearErrors: () => {}, reset: () => {}, formState: { errors: {}, isDirty: true } }) },
|
||||
"lucide-react": controls, "next/link": { default: "Link" }, sonner: { toast: { success: () => {} } },
|
||||
"@/components/auth-provider": { useAuth: () => ({ session }) },
|
||||
"@/components/confirm-provider": { useConfirm: () => options.confirm ?? (async () => true) },
|
||||
"@/components/option-combobox": controls, "@/components/project-dialog": controls,
|
||||
"@/components/ui/badge": controls, "@/components/ui/button": controls, "@/components/ui/field": controls, "@/components/ui/input": controls, "@/components/ui/switch": controls,
|
||||
"@/i18n/provider": { useI18n: () => key => key, useAppLocale: () => ({ locale: "en-US" }) },
|
||||
"@/lib/auth": { readSession: () => session }, "@/lib/session-request-guard": guard,
|
||||
"@/lib/generated/enums": enums, "@/lib/support-availability-i18n": mappings, "@/lib/support-availability": helpers,
|
||||
"@/components/support-availability/operator-status": { notifyAvailabilityUpdated: () => {} },
|
||||
"@/lib/api/support-availability": { updateMyAvailability: payload => { calls.push(payload); return apiResult }, updateAgentAvailability: (id, payload) => { calls.push({ id, payload }); return apiResult }, updateSupportCalendar: payload => { calls.push(payload); return options.apiResult ?? Promise.resolve(calendar) } },
|
||||
}
|
||||
const file = kind === "operator" ? "./operator-status.tsx" : "../../app/(dashboard)/dashboard/support-availability/_components/calendar-editor.tsx"
|
||||
const loaded = await compile(file, imports)
|
||||
const root = kind === "operator" ? loaded.OperatorStatusDialog({ value: operator, onClose() {}, onSaved: value => saved.push(value) }) : loaded.CalendarEditor({ initial: calendar, writable: options.writable ?? true, onDirtyChange() {}, onSaved: value => saved.push(value) })
|
||||
// Execute only lifetime effects: calendar also registers browser navigation listeners.
|
||||
const lifetime = effects.filter(fn => fn.toString().includes("mounted.current = true"))
|
||||
assert.equal(lifetime.length, 1)
|
||||
const setup = () => lifetime[0]()
|
||||
const submit = find(root, node => node.type === "form").props.onSubmit
|
||||
return { calls, saved, setup, submit: () => submit(kind === "operator" ? formValues : calendar), switchSession: () => { session = sessionB } }
|
||||
}
|
||||
|
||||
test("operator save still completes after StrictMode setup-cleanup-setup", async () => {
|
||||
const h = await harness("operator")
|
||||
h.setup()(); h.setup()
|
||||
await h.submit()
|
||||
assert.equal(h.calls.length, 1)
|
||||
assert.equal(h.saved.length, 1)
|
||||
})
|
||||
test("operator save is single-flight and a late prior-session result is ignored", async () => {
|
||||
const pending = deferred()
|
||||
const h = await harness("operator", { apiResult: pending.promise })
|
||||
h.setup()
|
||||
const first = h.submit(), second = h.submit()
|
||||
h.switchSession(); pending.resolve(operator)
|
||||
await Promise.all([first, second])
|
||||
assert.equal(h.calls.length, 1)
|
||||
assert.equal(h.saved.length, 0)
|
||||
})
|
||||
test("operator controls from a stale session cannot submit a mutation", async () => {
|
||||
const h = await harness("operator")
|
||||
h.setup(); h.switchSession(); await h.submit()
|
||||
assert.equal(h.calls.length, 0)
|
||||
})
|
||||
test("read-only calendar or preview Enter cannot save even when form appears dirty", async () => {
|
||||
for (const writable of [false, true]) {
|
||||
const h = await harness("calendar", { writable })
|
||||
h.setup(); await h.submit()
|
||||
assert.equal(h.calls.length, 0)
|
||||
}
|
||||
})
|
||||
test("calendar confirmation cannot cross session or unmount boundaries", async () => {
|
||||
for (const action of ["session", "unmount"]) {
|
||||
const approval = deferred()
|
||||
const h = await harness("calendar", { editing: true, confirm: () => approval.promise })
|
||||
const cleanup = h.setup()
|
||||
const submit = h.submit()
|
||||
if (action === "session") h.switchSession(); else cleanup()
|
||||
approval.resolve(true); await submit
|
||||
assert.equal(h.calls.length, 0)
|
||||
}
|
||||
})
|
||||
test("calendar save completes after StrictMode restart and explicit confirmation", async () => {
|
||||
const h = await harness("calendar", { editing: true })
|
||||
h.setup()(); h.setup(); await h.submit()
|
||||
assert.equal(h.calls.length, 1)
|
||||
assert.equal(h.saved.length, 1)
|
||||
})
|
||||
132
web/components/support-availability/operator-status.tsx
Normal file
132
web/components/support-availability/operator-status.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import { Clock3Icon, Loader2Icon, RefreshCwIcon } from "lucide-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useConfirm } from "@/components/confirm-provider"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { fetchAvailabilityAgents, fetchMyAvailability, updateAgentAvailability, updateMyAvailability, type OperatorAvailability } from "@/lib/api/support-availability"
|
||||
import { isSameSession } from "@/lib/session-request-guard"
|
||||
import { readSession } from "@/lib/auth"
|
||||
import { ServiceStatus } from "@/lib/generated/enums"
|
||||
import { availabilityReasonLabel, availabilityStatusLabel, availabilityStatusOptions } from "@/lib/support-availability-i18n"
|
||||
import { formatAvailabilityTime, localDateTimeCandidates, selectedLocalDateTimeISO, toLocalDateTime } from "@/lib/support-availability"
|
||||
|
||||
export const AVAILABILITY_UPDATED_EVENT = "support-availability-updated"
|
||||
export function notifyAvailabilityUpdated() { window.dispatchEvent(new Event(AVAILABILITY_UPDATED_EVENT)) }
|
||||
|
||||
export function OperatorStatusControl(props: { userId?: number; compact?: boolean }) {
|
||||
const { session } = useAuth()
|
||||
return <OperatorStatusControlContent key={session?.user.id ?? "signed-out"} {...props} />
|
||||
}
|
||||
function OperatorStatusControlContent({ userId, compact = false }: { userId?: number; compact?: boolean }) {
|
||||
const t = useI18n()
|
||||
const { session } = useAuth()
|
||||
const [value, setValue] = useState<OperatorAvailability | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const accountId = session?.user.id
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let inFlight = false
|
||||
let controller: AbortController | null = null
|
||||
async function load() {
|
||||
if (inFlight || !accountId) return
|
||||
inFlight = true
|
||||
controller = new AbortController()
|
||||
try {
|
||||
const data = userId ? (await fetchAvailabilityAgents(undefined, controller.signal)).find(item => item.userId === userId) ?? null : await fetchMyAvailability(controller.signal)
|
||||
if (active) { setValue(data); setError(false) }
|
||||
} catch { if (active) { setError(true); setValue(null) } }
|
||||
finally { inFlight = false; if (active) setLoading(false) }
|
||||
}
|
||||
void load()
|
||||
const refresh = () => { if (document.visibilityState === "visible") void load() }
|
||||
window.addEventListener(AVAILABILITY_UPDATED_EVENT, refresh)
|
||||
window.addEventListener("focus", refresh)
|
||||
const timer = window.setInterval(refresh, 30000)
|
||||
return () => { active = false; controller?.abort(); window.clearInterval(timer); window.removeEventListener("focus", refresh); window.removeEventListener(AVAILABILITY_UPDATED_EVENT, refresh) }
|
||||
}, [accountId, userId, refreshKey])
|
||||
// Responses are scoped to the mounted authenticated header/dialog; never show a previous account.
|
||||
if (!accountId || (value && !userId && value.userId !== accountId)) return null
|
||||
if (!value && !error) return loading ? <span className="inline-flex h-8 w-8 items-center justify-center" role="status" aria-label={t("supportAvailability.loading")}><Loader2Icon className="size-4 animate-spin" /></span> : null
|
||||
return <>
|
||||
<Button variant="outline" size="sm" className="h-8 max-w-56 gap-1.5 rounded-md px-2" title={t("supportAvailability.myStatus")} aria-label={t("supportAvailability.myStatus")} onClick={() => error ? setRefreshKey(key => key + 1) : setOpen(true)}>
|
||||
{error ? <RefreshCwIcon className="size-4 shrink-0" /> : <Clock3Icon className="size-4 shrink-0" />}
|
||||
<span className={compact ? "hidden truncate md:inline" : "truncate"}>{error ? t("supportAvailability.retryStatus") : value ? availabilityStatusLabel(value.effectiveServiceStatus, t) : t("supportAvailability.myStatus")}</span>
|
||||
</Button>
|
||||
{open && value ? <OperatorStatusDialog key={`${accountId}:${userId ?? "me"}`} value={value} admin={Boolean(userId)} onClose={() => setOpen(false)} onSaved={next => { setValue(next); notifyAvailabilityUpdated() }} /> : null}
|
||||
</>
|
||||
}
|
||||
|
||||
type StatusForm = { serviceStatus: string; start: string; end: string; startOccurrence: string; endOccurrence: string }
|
||||
export function OperatorStatusDialog({ value, admin = false, onClose, onSaved }: { value: OperatorAvailability; admin?: boolean; onClose: () => void; onSaved: (value: OperatorAvailability) => void }) {
|
||||
const t = useI18n()
|
||||
const { locale } = useAppLocale()
|
||||
const confirm = useConfirm()
|
||||
const { session } = useAuth()
|
||||
const capturedSession = useRef(session)
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [failure, setFailure] = useState("")
|
||||
const savingRef = useRef(false)
|
||||
const form = useForm<StatusForm>({ defaultValues: { serviceStatus: String(value.serviceStatus), start: toLocalDateTime(value.absenceStartAt), end: toLocalDateTime(value.absenceEndAt), startOccurrence: value.absenceStartAt ? new Date(value.absenceStartAt).toISOString() : "", endOccurrence: value.absenceEndAt ? new Date(value.absenceEndAt).toISOString() : "" } })
|
||||
const { register, control, handleSubmit, watch, setError, formState: { errors, isDirty } } = form
|
||||
const vacation = Number(watch("serviceStatus")) === ServiceStatus.Vacation
|
||||
const startISO = selectedLocalDateTimeISO(watch("start"), watch("startOccurrence")), endISO = selectedLocalDateTimeISO(watch("end"), watch("endOccurrence"))
|
||||
async function close() {
|
||||
if (savingRef.current) return
|
||||
if (isDirty && !(await confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }))) return
|
||||
onClose()
|
||||
}
|
||||
async function save(fields: StatusForm) {
|
||||
if (savingRef.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
const serviceStatus = Number(fields.serviceStatus) as ServiceStatus
|
||||
if (serviceStatus === ServiceStatus.Vacation) {
|
||||
const start = selectedLocalDateTimeISO(fields.start, fields.startOccurrence), end = selectedLocalDateTimeISO(fields.end, fields.endOccurrence)
|
||||
if (!start || !end || Date.parse(start) >= Date.parse(end)) { setError("end", { message: t("supportAvailability.invalidVacation") }); return }
|
||||
}
|
||||
savingRef.current = true; setSaving(true); setFailure("")
|
||||
try {
|
||||
const payload = { serviceStatus, ...(serviceStatus === ServiceStatus.Vacation ? { absenceStartAt: selectedLocalDateTimeISO(fields.start, fields.startOccurrence)!, absenceEndAt: selectedLocalDateTimeISO(fields.end, fields.endOccurrence)! } : {}) }
|
||||
const result = admin ? await updateAgentAvailability(value.userId, payload) : await updateMyAvailability(payload)
|
||||
if (!mounted.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
onSaved(result); toast.success(t("supportAvailability.statusSaved")); onClose()
|
||||
} catch (error) { if (mounted.current) setFailure(error instanceof Error ? error.message : t("supportAvailability.saveFailed")) }
|
||||
finally { savingRef.current = false; if (mounted.current) setSaving(false) }
|
||||
}
|
||||
return <ProjectDialog open onOpenChange={next => { if (!next) void close() }} title={admin ? t("supportAvailability.operatorStatus", { name: value.displayName || String(value.userId) }) : t("supportAvailability.myStatus")} description={t("supportAvailability.presenceHelp")} size="md" footer={<><Button variant="outline" disabled={saving} onClick={() => void close()}>{t("supportAvailability.cancel")}</Button><Button type="submit" form="operator-availability-form" disabled={saving || !isDirty}>{saving ? <Loader2Icon className="size-4 animate-spin" /> : null}{t(saving ? "supportAvailability.saving" : "supportAvailability.saveStatus")}</Button></>}>
|
||||
<div className="mb-4 space-y-2 rounded-md border p-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2"><Badge variant="outline">{t(value.online ? "supportAvailability.online" : "supportAvailability.offline")}</Badge><Badge variant="outline">{availabilityStatusLabel(value.effectiveServiceStatus, t)}</Badge></div>
|
||||
<p>{availabilityReasonLabel(value.reason, t)}</p>
|
||||
{value.nextOpeningAt ? <p className="text-muted-foreground">{t("supportAvailability.nextOpening", { time: formatAvailabilityTime(value.nextOpeningAt, value.calendar.effectiveTimezone, locale) })}</p> : null}
|
||||
</div>
|
||||
<form id="operator-availability-form" onSubmit={handleSubmit(save)} className="space-y-4">
|
||||
<Field><FieldLabel>{t("supportAvailability.workingStatus")}</FieldLabel><Controller control={control} name="serviceStatus" render={({ field }) => <OptionCombobox options={availabilityStatusOptions(t)} value={field.value} onChange={field.onChange} ariaLabel={t("supportAvailability.workingStatus")} placeholder={t("supportAvailability.workingStatus")} disabled={saving} />} /></Field>
|
||||
<p className="text-xs text-muted-foreground">{t("supportAvailability.statusHelp")}</p>
|
||||
{vacation ? <>
|
||||
<p className="text-sm text-muted-foreground">{t("supportAvailability.vacationTimezone", { timezone: browserTimezone })}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field><FieldLabel htmlFor="absence-start">{t("supportAvailability.vacationStart")}</FieldLabel><Input id="absence-start" type="datetime-local" disabled={saving} {...register("start")} /></Field>
|
||||
<Field data-invalid={Boolean(errors.end)}><FieldLabel htmlFor="absence-end">{t("supportAvailability.vacationEnd")}</FieldLabel><Input id="absence-end" type="datetime-local" disabled={saving} {...register("end")} aria-invalid={Boolean(errors.end)} /><FieldError errors={[errors.end]} /></Field>
|
||||
</div>
|
||||
{(["start", "end"] as const).map(name => localDateTimeCandidates(watch(name)).length > 1 ? <Field key={name}><FieldLabel>{t("supportAvailability.dstOccurrence", { field: t(name === "start" ? "supportAvailability.vacationStart" : "supportAvailability.vacationEnd") })}</FieldLabel><Controller control={control} name={name === "start" ? "startOccurrence" : "endOccurrence"} render={({ field }) => <OptionCombobox value={selectedLocalDateTimeISO(watch(name), field.value) ?? ""} options={localDateTimeCandidates(watch(name)).map(value => ({ value, label: value }))} onChange={field.onChange} disabled={saving} ariaLabel={t("supportAvailability.chooseOccurrence")} placeholder={t("supportAvailability.chooseOccurrence")} />} /></Field> : null)}
|
||||
{startISO && endISO ? <p className="break-words text-xs text-muted-foreground">{t("supportAvailability.vacationExact", { start: startISO, end: endISO })}</p> : null}
|
||||
<p className="text-xs text-muted-foreground">{t("supportAvailability.vacationHelp", { status: availabilityStatusLabel(value.serviceStatus === ServiceStatus.Vacation ? value.returnServiceStatus ?? value.effectiveServiceStatus : value.effectiveServiceStatus, t) })}</p>
|
||||
</> : value.serviceStatus === ServiceStatus.Vacation ? <p className="text-sm text-muted-foreground">{t("supportAvailability.cancelVacationHelp")}</p> : null}
|
||||
{failure ? <p role="alert" className="text-sm text-destructive">{failure}</p> : null}
|
||||
</form>
|
||||
</ProjectDialog>
|
||||
}
|
||||
47
web/components/support-availability/public-status.tsx
Normal file
47
web/components/support-availability/public-status.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { RefreshCwIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { fetchPublicSupportStatus, type PublicSupportStatus } from "@/lib/api/support-availability"
|
||||
import { formatAvailabilityTime } from "@/lib/support-availability"
|
||||
|
||||
// Public server aggregates only. This never reads employee identities or private absence data.
|
||||
// A successful HTTP check is not a socket connection and is not itself human presence.
|
||||
export function PublicSupportAvailability({ identityScope }: { identityScope: string }) {
|
||||
return <PublicSupportAvailabilityContent key={identityScope} />
|
||||
}
|
||||
function PublicSupportAvailabilityContent() {
|
||||
const t = useI18n()
|
||||
const { locale } = useAppLocale()
|
||||
const [status, setStatus] = useState<PublicSupportStatus | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [retry, setRetry] = useState(0)
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let inFlight = false
|
||||
let controller: AbortController | null = null
|
||||
async function check() {
|
||||
if (inFlight) return
|
||||
inFlight = true
|
||||
controller = new AbortController()
|
||||
try {
|
||||
const result = await fetchPublicSupportStatus(controller.signal)
|
||||
if (active) { setStatus(result); setFailed(false) }
|
||||
} catch { if (active) { setStatus(null); setFailed(true) } }
|
||||
finally { inFlight = false }
|
||||
}
|
||||
void check()
|
||||
const refresh = () => { if (document.visibilityState === "visible") void check() }
|
||||
window.addEventListener("focus", refresh)
|
||||
document.addEventListener("visibilitychange", refresh)
|
||||
const timer = window.setInterval(refresh, 30000)
|
||||
return () => { active = false; controller?.abort(); window.clearInterval(timer); window.removeEventListener("focus", refresh); document.removeEventListener("visibilitychange", refresh) }
|
||||
}, [retry])
|
||||
const reason = status?.available ? "available" : status && ["outside_hours", "temporarily_absent", "capacity"].includes(status.reason) ? status.reason : "unknown"
|
||||
return <div className="mt-2 flex items-start justify-between gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground" aria-live="polite">
|
||||
<div className="min-w-0 space-y-1"><p>{t(!status && !failed ? "supportAvailability.publicChecking" : `supportAvailability.public.${reason}`)}</p>{status?.nextOpeningAt ? <p>{t("supportAvailability.publicNextOpening", { time: formatAvailabilityTime(status.nextOpeningAt, status.timezone, locale) })}</p> : null}</div>
|
||||
{failed ? <Button type="button" variant="ghost" size="icon" className="size-6 shrink-0" aria-label={t("supportAvailability.retry")} onClick={() => setRetry(value => value + 1)}><RefreshCwIcon className="size-3" /></Button> : null}
|
||||
</div>
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
|
||||
// Modified for hardening and realtime recovery; see docs/hardening/README.md and docs/fixes/chat-realtime-recovery.md.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import { useShallow } from "zustand/react/shallow"
|
||||
|
||||
import { SupportChatErrorBoundary } from "@/components/support-chat/chat-error-boundary"
|
||||
import { SupportChatConnectionStatus } from "@/components/support-chat/connection-status"
|
||||
import { PublicSupportAvailability } from "@/components/support-availability/public-status"
|
||||
import { SupportChatConversationSwitcher } from "@/components/support-chat/conversation-switcher"
|
||||
import { SupportChatNewRequestDialog } from "@/components/support-chat/new-request-dialog"
|
||||
import { getStandaloneClosedUrl } from "@/components/support-chat/close-navigation"
|
||||
@@ -114,12 +115,14 @@ function SupportChatPanelContent() {
|
||||
useSupportChatSystemTheme()
|
||||
|
||||
const messageListRef = useRef<SupportChatMessageListHandle | null>(null)
|
||||
const mountedRef = useRef(false)
|
||||
const [isEmbedded, setIsEmbedded] = useState(false)
|
||||
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false)
|
||||
const [isClosingConversation, setIsClosingConversation] = useState(false)
|
||||
const [isNewRequestDialogOpen, setIsNewRequestDialogOpen] = useState(false)
|
||||
|
||||
const {
|
||||
identityScope,
|
||||
title,
|
||||
subtitle,
|
||||
themeColor,
|
||||
@@ -130,6 +133,8 @@ function SupportChatPanelContent() {
|
||||
loadOlderMessages,
|
||||
status,
|
||||
error,
|
||||
sending,
|
||||
uploadingAsset,
|
||||
isOpen,
|
||||
isVisible,
|
||||
setIsOpen,
|
||||
@@ -146,6 +151,7 @@ function SupportChatPanelContent() {
|
||||
setConversationSatisfaction,
|
||||
} = useSupportChatStore(
|
||||
useShallow((state) => ({
|
||||
identityScope: state.identityScope,
|
||||
title: state.title,
|
||||
subtitle: state.subtitle,
|
||||
themeColor: state.themeColor,
|
||||
@@ -156,6 +162,8 @@ function SupportChatPanelContent() {
|
||||
loadOlderMessages: state.loadOlderMessages,
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
sending: state.sending,
|
||||
uploadingAsset: state.uploadingAsset,
|
||||
isOpen: state.isOpen,
|
||||
isVisible: state.isVisible,
|
||||
setIsOpen: state.setIsOpen,
|
||||
@@ -185,17 +193,18 @@ function SupportChatPanelContent() {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return
|
||||
}
|
||||
if (useSupportChatStore.getState().conversation?.id !== conversation.id) return
|
||||
void markConversationRead().catch((readError) => {
|
||||
console.error("Failed to mark support chat conversation read", readError)
|
||||
})
|
||||
}, [conversation?.id, isVisible, markConversationRead])
|
||||
}, [conversation, isVisible, markConversationRead])
|
||||
|
||||
useEffect(() => {
|
||||
return bindSupportHostBridge({
|
||||
onInit: () => {
|
||||
if (useSupportChatStore.getState().isOpen) {
|
||||
bootstrap()
|
||||
}
|
||||
// Clear a previous visitor's private state even while the host keeps
|
||||
// this frame hidden; visibility must never race identity replacement.
|
||||
void bootstrap()
|
||||
},
|
||||
onOpen: () => {
|
||||
setIsOpen(true)
|
||||
@@ -208,10 +217,16 @@ function SupportChatPanelContent() {
|
||||
}, [bootstrap, setIsOpen, setIsVisible])
|
||||
|
||||
useEffect(() => {
|
||||
bootstrap()
|
||||
void bootstrap()
|
||||
}, [isOpen, bootstrap])
|
||||
|
||||
useEffect(() => () => disconnectSocket(), [disconnectSocket])
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
disconnectSocket()
|
||||
}
|
||||
}, [disconnectSocket])
|
||||
|
||||
useEffect(() => {
|
||||
maybeMarkConversationRead()
|
||||
@@ -239,9 +254,21 @@ function SupportChatPanelContent() {
|
||||
}
|
||||
}, [maybeMarkConversationRead, recoverConnection])
|
||||
|
||||
function requireDisplayedConversation() {
|
||||
const current = useSupportChatStore.getState()
|
||||
if (!conversation?.id || current.identityScope !== identityScope || current.conversation?.id !== conversation.id) {
|
||||
throw new Error(t("supportChat.sendUnavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(content: string) {
|
||||
requireDisplayedConversation()
|
||||
const submittedConversationId = conversation?.id
|
||||
await handleSendMessage(content)
|
||||
messageListRef.current?.scrollToBottom()
|
||||
const current = useSupportChatStore.getState()
|
||||
if (current.identityScope === identityScope && current.conversation?.id === submittedConversationId) {
|
||||
messageListRef.current?.scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCloseWindow() {
|
||||
@@ -249,7 +276,8 @@ function SupportChatPanelContent() {
|
||||
if (isEmbedded) {
|
||||
requestSupportHostMinimize()
|
||||
} else {
|
||||
window.location.replace(getStandaloneClosedUrl())
|
||||
setIsOpen(false)
|
||||
window.location.replace(getStandaloneClosedUrl(window.location.search))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +288,7 @@ function SupportChatPanelContent() {
|
||||
setIsClosingConversation(true)
|
||||
try {
|
||||
if (conversation?.id) {
|
||||
requireDisplayedConversation()
|
||||
await closeConversation()
|
||||
}
|
||||
setIsCloseDialogOpen(false)
|
||||
@@ -335,6 +364,8 @@ function SupportChatPanelContent() {
|
||||
</WindowActionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2"><SupportChatConnectionStatus status={status} /></div>
|
||||
<PublicSupportAvailability identityScope={identityScope} />
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden bg-muted/60 dark:bg-muted/30">
|
||||
@@ -354,15 +385,29 @@ function SupportChatPanelContent() {
|
||||
conversationId={conversation.id}
|
||||
customerToken={getCustomerSessionToken()}
|
||||
initialSatisfaction={conversation.satisfaction}
|
||||
onSubmitted={setConversationSatisfaction}
|
||||
onSubmitted={(satisfaction) => {
|
||||
if (mountedRef.current && useSupportChatStore.getState().identityScope === identityScope) {
|
||||
setConversationSatisfaction(satisfaction)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<CustomerMessageEditor
|
||||
conversationId={conversation?.id}
|
||||
draftScope={identityScope}
|
||||
sending={sending}
|
||||
uploadingAsset={uploadingAsset}
|
||||
disabled={!conversation || conversation.status === 4}
|
||||
onSend={handleSend}
|
||||
onUploadImage={uploadMessageImage}
|
||||
onSendAttachment={sendAttachment}
|
||||
onUploadImage={async (file) => {
|
||||
requireDisplayedConversation()
|
||||
return uploadMessageImage(file)
|
||||
}}
|
||||
onSendAttachment={async (file) => {
|
||||
requireDisplayedConversation()
|
||||
await sendAttachment(file)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import ts from "typescript"
|
||||
@@ -14,6 +15,7 @@ async function loadCloseNavigation() {
|
||||
fileName: "close-navigation.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
URLSearchParams,
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
}
|
||||
@@ -27,3 +29,10 @@ test("standalone close navigates to the non-bootstrapping closed page", async ()
|
||||
|
||||
assert.equal(getStandaloneClosedUrl(), "/support/chat/closed")
|
||||
})
|
||||
|
||||
test("dismiss and return retain the entry channel without accepting a redirect destination", async () => {
|
||||
const { getStandaloneClosedUrl, getStandaloneChatUrl } = await loadCloseNavigation()
|
||||
const search = "?channelId=help&externalId=visitor&returnUrl=https%3A%2F%2Fexample.org"
|
||||
assert.equal(getStandaloneClosedUrl(search), `/support/chat/closed${search}`)
|
||||
assert.equal(getStandaloneChatUrl(search), `/support/chat${search}`)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
export const SUPPORT_CHAT_CLOSED_PATH = "/support/chat/closed"
|
||||
|
||||
export function getStandaloneClosedUrl() {
|
||||
return SUPPORT_CHAT_CLOSED_PATH
|
||||
// Keep the entry context when dismissing/returning. These are always local
|
||||
// paths, never a caller-supplied redirect destination.
|
||||
function querySuffix(search: string) {
|
||||
return search ? `?${new URLSearchParams(search).toString()}` : ""
|
||||
}
|
||||
|
||||
export function getStandaloneClosedUrl(search = "") {
|
||||
return `${SUPPORT_CHAT_CLOSED_PATH}${querySuffix(search)}`
|
||||
}
|
||||
|
||||
export function getStandaloneChatUrl(search = "") {
|
||||
return `/support/chat${querySuffix(search)}`
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -20,16 +21,16 @@ export function SupportChatConnectionStatus({ status }: SupportChatConnectionSta
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("h-6 gap-2 px-2.5 text-[11px] font-medium shadow-sm", toneClass)}
|
||||
className={cn("h-6 gap-2 px-2.5 text-[11px] font-medium", toneClass)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
status === "connected"
|
||||
? "bg-emerald-500 shadow-[0_0_0_4px_rgba(16,185,129,0.14)]"
|
||||
? "bg-emerald-500"
|
||||
: status === "connecting"
|
||||
? "bg-amber-500 shadow-[0_0_0_4px_rgba(245,158,11,0.16)]"
|
||||
: "bg-muted-foreground shadow-[0_0_0_4px_rgba(148,163,184,0.14)]"
|
||||
? "bg-amber-500"
|
||||
: "bg-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<span>{t(`supportChat.${status}`)}</span>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: forward customer conversation identity and pending-send state.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
@@ -6,6 +8,9 @@ import {
|
||||
} from "@/components/chat/shared-message-editor"
|
||||
|
||||
type CustomerMessageEditorProps = {
|
||||
conversationId?: number | null
|
||||
draftScope?: string
|
||||
sending?: boolean
|
||||
disabled?: boolean
|
||||
uploadingAsset?: boolean
|
||||
onSend: (html: string) => Promise<void>
|
||||
@@ -14,6 +19,9 @@ type CustomerMessageEditorProps = {
|
||||
}
|
||||
|
||||
export function CustomerMessageEditor({
|
||||
conversationId,
|
||||
draftScope,
|
||||
sending = false,
|
||||
disabled = false,
|
||||
uploadingAsset = false,
|
||||
onSend,
|
||||
@@ -23,6 +31,9 @@ export function CustomerMessageEditor({
|
||||
return (
|
||||
<SharedMessageEditor
|
||||
variant="customer"
|
||||
conversationId={conversationId}
|
||||
draftScope={draftScope}
|
||||
sending={sending}
|
||||
disabled={disabled}
|
||||
uploadingAsset={uploadingAsset}
|
||||
manageLocalUploading
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import { BellIcon, KeyRoundIcon, LogOutIcon, UserIcon } from "lucide-react"
|
||||
@@ -5,6 +6,7 @@ import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
|
||||
import { ChangePasswordDialog } from "@/components/change-password-dialog"
|
||||
import { OperatorStatusControl } from "@/components/support-availability/operator-status"
|
||||
import { OperatorReadinessIndicator } from "@/components/operator-readiness-indicator"
|
||||
import { PaletteToggle } from "@/components/palette-toggle"
|
||||
import { RealtimeConnectionStatus } from "@/components/realtime-connection-status"
|
||||
@@ -33,7 +35,8 @@ export function WorkbenchHeader() {
|
||||
<header className="flex h-(--header-height) shrink-0 items-center border-b border-border/70 bg-background/88 backdrop-blur supports-[backdrop-filter]:bg-background/76">
|
||||
<div className="flex w-full min-w-0 items-center justify-end gap-3 px-3 lg:px-4">
|
||||
<div className="flex min-w-0 items-center justify-end gap-2">
|
||||
<OperatorReadinessIndicator />
|
||||
<OperatorStatusControl compact />
|
||||
<OperatorReadinessIndicator compact />
|
||||
<div className="hidden sm:block">
|
||||
<RealtimeConnectionStatus status={realtimeStatus} compact />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, it } from "node:test";
|
||||
@@ -26,7 +27,9 @@ it("does not auto-open the rail menu from focus or hover events", async () => {
|
||||
it("centers the dashboard switcher logo and shows a collapsed switch indicator", async () => {
|
||||
assert.match(source, /variant === "sidebar" &&[\s\S]*group-data-\[collapsible=icon\]:p-0!/);
|
||||
assert.match(source, /variant === "sidebar" &&[\s\S]*group-data-\[collapsible=icon\]:justify-center/);
|
||||
assert.match(appSidebarSource, /className="relative data-\[slot=sidebar-menu-button\]:p-1\.5! group-data-\[collapsible=icon\]:justify-center group-data-\[collapsible=icon\]:p-0!"/);
|
||||
assert.match(appSidebarSource, /<WorkspaceSwitcher\s+currentWorkspace="dashboard"\s+variant="sidebar"\s*\/>/);
|
||||
assert.match(source, /variant === "sidebar" &&\s*"relative data-\[slot=sidebar-menu-button\]:p-1\.5! group-data-\[collapsible=icon\]:justify-center group-data-\[collapsible=icon\]:p-0!/);
|
||||
assert.match(source, /trigger \?\? <Button variant="ghost" className=\{triggerClassName\} \/>/);
|
||||
assert.match(source, /const switchIndicatorClassName =\s*"absolute bottom-0\.5 right-0\.5 size-2\.5/);
|
||||
assert.match(source, /variant === "rail" \? \([\s\S]*<ChevronsUpDownIcon className=\{switchIndicatorClassName\} \/>/);
|
||||
assert.match(source, /className=\{cn\(switchIndicatorClassName, "hidden group-data-\[collapsible=icon\]:block"\)\}/);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: bind realtime work to the current session and sanitize diagnostics.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useSession } from "@/components/session-provider"
|
||||
import { realtimeLogContext } from "@/lib/realtime-log-context"
|
||||
import { createAdminWebSocketUrl } from "@/lib/api/admin"
|
||||
import { type AgentMessage } from "@/lib/api/agent"
|
||||
import { shouldReloadConversationListForRealtimePatch } from "@/lib/agent-conversation-realtime"
|
||||
@@ -50,6 +54,8 @@ export function sendOperatorPresence(
|
||||
|
||||
export function useAgentConversationRealtime() {
|
||||
const t = useI18n()
|
||||
const { session } = useSession()
|
||||
const accessToken = session?.accessToken
|
||||
const selectedConversationId = useAgentConversationsStore(
|
||||
(state) => state.selectedConversationId
|
||||
)
|
||||
@@ -59,19 +65,22 @@ export function useAgentConversationRealtime() {
|
||||
const realtimeRef = useRef<AgentRealtimeConnection | null>(null)
|
||||
const subscribedConversationIdRef = useRef<number | null>(null)
|
||||
const selectedConversationIdRef = useRef<number | null>(selectedConversationId)
|
||||
const currentUserIdRef = useRef<number>(readSession()?.user.id ?? 0)
|
||||
const currentUserId = session?.user.id ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
selectedConversationIdRef.current = selectedConversationId
|
||||
}, [selectedConversationId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return
|
||||
const isCurrentSession = () => readSession()?.accessToken === accessToken
|
||||
const realtime = createRealtimeConnectionManager({
|
||||
createSocket: () => new WebSocket(createAdminWebSocketUrl()),
|
||||
onStatusChange: setRealtimeStatus,
|
||||
onStatusChange: (status) => { if (isCurrentSession()) setRealtimeStatus(status) },
|
||||
onOpen: (socket) => {
|
||||
if (!isCurrentSession()) return
|
||||
console.info("[agent-realtime] websocket connected", {
|
||||
url: socket.url,
|
||||
...realtimeLogContext(socket.url),
|
||||
})
|
||||
|
||||
const conversationId = selectedConversationIdRef.current
|
||||
@@ -95,6 +104,7 @@ export function useAgentConversationRealtime() {
|
||||
}
|
||||
},
|
||||
onMessage: (event, socket) => {
|
||||
if (!isCurrentSession()) return
|
||||
try {
|
||||
const envelope = JSON.parse(event.data) as AgentRealtimeEnvelope
|
||||
const eventType = envelope.type ?? ""
|
||||
@@ -130,7 +140,7 @@ export function useAgentConversationRealtime() {
|
||||
}
|
||||
|
||||
if (eventType === "operator.presence" && payload) {
|
||||
store.applyRealtimeOperatorPresence(payload, currentUserIdRef.current)
|
||||
store.applyRealtimeOperatorPresence(payload, currentUserId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -145,7 +155,7 @@ export function useAgentConversationRealtime() {
|
||||
store.applyRealtimeMessageCreated(message)
|
||||
|
||||
const isCustomerMsg = message.senderType === "customer"
|
||||
const isAssignedToMe = (payload?.currentAssigneeId ?? 0) === currentUserIdRef.current
|
||||
const isAssignedToMe = (payload?.currentAssigneeId ?? 0) === currentUserId
|
||||
const isUnassigned = (payload?.currentAssigneeId ?? 0) === 0
|
||||
const isCurrentActive = message.conversationId === selectedConversationIdRef.current
|
||||
|
||||
@@ -182,17 +192,16 @@ export function useAgentConversationRealtime() {
|
||||
},
|
||||
onClose: (event, socket) => {
|
||||
console.log("[agent-realtime] websocket closed", {
|
||||
url: socket.url,
|
||||
...realtimeLogContext(socket.url),
|
||||
readyState: socket.readyState,
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean,
|
||||
})
|
||||
subscribedConversationIdRef.current = null
|
||||
},
|
||||
onError: (_event, socket) => {
|
||||
console.log("[agent-realtime] websocket error", {
|
||||
url: socket.url,
|
||||
...realtimeLogContext(socket.url),
|
||||
readyState: socket.readyState,
|
||||
})
|
||||
},
|
||||
@@ -225,7 +234,7 @@ export function useAgentConversationRealtime() {
|
||||
realtime.disconnect()
|
||||
subscribedConversationIdRef.current = null
|
||||
}
|
||||
}, [setRealtimeStatus, t])
|
||||
}, [accessToken, currentUserId, setRealtimeStatus, t])
|
||||
|
||||
useEffect(() => {
|
||||
const socket = realtimeRef.current?.getSocket()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: prevent stale profile updates and logout from modifying a newer session.
|
||||
import { clearSession, readSession, writeSession, type AuthSession } from "@/lib/auth"
|
||||
import { isSameSession } from "@/lib/session-request-guard"
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type LoginRequest = {
|
||||
@@ -41,17 +44,17 @@ export async function fetchProfile() {
|
||||
}
|
||||
|
||||
export async function updateProfile(payload: UpdateProfileRequest) {
|
||||
const stored = readSession()
|
||||
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)
|
||||
if (isSameSession(stored, readSession())) writeSession(nextSession)
|
||||
return nextSession
|
||||
}
|
||||
|
||||
@@ -65,11 +68,12 @@ export function uploadProfileAvatar(file: File) {
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const stored = readSession()
|
||||
try {
|
||||
await request("/api/auth/logout", {
|
||||
method: "POST",
|
||||
})
|
||||
} finally {
|
||||
clearSession()
|
||||
if (isSameSession(stored, readSession())) clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { expireSession, readSession } from "@/lib/auth"
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: scope authentication expiry to the session that issued each request.
|
||||
import { expireSession, readSession, type AuthSession } from "@/lib/auth"
|
||||
import { isSameSession } from "@/lib/session-request-guard"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
|
||||
const API_BASE_URL =
|
||||
@@ -50,10 +53,10 @@ function normalizeApiRequestError(error: unknown) {
|
||||
)
|
||||
}
|
||||
|
||||
async function parseResult<T>(response: Response) {
|
||||
async function parseResult<T>(response: Response, requestSession: AuthSession | null) {
|
||||
const payload = (await response.json()) as JsonResult<T>
|
||||
if (!response.ok || !payload.success) {
|
||||
if (payload.errorCode === 3000 || payload.errorCode === 3002) {
|
||||
if ((payload.errorCode === 3000 || payload.errorCode === 3002) && isSameSession(requestSession, readSession())) {
|
||||
expireSession()
|
||||
}
|
||||
throw new ApiRequestError(
|
||||
@@ -65,11 +68,10 @@ async function parseResult<T>(response: Response) {
|
||||
return payload.data
|
||||
}
|
||||
|
||||
function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolean, body?: BodyInit | null) {
|
||||
const session = readSession()
|
||||
function buildRequestHeaders(headers: HeadersInit | undefined, session: AuthSession | null, body?: BodyInit | null) {
|
||||
const authHeaders = new Headers(headers)
|
||||
|
||||
if (!skipAuth && session?.accessToken) {
|
||||
if (session?.accessToken) {
|
||||
authHeaders.set("Authorization", `Bearer ${session.accessToken}`)
|
||||
}
|
||||
if (
|
||||
@@ -101,7 +103,8 @@ export async function request<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 requestSession = skipAuth ? null : readSession()
|
||||
const authHeaders = buildRequestHeaders(headers, requestSession, rest.body)
|
||||
|
||||
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
||||
try {
|
||||
@@ -111,7 +114,7 @@ export async function request<T>(
|
||||
cache: "no-store",
|
||||
})
|
||||
onResponse?.(response)
|
||||
return await parseResult<T>(response)
|
||||
return await parseResult<T>(response, requestSession)
|
||||
} catch (error) {
|
||||
throw normalizeApiRequestError(error)
|
||||
}
|
||||
@@ -124,7 +127,8 @@ export async function requestBlob(
|
||||
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 requestSession = skipAuth ? null : readSession()
|
||||
const authHeaders = buildRequestHeaders(headers, requestSession, rest.body)
|
||||
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
||||
const response = await fetch(`${requestBaseUrl}${path}`, {
|
||||
...rest,
|
||||
@@ -136,7 +140,7 @@ export async function requestBlob(
|
||||
const contentType = response.headers.get("Content-Type") ?? ""
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
await parseResult<never>(response)
|
||||
await parseResult<never>(response, requestSession)
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
173
web/lib/api/im-session.test.mjs
Normal file
173
web/lib/api/im-session.test.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import test from "node:test"
|
||||
import vm from "node:vm"
|
||||
import ts from "typescript"
|
||||
|
||||
const storage = () => {
|
||||
const values = new Map()
|
||||
return { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: (key) => values.delete(key) }
|
||||
}
|
||||
const deferred = () => {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const token = (identityKey, channelCode = "support", mode = "guest", extra = {}) =>
|
||||
`test.${Buffer.from(JSON.stringify({ identityKey, channelCode, identityScope: `v1:${mode}:test-channel-scope`, ...extra })).toString("base64url")}.not-a-real-signature`
|
||||
const exchange = (identityKey = "guest:server-assigned", channelCode = "support", mode = "guest") => ({
|
||||
identityKey, customerSessionToken: token(identityKey, channelCode, mode),
|
||||
expiresAt: "2099-01-01T00:00:00Z", customer: { id: 1, name: "Visitor" },
|
||||
})
|
||||
|
||||
function harness(request) {
|
||||
let config = { baseUrl: "https://support.example", channelId: "support", externalId: "client-guest" }
|
||||
const localStorage = storage(), sessionStorage = storage()
|
||||
const source = readFileSync(new URL("./im.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } })
|
||||
const moduleContext = { exports: {} }
|
||||
const mocks = {
|
||||
"@/lib/api/client": { request },
|
||||
"@/i18n/messages": { translateCurrentMessage: (key) => key },
|
||||
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => config },
|
||||
"@/lib/utils": { generateUUID: () => "browser-local-guest", parseApiTimestamp: Date.parse },
|
||||
}
|
||||
vm.runInNewContext(compiled.outputText, { module: moduleContext, exports: moduleContext.exports,
|
||||
require: (name) => { if (!mocks[name]) throw new Error(`Unexpected import ${name}`); return mocks[name] },
|
||||
process: { env: {} }, window: { localStorage, sessionStorage }, atob, TextDecoder, URLSearchParams })
|
||||
return { api: moduleContext.exports, sessionStorage, setConfig: (next) => { config = { ...config, ...next } } }
|
||||
}
|
||||
|
||||
test("concurrent session ensures share one guest exchange and cache server-owned identity", async () => {
|
||||
const pending = deferred()
|
||||
let calls = 0
|
||||
const h = harness(() => { calls++; return pending.promise })
|
||||
const first = h.api.ensureCustomerSession(), second = h.api.ensureCustomerSession()
|
||||
assert.equal(calls, 1)
|
||||
pending.resolve(exchange())
|
||||
const [left, right] = await Promise.all([first, second])
|
||||
assert.equal(left.identityKey, "guest:server-assigned")
|
||||
assert.equal(right.identityKey, left.identityKey)
|
||||
assert.equal(left.guestContext, "client-guest")
|
||||
await h.api.ensureCustomerSession()
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test("guest token cannot be reused under a different initiating visitor context", async () => {
|
||||
let calls = 0
|
||||
const h = harness(async () => exchange(`guest:server-${++calls}`))
|
||||
await h.api.ensureCustomerSession()
|
||||
h.setConfig({ externalId: "someone-else" })
|
||||
assert.equal(h.api.getCustomerSessionToken(), "")
|
||||
const next = await h.api.ensureCustomerSession()
|
||||
assert.equal(calls, 2)
|
||||
assert.equal(next.identityKey, "guest:server-2")
|
||||
})
|
||||
|
||||
test("legacy unscoped tokens are re-exchanged instead of sent to protected endpoints", async () => {
|
||||
let calls = 0
|
||||
const h = harness(async () => { calls++; return exchange() })
|
||||
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...exchange("guest:client-guest"),
|
||||
customerSessionToken: token("guest:client-guest", "support", "guest", { identityScope: undefined }),
|
||||
baseUrl: "https://support.example", channelId: "support", guestContext: "client-guest" }))
|
||||
assert.equal(h.api.getCustomerSessionToken(), "")
|
||||
await h.api.ensureCustomerSession()
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test("late session exchange from old identity cannot overwrite new session", async () => {
|
||||
const old = deferred()
|
||||
let calls = 0
|
||||
const h = harness(() => ++calls === 1 ? old.promise : Promise.resolve(exchange("guest:new-server")))
|
||||
const first = h.api.ensureCustomerSession()
|
||||
h.setConfig({ externalId: "new-visitor" })
|
||||
const next = await h.api.ensureCustomerSession()
|
||||
old.resolve(exchange("guest:old-server"))
|
||||
await assert.rejects(first, /supportAuthenticationRequired/)
|
||||
assert.equal(h.api.readCustomerSession().identityKey, next.identityKey)
|
||||
})
|
||||
|
||||
test("A to B to A entry changes invalidate old same-value exchange", async () => {
|
||||
const old = deferred()
|
||||
let calls = 0
|
||||
const h = harness(() => ++calls === 1 ? old.promise : Promise.resolve(exchange("guest:new-A")))
|
||||
const first = h.api.ensureCustomerSession()
|
||||
h.setConfig({ externalId: "B" })
|
||||
h.api.getCustomerIdentityScope()
|
||||
h.setConfig({ externalId: "client-guest" })
|
||||
await h.api.ensureCustomerSession()
|
||||
old.resolve(exchange("guest:old-A"))
|
||||
await assert.rejects(first, /supportAuthenticationRequired/)
|
||||
assert.equal(h.api.readCustomerSession().identityKey, "guest:new-A")
|
||||
})
|
||||
|
||||
test("customer token rotation preserves scope, but replacement guest identity changes it", async () => {
|
||||
let calls = 0
|
||||
const h = harness(async () => exchange(`guest:server-${++calls}`))
|
||||
await h.api.ensureCustomerSession()
|
||||
const scope = h.api.getCustomerIdentityScope()
|
||||
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-1", "support", "guest", { revision: 2 }), expiresAt: "2099-02-01T00:00:00Z" })
|
||||
assert.equal(h.api.getCustomerIdentityScope(), scope)
|
||||
await h.api.exchangeCustomerSession()
|
||||
assert.notEqual(h.api.getCustomerIdentityScope(), scope)
|
||||
})
|
||||
|
||||
test("refresh with another auth mode or channel cannot poison cached session", async () => {
|
||||
const h = harness(async () => exchange())
|
||||
await h.api.ensureCustomerSession()
|
||||
const original = h.api.getCustomerSessionToken()
|
||||
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-assigned", "support", "jwt"), expiresAt: "2099-02-01T00:00:00Z" })
|
||||
assert.equal(h.api.getCustomerSessionToken(), original)
|
||||
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-assigned", "other"), expiresAt: "2099-02-01T00:00:00Z" })
|
||||
assert.equal(h.api.getCustomerSessionToken(), original)
|
||||
})
|
||||
|
||||
test("signed identity still requires exact user ID and correct auth mode", async () => {
|
||||
const h = harness(async () => exchange("user:wrong", "support", "hmac"))
|
||||
h.setConfig({ externalId: "expected", externalIdSignature: "test-not-a-real-signature", externalIdSignedAt: 1 })
|
||||
await assert.rejects(h.api.ensureCustomerSession(), /supportAuthenticationRequired/)
|
||||
assert.equal(h.api.getCustomerSessionToken(), "")
|
||||
})
|
||||
|
||||
test("matching renewal arriving during old token expiry window keeps guest identity", async () => {
|
||||
const h = harness(async () => exchange())
|
||||
await h.api.ensureCustomerSession()
|
||||
const cached = h.api.readCustomerSession()
|
||||
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: "2000-01-01T00:00:00Z" }))
|
||||
const renewed = token(cached.identityKey, "support", "guest", { revision: 2 })
|
||||
h.api.applyCustomerSessionRefresh({ customerSessionToken: renewed, expiresAt: "2099-02-01T00:00:00Z" })
|
||||
assert.equal(h.api.getCustomerSessionToken(), renewed)
|
||||
})
|
||||
|
||||
test("near-expiry guest renews its existing identity instead of exchanging a fresh guest", async () => {
|
||||
const routes = []
|
||||
const h = harness(async (route, options) => {
|
||||
routes.push(route)
|
||||
if (route.endsWith("session_refresh")) {
|
||||
assert.equal(options.method, "POST")
|
||||
assert.ok(options.headers.Authorization.startsWith("Bearer "))
|
||||
}
|
||||
return exchange("guest:original")
|
||||
})
|
||||
await h.api.ensureCustomerSession()
|
||||
const cached = h.api.readCustomerSession()
|
||||
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: new Date(Date.now() + 4000).toISOString() }))
|
||||
const resumed = await h.api.ensureCustomerSession()
|
||||
assert.equal(resumed.identityKey, cached.identityKey)
|
||||
assert.deepEqual(routes, ["/api/customer/session_exchange", "/api/customer/session_refresh"])
|
||||
})
|
||||
|
||||
test("failed near-expiry refresh never falls back to a different anonymous guest", async () => {
|
||||
let calls = 0
|
||||
const h = harness(async (route) => {
|
||||
calls++
|
||||
if (route.endsWith("session_refresh")) throw new Error("offline")
|
||||
return exchange("guest:original")
|
||||
})
|
||||
await h.api.ensureCustomerSession()
|
||||
const cached = h.api.readCustomerSession()
|
||||
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: new Date(Date.now() + 4000).toISOString() }))
|
||||
await assert.rejects(h.api.ensureCustomerSession(), /offline/)
|
||||
assert.equal(calls, 2)
|
||||
assert.equal(h.api.readCustomerSession().identityKey, "guest:original")
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
|
||||
// Modified for hardening and realtime recovery; see docs/hardening/README.md and docs/fixes/chat-realtime-recovery.md.
|
||||
import { request } from "@/lib/api/client"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
|
||||
@@ -148,6 +148,8 @@ export type ImCustomerSessionExchangeResponse = {
|
||||
export type ImCustomerSession = ImCustomerSessionExchangeResponse & {
|
||||
channelId: string
|
||||
baseUrl: string
|
||||
/** Local binding only. Anonymous identity itself is assigned by the server. */
|
||||
guestContext?: string
|
||||
}
|
||||
|
||||
const GUEST_STORAGE_KEY = "cs_ai_agent_im_guest_id"
|
||||
@@ -160,6 +162,18 @@ const OPEN_IM_CHANNEL_ID =
|
||||
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || ""
|
||||
|
||||
let entryIdentityExchangeKey = ""
|
||||
let observedIdentityContext = ""
|
||||
let identityGeneration = 0
|
||||
let observedSessionIdentity = ""
|
||||
let sessionIdentityGeneration = 0
|
||||
let pendingSessionExchange: {
|
||||
scope: string
|
||||
promise: Promise<ImCustomerSession>
|
||||
} | null = null
|
||||
let pendingSessionRefresh: {
|
||||
scope: string
|
||||
promise: Promise<ImCustomerSession>
|
||||
} | null = null
|
||||
|
||||
function buildGuestId() {
|
||||
return `guest_${generateUUID()}`
|
||||
@@ -209,6 +223,36 @@ function identityContextKey(config: RuntimeImConfig) {
|
||||
])
|
||||
}
|
||||
|
||||
// An opaque, non-secret scope for in-memory drafts and request fencing. Token
|
||||
// renewal does not change it, but a new entry identity/channel/server does.
|
||||
export function getCustomerEntryScope() {
|
||||
const config = getRuntimeImConfig()
|
||||
const context = JSON.stringify([
|
||||
identityContextKey(config),
|
||||
config.externalId || config.userToken ? "" : getGuestId(),
|
||||
])
|
||||
if (context !== observedIdentityContext) {
|
||||
observedIdentityContext = context
|
||||
identityGeneration += 1
|
||||
}
|
||||
return `customer-${identityGeneration}`
|
||||
}
|
||||
|
||||
export function getCustomerIdentityScope() {
|
||||
const entry = getCustomerEntryScope()
|
||||
const session = readCustomerSession()
|
||||
const config = getRuntimeImConfig()
|
||||
const identity = JSON.stringify([
|
||||
entry,
|
||||
session && matchesSessionIdentity(session, config) ? session.identityKey : "",
|
||||
])
|
||||
if (identity !== observedSessionIdentity) {
|
||||
observedSessionIdentity = identity
|
||||
sessionIdentityGeneration += 1
|
||||
}
|
||||
return `${entry}-${sessionIdentityGeneration}`
|
||||
}
|
||||
|
||||
function requireEntryToken(config: RuntimeImConfig) {
|
||||
if (config.requireUserToken && !config.userToken) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
@@ -218,6 +262,12 @@ function requireEntryToken(config: RuntimeImConfig) {
|
||||
function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImConfig) {
|
||||
if (typeof session.identityKey !== "string") return false
|
||||
if (session.baseUrl !== config.baseUrl || session.channelId !== config.channelId) return false
|
||||
const claims = sessionTokenClaims(session.customerSessionToken)
|
||||
const identityMode = config.userToken ? "jwt" : config.externalIdSignature ? "hmac" : "guest"
|
||||
// Legacy tokens predate channel/auth-mode isolation and must be exchanged.
|
||||
// This is only cache validation; the server verifies every token signature.
|
||||
if (claims?.identityKey !== session.identityKey || claims.channelCode !== config.channelId ||
|
||||
typeof claims.identityScope !== "string" || !claims.identityScope.startsWith(`v1:${identityMode}:`)) return false
|
||||
if (config.userToken || config.requireUserToken) {
|
||||
return (
|
||||
!!config.userToken &&
|
||||
@@ -227,10 +277,8 @@ function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImCon
|
||||
)
|
||||
}
|
||||
const externalId = config.externalId || getGuestId()
|
||||
return (
|
||||
session.identityKey === `guest:${externalId}` ||
|
||||
(!!config.externalIdSignature && session.identityKey === `user:${externalId}`)
|
||||
)
|
||||
if (config.externalIdSignature) return session.identityKey === `user:${externalId}`
|
||||
return session.identityKey.startsWith("guest:") && session.guestContext === externalId
|
||||
}
|
||||
|
||||
function usableCustomerSession(session: ImCustomerSession | null, config: RuntimeImConfig) {
|
||||
@@ -243,7 +291,7 @@ function usableCustomerSession(session: ImCustomerSession | null, config: Runtim
|
||||
return true
|
||||
}
|
||||
|
||||
function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string } | null {
|
||||
function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string; identityScope?: string } | null {
|
||||
try {
|
||||
const payload = token.split(".")[1]
|
||||
if (!payload) return null
|
||||
@@ -276,7 +324,7 @@ function isCustomerSessionValid(
|
||||
if (identityKey && session.identityKey !== identityKey) {
|
||||
return false
|
||||
}
|
||||
return parseExpiresAt(session.expiresAt) > Date.now() + 5000
|
||||
return parseExpiresAt(session.expiresAt) > Date.now()
|
||||
}
|
||||
|
||||
export function readCustomerSession(): ImCustomerSession | null {
|
||||
@@ -321,12 +369,15 @@ export function applyCustomerSessionRefresh(payload?: {
|
||||
}
|
||||
const current = readCustomerSession()
|
||||
const config = getRuntimeImConfig()
|
||||
if (!current || !usableCustomerSession(current, config)) return
|
||||
// The response may arrive during the expiry safety window. It is still a
|
||||
// valid renewal of the same server-authorized identity, not a new identity.
|
||||
if (!current || !matchesSessionIdentity(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
|
||||
if (claims?.identityKey !== current.identityKey || claims?.channelCode !== current.channelId ||
|
||||
claims?.identityScope !== sessionTokenClaims(current.customerSessionToken)?.identityScope) return
|
||||
writeCustomerSession({
|
||||
...current,
|
||||
customerSessionToken: token,
|
||||
@@ -396,7 +447,7 @@ function createRequestOptions(
|
||||
} {
|
||||
const config = getRuntimeImConfig()
|
||||
requireEntryToken(config)
|
||||
const contextKey = identityContextKey(config)
|
||||
const identityScope = getCustomerIdentityScope()
|
||||
return {
|
||||
...init,
|
||||
skipAuth: true,
|
||||
@@ -405,7 +456,7 @@ function createRequestOptions(
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
onResponse: (response) => {
|
||||
if (contextKey === identityContextKey(getRuntimeImConfig())) {
|
||||
if (identityScope === getCustomerIdentityScope()) {
|
||||
applyCustomerSessionHeaders(response)
|
||||
}
|
||||
},
|
||||
@@ -429,7 +480,7 @@ function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export async function exchangeCustomerSession() {
|
||||
async function exchangeCustomerSessionForScope(identityScope: string) {
|
||||
const config = getRuntimeImConfig()
|
||||
requireEntryToken(config)
|
||||
const contextKey = identityContextKey(config)
|
||||
@@ -442,13 +493,14 @@ export async function exchangeCustomerSession() {
|
||||
headers: createExchangeHeaders(),
|
||||
}
|
||||
)
|
||||
if (contextKey !== identityContextKey(getRuntimeImConfig())) {
|
||||
if (identityScope !== getCustomerEntryScope()) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
}
|
||||
const session: ImCustomerSession = {
|
||||
...result,
|
||||
channelId: config.channelId,
|
||||
baseUrl: config.baseUrl,
|
||||
...(!config.userToken && !config.externalIdSignature ? { guestContext: config.externalId || getGuestId() } : {}),
|
||||
}
|
||||
if (!isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
@@ -458,14 +510,66 @@ export async function exchangeCustomerSession() {
|
||||
return session
|
||||
}
|
||||
|
||||
export function exchangeCustomerSession() {
|
||||
const scope = getCustomerEntryScope()
|
||||
if (pendingSessionExchange?.scope === scope) {
|
||||
return pendingSessionExchange.promise
|
||||
}
|
||||
const promise = exchangeCustomerSessionForScope(scope)
|
||||
pendingSessionExchange = { scope, promise }
|
||||
void promise.then(
|
||||
() => {
|
||||
if (pendingSessionExchange?.promise === promise) pendingSessionExchange = null
|
||||
},
|
||||
() => {
|
||||
if (pendingSessionExchange?.promise === promise) pendingSessionExchange = null
|
||||
}
|
||||
)
|
||||
return promise
|
||||
}
|
||||
|
||||
export async function ensureCustomerSession() {
|
||||
const config = getRuntimeImConfig()
|
||||
requireEntryToken(config)
|
||||
const cached = readCustomerSession()
|
||||
if (usableCustomerSession(cached, config)) return cached!
|
||||
if (usableCustomerSession(cached, config)) {
|
||||
if (parseExpiresAt(cached!.expiresAt) <= Date.now() + 30000) {
|
||||
return refreshCustomerSession(cached!)
|
||||
}
|
||||
return cached!
|
||||
}
|
||||
return exchangeCustomerSession()
|
||||
}
|
||||
|
||||
function refreshCustomerSession(cached: ImCustomerSession) {
|
||||
const scope = getCustomerIdentityScope()
|
||||
if (pendingSessionRefresh?.scope === scope) return pendingSessionRefresh.promise
|
||||
const refresh = async () => {
|
||||
// Refresh with proof of the existing identity. An unsigned exchange would
|
||||
// intentionally create a different guest and abandon the current history.
|
||||
const result = await request<ImCustomerSessionExchangeResponse>("/api/customer/session_refresh", {
|
||||
...createRequestOptions({ method: "POST" }),
|
||||
})
|
||||
if (scope !== getCustomerIdentityScope()) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
}
|
||||
const renewed: ImCustomerSession = { ...cached, ...result }
|
||||
if (!isCustomerSessionValid(renewed) || !matchesSessionIdentity(renewed, getRuntimeImConfig()) ||
|
||||
renewed.identityKey !== cached.identityKey || renewed.customer.id !== cached.customer.id) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
}
|
||||
writeCustomerSession(renewed)
|
||||
return renewed
|
||||
}
|
||||
const promise = refresh()
|
||||
pendingSessionRefresh = { scope, promise }
|
||||
const clear = () => {
|
||||
if (pendingSessionRefresh?.promise === promise) pendingSessionRefresh = null
|
||||
}
|
||||
void promise.then(clear, clear)
|
||||
return promise
|
||||
}
|
||||
|
||||
export function fetchImConversationDetail(id: number) {
|
||||
return request<ImConversationDetail>(`/api/conversation/${id}`, {
|
||||
...createRequestOptions(),
|
||||
|
||||
115
web/lib/api/session-isolation.test.mjs
Normal file
115
web/lib/api/session-isolation.test.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { isSameSession } from "../session-request-guard.ts"
|
||||
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
|
||||
|
||||
function fixture() {
|
||||
let session = { user: { id: 1 }, accessToken: "test-session-a" }
|
||||
let expirations = 0
|
||||
const response = deferred()
|
||||
const client = loadTypeScriptModule(new URL("./client.ts", import.meta.url), {
|
||||
"@/lib/auth": {
|
||||
readSession: () => session,
|
||||
expireSession: () => { session = null; expirations += 1 },
|
||||
},
|
||||
"@/lib/session-request-guard": { isSameSession },
|
||||
"@/i18n/messages": { translateCurrentMessage: (key) => key },
|
||||
}, { process: { env: {} }, Headers, FormData, fetch: () => response.promise })
|
||||
return {
|
||||
client, response,
|
||||
get session() { return session },
|
||||
set session(next) { session = next },
|
||||
get expirations() { return expirations },
|
||||
}
|
||||
}
|
||||
|
||||
function expiredResponse() {
|
||||
return new Response(JSON.stringify({ success: false, errorCode: 3002, message: "Expired" }), {
|
||||
status: 401, headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
for (const method of ["request", "requestBlob"]) {
|
||||
test(`${method}: old auth failure cannot expire a newer login`, async () => {
|
||||
const f = fixture()
|
||||
const pending = f.client[method]("/test")
|
||||
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||
f.response.resolve(expiredResponse())
|
||||
await assert.rejects(pending, { errorCode: 3002 })
|
||||
assert.equal(f.session.user.id, 2)
|
||||
assert.equal(f.expirations, 0)
|
||||
})
|
||||
|
||||
test(`${method}: matching auth failure expires only its own session`, async () => {
|
||||
const f = fixture()
|
||||
const pending = f.client[method]("/test")
|
||||
f.response.resolve(expiredResponse())
|
||||
await assert.rejects(pending, { errorCode: 3002 })
|
||||
assert.equal(f.session, null)
|
||||
assert.equal(f.expirations, 1)
|
||||
})
|
||||
|
||||
test(`${method}: unauthenticated failures do not expire a dashboard session`, async () => {
|
||||
const f = fixture()
|
||||
const pending = f.client[method]("/test", { skipAuth: true })
|
||||
f.response.resolve(expiredResponse())
|
||||
await assert.rejects(pending, { errorCode: 3002 })
|
||||
assert.equal(f.expirations, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test("old logout and profile update cannot overwrite a new account", async () => {
|
||||
for (const action of ["logout", "updateProfile"]) {
|
||||
let session = { user: { id: 1 }, accessToken: "test-session-a" }
|
||||
const response = deferred()
|
||||
let writes = 0
|
||||
const auth = loadTypeScriptModule(new URL("./auth.ts", import.meta.url), {
|
||||
"@/lib/auth": {
|
||||
readSession: () => session,
|
||||
clearSession: () => { session = null; writes += 1 },
|
||||
writeSession: (next) => { session = next; writes += 1 },
|
||||
},
|
||||
"@/lib/session-request-guard": { isSameSession },
|
||||
"@/lib/api/client": { request: () => response.promise },
|
||||
})
|
||||
const pending = auth[action]({ nickname: "Updated" })
|
||||
session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||
response.resolve({ user: { id: 1 }, accessToken: "test-session-a" })
|
||||
await pending
|
||||
assert.equal(session.user.id, 2)
|
||||
assert.equal(writes, 0)
|
||||
}
|
||||
})
|
||||
|
||||
for (const newerLogin of [true, false]) {
|
||||
test(`logout navigation honors current session (newer login: ${newerLogin})`, async () => {
|
||||
let session = null
|
||||
const pendingLogout = deferred()
|
||||
const redirects = []
|
||||
const auth = loadTypeScriptModule(new URL("../../components/auth-provider.tsx", import.meta.url), {
|
||||
react: {
|
||||
createContext: () => ({ Provider: "context" }),
|
||||
startTransition: (callback) => callback(),
|
||||
useCallback: (callback) => callback,
|
||||
useEffect: () => {},
|
||||
},
|
||||
"react/jsx-runtime": { jsx: (type, props) => ({ type, props }) },
|
||||
"next/navigation": {
|
||||
usePathname: () => "/dashboard",
|
||||
useRouter: () => ({ replace: (url) => redirects.push(url) }),
|
||||
},
|
||||
"@/components/session-provider": {
|
||||
SessionProvider: "session",
|
||||
useSession: () => ({ session, ready: true, signOut: () => pendingLogout.promise }),
|
||||
},
|
||||
"@/lib/auth": { readSession: () => session },
|
||||
})
|
||||
const dashboardElement = auth.AuthProvider({ children: null }).props.children
|
||||
const context = dashboardElement.type(dashboardElement.props)
|
||||
const signOut = context.props.value.signOut()
|
||||
if (newerLogin) session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||
pendingLogout.resolve()
|
||||
await signOut
|
||||
assert.equal(redirects.length, newerLogin ? 0 : 1)
|
||||
})
|
||||
}
|
||||
67
web/lib/api/setup-readiness.test.mjs
Normal file
67
web/lib/api/setup-readiness.test.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
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 loadSetupAPI(request) {
|
||||
const source = await readFile(new URL("./setup-readiness.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS },
|
||||
fileName: "setup-readiness.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
require(name) {
|
||||
assert.equal(name, "@/lib/api/client")
|
||||
return { request }
|
||||
},
|
||||
}
|
||||
sandbox.exports = sandbox.module.exports
|
||||
vm.runInNewContext(compiled.outputText, sandbox)
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
test("simulation normalizes null or absent arrays from older servers", async () => {
|
||||
for (const response of [{ candidate_operators: null, hops: null }, {}]) {
|
||||
const api = await loadSetupAPI(async () => response)
|
||||
const result = await api.simulateSetupRoute({ channel_id: 7 })
|
||||
assert.equal(Array.isArray(result.candidate_operators), true)
|
||||
assert.equal(result.candidate_operators.length, 0)
|
||||
assert.equal(Array.isArray(result.hops), true)
|
||||
assert.equal(result.hops.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test("simulation preserves returned candidates", async () => {
|
||||
const candidates = [{ user_id: 17, display_name: "Operator", eligible: true }]
|
||||
const api = await loadSetupAPI(async () => ({ candidate_operators: candidates, hops: [] }))
|
||||
const result = await api.simulateSetupRoute({ channel_id: 7 })
|
||||
assert.equal(result.candidate_operators, candidates)
|
||||
})
|
||||
|
||||
test("quick fix preview does not manufacture apply intent", async () => {
|
||||
const calls = []
|
||||
const api = await loadSetupAPI(async (url, options) => {
|
||||
calls.push({ url, body: JSON.parse(options.body) })
|
||||
return { applied: false, changes: [], preview_token: "reviewed" }
|
||||
})
|
||||
await api.applySetupQuickFix({ action: "fix_ai_agent_handoff", entity_id: 17 })
|
||||
assert.equal(calls[0].url, "/api/dashboard/setup/quick_fix")
|
||||
assert.equal(calls[0].body.entity_id, 17)
|
||||
assert.equal("apply" in calls[0].body, false)
|
||||
assert.equal("preview_token" in calls[0].body, false)
|
||||
})
|
||||
|
||||
test("explicit apply carries the reviewed target and fingerprint", async () => {
|
||||
let sent
|
||||
const api = await loadSetupAPI(async (_url, options) => {
|
||||
sent = JSON.parse(options.body)
|
||||
return { applied: true, changes: [], applied_actions: [] }
|
||||
})
|
||||
await api.applySetupQuickFix({ action: "fix_ai_agent_handoff", entity_id: 17, apply: true, preview_token: "current-preview" })
|
||||
assert.equal(sent.apply, true)
|
||||
assert.equal(sent.entity_id, 17)
|
||||
assert.equal(sent.preview_token, "current-preview")
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type SetupStepStatus = "ok" | "warning" | "error"
|
||||
@@ -101,31 +102,51 @@ export type SetupSimulateRouteResponse = {
|
||||
candidate_operators: SimulatedOperatorCandidate[]
|
||||
}
|
||||
|
||||
export type SetupQuickFixChange = {
|
||||
entity_type: string
|
||||
entity_id: number
|
||||
entity_name: string
|
||||
operation: "create" | "update"
|
||||
before: Record<string, unknown> | null
|
||||
after: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SetupQuickFixResponse = {
|
||||
applied: boolean
|
||||
preview_token: string
|
||||
changes: SetupQuickFixChange[]
|
||||
warning_codes: string[]
|
||||
applied_actions: string[]
|
||||
readiness: SetupReadinessResponse
|
||||
readiness?: SetupReadinessResponse
|
||||
}
|
||||
|
||||
export function fetchSetupReadiness() {
|
||||
return request<SetupReadinessResponse>("/api/dashboard/setup/readiness")
|
||||
}
|
||||
|
||||
export function simulateSetupRoute(payload: {
|
||||
export async function simulateSetupRoute(payload: {
|
||||
channel_id?: number
|
||||
bypass_ai?: boolean
|
||||
}) {
|
||||
return request<SetupSimulateRouteResponse>(
|
||||
const result = await request<SetupSimulateRouteResponse>(
|
||||
"/api/dashboard/setup/simulate_route",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
return {
|
||||
...result,
|
||||
hops: result.hops ?? [],
|
||||
candidate_operators: result.candidate_operators ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function applySetupQuickFix(payload: {
|
||||
action: SetupQuickFixAction
|
||||
entity_id?: number
|
||||
apply?: boolean
|
||||
preview_token?: string
|
||||
}) {
|
||||
return request<SetupQuickFixResponse>("/api/dashboard/setup/quick_fix", {
|
||||
method: "POST",
|
||||
|
||||
147
web/lib/api/support-availability.transport.test.mjs
Normal file
147
web/lib/api/support-availability.transport.test.mjs
Normal file
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
|
||||
|
||||
const helpers = loadTypeScriptModule(new URL("../support-availability.ts", import.meta.url), {}, { Date, Intl, Set })
|
||||
|
||||
// Execute real API wrappers with offline transport and a deterministic deadline clock.
|
||||
function fixture(request, initialConfig = {}) {
|
||||
const timers = new Map()
|
||||
let timerId = 0
|
||||
let runtimeConfig = initialConfig
|
||||
const api = loadTypeScriptModule(new URL("./support-availability.ts", import.meta.url), {
|
||||
"@/lib/api/client": { request },
|
||||
"@/lib/support-availability": helpers,
|
||||
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => runtimeConfig },
|
||||
}, {
|
||||
AbortController,
|
||||
process: { env: {} },
|
||||
setTimeout(callback, delay) {
|
||||
const id = ++timerId
|
||||
timers.set(id, { callback, delay })
|
||||
return id
|
||||
},
|
||||
clearTimeout(id) { timers.delete(id) },
|
||||
})
|
||||
return {
|
||||
api,
|
||||
timers,
|
||||
setRuntimeConfig(value) { runtimeConfig = value },
|
||||
}
|
||||
}
|
||||
|
||||
test("calendar reads normalize nullable collections without changing the transport result", async () => {
|
||||
const transportResult = {
|
||||
teamId: 7, mode: "legacy", timezone: "", effectiveTimezone: "UTC", deploymentTimezone: "UTC",
|
||||
weekly: null, exceptions: [{ date: "2026-12-31", closed: true, periods: null }],
|
||||
}
|
||||
const f = fixture(async () => transportResult)
|
||||
const calendar = await f.api.fetchSupportCalendar(7)
|
||||
assert.equal(calendar.weekly.length, 0)
|
||||
assert.equal(calendar.exceptions[0].periods.length, 0)
|
||||
assert.equal(transportResult.weekly, null)
|
||||
assert.equal(transportResult.exceptions[0].periods, null)
|
||||
assert.equal(f.timers.size, 0)
|
||||
|
||||
transportResult.exceptions = null
|
||||
assert.equal((await f.api.fetchSupportCalendar(7)).exceptions.length, 0)
|
||||
})
|
||||
|
||||
test("an already-aborted caller signal is not replaced with a live deadline signal", async () => {
|
||||
const caller = new AbortController()
|
||||
caller.abort()
|
||||
const f = fixture(async (_path, options) => {
|
||||
assert.equal(options.signal.aborted, true)
|
||||
throw new Error("cancelled fixture request")
|
||||
})
|
||||
await assert.rejects(f.api.fetchMyAvailability(caller.signal), /cancelled fixture request/)
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test("the next availability read can recover after a deadline abort", async () => {
|
||||
let calls = 0
|
||||
const f = fixture((_path, options) => {
|
||||
if (++calls > 1) return Promise.resolve({ userId: 42, eligible: false, reason: "offline" })
|
||||
return new Promise((_, reject) => options.signal.addEventListener("abort", () => reject(new Error("fixture deadline")), { once: true }))
|
||||
})
|
||||
const first = f.api.fetchMyAvailability()
|
||||
const deadline = [...f.timers.values()][0]
|
||||
assert.equal(deadline.delay, 10000)
|
||||
deadline.callback()
|
||||
await assert.rejects(first, /fixture deadline/)
|
||||
assert.equal(f.timers.size, 0)
|
||||
const next = await f.api.fetchMyAvailability()
|
||||
assert.equal(next.userId, 42)
|
||||
assert.equal(next.eligible, false)
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test("concurrent availability reads own separate abort signals and deadlines", async () => {
|
||||
const pending = []
|
||||
const f = fixture((_path, options) => {
|
||||
const response = deferred()
|
||||
options.signal.addEventListener("abort", () => response.reject(new Error("fixture deadline")), { once: true })
|
||||
pending.push({ ...response, signal: options.signal })
|
||||
return response.promise
|
||||
})
|
||||
const first = f.api.fetchMyAvailability()
|
||||
const second = f.api.fetchAvailabilityAgents(19)
|
||||
assert.equal(f.timers.size, 2)
|
||||
assert.notEqual(pending[0].signal, pending[1].signal)
|
||||
;[...f.timers.values()][0].callback()
|
||||
await assert.rejects(first, /fixture deadline/)
|
||||
assert.equal(pending[1].signal.aborted, false)
|
||||
pending[1].resolve([])
|
||||
assert.equal((await second).length, 0)
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test("settled reads remove the caller's abort listener on both success and failure", async () => {
|
||||
for (const fail of [false, true]) {
|
||||
const listeners = new Set()
|
||||
const signal = {
|
||||
aborted: false,
|
||||
addEventListener(event, listener) { assert.equal(event, "abort"); listeners.add(listener) },
|
||||
removeEventListener(event, listener) { assert.equal(event, "abort"); assert.equal(listeners.delete(listener), true) },
|
||||
}
|
||||
const f = fixture(async () => {
|
||||
assert.equal(listeners.size, 1)
|
||||
if (fail) throw new Error("fixture transport failure")
|
||||
return null
|
||||
})
|
||||
const pending = f.api.fetchMyAvailability(signal)
|
||||
if (fail) await assert.rejects(pending, /fixture transport failure/)
|
||||
else assert.equal(await pending, null)
|
||||
assert.equal(listeners.size, 0)
|
||||
assert.equal(f.timers.size, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test("each public check reads the current runtime channel and endpoint rather than caching the prior identity", async () => {
|
||||
const calls = []
|
||||
const f = fixture(async (path, options) => {
|
||||
calls.push({ path, options })
|
||||
return { available: false, reason: "temporarily_absent", timezone: "UTC" }
|
||||
}, { channelId: "channel-a", apiBaseUrl: "https://first.example.invalid/", baseUrl: "https://unused.example.invalid/" })
|
||||
await f.api.fetchPublicSupportStatus()
|
||||
f.setRuntimeConfig({ channelId: "channel-b", baseUrl: "https://second.example.invalid/" })
|
||||
await f.api.fetchPublicSupportStatus()
|
||||
assert.deepEqual(calls.map(({ options }) => options.baseUrl), ["https://first.example.invalid", "https://second.example.invalid"])
|
||||
assert.deepEqual(calls.map(({ options }) => options.headers["X-Channel-Id"]), ["channel-a", "channel-b"])
|
||||
for (const { path, options } of calls) {
|
||||
assert.equal(path, "/api/support/status")
|
||||
assert.equal(options.skipAuth, true)
|
||||
assert.deepEqual(Object.keys(options.headers), ["X-Channel-Id"])
|
||||
assert.equal(options.body, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test("successful public HTTP requests never manufacture human availability", async () => {
|
||||
for (const reason of ["outside_hours", "temporarily_absent", "capacity"]) {
|
||||
const response = { available: false, reason, timezone: "UTC", nextOpeningAt: "2026-12-31T09:00:00Z" }
|
||||
const f = fixture(async () => response)
|
||||
assert.equal(await f.api.fetchPublicSupportStatus(), response)
|
||||
assert.equal(response.available, false)
|
||||
assert.equal(f.timers.size, 0)
|
||||
}
|
||||
})
|
||||
67
web/lib/api/support-availability.ts
Normal file
67
web/lib/api/support-availability.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// Availability is computed by the server; never infer presence from HTTP success.
|
||||
import { supportCalendarPayload } from "@/lib/support-availability"
|
||||
import { request } from "@/lib/api/client"
|
||||
import type { ServiceStatus } from "@/lib/generated/enums"
|
||||
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
|
||||
|
||||
export type SupportPeriod = { start: string; end: string }
|
||||
export type SupportCalendar = {
|
||||
teamId: number
|
||||
mode: "legacy" | "recurring"
|
||||
timezone: string
|
||||
effectiveTimezone: string
|
||||
deploymentTimezone: string
|
||||
weekly: (SupportPeriod & { weekday: number })[]
|
||||
exceptions: { date: string; closed: boolean; periods: SupportPeriod[] }[]
|
||||
}
|
||||
export type AvailabilityReason = "eligible" | "profile_disabled" | "user_disabled" | "team_disabled" | "outside_hours" | "offline" | "busy" | "break" | "unavailable" | "vacation" | "auto_assign_disabled" | "capacity"
|
||||
export type OperatorAvailability = {
|
||||
userId: number
|
||||
profileId: number
|
||||
teamId: number
|
||||
displayName?: string
|
||||
serviceStatus: ServiceStatus
|
||||
effectiveServiceStatus: ServiceStatus
|
||||
returnServiceStatus?: ServiceStatus
|
||||
absenceStartAt?: string
|
||||
absenceEndAt?: string
|
||||
online: boolean
|
||||
eligible: boolean
|
||||
reason: AvailabilityReason
|
||||
calendar: SupportCalendar
|
||||
calendarOpen: boolean
|
||||
nextOpeningAt?: string
|
||||
}
|
||||
export type AvailabilityUpdate = { serviceStatus: ServiceStatus; absenceStartAt?: string; absenceEndAt?: string }
|
||||
export type CalendarPreview = { open: boolean; timezone: string; nextOpeningAt?: string; mode: SupportCalendar["mode"] }
|
||||
export type PublicSupportStatus = { available: boolean; reason: "available" | "outside_hours" | "temporarily_absent" | "capacity"; timezone: string; nextOpeningAt?: string }
|
||||
const base = "/api/dashboard/support_availability"
|
||||
// Bound background reads so one hung request cannot freeze polling forever.
|
||||
async function availabilityRead<T>(path: string, options: Parameters<typeof request>[1] = {}) {
|
||||
const controller = new AbortController()
|
||||
const abort = () => controller.abort()
|
||||
const source = options.signal
|
||||
if (source?.aborted) controller.abort()
|
||||
else source?.addEventListener("abort", abort, { once: true })
|
||||
const deadline = setTimeout(abort, 10000)
|
||||
try { return await request<T>(path, { ...options, signal: controller.signal }) }
|
||||
finally { clearTimeout(deadline); source?.removeEventListener("abort", abort) }
|
||||
}
|
||||
export function fetchMyAvailability(signal?: AbortSignal) { return availabilityRead<OperatorAvailability | null>(`${base}/me`, { signal }) }
|
||||
export function updateMyAvailability(payload: AvailabilityUpdate) { return request<OperatorAvailability>(`${base}/me`, { method: "POST", body: JSON.stringify(payload) }) }
|
||||
export function fetchAvailabilityAgents(conversationId?: number, signal?: AbortSignal) { return availabilityRead<OperatorAvailability[]>(`${base}/agents${conversationId ? `?conversationId=${conversationId}` : ""}`, { signal }) }
|
||||
export function updateAgentAvailability(userId: number, payload: AvailabilityUpdate) { return request<OperatorAvailability>(`${base}/agent`, { method: "POST", body: JSON.stringify({ ...payload, userId }) }) }
|
||||
export async function fetchSupportCalendar(teamId: number) {
|
||||
const data = await availabilityRead<SupportCalendar>(`${base}/calendar?teamId=${teamId}`)
|
||||
return { ...data, weekly: data.weekly ?? [], exceptions: (data.exceptions ?? []).map(item => ({ ...item, periods: item.periods ?? [] })) }
|
||||
}
|
||||
export function updateSupportCalendar(calendar: SupportCalendar) { return request<SupportCalendar>(`${base}/calendar`, { method: "POST", body: JSON.stringify(supportCalendarPayload(calendar)) }) }
|
||||
export function previewSupportCalendar(calendar: SupportCalendar, at?: string) { return availabilityRead<CalendarPreview>(`${base}/calendar/preview`, { method: "POST", body: JSON.stringify({ calendar: supportCalendarPayload(calendar), at }) }) }
|
||||
export function fetchPublicSupportStatus(signal?: AbortSignal) {
|
||||
const config = readSupportChatRuntimeConfig()
|
||||
return availabilityRead<PublicSupportStatus>("/api/support/status", {
|
||||
skipAuth: true, signal,
|
||||
baseUrl: (config.apiBaseUrl || config.baseUrl || process.env.NEXT_PUBLIC_API_BASE_URL || "").trim().replace(/\/$/, ""),
|
||||
headers: { "X-Channel-Id": config.channelId || process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || "" },
|
||||
})
|
||||
}
|
||||
@@ -337,10 +337,16 @@ export const KnowledgeRetrieveSceneLabels: Record<KnowledgeRetrieveScene, string
|
||||
export enum ServiceStatus {
|
||||
Idle = 0,
|
||||
Busy = 1,
|
||||
Break = 2,
|
||||
Unavailable = 3,
|
||||
Vacation = 4,
|
||||
}
|
||||
export const ServiceStatusLabels: Record<ServiceStatus, string> = {
|
||||
[ServiceStatus.Idle]: "空闲",
|
||||
[ServiceStatus.Busy]: "忙碌",
|
||||
[ServiceStatus.Break]: "休息",
|
||||
[ServiceStatus.Unavailable]: "不可用",
|
||||
[ServiceStatus.Vacation]: "休假",
|
||||
}
|
||||
|
||||
export enum Status {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import ts from "typescript"
|
||||
@@ -54,3 +55,27 @@ test("localizes knowledge retrieve enum labels from stable values", async () =>
|
||||
assert.equal(getKnowledgeAnswerStatusLabel(2, "\u65e0\u7b54\u6848", t), "No answer")
|
||||
assert.equal(getKnowledgeChunkProviderLabel("semantic", t), "Semantic chunks")
|
||||
})
|
||||
|
||||
for (const locale of ["en-US", "ru-RU", "zh-CN"]) {
|
||||
test(`knowledge labels and safe errors use ${locale} catalog, not server names`, async () => {
|
||||
const helpers = await loadModule()
|
||||
const catalog = JSON.parse(await readFile(new URL(`../messages/${locale}.json`, import.meta.url), "utf8"))
|
||||
const translate = (key) => key.split(".").reduce((value, part) => value?.[part], catalog) ?? key
|
||||
for (const [code, key] of [["document", "typeDocument"], ["faq", "typeFAQ"]]) {
|
||||
assert.equal(helpers.getKnowledgeTypeLabel(code, translate), catalog.knowledge[key])
|
||||
}
|
||||
for (const [code, key] of [[0, "statusOk"], [1, "statusDisabled"], [2, "statusDeleted"]]) {
|
||||
assert.equal(helpers.getKnowledgeStatusLabel(code, translate), catalog.knowledge[key])
|
||||
}
|
||||
for (const [code, key] of [["pending", "indexPending"], ["indexed", "indexIndexed"], ["failed", "indexFailed"]]) {
|
||||
assert.equal(helpers.getKnowledgeIndexStatusLabel(code, translate), catalog.knowledge[key])
|
||||
}
|
||||
assert.equal(helpers.getKnowledgeStatusLabel(999, translate), catalog.knowledge.unknownStatus)
|
||||
assert.equal(helpers.getKnowledgeTypeLabel("future", translate), catalog.knowledge.unknownType)
|
||||
assert.equal(helpers.getKnowledgeIndexErrorLabel("SQL password=secret-example", translate), catalog.knowledge.indexFailureSummary)
|
||||
assert.equal(helpers.getKnowledgeIndexErrorLabel("", translate), "")
|
||||
for (const key of ["requestId", "rerank", "topScore", "promptTokens", "completionTokens", "traceData", "chunkNumber", "providerValue", "scoreValue", "rerankValue", "historyRecordedNotice"]) {
|
||||
assert.ok(catalog.knowledge[key], `missing ${locale} knowledge.${key}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
const CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
@@ -46,3 +47,37 @@ export function getKnowledgeChunkProviderLabel(value: string, t: TFunction) {
|
||||
function translateByKey(key: string | undefined, fallback: string, t: TFunction) {
|
||||
return key ? t(key) : fallback
|
||||
}
|
||||
|
||||
const TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
document: "knowledge.typeDocument",
|
||||
faq: "knowledge.typeFAQ",
|
||||
}
|
||||
|
||||
const STATUS_LABEL_KEYS: Record<number, string> = {
|
||||
0: "knowledge.statusOk",
|
||||
1: "knowledge.statusDisabled",
|
||||
2: "knowledge.statusDeleted",
|
||||
}
|
||||
|
||||
const INDEX_STATUS_LABEL_KEYS: Record<string, string> = {
|
||||
pending: "knowledge.indexPending",
|
||||
indexed: "knowledge.indexIndexed",
|
||||
failed: "knowledge.indexFailed",
|
||||
}
|
||||
|
||||
export function getKnowledgeTypeLabel(value: string, t: TFunction) {
|
||||
return translateByKey(TYPE_LABEL_KEYS[value], t("knowledge.unknownType"), t)
|
||||
}
|
||||
|
||||
export function getKnowledgeStatusLabel(value: number, t: TFunction) {
|
||||
return translateByKey(STATUS_LABEL_KEYS[value], t("knowledge.unknownStatus"), t)
|
||||
}
|
||||
|
||||
export function getKnowledgeIndexStatusLabel(value: string, t: TFunction) {
|
||||
return translateByKey(INDEX_STATUS_LABEL_KEYS[value], t("knowledge.unknownStatus"), t)
|
||||
}
|
||||
|
||||
// Never expose a stored provider/SQL error, even when talking to an older server.
|
||||
export function getKnowledgeIndexErrorLabel(error: string | undefined, t: TFunction) {
|
||||
return error ? t("knowledge.indexFailureSummary") : ""
|
||||
}
|
||||
|
||||
99
web/lib/message-editor-drafts.test.mjs
Normal file
99
web/lib/message-editor-drafts.test.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
|
||||
import { createMessageEditorDraftStore } from "./message-editor-drafts.ts"
|
||||
|
||||
test("conversation drafts stay separate across A → B → A", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
const a = store.get("1")
|
||||
store.update(a, "<p>For A</p>")
|
||||
const b = store.get("2")
|
||||
assert.equal(b.html, "")
|
||||
store.update(b, "<p>For B</p>")
|
||||
assert.equal(store.get("1").html, "<p>For A</p>")
|
||||
assert.equal(store.get("2").html, "<p>For B</p>")
|
||||
})
|
||||
|
||||
test("failed send preserves text and uploaded image metadata, and can retry", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
const draft = store.get("1")
|
||||
store.update(draft, '<p>Message</p><img title="uploading-test">')
|
||||
draft.uploadedImages.set("uploading-test", { assetId: "test", provider: "local", storageKey: "test", url: "/test" })
|
||||
assert.equal(store.finishSend(store.beginSend("1"), false), false)
|
||||
assert.equal(draft.html, '<p>Message</p><img title="uploading-test">')
|
||||
assert.equal(draft.uploadedImages.size, 1)
|
||||
assert.ok(store.beginSend("1"))
|
||||
})
|
||||
|
||||
test("send is single-flight, success clears only the captured draft", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
const a = store.get("1")
|
||||
const b = store.get("2")
|
||||
store.update(a, "For A")
|
||||
store.update(b, "For B")
|
||||
const sent = store.beginSend("1")
|
||||
assert.equal(store.beginSend("1"), null)
|
||||
assert.equal(store.beginSend("2"), null)
|
||||
assert.equal(store.finishSend(sent, true), true)
|
||||
assert.equal(a.html, "")
|
||||
assert.equal(b.html, "For B")
|
||||
assert.ok(store.beginSend("2"))
|
||||
})
|
||||
|
||||
test("typing during a pending send survives success, even after returning to identical text", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
const draft = store.get("1")
|
||||
store.update(draft, "Initial")
|
||||
const sent = store.beginSend("1")
|
||||
store.update(draft, "Next draft")
|
||||
assert.equal(store.finishSend(sent, true), false)
|
||||
assert.equal(draft.html, "Next draft")
|
||||
const nextSend = store.beginSend("1")
|
||||
store.update(draft, "Changed")
|
||||
store.update(draft, "Next draft")
|
||||
assert.equal(store.finishSend(nextSend, true), false)
|
||||
assert.equal(draft.html, "Next draft")
|
||||
})
|
||||
|
||||
test("identity change clears private drafts and invalidates old completions", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
store.setScope("account-a")
|
||||
const previous = store.get("1")
|
||||
store.update(previous, "Private")
|
||||
const sent = store.beginSend("1")
|
||||
store.setScope("account-b")
|
||||
const next = store.get("1")
|
||||
store.update(next, "New account")
|
||||
const nextSent = store.beginSend("1")
|
||||
assert.equal(store.contains("1", previous), false)
|
||||
assert.equal(store.finishSend(sent, true), false)
|
||||
assert.equal(next.html, "New account")
|
||||
assert.equal(store.finishSend(nextSent, true), true)
|
||||
})
|
||||
|
||||
test("a newly mounted editor observes completion from the previous editor instance", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
const draft = store.get("1")
|
||||
store.update(draft, "Pending")
|
||||
const sent = store.beginSend("1")
|
||||
let rendered = draft.html
|
||||
const unsubscribe = store.subscribe((changed) => {
|
||||
if (changed === draft) rendered = changed.html
|
||||
})
|
||||
store.finishSend(sent, true)
|
||||
assert.equal(rendered, "")
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
test("pending status stays observable when switching or remounting editors", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
store.get("1")
|
||||
const states = []
|
||||
const unsubscribe = store.subscribeSending(() => states.push(store.isSending()))
|
||||
const sent = store.beginSend("1")
|
||||
store.get("2")
|
||||
assert.equal(store.isSending(), true)
|
||||
store.finishSend(sent, false)
|
||||
assert.deepEqual(states, [true, false])
|
||||
unsubscribe()
|
||||
})
|
||||
95
web/lib/message-editor-drafts.ts
Normal file
95
web/lib/message-editor-drafts.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { UploadedEditorImage } from "./im-editor-image"
|
||||
|
||||
export type MessageEditorDraft = {
|
||||
html: string
|
||||
revision: number
|
||||
objectUrls: Set<string>
|
||||
uploadedImages: Map<string, UploadedEditorImage & { url: string }>
|
||||
}
|
||||
|
||||
type PendingDraftSend = {
|
||||
key: string
|
||||
draft: MessageEditorDraft
|
||||
revision: number
|
||||
}
|
||||
|
||||
/** In-memory only: neither messages nor temporary image URLs belong in storage. */
|
||||
export function createMessageEditorDraftStore() {
|
||||
const drafts = new Map<string, MessageEditorDraft>()
|
||||
let pendingSend: PendingDraftSend | null = null
|
||||
let scope: string | undefined
|
||||
const sendingListeners = new Set<() => void>()
|
||||
const notifySending = () => { for (const listener of sendingListeners) listener() }
|
||||
const listeners = new Set<(draft: MessageEditorDraft) => void>()
|
||||
const notify = (draft: MessageEditorDraft) => { for (const listener of listeners) listener(draft) }
|
||||
|
||||
function release(draft: MessageEditorDraft) {
|
||||
for (const url of draft.objectUrls) URL.revokeObjectURL(url)
|
||||
draft.objectUrls.clear()
|
||||
draft.uploadedImages.clear()
|
||||
}
|
||||
|
||||
function clear() {
|
||||
for (const draft of drafts.values()) release(draft)
|
||||
drafts.clear()
|
||||
pendingSend = null
|
||||
notifySending()
|
||||
}
|
||||
|
||||
return {
|
||||
clear,
|
||||
notify,
|
||||
subscribe(listener: (draft: MessageEditorDraft) => void) {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
isSending() { return pendingSend !== null },
|
||||
subscribeSending(listener: () => void) {
|
||||
sendingListeners.add(listener)
|
||||
return () => { sendingListeners.delete(listener) }
|
||||
},
|
||||
setScope(nextScope: string) {
|
||||
if (scope !== nextScope) {
|
||||
clear()
|
||||
scope = nextScope
|
||||
}
|
||||
},
|
||||
get(key: string): MessageEditorDraft {
|
||||
let draft = drafts.get(key)
|
||||
if (!draft) {
|
||||
draft = { html: "", revision: 0, objectUrls: new Set(), uploadedImages: new Map() }
|
||||
drafts.set(key, draft)
|
||||
}
|
||||
return draft
|
||||
},
|
||||
contains(key: string, draft: MessageEditorDraft) {
|
||||
return drafts.get(key) === draft
|
||||
},
|
||||
update(draft: MessageEditorDraft, html: string) {
|
||||
draft.html = html
|
||||
draft.revision += 1
|
||||
},
|
||||
beginSend(key: string): PendingDraftSend | null {
|
||||
const draft = drafts.get(key)
|
||||
if (!draft || pendingSend) return null
|
||||
pendingSend = { key, draft, revision: draft.revision }
|
||||
notifySending()
|
||||
return pendingSend
|
||||
},
|
||||
finishSend(sent: PendingDraftSend, succeeded: boolean) {
|
||||
if (pendingSend !== sent) return false
|
||||
pendingSend = null
|
||||
notifySending()
|
||||
if (!succeeded || drafts.get(sent.key) !== sent.draft || sent.draft.revision !== sent.revision) {
|
||||
return false
|
||||
}
|
||||
sent.draft.html = ""
|
||||
sent.draft.revision += 1
|
||||
release(sent.draft)
|
||||
notify(sent.draft)
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type MessageEditorDraftStore = ReturnType<typeof createMessageEditorDraftStore>
|
||||
14
web/lib/message-file-identity.test.mjs
Normal file
14
web/lib/message-file-identity.test.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { getMessageFileIdentity } from "./message-file-identity.ts"
|
||||
|
||||
const file = (content, name = "attachment.txt") => ({ name, type: "text/plain",
|
||||
arrayBuffer: async () => new TextEncoder().encode(content).buffer })
|
||||
|
||||
test("reselecting identical file content keeps attachment retry identity", async () => {
|
||||
assert.equal(await getMessageFileIdentity(file("same")), await getMessageFileIdentity(file("same")))
|
||||
})
|
||||
|
||||
test("equal-name equal-size files with different bytes cannot share retry identity", async () => {
|
||||
assert.notEqual(await getMessageFileIdentity(file("left")), await getMessageFileIdentity(file("next")))
|
||||
})
|
||||
13
web/lib/message-file-identity.ts
Normal file
13
web/lib/message-file-identity.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Retry identity for a selected attachment. A file picker returns a fresh File
|
||||
* object on reselection; metadata alone can also collide across different bytes.
|
||||
*/
|
||||
export async function getMessageFileIdentity(file: Pick<File, "name" | "type" | "arrayBuffer">) {
|
||||
const bytes = await file.arrayBuffer()
|
||||
const subtle = globalThis.crypto?.subtle
|
||||
// Self-hosted HTTP deployments may lack WebCrypto. Exact bytes provide a
|
||||
// collision-free in-memory fallback, rather than silently weakening the key.
|
||||
const digest = subtle ? await subtle.digest("SHA-256", bytes) : bytes
|
||||
const key = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
|
||||
return JSON.stringify([file.name, file.type, subtle ? "sha256" : "bytes", key])
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export type DashboardNavItemConfig = Omit<DashboardNavMenuItem, "title"> & {
|
||||
* admin can see the module.
|
||||
*/
|
||||
requiredPermission?: string;
|
||||
requiredAnyPermission?: readonly string[];
|
||||
};
|
||||
|
||||
export type DashboardNavSectionConfig = {
|
||||
@@ -51,6 +52,9 @@ function navItemVisible(
|
||||
superAdmin: boolean,
|
||||
permissionSet: Set<string>,
|
||||
): boolean {
|
||||
if (item.requiredAnyPermission) {
|
||||
return item.requiredAnyPermission.some((permission) => permissionSet.has(permission));
|
||||
}
|
||||
if (superAdmin) {
|
||||
return true;
|
||||
}
|
||||
@@ -264,6 +268,12 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
|
||||
titleKey: "nav.system",
|
||||
icon: <ShieldCheckIcon />,
|
||||
items: [
|
||||
{
|
||||
titleKey: "supportAvailability.title",
|
||||
url: "/dashboard/support-availability",
|
||||
icon: <CalendarClockIcon />,
|
||||
requiredAnyPermission: ["supportConfig.view", "agentTeamSchedule.view"],
|
||||
},
|
||||
{
|
||||
titleKey: "nav.setupWizard",
|
||||
url: "/dashboard/setup",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
|
||||
// Modified for hardening and realtime recovery; see docs/hardening/README.md and docs/fixes/chat-realtime-recovery.md.
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
|
||||
@@ -208,3 +208,41 @@ test("becoming ineligible during authentication clears the opening deadline", as
|
||||
t.mock.timers.tick(200)
|
||||
assert.equal(errors, 0)
|
||||
})
|
||||
|
||||
test("disconnect invalidates a pending beforeConnect even across reconnect", async () => {
|
||||
let resolveOld
|
||||
let attempts = 0, created = 0
|
||||
const old = new Promise((resolve) => { resolveOld = resolve })
|
||||
const manager = createRealtimeConnectionManager({
|
||||
beforeConnect: () => ++attempts === 1 ? old : Promise.resolve(),
|
||||
createSocket: () => { created++; return new MockWebSocket() },
|
||||
})
|
||||
const first = manager.connect()
|
||||
manager.disconnect()
|
||||
await manager.connect()
|
||||
resolveOld()
|
||||
await first
|
||||
assert.equal(created, 1)
|
||||
manager.disconnect()
|
||||
})
|
||||
|
||||
test("late old socket close cannot clear current connection or notify current owner", async () => {
|
||||
let closed = 0
|
||||
const sockets = []
|
||||
const manager = createRealtimeConnectionManager({
|
||||
createSocket: () => {
|
||||
const socket = new MockWebSocket()
|
||||
// Simulate an asynchronously arriving close event after replacement.
|
||||
socket.close = () => { socket.readyState = MockWebSocket.CLOSED }
|
||||
sockets.push(socket)
|
||||
return socket
|
||||
},
|
||||
onClose: () => { closed++ },
|
||||
})
|
||||
await manager.connect()
|
||||
await manager.connect()
|
||||
sockets[0].emit("close", {})
|
||||
assert.equal(manager.getSocket(), sockets[1])
|
||||
assert.equal(closed, 0)
|
||||
manager.disconnect()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
|
||||
// Modified for hardening and realtime recovery; see docs/hardening/README.md and docs/fixes/chat-realtime-recovery.md.
|
||||
"use client"
|
||||
|
||||
export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected"
|
||||
|
||||
11
web/lib/realtime-log-context.test.mjs
Normal file
11
web/lib/realtime-log-context.test.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { realtimeLogContext } from "./realtime-log-context.ts"
|
||||
|
||||
test("socket diagnostics contain only origin and path, never URL credentials or query", () => {
|
||||
assert.deepEqual(realtimeLogContext("wss://user:password@example.test/api/ws/admin?accessToken=not-a-secret#fragment"), {
|
||||
origin: "wss://example.test",
|
||||
path: "/api/ws/admin",
|
||||
})
|
||||
assert.deepEqual(realtimeLogContext("invalid token-bearing input"), { origin: "", path: "" })
|
||||
})
|
||||
9
web/lib/realtime-log-context.ts
Normal file
9
web/lib/realtime-log-context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/** Never include WebSocket query parameters, userinfo, or arbitrary close reasons in logs. */
|
||||
export function realtimeLogContext(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return { origin: parsed.origin, path: parsed.pathname }
|
||||
} catch {
|
||||
return { origin: "", path: "" }
|
||||
}
|
||||
}
|
||||
32
web/lib/session-request-guard.test.mjs
Normal file
32
web/lib/session-request-guard.test.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { createSessionRequestGuard, isSameSession } from "./session-request-guard.ts"
|
||||
|
||||
const account = (id, accessToken) => ({ user: { id }, accessToken })
|
||||
|
||||
test("late profile success, error, and finally are stale after sign-out", () => {
|
||||
let current = account(1, "test-session-a")
|
||||
const guard = createSessionRequestGuard(() => current)
|
||||
const isCurrent = guard.begin(current)
|
||||
current = null
|
||||
assert.equal(isCurrent(), false)
|
||||
})
|
||||
|
||||
test("old account and rotated-token responses cannot clear the current session", () => {
|
||||
const previous = account(1, "test-session-a")
|
||||
assert.equal(isSameSession(previous, account(2, "test-session-b")), false)
|
||||
assert.equal(isSameSession(previous, account(1, "test-session-rotated")), false)
|
||||
assert.equal(isSameSession(null, account(1, "test-session-a")), false)
|
||||
assert.equal(isSameSession(previous, previous), true)
|
||||
})
|
||||
|
||||
test("only the latest profile request can commit, including same-token requests", () => {
|
||||
const current = account(1, "test-session-a")
|
||||
const guard = createSessionRequestGuard(() => current)
|
||||
const first = guard.begin(current)
|
||||
const second = guard.begin(current)
|
||||
assert.equal(first(), false)
|
||||
assert.equal(second(), true)
|
||||
guard.invalidate()
|
||||
assert.equal(second(), false)
|
||||
})
|
||||
20
web/lib/session-request-guard.ts
Normal file
20
web/lib/session-request-guard.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Kept transport-independent so all late session completions use the same rule.
|
||||
export type SessionIdentity = {
|
||||
accessToken: string
|
||||
user: { id: number }
|
||||
}
|
||||
|
||||
export function isSameSession(expected: SessionIdentity | null, current: SessionIdentity | null) {
|
||||
return Boolean(expected && current && expected.accessToken === current.accessToken && expected.user.id === current.user.id)
|
||||
}
|
||||
|
||||
export function createSessionRequestGuard(readCurrent: () => SessionIdentity | null) {
|
||||
let generation = 0
|
||||
return {
|
||||
invalidate() { generation += 1 },
|
||||
begin(session: SessionIdentity) {
|
||||
const requestGeneration = ++generation
|
||||
return () => generation === requestGeneration && isSameSession(session, readCurrent())
|
||||
},
|
||||
}
|
||||
}
|
||||
127
web/lib/stores/agent-conversations.test.mjs
Normal file
127
web/lib/stores/agent-conversations.test.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
|
||||
|
||||
function createStore(initialize) {
|
||||
let state
|
||||
const setState = (update, replace = false) => {
|
||||
const next = typeof update === "function" ? update(state) : update
|
||||
state = replace ? next : { ...state, ...next }
|
||||
}
|
||||
state = initialize(setState, () => state)
|
||||
const initial = state
|
||||
return { getState: () => state, setState, getInitialState: () => initial }
|
||||
}
|
||||
|
||||
function fixture(api = {}) {
|
||||
let session = { user: { id: 1 }, accessToken: "test-session-a" }
|
||||
const loaded = loadTypeScriptModule(new URL("./agent-conversations.ts", import.meta.url), {
|
||||
zustand: { create: createStore },
|
||||
"@/lib/api/agent": api,
|
||||
"@/lib/auth": { readSession: () => session },
|
||||
"@/lib/utils": { generateUUID: () => "test-id" },
|
||||
"@/lib/im-message": { summarizeIMMessage: () => "test-summary" },
|
||||
"@/lib/im-message-merge": {
|
||||
cursorFromLoadedImMessages: (messages) => String(messages[0]?.id ?? ""),
|
||||
hasMoreAfterLatestImMessageMerge: () => false,
|
||||
mergeImMessagesByIdAsc: (left, right) => [...left, ...right],
|
||||
parseImMessageCursorId: Number,
|
||||
},
|
||||
"@/lib/im-realtime-state": {
|
||||
patchConversationList: (list) => list,
|
||||
patchConversationListWithMessage: (list) => list,
|
||||
},
|
||||
"@/lib/agent-activity-state": {},
|
||||
"@/lib/operator-presence-state": {},
|
||||
})
|
||||
return {
|
||||
store: loaded.useAgentConversationsStore,
|
||||
reset: loaded.resetAgentConversations,
|
||||
set session(next) { session = next },
|
||||
}
|
||||
}
|
||||
|
||||
test("late conversation list cannot restore private data after reset/account change", async () => {
|
||||
const old = deferred()
|
||||
const f = fixture({ fetchAgentConversations: () => old.promise })
|
||||
const loading = f.store.getState().loadConversations()
|
||||
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||
f.reset()
|
||||
f.store.setState({ conversations: [{ id: 20 }], selectedConversationId: 20 })
|
||||
old.resolve({ results: [{ id: 10 }] })
|
||||
await loading
|
||||
assert.equal(f.store.getState().conversations[0].id, 20)
|
||||
assert.equal(f.store.getState().selectedConversationId, 20)
|
||||
})
|
||||
|
||||
test("same-account relogin invalidates old load generations too", async () => {
|
||||
const old = deferred()
|
||||
const f = fixture({ fetchAgentMessages: () => old.promise })
|
||||
f.store.setState({ selectedConversationId: 10 })
|
||||
const loading = f.store.getState().loadMessages(10)
|
||||
f.reset()
|
||||
f.store.setState({ selectedConversationId: 10, messages: [{ id: 200 }] })
|
||||
old.resolve({ results: [{ id: 100 }] })
|
||||
await loading
|
||||
assert.equal(f.store.getState().messages[0].id, 200)
|
||||
})
|
||||
|
||||
test("operator send binds its recipient, is single-flight, and does not patch the new selection", async () => {
|
||||
const response = deferred()
|
||||
const calls = []
|
||||
const f = fixture({ sendAgentMessage: (payload) => { calls.push(payload); return response.promise } })
|
||||
f.store.setState({ selectedConversationId: 10 })
|
||||
assert.equal(await f.store.getState().sendMessage("Wrong recipient", 20), null)
|
||||
const sending = f.store.getState().sendMessage("For A", 10)
|
||||
assert.equal(await f.store.getState().sendMessage("Duplicate", 10), null)
|
||||
f.store.setState({ selectedConversationId: 20, messages: [{ id: 200 }] })
|
||||
response.resolve({ id: 100, conversationId: 10 })
|
||||
await sending
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].conversationId, 10)
|
||||
assert.equal(f.store.getState().messages[0].id, 200)
|
||||
})
|
||||
|
||||
test("old send finally does not unlock a new account's pending send", async () => {
|
||||
const old = deferred()
|
||||
const f = fixture({ sendAgentMessage: () => old.promise })
|
||||
f.store.setState({ selectedConversationId: 10 })
|
||||
const sending = f.store.getState().sendMessage("For A", 10)
|
||||
f.reset()
|
||||
f.store.setState({ sending: true, selectedConversationId: 20 })
|
||||
old.resolve({ id: 100, conversationId: 10 })
|
||||
await sending
|
||||
assert.equal(f.store.getState().sending, true)
|
||||
})
|
||||
|
||||
test("attachment upload cannot send under a new account after identity change", async () => {
|
||||
const upload = deferred()
|
||||
let sends = 0
|
||||
const f = fixture({
|
||||
uploadAgentConversationAttachment: () => upload.promise,
|
||||
sendAgentMessage: async () => { sends += 1; return { id: 100 } },
|
||||
})
|
||||
f.store.setState({ selectedConversationId: 10 })
|
||||
const pending = f.store.getState().sendAttachment({}, 10)
|
||||
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||
f.reset()
|
||||
upload.resolve({ assetId: "test", filename: "test.txt" })
|
||||
assert.equal(await pending, null)
|
||||
assert.equal(sends, 0)
|
||||
})
|
||||
|
||||
test("a fresh snapshot invalidates old pagination without leaving its loading flag stuck", async () => {
|
||||
const oldPage = deferred()
|
||||
const fresh = deferred()
|
||||
const f = fixture({ fetchAgentMessages: (params) => params.cursor ? oldPage.promise : fresh.promise })
|
||||
f.store.setState({ selectedConversationId: 10, messagesCursor: "100", messagesHasMore: true })
|
||||
const older = f.store.getState().loadOlderMessages()
|
||||
assert.equal(f.store.getState().messagesLoadingMore, true)
|
||||
const refresh = f.store.getState().loadMessages(10)
|
||||
fresh.resolve({ results: [{ id: 200 }], hasMore: true })
|
||||
await refresh
|
||||
oldPage.resolve({ results: [{ id: 50 }], hasMore: false })
|
||||
await older
|
||||
assert.equal(f.store.getState().messages[0].id, 200)
|
||||
assert.equal(f.store.getState().messagesLoadingMore, false)
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: guard private state, captured recipients, and stale async completions.
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
@@ -104,9 +106,9 @@ type AgentConversationsStore = {
|
||||
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>
|
||||
sendMessage: (html: string, conversationId?: number) => Promise<AgentMessage | null>
|
||||
uploadImage: (file: File, conversationId?: number) => Promise<AgentAsset | null>
|
||||
sendAttachment: (file: File, conversationId?: number) => Promise<AgentMessage | null>
|
||||
recallMessage: (messageId: number) => Promise<AgentMessage | null>
|
||||
applyRealtimeMessageCreated: (message: AgentMessage) => void
|
||||
applyRealtimeAgentActivity: (payload: AgentActivityEventPayload) => void
|
||||
@@ -119,6 +121,22 @@ type AgentConversationsStore = {
|
||||
|
||||
let conversationsRequestSeq = 0
|
||||
let messagesRequestSeq = 0
|
||||
let sessionGeneration = 0
|
||||
|
||||
function captureSessionScope() {
|
||||
const generation = sessionGeneration
|
||||
const userId = readSession()?.user.id
|
||||
return () =>
|
||||
generation === sessionGeneration && Boolean(userId) && readSession()?.user.id === userId
|
||||
}
|
||||
|
||||
/** Invalidates every outstanding request before removing private dashboard data. */
|
||||
export function resetAgentConversations() {
|
||||
sessionGeneration += 1
|
||||
conversationsRequestSeq += 1
|
||||
messagesRequestSeq += 1
|
||||
useAgentConversationsStore.setState(useAgentConversationsStore.getInitialState(), true)
|
||||
}
|
||||
|
||||
export const useAgentConversationsStore = create<AgentConversationsStore>((set, get) => ({
|
||||
searchKeyword: "",
|
||||
@@ -167,6 +185,8 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
},
|
||||
|
||||
loadConversations: async () => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
if (!isCurrentSession()) return
|
||||
const requestSeq = ++conversationsRequestSeq
|
||||
const store = get()
|
||||
|
||||
@@ -180,7 +200,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
)
|
||||
const conversations = ensureArray(data.results)
|
||||
|
||||
if (requestSeq !== conversationsRequestSeq) {
|
||||
if (requestSeq !== conversationsRequestSeq || !isCurrentSession()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -216,7 +236,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestSeq === conversationsRequestSeq) {
|
||||
if (requestSeq === conversationsRequestSeq && isCurrentSession()) {
|
||||
set({ conversationsLoading: false })
|
||||
}
|
||||
throw error
|
||||
@@ -246,10 +266,14 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
},
|
||||
|
||||
loadMessages: async (conversationId, options = {}) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
if (!isCurrentSession() || get().selectedConversationId !== conversationId) return
|
||||
const requestSeq = ++messagesRequestSeq
|
||||
const store = get()
|
||||
const shouldShowLoading =
|
||||
options.forceLoading || store.messagesLoadedConversationId !== conversationId
|
||||
// A fresh snapshot supersedes any older page request in the prior generation.
|
||||
set({ messagesLoadingMore: false })
|
||||
|
||||
if (shouldShowLoading) {
|
||||
set({
|
||||
@@ -270,7 +294,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
if (requestSeq !== messagesRequestSeq) {
|
||||
if (requestSeq !== messagesRequestSeq || !isCurrentSession()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -288,7 +312,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
messagesHasMore: Boolean(data.hasMore),
|
||||
})
|
||||
} catch (error) {
|
||||
if (requestSeq === messagesRequestSeq) {
|
||||
if (requestSeq === messagesRequestSeq && isCurrentSession()) {
|
||||
set({ messagesLoading: false })
|
||||
}
|
||||
throw error
|
||||
@@ -296,6 +320,9 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
},
|
||||
|
||||
loadOlderMessages: async () => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
if (!isCurrentSession()) return
|
||||
const selectionGeneration = messagesRequestSeq
|
||||
const conversationId = get().selectedConversationId
|
||||
if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) {
|
||||
return
|
||||
@@ -312,7 +339,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
cursor: cursorId,
|
||||
limit: 50,
|
||||
})
|
||||
if (get().selectedConversationId !== conversationId) {
|
||||
if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) {
|
||||
return
|
||||
}
|
||||
const incoming = ensureArray(data.results)
|
||||
@@ -328,12 +355,17 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
set({ messagesLoadingMore: false })
|
||||
if (isCurrentSession() && selectionGeneration === messagesRequestSeq && get().selectedConversationId === conversationId) {
|
||||
set({ messagesLoadingMore: false })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
syncLatestMessages: async (conversationId) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
if (!isCurrentSession()) return
|
||||
const selectionGeneration = messagesRequestSeq
|
||||
if (conversationId <= 0) {
|
||||
return
|
||||
}
|
||||
@@ -342,7 +374,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
conversationId,
|
||||
limit: 50,
|
||||
})
|
||||
if (get().selectedConversationId !== conversationId) {
|
||||
if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) {
|
||||
return
|
||||
}
|
||||
const batch = ensureArray(data.results)
|
||||
@@ -370,6 +402,8 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
},
|
||||
|
||||
markSelectedConversationRead: async () => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
if (!isCurrentSession()) return
|
||||
const store = get()
|
||||
const conversationId = store.selectedConversationId
|
||||
const conversation = store.conversations.find((item) => item.id === conversationId)
|
||||
@@ -390,6 +424,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
set({ readingMessageId: lastMessage.id })
|
||||
try {
|
||||
await markAgentMessageRead(conversationId, lastMessage.id)
|
||||
if (!isCurrentSession() || get().readingMessageId !== lastMessage.id) return
|
||||
set((current) => {
|
||||
if (current.selectedConversationId !== conversationId) {
|
||||
return { readingMessageId: 0 }
|
||||
@@ -414,7 +449,9 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
set({ readingMessageId: 0 })
|
||||
if (isCurrentSession() && get().readingMessageId === lastMessage.id) {
|
||||
set({ readingMessageId: 0 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
@@ -513,7 +550,10 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
},
|
||||
|
||||
resyncRealtimeData: async (conversationId) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
if (!isCurrentSession()) return
|
||||
await get().loadConversations()
|
||||
if (!isCurrentSession()) return
|
||||
const selectedConversationId = get().selectedConversationId
|
||||
const targetConversationId = conversationId ?? selectedConversationId
|
||||
if (targetConversationId && selectedConversationId === targetConversationId) {
|
||||
@@ -524,10 +564,15 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (html) => {
|
||||
sendMessage: async (html, recipientId) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
const trimmedContent = html.trim()
|
||||
const { selectedConversationId, sending } = get()
|
||||
if (!selectedConversationId || !trimmedContent || sending) {
|
||||
if (
|
||||
!isCurrentSession() || !selectedConversationId ||
|
||||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
|
||||
!trimmedContent || sending || get().uploadingAsset
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -540,7 +585,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
clientMsgId: `agent_${generateUUID()}`,
|
||||
})
|
||||
|
||||
if (get().selectedConversationId === selectedConversationId) {
|
||||
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
|
||||
set((current) => ({
|
||||
messages: current.messages.some((m) => m.id === message.id)
|
||||
? current.messages.map((m) => (m.id === message.id ? message : m))
|
||||
@@ -561,33 +606,45 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
|
||||
return message
|
||||
} finally {
|
||||
set({ sending: false })
|
||||
if (isCurrentSession()) set({ sending: false })
|
||||
}
|
||||
},
|
||||
|
||||
uploadImage: async (file) => {
|
||||
uploadImage: async (file, recipientId) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
const { selectedConversationId, sending, uploadingAsset } = get()
|
||||
if (!selectedConversationId || sending || uploadingAsset) {
|
||||
if (
|
||||
!isCurrentSession() || !selectedConversationId ||
|
||||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
|
||||
sending || uploadingAsset
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
set({ uploadingAsset: true })
|
||||
try {
|
||||
return await uploadAgentConversationImage(selectedConversationId, file)
|
||||
const asset = await uploadAgentConversationImage(selectedConversationId, file)
|
||||
return isCurrentSession() ? asset : null
|
||||
} finally {
|
||||
set({ uploadingAsset: false })
|
||||
if (isCurrentSession()) set({ uploadingAsset: false })
|
||||
}
|
||||
},
|
||||
|
||||
sendAttachment: async (file) => {
|
||||
sendAttachment: async (file, recipientId) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
const { selectedConversationId, sending, uploadingAsset } = get()
|
||||
if (!selectedConversationId || sending || uploadingAsset) {
|
||||
if (
|
||||
!isCurrentSession() || !selectedConversationId ||
|
||||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
|
||||
sending || uploadingAsset
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
set({ uploadingAsset: true })
|
||||
try {
|
||||
const asset = await uploadAgentConversationAttachment(selectedConversationId, file)
|
||||
if (!isCurrentSession()) return null
|
||||
const message = await sendAgentMessage({
|
||||
conversationId: selectedConversationId,
|
||||
messageType: "attachment",
|
||||
@@ -596,7 +653,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
clientMsgId: `agent_attachment_${generateUUID()}`,
|
||||
})
|
||||
|
||||
if (get().selectedConversationId === selectedConversationId) {
|
||||
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
|
||||
set((current) => ({
|
||||
messages: current.messages.some((m) => m.id === message.id)
|
||||
? current.messages.map((m) => (m.id === message.id ? message : m))
|
||||
@@ -617,20 +674,22 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
|
||||
return message
|
||||
} finally {
|
||||
set({ uploadingAsset: false })
|
||||
if (isCurrentSession()) set({ uploadingAsset: false })
|
||||
}
|
||||
},
|
||||
|
||||
recallMessage: async (messageId) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
const { selectedConversationId, recallingMessageId } = get()
|
||||
if (!selectedConversationId || messageId <= 0 || recallingMessageId === messageId) {
|
||||
if (!isCurrentSession() || !selectedConversationId || messageId <= 0 || recallingMessageId === messageId) {
|
||||
return null
|
||||
}
|
||||
|
||||
set({ recallingMessageId: messageId })
|
||||
try {
|
||||
const message = await recallAgentMessage(messageId)
|
||||
if (get().selectedConversationId === selectedConversationId) {
|
||||
if (!isCurrentSession()) return null
|
||||
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
|
||||
set((current) => {
|
||||
const nextMessages = current.messages.map((item) =>
|
||||
item.id === message.id ? message : item
|
||||
@@ -656,11 +715,11 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
set({ recallingMessageId: 0 })
|
||||
if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 })
|
||||
}
|
||||
return message
|
||||
} catch (error) {
|
||||
set({ recallingMessageId: 0 })
|
||||
if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,7 +9,8 @@ const deferred = () => {
|
||||
const promise = new Promise((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
const conversation = (id) => ({ id, status: 3, customerUnreadCount: 0 })
|
||||
const conversation = (id) => ({ id, channelId: 1, status: 3, lastMessageId: 0,
|
||||
customerUnreadCount: 0, agentUnreadCount: 0, customerLastReadMessageId: 0, agentLastReadMessageId: 0 })
|
||||
const message = (id, conversationId) => ({ id, conversationId, senderType: "agent",
|
||||
senderId: 1, messageType: "html", content: "Reply", sendStatus: 1,
|
||||
customerRead: false, agentRead: false })
|
||||
@@ -24,6 +25,8 @@ function harness(overrides = {}) {
|
||||
const config = { channelId: "fixture", externalId: "visitor-1" }
|
||||
const document = { visibilityState: "visible" }
|
||||
const api = {
|
||||
getCustomerIdentityScope: () => JSON.stringify(config),
|
||||
getCustomerEntryScope: () => JSON.stringify(config),
|
||||
ensureCustomerSession: async () => ({}),
|
||||
fetchImWidgetConfig: async () => ({}),
|
||||
fetchImConversationList: async () => page([conversation(1)]),
|
||||
@@ -41,6 +44,7 @@ function harness(overrides = {}) {
|
||||
return { getState: () => state, setState: set }
|
||||
} },
|
||||
"@/lib/api/im": api,
|
||||
"@/lib/message-file-identity": { getMessageFileIdentity: async () => "fixture-file" },
|
||||
"@/lib/im-realtime": { createImRealtimeConnection: () => {} },
|
||||
"@/lib/im-message": { summarizeIMMessage: (item) => item.content, parseMessageAssetPayload: () => null },
|
||||
"@/lib/utils": { generateUUID: () => "fixture" },
|
||||
@@ -67,9 +71,9 @@ function harness(overrides = {}) {
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS,
|
||||
} })
|
||||
const module = { exports: {} }
|
||||
modules.set(name, module.exports)
|
||||
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
|
||||
const moduleContext = { exports: {} }
|
||||
modules.set(name, moduleContext.exports)
|
||||
vm.runInNewContext(compiled.outputText, { module: moduleContext, exports: moduleContext.exports,
|
||||
require: load, console, document, AbortController, WebSocket: { OPEN: 1, CONNECTING: 0 },
|
||||
setTimeout: (callback, delay) => {
|
||||
const id = ++nextTimer
|
||||
@@ -78,10 +82,10 @@ function harness(overrides = {}) {
|
||||
},
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
})
|
||||
return module.exports
|
||||
return moduleContext.exports
|
||||
}
|
||||
const store = load("./support-chat.ts").useSupportChatStore
|
||||
store.setState({ initialized: true, isOpen: true, isVisible: true,
|
||||
store.setState({ identityScope: api.getCustomerIdentityScope(), initialized: true, isOpen: true, isVisible: true,
|
||||
conversations: [conversation(1)], conversation: conversation(1), activeConversationId: 1 })
|
||||
return { store, api, config, document, timers, sent,
|
||||
get connects() { return connects }, get disconnects() { return disconnects },
|
||||
|
||||
289
web/lib/stores/support-chat.test.mjs
Normal file
289
web/lib/stores/support-chat.test.mjs
Normal file
@@ -0,0 +1,289 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import test from "node:test"
|
||||
import vm from "node:vm"
|
||||
import ts from "typescript"
|
||||
|
||||
const deferred = () => {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const conversation = (id, extra = {}) => ({ id, channelId: 1, customerName: "Visitor", status: 3,
|
||||
serviceMode: 1, priority: 0, currentAssigneeId: 0, lastMessageId: 0,
|
||||
customerUnreadCount: 0, agentUnreadCount: 0, customerLastReadMessageId: 0,
|
||||
agentLastReadMessageId: 0, ...extra })
|
||||
const message = (id, conversationId, extra = {}) => ({ id, conversationId,
|
||||
senderType: "customer", senderId: 1, messageType: "html", content: `<p>${id}</p>`,
|
||||
sendStatus: 1, customerRead: false, agentRead: false, ...extra })
|
||||
const page = (results, hasMore = false) => ({ results, hasMore })
|
||||
|
||||
// Exercise the actual store, including its asynchronous continuations, without
|
||||
// connecting a socket, browser, database, or service.
|
||||
function harness(overrides = {}) {
|
||||
let identity = "visitor-1", uuid = 0, state, realtimeOptions
|
||||
let connectCount = 0, disconnectCount = 0
|
||||
const timers = new Map()
|
||||
const api = {
|
||||
getCustomerIdentityScope: () => identity,
|
||||
getCustomerEntryScope: () => identity,
|
||||
ensureCustomerSession: async () => ({}),
|
||||
fetchImWidgetConfig: async () => ({}),
|
||||
fetchImConversationList: async () => page([conversation(1), conversation(2)]),
|
||||
createOrMatchImConversation: async () => conversation(1),
|
||||
fetchImMessages: async () => page([]),
|
||||
createImConversation: async () => conversation(3),
|
||||
markImMessageRead: async () => {},
|
||||
closeImConversation: async () => {},
|
||||
applyCustomerSessionRefresh: () => {},
|
||||
...overrides,
|
||||
}
|
||||
const modules = new Map()
|
||||
const mocks = {
|
||||
zustand: { create: (initializer) => {
|
||||
const set = (patch) => { state = { ...state, ...(typeof patch === "function" ? patch(state) : patch) } }
|
||||
state = initializer(set, () => state)
|
||||
return { getState: () => state, setState: set }
|
||||
} },
|
||||
"@/lib/api/im": api,
|
||||
"@/lib/message-file-identity": { getMessageFileIdentity: async (file) => file.content },
|
||||
"@/lib/im-realtime": { createImRealtimeConnection: () => {} },
|
||||
"@/lib/im-message": { summarizeIMMessage: (item) => item.content, parseMessageAssetPayload: () => null },
|
||||
"@/lib/utils": { generateUUID: () => `uuid-${++uuid}` },
|
||||
"@/i18n/messages": { translateCurrentMessage: (key) => key },
|
||||
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => ({}), setSupportChatRuntimeConfig: () => {} },
|
||||
"@/lib/realtime-connection": { createRealtimeConnectionManager: (options) => {
|
||||
realtimeOptions = options
|
||||
return { connect: async () => { connectCount++ }, disconnect: () => { disconnectCount++ } }
|
||||
} },
|
||||
}
|
||||
const load = (name) => {
|
||||
if (mocks[name]) return mocks[name]
|
||||
if (modules.has(name)) return modules.get(name)
|
||||
const url = name.startsWith("@/")
|
||||
? new URL(`../../${name.slice(2)}.ts`, import.meta.url)
|
||||
: new URL(name, import.meta.url)
|
||||
const source = readFileSync(url, "utf8")
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS,
|
||||
} })
|
||||
const moduleContext = { exports: {} }
|
||||
modules.set(name, moduleContext.exports)
|
||||
vm.runInNewContext(compiled.outputText, { module: moduleContext, exports: moduleContext.exports, require: load,
|
||||
console, AbortController, setTimeout: (callback) => { const id = timers.size + 1; timers.set(id, callback); return id },
|
||||
clearTimeout: (id) => timers.delete(id), WebSocket: { OPEN: 1, CONNECTING: 0 } })
|
||||
return moduleContext.exports
|
||||
}
|
||||
const store = load("./support-chat.ts").useSupportChatStore
|
||||
const seed = (extra = {}) => store.setState({ identityScope: identity, initialized: true,
|
||||
conversations: [conversation(1), conversation(2)], activeConversationId: 1,
|
||||
conversation: conversation(1), ...extra })
|
||||
seed()
|
||||
return { store, api, seed, timers, setIdentity: (value) => { identity = value },
|
||||
emit: (type, data) => realtimeOptions.onMessage({ data: JSON.stringify({ type, data }) }),
|
||||
get connectCount() { return connectCount }, get disconnectCount() { return disconnectCount } }
|
||||
}
|
||||
|
||||
for (const loader of ["refreshMessages", "syncLatestMessages", "loadOlderMessages"]) {
|
||||
test(`${loader} cannot paint a late response into a newly selected request`, async () => {
|
||||
const late = deferred()
|
||||
const h = harness({ fetchImMessages: ({ conversationId }) => conversationId === 1 ? late.promise : Promise.resolve(page([message(20, 2)])) })
|
||||
h.seed({ messages: [message(10, 1)], messagesHasMore: true, messagesCursor: "10" })
|
||||
const pending = h.store.getState()[loader]()
|
||||
await h.store.getState().selectConversation(2)
|
||||
late.resolve(page([message(5, 1)], true))
|
||||
await pending
|
||||
assert.equal(h.store.getState().conversation.id, 2)
|
||||
assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.conversationId), [2])
|
||||
assert.equal(h.store.getState().messagesLoadingMore, false)
|
||||
})
|
||||
}
|
||||
|
||||
test("A to B to A selection invalidates the first A response", async () => {
|
||||
const late = deferred()
|
||||
let calls = 0
|
||||
const h = harness({ fetchImMessages: ({ conversationId }) => conversationId === 1 && calls++ === 0
|
||||
? late.promise : Promise.resolve(page([message(30, conversationId)])) })
|
||||
const pending = h.store.getState().refreshMessages()
|
||||
await h.store.getState().selectConversation(2)
|
||||
await h.store.getState().selectConversation(1)
|
||||
late.resolve(page([message(10, 1)]))
|
||||
await pending
|
||||
assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.id), [30])
|
||||
})
|
||||
|
||||
test("newer latest request wins and stale failure cannot replace its error state", async () => {
|
||||
const old = deferred(), current = deferred()
|
||||
let calls = 0
|
||||
const h = harness({ fetchImMessages: () => ++calls === 1 ? old.promise : current.promise })
|
||||
const first = h.store.getState().refreshMessages()
|
||||
const second = h.store.getState().refreshMessages()
|
||||
current.resolve(page([message(30, 1)]))
|
||||
await second
|
||||
old.reject(new Error("stale error"))
|
||||
await first
|
||||
assert.equal(h.store.getState().error, "")
|
||||
assert.equal(h.store.getState().messages[0].id, 30)
|
||||
})
|
||||
|
||||
test("read acknowledgment updates its original list row without marking the new view", async () => {
|
||||
const read = deferred()
|
||||
const h = harness({ markImMessageRead: () => read.promise, fetchImMessages: async () => page([message(20, 2)]) })
|
||||
const first = conversation(1, { lastMessageId: 10, customerUnreadCount: 1 })
|
||||
h.seed({ conversations: [first, conversation(2, { customerUnreadCount: 2 })], conversation: first, messages: [message(10, 1)] })
|
||||
const pending = h.store.getState().markConversationRead()
|
||||
await h.store.getState().selectConversation(2)
|
||||
read.resolve()
|
||||
await pending
|
||||
const state = h.store.getState()
|
||||
assert.equal(state.conversations.find((item) => item.id === 1).customerUnreadCount, 0)
|
||||
assert.equal(state.conversation.customerUnreadCount, 2)
|
||||
assert.equal(state.messages[0].customerRead, false)
|
||||
})
|
||||
|
||||
test("read acknowledgment does not erase a newer unread reply", async () => {
|
||||
const read = deferred()
|
||||
const h = harness({ markImMessageRead: () => read.promise })
|
||||
const first = conversation(1, { lastMessageId: 10, customerUnreadCount: 1 })
|
||||
h.seed({ conversations: [first], conversation: first, messages: [message(10, 1)] })
|
||||
const pending = h.store.getState().markConversationRead()
|
||||
h.emit("conversation.updated", { conversationId: 1, lastMessageId: 11, customerUnreadCount: 2 })
|
||||
read.resolve()
|
||||
await pending
|
||||
assert.equal(h.store.getState().conversation.customerUnreadCount, 2)
|
||||
assert.equal(h.store.getState().conversation.customerLastReadMessageId, 10)
|
||||
})
|
||||
|
||||
test("send is single-flight and failed logical send keeps its idempotency key", async () => {
|
||||
const first = deferred(), sent = []
|
||||
const h = harness({ sendImMessage: (payload) => { sent.push(payload); return sent.length === 1 ? first.promise : Promise.resolve(message(10, 1)) } })
|
||||
const pending = h.store.getState().handleSendMessage("<p>Hello</p>")
|
||||
await assert.rejects(h.store.getState().handleSendMessage("<p>Hello</p>"), /sendInProgress/)
|
||||
assert.equal(sent.length, 1)
|
||||
first.reject(new Error("response lost"))
|
||||
await assert.rejects(pending, /response lost/)
|
||||
await h.store.getState().handleSendMessage("<p>Hello</p>")
|
||||
assert.equal(sent[0].clientMsgId, sent[1].clientMsgId)
|
||||
await h.store.getState().handleSendMessage("<p>Hello</p>")
|
||||
assert.notEqual(sent[1].clientMsgId, sent[2].clientMsgId)
|
||||
})
|
||||
|
||||
test("delivered send after switching updates only original request summary", async () => {
|
||||
const send = deferred()
|
||||
const h = harness({ sendImMessage: () => send.promise, fetchImMessages: async () => page([message(20, 2)]) })
|
||||
const pending = h.store.getState().handleSendMessage("hello")
|
||||
await h.store.getState().selectConversation(2)
|
||||
assert.equal(h.store.getState().sending, false)
|
||||
send.resolve(message(10, 1))
|
||||
await pending
|
||||
const state = h.store.getState()
|
||||
assert.equal(state.conversations.find((item) => item.id === 1).lastMessageId, 10)
|
||||
assert.equal(state.conversation.lastMessageId, 0)
|
||||
assert.equal(state.messages[0].conversationId, 2)
|
||||
})
|
||||
|
||||
test("close response for old selection does not close or disconnect current request", async () => {
|
||||
const close = deferred()
|
||||
const h = harness({ closeImConversation: () => close.promise })
|
||||
const pending = h.store.getState().closeConversation()
|
||||
await h.store.getState().selectConversation(2)
|
||||
close.resolve()
|
||||
await pending
|
||||
assert.equal(h.store.getState().conversation.status, 3)
|
||||
assert.equal(h.store.getState().conversations.find((item) => item.id === 1).status, 4)
|
||||
assert.equal(h.disconnectCount, 0)
|
||||
})
|
||||
|
||||
test("identity change invalidates old delivered result even for same numeric conversation", async () => {
|
||||
const send = deferred()
|
||||
const h = harness({ sendImMessage: () => send.promise })
|
||||
const pending = h.store.getState().handleSendMessage("private")
|
||||
h.setIdentity("visitor-2")
|
||||
h.seed()
|
||||
send.resolve(message(10, 1))
|
||||
await pending
|
||||
assert.equal(h.store.getState().messages.length, 0)
|
||||
assert.equal(h.store.getState().conversation.lastMessageId, 0)
|
||||
})
|
||||
|
||||
test("unmount invalidates pending bootstrap and prevents sockets from reopening", async () => {
|
||||
const config = deferred()
|
||||
const h = harness({ fetchImWidgetConfig: () => config.promise })
|
||||
h.seed({ initialized: false, isOpen: true, conversation: null, activeConversationId: 0 })
|
||||
const pending = h.store.getState().bootstrap()
|
||||
h.store.getState().disconnectSocket()
|
||||
config.resolve({ title: "stale" })
|
||||
await pending
|
||||
assert.equal(h.store.getState().conversation, null)
|
||||
assert.equal(h.connectCount, 0)
|
||||
})
|
||||
|
||||
test("retry reboots a failed initial load, and concurrent bootstrap is single-flight", async () => {
|
||||
let listCalls = 0
|
||||
const config = deferred()
|
||||
const h = harness({ fetchImWidgetConfig: () => config.promise,
|
||||
fetchImConversationList: async () => { if (++listCalls === 1) throw new Error("offline"); return page([conversation(1)]) } })
|
||||
h.seed({ initialized: false, isOpen: true, conversation: null, activeConversationId: 0 })
|
||||
const first = h.store.getState().bootstrap()
|
||||
assert.equal(h.store.getState().bootstrap(), first)
|
||||
config.resolve({})
|
||||
await first
|
||||
assert.equal(h.store.getState().conversation, null)
|
||||
await h.store.getState().retry()
|
||||
assert.equal(h.store.getState().conversation.id, 1)
|
||||
assert.equal(h.store.getState().error, "")
|
||||
})
|
||||
|
||||
test("new request and request switching reconnect after transport was disconnected", async () => {
|
||||
const h = harness()
|
||||
h.seed({ isOpen: true, socket: null })
|
||||
await h.store.getState().startNewConversation()
|
||||
assert.equal(h.store.getState().conversation.id, 3)
|
||||
assert.ok(h.connectCount > 0)
|
||||
const connects = h.connectCount
|
||||
await h.store.getState().selectConversation(2)
|
||||
assert.ok(h.connectCount > connects)
|
||||
})
|
||||
|
||||
test("list refresh cannot overwrite a delivered send that happened after its snapshot", async () => {
|
||||
const list = deferred()
|
||||
const h = harness({ fetchImConversationList: () => list.promise,
|
||||
sendImMessage: async () => message(10, 1) })
|
||||
const pending = h.store.getState().refreshConversations()
|
||||
await h.store.getState().handleSendMessage("hello")
|
||||
list.resolve(page([conversation(1), conversation(2)]))
|
||||
await pending
|
||||
assert.equal(h.store.getState().conversation.lastMessageId, 10)
|
||||
})
|
||||
|
||||
test("image upload completes into captured draft even after selecting another request", async () => {
|
||||
const upload = deferred()
|
||||
const h = harness({ uploadImImage: () => upload.promise })
|
||||
const pending = h.store.getState().uploadMessageImage({})
|
||||
await h.store.getState().selectConversation(2)
|
||||
const asset = { assetId: "image-1" }
|
||||
upload.resolve(asset)
|
||||
assert.equal(await pending, asset)
|
||||
})
|
||||
|
||||
test("changed identity clears private state while closed, before reopening", async () => {
|
||||
const h = harness()
|
||||
h.seed({ messages: [message(10, 1)], isOpen: false })
|
||||
h.setIdentity("visitor-2")
|
||||
await h.store.getState().bootstrap()
|
||||
assert.equal(h.store.getState().conversation, null)
|
||||
assert.equal(h.store.getState().conversations.length, 0)
|
||||
assert.equal(h.store.getState().messages.length, 0)
|
||||
assert.equal(h.store.getState().identityScope, "visitor-2")
|
||||
})
|
||||
|
||||
test("reselected attachment reuses upload and logical send key after uncertain failure", async () => {
|
||||
const sent = []
|
||||
let uploads = 0
|
||||
const h = harness({ uploadImAttachment: async () => { uploads++; return { assetId: "asset-1", filename: "attachment.txt" } },
|
||||
sendImMessage: async (payload) => { sent.push(payload); if (sent.length === 1) throw new Error("ack lost"); return message(10, 1) } })
|
||||
await assert.rejects(h.store.getState().sendAttachment({ content: "same file" }), /ack lost/)
|
||||
await h.store.getState().sendAttachment({ content: "same file" })
|
||||
assert.equal(uploads, 1)
|
||||
assert.equal(sent[0].clientMsgId, sent[1].clientMsgId)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
10
web/lib/support-availability-i18n.ts
Normal file
10
web/lib/support-availability-i18n.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ServiceStatus } from "@/lib/generated/enums"
|
||||
import type { AvailabilityReason } from "@/lib/api/support-availability"
|
||||
type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
const statusKeys: Record<ServiceStatus, string> = {
|
||||
[ServiceStatus.Idle]: "ready", [ServiceStatus.Busy]: "busy", [ServiceStatus.Break]: "break", [ServiceStatus.Unavailable]: "unavailable", [ServiceStatus.Vacation]: "vacation",
|
||||
}
|
||||
export function availabilityStatusLabel(status: ServiceStatus, t: Translate) { return t(`supportAvailability.status.${statusKeys[status] ?? "unknown"}`) }
|
||||
export function availabilityStatusOptions(t: Translate, withVacation = true) { return Object.values(ServiceStatus).filter((value): value is ServiceStatus => typeof value === "number" && (withVacation || value !== ServiceStatus.Vacation)).map(value => ({ value: String(value), label: availabilityStatusLabel(value, t) })) }
|
||||
const reasonKeys = new Set<AvailabilityReason>(["eligible", "profile_disabled", "user_disabled", "team_disabled", "outside_hours", "offline", "busy", "break", "unavailable", "vacation", "auto_assign_disabled", "capacity"])
|
||||
export function availabilityReasonLabel(reason: AvailabilityReason, t: Translate) { return t(`supportAvailability.reason.${reasonKeys.has(reason) ? reason : "unknown"}`) }
|
||||
53
web/lib/support-availability.locale-edge.test.mjs
Normal file
53
web/lib/support-availability.locale-edge.test.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import test from "node:test"
|
||||
import { loadTypeScriptModule } from "./test-support/load-typescript-module.mjs"
|
||||
|
||||
const helpers = loadTypeScriptModule(new URL("./support-availability.ts", import.meta.url), {}, { Date, Intl, Set })
|
||||
|
||||
function inTimezone(timezone, run) {
|
||||
const previous = process.env.TZ
|
||||
process.env.TZ = timezone
|
||||
try { run() } finally {
|
||||
if (previous === undefined) delete process.env.TZ
|
||||
else process.env.TZ = previous
|
||||
}
|
||||
}
|
||||
|
||||
test("European vacation wall times reject the spring gap and preserve both autumn occurrences", () => {
|
||||
inTimezone("Europe/Berlin", () => {
|
||||
assert.equal(helpers.localDateTimeToISO("2026-03-29T02:30"), null)
|
||||
assert.deepEqual(Array.from(helpers.localDateTimeCandidates("2026-10-25T02:30")), [
|
||||
"2026-10-25T00:30:00.000Z", "2026-10-25T01:30:00.000Z",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
test("half-hour DST overlaps expose both instants rather than assuming an hour", () => {
|
||||
inTimezone("Australia/Lord_Howe", () => {
|
||||
const values = Array.from(helpers.localDateTimeCandidates("2026-04-05T01:45"))
|
||||
assert.deepEqual(values, ["2026-04-04T14:45:00.000Z", "2026-04-04T15:15:00.000Z"])
|
||||
assert.equal(helpers.selectedLocalDateTimeISO("2026-04-05T01:45", values[1]), values[1])
|
||||
})
|
||||
})
|
||||
|
||||
function flatten(value, prefix = "") {
|
||||
return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => (
|
||||
item && typeof item === "object" ? Object.entries(flatten(item, `${prefix}${key}.`)) : [[`${prefix}${key}`, item]]
|
||||
)))
|
||||
}
|
||||
|
||||
test("every availability translation and interpolation remains complete across Russian, English and Chinese", () => {
|
||||
const catalogs = Object.fromEntries(["en-US", "ru-RU", "zh-CN"].map(locale => [
|
||||
locale, flatten(JSON.parse(readFileSync(new URL(`../messages/${locale}.json`, import.meta.url), "utf8")).supportAvailability),
|
||||
]))
|
||||
const reference = catalogs["en-US"]
|
||||
for (const [locale, catalog] of Object.entries(catalogs)) {
|
||||
assert.deepEqual(Object.keys(catalog).sort(), Object.keys(reference).sort(), `${locale} availability keys`)
|
||||
for (const [key, text] of Object.entries(catalog)) {
|
||||
assert.equal(typeof text, "string", `${locale}: ${key}`)
|
||||
assert.ok(text.trim(), `${locale}: ${key} is empty`)
|
||||
assert.deepEqual((text.match(/\{[^}]+\}/g) ?? []).sort(), (reference[key].match(/\{[^}]+\}/g) ?? []).sort(), `${locale}: ${key} interpolation`)
|
||||
}
|
||||
}
|
||||
})
|
||||
134
web/lib/support-availability.test.mjs
Normal file
134
web/lib/support-availability.test.mjs
Normal file
@@ -0,0 +1,134 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import test from "node:test"
|
||||
import vm from "node:vm"
|
||||
import ts from "typescript"
|
||||
|
||||
async function load(path, imports = {}, extras = {}) {
|
||||
const source = await readFile(new URL(path, import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX } })
|
||||
const loaded = { exports: {} }
|
||||
vm.runInNewContext(compiled.outputText, { exports: loaded.exports, module: loaded, require: name => { assert.ok(name in imports, `unexpected import ${name}`); return imports[name] }, Intl, Date, Set, AbortController, setTimeout, clearTimeout, process, ...extras })
|
||||
return loaded.exports
|
||||
}
|
||||
const helpers = await load("./support-availability.ts")
|
||||
const base = { teamId: 5, mode: "recurring", timezone: "Europe/Moscow", effectiveTimezone: "Europe/Moscow", deploymentTimezone: "UTC", weekly: [], exceptions: [] }
|
||||
|
||||
test("calendar accepts split, overnight and full-day midnight intervals", () => {
|
||||
for (const weekly of [
|
||||
[{ weekday: 1, start: "09:00", end: "12:00" }, { weekday: 1, start: "13:00", end: "18:00" }],
|
||||
[{ weekday: 7, start: "22:00", end: "02:00" }],
|
||||
[{ weekday: 1, start: "00:00", end: "24:00" }],
|
||||
]) assert.equal(helpers.calendarValidationKey({ ...base, weekly }), null)
|
||||
})
|
||||
test("invalid IANA zones, offsets, equal intervals and invalid midnight starts fail", () => {
|
||||
for (const timezone of ["Mars/Olympus", "+03:00", "Local"]) assert.equal(helpers.calendarValidationKey({ ...base, timezone }), "supportAvailability.invalidTimezone")
|
||||
assert.equal(helpers.calendarValidationKey({ ...base, teamId: 0, timezone: "" }), "supportAvailability.invalidTimezone")
|
||||
assert.equal(helpers.calendarValidationKey({ ...base, timezone: "" }), null)
|
||||
for (const [start, end] of [["08:00", "08:00"], ["24:00", "08:00"], ["09:00", "24:01"]]) assert.equal(helpers.calendarValidationKey({ ...base, weekly: [{ weekday: 1, start, end }] }), "supportAvailability.invalidHours")
|
||||
})
|
||||
test("invalid, duplicate and leap exception dates are checked", () => {
|
||||
for (const date of ["2026-02-29", "2026-04-31", "invalid"]) assert.equal(helpers.calendarValidationKey({ ...base, exceptions: [{ date, closed: true, periods: [] }] }), "supportAvailability.invalidExceptionDate")
|
||||
const leap = { date: "2028-02-29", closed: true, periods: [] }
|
||||
assert.equal(helpers.calendarValidationKey({ ...base, exceptions: [leap] }), null)
|
||||
assert.equal(helpers.calendarValidationKey({ ...base, exceptions: [leap, leap] }), "supportAvailability.invalidExceptionDate")
|
||||
})
|
||||
test("closing a holiday normalizes outgoing periods without discarding the editing draft", () => {
|
||||
const calendar = { ...base, exceptions: [{ date: "2026-12-25", closed: true, periods: [{ start: "00:00", end: "24:00" }] }] }
|
||||
const payload = helpers.supportCalendarPayload(calendar)
|
||||
assert.equal(payload.exceptions[0].periods.length, 0)
|
||||
assert.equal(calendar.exceptions[0].periods.length, 1)
|
||||
calendar.exceptions[0].closed = false
|
||||
assert.equal(helpers.supportCalendarPayload(calendar).exceptions[0].periods[0].end, "24:00")
|
||||
})
|
||||
test("vacation validates absolute interval and rejects DST gap without shifting", () => {
|
||||
const previous = process.env.TZ
|
||||
process.env.TZ = "America/New_York"
|
||||
try {
|
||||
assert.equal(helpers.localDateTimeToISO("2026-03-08T02:30"), null)
|
||||
assert.equal(helpers.localDateTimeToISO("2026-03-08T03:30"), "2026-03-08T07:30:00.000Z")
|
||||
assert.equal(helpers.vacationValidationKey("2026-10-16T10:00", "2026-10-16T09:00"), "supportAvailability.invalidVacation")
|
||||
assert.equal(helpers.vacationValidationKey("2026-10-16T10:00", "2026-10-16T11:00"), null)
|
||||
} finally { process.env.TZ = previous }
|
||||
})
|
||||
test("DST overlap exposes both exact instants and preserves existing later occurrence", () => {
|
||||
const previous = process.env.TZ
|
||||
process.env.TZ = "America/New_York"
|
||||
try {
|
||||
const values = Array.from(helpers.localDateTimeCandidates("2026-11-01T01:30"))
|
||||
assert.deepEqual(values, ["2026-11-01T05:30:00.000Z", "2026-11-01T06:30:00.000Z"])
|
||||
assert.equal(helpers.selectedLocalDateTimeISO("2026-11-01T01:30", values[1]), values[1])
|
||||
assert.equal(helpers.selectedLocalDateTimeISO("2026-11-01T01:30", "stale"), values[0])
|
||||
} finally { process.env.TZ = previous }
|
||||
})
|
||||
test("preview renders calendar zone and explicitly uses UTC for opaque legacy process zones", () => {
|
||||
assert.match(helpers.formatAvailabilityTime("2026-10-16T12:00:00Z", "Europe/Moscow", "en-US"), /03:00|15:00/)
|
||||
assert.match(helpers.formatAvailabilityTime("2026-10-16T12:00:00Z", "Local", "en-US"), /UTC/)
|
||||
})
|
||||
|
||||
async function apiWith(request, extras = {}) {
|
||||
return load("./api/support-availability.ts", {
|
||||
"@/lib/api/client": { request },
|
||||
"@/lib/support-availability": helpers,
|
||||
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => ({ channelId: "channel-a", baseUrl: "https://support.example/", userToken: "must-not-send" }) },
|
||||
}, extras)
|
||||
}
|
||||
test("public status is channel-scoped, anonymous and uses shared request without private credentials", async () => {
|
||||
let call
|
||||
const api = await apiWith(async (url, options) => { call = { url, options }; return { available: false, reason: "temporarily_absent", timezone: "UTC" } })
|
||||
const response = await api.fetchPublicSupportStatus()
|
||||
assert.equal(call.url, "/api/support/status")
|
||||
assert.equal(call.options.skipAuth, true)
|
||||
assert.equal(call.options.baseUrl, "https://support.example")
|
||||
assert.equal(call.options.headers["X-Channel-Id"], "channel-a")
|
||||
assert.equal("Authorization" in call.options.headers, false)
|
||||
assert.equal(response.available, false)
|
||||
})
|
||||
test("assignment candidates use captured conversation and trust server eligibility, not idle alone", async () => {
|
||||
let path
|
||||
const api = await apiWith(async url => { path = url; return [{ online: false, serviceStatus: 0, eligible: false, reason: "offline" }] })
|
||||
const items = await api.fetchAvailabilityAgents(19)
|
||||
assert.equal(path, "/api/dashboard/support_availability/agents?conversationId=19")
|
||||
assert.equal(items[0].eligible, false)
|
||||
})
|
||||
test("calendar save and preview normalize closed periods identically and retain explicit mode", async () => {
|
||||
const calls = []
|
||||
const api = await apiWith(async (url, options) => { calls.push({ url, body: JSON.parse(options.body) }); return base })
|
||||
const draft = { ...base, mode: "legacy", exceptions: [{ date: "2026-12-25", closed: true, periods: [{ start: "09:00", end: "17:00" }] }] }
|
||||
await api.updateSupportCalendar(draft)
|
||||
await api.previewSupportCalendar(draft, "2026-12-25T12:00:00Z")
|
||||
assert.equal(calls[0].body.exceptions[0].periods.length, 0)
|
||||
assert.equal(calls[0].body.mode, "legacy")
|
||||
assert.equal(calls[1].body.calendar.exceptions[0].periods.length, 0)
|
||||
assert.equal(calls[1].body.at, "2026-12-25T12:00:00Z")
|
||||
assert.equal(draft.exceptions[0].periods.length, 1)
|
||||
})
|
||||
test("a hung availability read has a bounded deadline and cleans up timers", async () => {
|
||||
let timeout, cleared = false, signal
|
||||
const api = await apiWith(async (_url, options) => new Promise((_, reject) => {
|
||||
signal = options.signal
|
||||
signal.addEventListener("abort", () => reject(new Error("aborted")))
|
||||
}), { setTimeout(fn, delay) { assert.equal(delay, 10000); timeout = fn; return 1 }, clearTimeout(id) { assert.equal(id, 1); cleared = true } })
|
||||
const pending = api.fetchMyAvailability()
|
||||
timeout()
|
||||
await assert.rejects(pending, /aborted/)
|
||||
assert.equal(signal.aborted, true)
|
||||
assert.equal(cleared, true)
|
||||
})
|
||||
test("unmount/account-scope cancellation aborts an in-flight availability read", async () => {
|
||||
const controller = new AbortController()
|
||||
const api = await apiWith(async (_url, options) => new Promise((_, reject) => options.signal.addEventListener("abort", () => reject(new Error("aborted")))))
|
||||
const pending = api.fetchAvailabilityAgents(undefined, controller.signal)
|
||||
controller.abort()
|
||||
await assert.rejects(pending, /aborted/)
|
||||
})
|
||||
test("all generated manual statuses and blocking reasons have complete translations", async () => {
|
||||
const enums = await load("./generated/enums.ts")
|
||||
const mapping = await load("./support-availability-i18n.ts", { "@/lib/generated/enums": enums })
|
||||
for (const locale of ["en-US", "ru-RU", "zh-CN"]) {
|
||||
const catalog = JSON.parse(await readFile(new URL(`../messages/${locale}.json`, import.meta.url), "utf8"))
|
||||
const t = key => { const value = key.split(".").reduce((value, part) => value?.[part], catalog); assert.equal(typeof value, "string", `${locale}: ${key}`); return value }
|
||||
assert.equal(mapping.availabilityStatusOptions(t).length, 5)
|
||||
for (const reason of ["eligible", "profile_disabled", "user_disabled", "team_disabled", "outside_hours", "offline", "busy", "break", "unavailable", "vacation", "auto_assign_disabled", "capacity"]) mapping.availabilityReasonLabel(reason, t)
|
||||
}
|
||||
})
|
||||
70
web/lib/support-availability.ts
Normal file
70
web/lib/support-availability.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { SupportCalendar } from "@/lib/api/support-availability"
|
||||
|
||||
export function isValidIanaTimezone(value: string): boolean {
|
||||
// Intl also accepts offset identifiers on some engines; the server expects an IANA name.
|
||||
if (!value || /^[+-]/.test(value)) return false
|
||||
try { new Intl.DateTimeFormat("en-US", { timeZone: value }).format(); return true } catch { return false }
|
||||
}
|
||||
export function calendarValidationKey(calendar: SupportCalendar): string | null {
|
||||
if ((!calendar.timezone && calendar.teamId === 0) || (calendar.timezone && !isValidIanaTimezone(calendar.timezone))) return "supportAvailability.invalidTimezone"
|
||||
if (calendar.mode === "legacy") return null
|
||||
const validTime = (value: string) => /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value)
|
||||
const validPeriod = (period: { start: string; end: string }) => validTime(period.start) && (period.end === "24:00" || validTime(period.end)) && period.start !== period.end
|
||||
if (calendar.weekly.some(p => !Number.isInteger(p.weekday) || p.weekday < 1 || p.weekday > 7 || !validPeriod(p))) return "supportAvailability.invalidHours"
|
||||
const dates = new Set<string>()
|
||||
for (const day of calendar.exceptions) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(day.date) || !Number.isFinite(Date.parse(`${day.date}T00:00:00Z`)) || new Date(`${day.date}T00:00:00Z`).toISOString().slice(0, 10) !== day.date || dates.has(day.date)) return "supportAvailability.invalidExceptionDate"
|
||||
dates.add(day.date)
|
||||
if (!day.closed && (day.periods.length === 0 || day.periods.some(p => !validPeriod(p)))) return "supportAvailability.invalidHours"
|
||||
}
|
||||
return null
|
||||
}
|
||||
export function toLocalDateTime(value?: string): string {
|
||||
if (!value) return ""
|
||||
const date = new Date(value)
|
||||
if (!Number.isFinite(date.getTime())) return ""
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
export function localDateTimeToISO(value: string): string | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) return null
|
||||
const date = new Date(value)
|
||||
// Reject invalid dates and the DST gap rather than silently shifting the user's input.
|
||||
if (!Number.isFinite(date.getTime()) || toLocalDateTime(date.toISOString()) !== value) return null
|
||||
return date.toISOString()
|
||||
}
|
||||
export function vacationValidationKey(start: string, end: string): string | null {
|
||||
const first = localDateTimeToISO(start), last = localDateTimeToISO(end)
|
||||
return !first || !last || Date.parse(first) >= Date.parse(last) ? "supportAvailability.invalidVacation" : null
|
||||
}
|
||||
export function formatAvailabilityTime(value: string | undefined, timezone: string, locale: string): string {
|
||||
if (!value) return ""
|
||||
const date = new Date(value)
|
||||
if (!Number.isFinite(date.getTime())) return ""
|
||||
// Legacy process zones can be named "Local". Show the actual UTC instant, not an invented offset.
|
||||
if (!isValidIanaTimezone(timezone)) timezone = "UTC"
|
||||
// This feature explicitly previews a configured timezone, unlike the default app timestamp formatter.
|
||||
return new Intl.DateTimeFormat(locale, { timeZone: timezone, year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short" }).format(date)
|
||||
}
|
||||
|
||||
// Explicit alternatives for repeated local wall-clock times during a DST overlap.
|
||||
export function localDateTimeCandidates(value: string): string[] {
|
||||
const first = localDateTimeToISO(value)
|
||||
if (!first) return []
|
||||
const result = new Set([first])
|
||||
const timestamp = Date.parse(first)
|
||||
for (let minutes = -180; minutes <= 180; minutes += 15) {
|
||||
const candidate = new Date(timestamp + minutes * 60000).toISOString()
|
||||
if (toLocalDateTime(candidate) === value) result.add(candidate)
|
||||
}
|
||||
return [...result].sort()
|
||||
}
|
||||
export function selectedLocalDateTimeISO(value: string, occurrence: string): string | null {
|
||||
const candidates = localDateTimeCandidates(value)
|
||||
return candidates.includes(occurrence) ? occurrence : candidates[0] ?? null
|
||||
}
|
||||
|
||||
// Closing an exception ignores its draft periods but does not erase them while editing.
|
||||
export function supportCalendarPayload(calendar: SupportCalendar): SupportCalendar {
|
||||
return { ...calendar, timezone: calendar.timezone.trim(), weekly: calendar.weekly.map(period => ({ ...period })), exceptions: calendar.exceptions.map(day => ({ ...day, periods: day.closed ? [] : day.periods.map(period => ({ ...period })) })) }
|
||||
}
|
||||
28
web/lib/test-support/load-typescript-module.mjs
Normal file
28
web/lib/test-support/load-typescript-module.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import vm from "node:vm"
|
||||
import ts from "typescript"
|
||||
|
||||
/** Runs the real module with explicitly supplied offline transports/dependencies. */
|
||||
export function loadTypeScriptModule(url, dependencies, globals = {}) {
|
||||
const { outputText } = ts.transpileModule(readFileSync(url, "utf8"), {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
|
||||
fileName: url.pathname,
|
||||
})
|
||||
const moduleContext = { exports: {} }
|
||||
const require = (name) => {
|
||||
if (!Object.hasOwn(dependencies, name)) throw new Error(`Unmocked dependency: ${name}`)
|
||||
return dependencies[name]
|
||||
}
|
||||
vm.runInNewContext(outputText, { module: moduleContext, exports: moduleContext.exports, require, ...globals }, { filename: url.pathname })
|
||||
return moduleContext.exports
|
||||
}
|
||||
|
||||
export function deferred() {
|
||||
let resolve
|
||||
let reject
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
@@ -26,7 +26,11 @@
|
||||
"actions": "Actions",
|
||||
"name": "Name",
|
||||
"saving": "Saving...",
|
||||
"reset": "Reset"
|
||||
"reset": "Reset",
|
||||
"operationFailed": "The operation failed. Please try again.",
|
||||
"loadFailed": "Could not load data. Please try again.",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"language": {
|
||||
"enUS": "English",
|
||||
@@ -531,9 +535,9 @@
|
||||
"messageSummary": "[Message]",
|
||||
"closedTitle": "Conversation ended",
|
||||
"closedDescription": "This support conversation has been closed. Thanks for chatting with us.",
|
||||
"connecting": "Connecting",
|
||||
"connected": "Online support",
|
||||
"disconnected": "Disconnected",
|
||||
"connecting": "Connecting chat",
|
||||
"connected": "Chat connected",
|
||||
"disconnected": "Chat disconnected",
|
||||
"retry": "Reconnect",
|
||||
"moreActions": "More actions",
|
||||
"minimize": "Minimize window",
|
||||
@@ -583,7 +587,12 @@
|
||||
"startFirstRequest": "Start your first request",
|
||||
"openRequestCount": "{open} open of {total}",
|
||||
"loadConversationsFailed": "Could not load your requests",
|
||||
"createConversationFailed": "Could not create the request"
|
||||
"createConversationFailed": "Could not create the request",
|
||||
"dismissedTitle": "Chat window closed",
|
||||
"dismissedDescription": "Your support request has not been ended. You can return to continue the conversation.",
|
||||
"returnToChat": "Return to chat",
|
||||
"sendUnavailable": "This conversation is unavailable. Return to it or retry before sending.",
|
||||
"sendInProgress": "A message is already being sent."
|
||||
},
|
||||
"dashboardHome": {
|
||||
"rangeToday": "Today",
|
||||
@@ -2493,7 +2502,23 @@
|
||||
"debugAnswer": "Debug Answer",
|
||||
"answerResult": "Answer Result",
|
||||
"searchHits": "Search Hits",
|
||||
"hitsSummary": "{count} hits · {latency}ms"
|
||||
"hitsSummary": "{count} hits · {latency}ms",
|
||||
"unknownType": "Unknown type",
|
||||
"unknownStatus": "Unknown status",
|
||||
"indexFailureSummary": "Indexing failed. Check the indexing configuration and try again.",
|
||||
"requestId": "Request ID",
|
||||
"rerank": "Reranking",
|
||||
"topScore": "Top score",
|
||||
"promptTokens": "Input tokens",
|
||||
"completionTokens": "Output tokens",
|
||||
"traceData": "Trace data",
|
||||
"chunkNumber": "Chunk #{number}",
|
||||
"providerValue": "Strategy: {value}",
|
||||
"scoreValue": "Score: {value}",
|
||||
"rerankValue": "Rerank score: {value}",
|
||||
"historyRecordedNotice": "Historical answers and excerpts are shown as recorded. Changing the interface language does not rewrite them.",
|
||||
"faqNumber": "FAQ #{number}",
|
||||
"documentNumber": "Document #{number}"
|
||||
},
|
||||
"scaffold": {
|
||||
"moduleBoard": "Module Delivery Board",
|
||||
@@ -3370,8 +3395,8 @@
|
||||
"statusReady": "Ready to Receive Requests",
|
||||
"statusNeedsSetup": "Setup Incomplete ({ready}/{total} steps ready)",
|
||||
"stepsProgress": "{ready} of {total} steps ready",
|
||||
"quickSetupAll": "Configure Everything in 1 Click",
|
||||
"quickSetupRunning": "Applying setup...",
|
||||
"quickSetupAll": "Review missing defaults",
|
||||
"quickSetupRunning": "Preparing preview...",
|
||||
"quickFixSuccess": "Configuration updated and routing re-checked",
|
||||
"refresh": "Re-check Readiness",
|
||||
"openSettings": "Open Settings",
|
||||
@@ -3394,12 +3419,12 @@
|
||||
"description": "At least one active operator team is required to group agents and define shift schedules."
|
||||
},
|
||||
"schedules": {
|
||||
"title": "Shift Schedules (Active Now)",
|
||||
"description": "Teams require an active schedule covering the current time; off-hours requests stay in queue."
|
||||
"title": "Active support hours",
|
||||
"description": "Teams need an active support calendar now, using their selected dated-shift or recurring-hours mode. Off-hours requests remain queued."
|
||||
},
|
||||
"operators": {
|
||||
"title": "Operator Profiles & Capacity",
|
||||
"description": "Operators must have an enabled profile, belong to an active team, have auto-assign on, be idle, and have free capacity."
|
||||
"description": "Operators need an enabled account and profile, an active team calendar, a live platform connection, a ready working status, no active vacation, automatic assignment enabled, and available capacity."
|
||||
},
|
||||
"ai_agents": {
|
||||
"title": "AI Agents & Human Handoff",
|
||||
@@ -3433,7 +3458,15 @@
|
||||
"channel_no_agent": "Channel is not connected to an AI agent.",
|
||||
"channel_agent_disabled": "Channel points to a disabled or missing AI agent.",
|
||||
"channel_zero_rollout": "Channel has a 0% AI agent rollout rate.",
|
||||
"no_end_to_end_path": "No channel can successfully route to an available human operator right now."
|
||||
"no_end_to_end_path": "No channel can successfully route to an available human operator right now.",
|
||||
"agent_unpublished": "The agent has no published revision. Publish it in agent settings before using it for a channel.",
|
||||
"channel_agent_unpublished": "The channel points to an unpublished agent. Publish the agent or deliberately choose a published one.",
|
||||
"operator_offline": "The operator has no active connection to the support platform.",
|
||||
"operator_break": "The operator is on break and cannot receive new assignments.",
|
||||
"operator_unavailable": "The operator is unavailable for new assignments.",
|
||||
"operator_vacation": "The operator is currently on scheduled vacation.",
|
||||
"operator_outside_hours": "The operator is outside scheduled working hours.",
|
||||
"operator_availability_unavailable": "The operator’s current availability could not be determined."
|
||||
},
|
||||
"operatorBlockers": {
|
||||
"not_authenticated": "You are not signed in.",
|
||||
@@ -3443,25 +3476,31 @@
|
||||
"team_off_hours": "Your team is currently off-hours (no active shift schedule).",
|
||||
"auto_assign_disabled": "Automatic conversation assignment is disabled for your profile.",
|
||||
"status_busy": "Your status is set to Busy.",
|
||||
"at_capacity": "You have reached your maximum active conversation limit."
|
||||
"at_capacity": "You have reached your maximum active conversation limit.",
|
||||
"offline": "No active connection to the support platform.",
|
||||
"break": "On break: new assignments are paused.",
|
||||
"unavailable": "Unavailable for new assignments.",
|
||||
"vacation": "Scheduled vacation is active.",
|
||||
"availability_unavailable": "Current availability could not be determined.",
|
||||
"user_disabled": "The account is disabled or deleted."
|
||||
},
|
||||
"quickFixLabels": {
|
||||
"quick_setup_all": "Configure Everything in 1 Click",
|
||||
"ensure_team_and_schedule": "Create/Enable Team & 24/7 Schedule",
|
||||
"quick_setup_all": "Review missing defaults",
|
||||
"ensure_team_and_schedule": "Review team setup",
|
||||
"enable_my_operator_profile": "Enable My Reception & Set Idle",
|
||||
"fix_ai_agent_handoff": "Fix AI Agent Handoff & Bind Teams",
|
||||
"ensure_default_channel": "Create/Fix Default Web Channel"
|
||||
},
|
||||
"simulator": {
|
||||
"title": "Operator Route Simulator",
|
||||
"description": "Simulate what happens when a customer sends a message on a channel and see every decision hop in real time.",
|
||||
"description": "Read-only check of current human-only routing eligibility. AI behavior and partial rollout are not simulated, and no message is sent.",
|
||||
"selectChannelPlaceholder": "Select a channel to simulate...",
|
||||
"runSimulation": "Simulate Dispatch",
|
||||
"running": "Simulating...",
|
||||
"outcomeSuccess": "Request WILL reach operator: {operator}",
|
||||
"outcomeBlocked": "Request WILL NOT reach an operator",
|
||||
"outcomeSuccess": "Eligible operator now: {operator}",
|
||||
"outcomeBlocked": "Operator delivery not confirmed",
|
||||
"outcomes": {
|
||||
"assigned_to_operator": "Success: The conversation is assigned to an idle operator.",
|
||||
"assigned_to_operator": "Human-only routing currently has an eligible operator. This is a read-only check, not a delivery guarantee.",
|
||||
"blocked_no_channel": "Blocked: No channels exist or the requested channel was not found.",
|
||||
"blocked_channel_disabled": "Blocked: This channel is disabled.",
|
||||
"blocked_zero_rollout": "Blocked: Channel AI agent rollout rate is set to 0%.",
|
||||
@@ -3469,7 +3508,10 @@
|
||||
"blocked_ai_only_mode": "Blocked: AI agent is set to AI-Only mode and never hands off to human operators.",
|
||||
"blocked_agent_no_teams": "Blocked: AI agent has no valid operator teams assigned for handoff.",
|
||||
"queued_off_hours_no_schedule": "Queued: Target teams have no active shift schedule right now (Off-Hours).",
|
||||
"queued_no_eligible_operator": "Queued: Shift is active, but no idle operators with available capacity are online."
|
||||
"queued_no_eligible_operator": "Queued: Shift is active, but no idle operators with available capacity are online.",
|
||||
"ai_execution_not_simulated": "AI behavior is not simulated; operator delivery is unverified.",
|
||||
"partial_rollout_not_simulated": "Partial rollout is not simulated; operator delivery is unverified.",
|
||||
"blocked_agent_unpublished": "Operator routing is not verified for an unpublished agent. Publish the agent first."
|
||||
},
|
||||
"hops": {
|
||||
"channel": "Channel",
|
||||
@@ -3491,7 +3533,10 @@
|
||||
"blocked_agent_no_teams": "No active teams assigned to this agent",
|
||||
"queued_off_hours_no_schedule": "No active shift schedule covers the current time (Off-Hours)",
|
||||
"queued_no_eligible_operator": "No idle operators with available capacity in active teams",
|
||||
"assigned_to_operator": "Assigned to eligible operator"
|
||||
"assigned_to_operator": "Assigned to eligible operator",
|
||||
"ai_execution_not_simulated": "AI behavior is not simulated; operator delivery is unverified.",
|
||||
"partial_rollout_not_simulated": "Partial rollout is not simulated; operator delivery is unverified.",
|
||||
"blocked_agent_unpublished": "Operator routing is not verified for an unpublished agent. Publish the agent first."
|
||||
},
|
||||
"candidateTableTitle": "Evaluated Operator Candidates",
|
||||
"colOperator": "Operator",
|
||||
@@ -3505,12 +3550,19 @@
|
||||
"eligibleBadge": "Eligible",
|
||||
"skipReasons": {
|
||||
"profile_disabled": "Profile disabled",
|
||||
"user_disabled": "User account disabled",
|
||||
"user_disabled": "The account is disabled or deleted.",
|
||||
"team_not_in_agent": "Team not in agent scope",
|
||||
"team_off_hours": "Team off-hours",
|
||||
"auto_assign_off": "Auto-assign disabled",
|
||||
"status_busy": "Status is Busy",
|
||||
"at_capacity": "At maximum capacity"
|
||||
"at_capacity": "At maximum capacity",
|
||||
"offline": "No active connection to the support platform.",
|
||||
"break": "On break: new assignments are paused.",
|
||||
"unavailable": "Unavailable for new assignments.",
|
||||
"vacation": "Scheduled vacation is active.",
|
||||
"availability_unavailable": "Current availability could not be determined.",
|
||||
"auto_assign_disabled": "Automatic assignment is disabled.",
|
||||
"no_team": "No enabled team is assigned."
|
||||
}
|
||||
},
|
||||
"banner": {
|
||||
@@ -3520,6 +3572,185 @@
|
||||
"headerIndicator": {
|
||||
"readyLabel": "Ready for Chats",
|
||||
"blockedLabel": "Routing Blocked ({count})"
|
||||
},
|
||||
"preview": {
|
||||
"review": "Review setup changes",
|
||||
"title": "Review exact changes before applying",
|
||||
"description": "Only the named records and fields below will change. No shifts are added. Unlisted configurations and rollout percentages remain unchanged. Applying may dispatch pending conversations after the changes commit.",
|
||||
"noChanges": "No safe automatic changes are available. Use the relevant settings to choose a team, route or working hours.",
|
||||
"apply": "Apply reviewed changes",
|
||||
"create": "Create",
|
||||
"update": "Update",
|
||||
"notApplied": "The reviewed changes were not applied.",
|
||||
"manageSchedules": "Manage explicit working hours"
|
||||
},
|
||||
"repairFields": {
|
||||
"name": "Name",
|
||||
"leader_user_id": "Team leader ID",
|
||||
"status": "Enabled status (0 enabled, 1 disabled)",
|
||||
"user_id": "User ID",
|
||||
"team_id": "Team ID / new team name",
|
||||
"team_ids": "Routing teams",
|
||||
"service_status": "Availability (0 idle, 1 busy)",
|
||||
"auto_assign_enabled": "Automatic assignment",
|
||||
"max_concurrent_count": "Concurrent conversation limit",
|
||||
"priority_level": "Assignment priority",
|
||||
"receive_offline_message": "Receive offline messages",
|
||||
"service_mode": "Service mode (1 AI only, 2 human only, 3 AI first)",
|
||||
"rollout_percent": "Agent rollout (%)",
|
||||
"handoff_mode": "Handoff mode (2 team queue)",
|
||||
"fallback_mode": "Fallback mode",
|
||||
"auto_handoff_on_failure": "Automatic handoff on failure",
|
||||
"reply_timeout_seconds": "Reply timeout (seconds)",
|
||||
"max_steps": "Maximum AI steps",
|
||||
"channel_id": "Channel identifier",
|
||||
"channel_type": "Channel type",
|
||||
"ai_agent_id": "Agent ID / new agent name",
|
||||
"ai_agent_rollout_percent": "Channel rollout (%)",
|
||||
"config_json": "Channel configuration"
|
||||
},
|
||||
"repairWarnings": {
|
||||
"schedules_unchanged": "Working hours were not changed. Configure shifts explicitly; being off duty is not a setup error.",
|
||||
"active_team_required": "Select or enable a team in team settings before linking new records.",
|
||||
"disabled_configuration_preserved": "The selected configuration is disabled or has zero rollout and was left unchanged.",
|
||||
"agent_team_selection_required": "Choose the agent’s intended teams in agent settings; no teams were added automatically.",
|
||||
"channel_selection_required": "Choose the intended agent and rollout in channel settings. Existing channel routing was preserved.",
|
||||
"existing_configuration_preserved": "Existing configuration was preserved. Review any remaining readiness issues in the relevant settings.",
|
||||
"dispatch_failed": "Changes were saved, but pending conversation dispatch failed. Review the queue; do not reapply the repair.",
|
||||
"readiness_refresh_failed": "The readiness refresh failed. Reload diagnostics to see the current state; committed changes are not rolled back.",
|
||||
"agent_publication_required": "Publish the intended agent in agent settings before creating a channel. Setup does not publish agents or bypass the publication requirement."
|
||||
}
|
||||
},
|
||||
"supportAvailability": {
|
||||
"title": "Support availability",
|
||||
"description": "Configure support hours and timezones. Connection, working status, and eligibility are separate.",
|
||||
"loading": "Loading availability…",
|
||||
"myStatus": "My availability",
|
||||
"retryStatus": "Status unavailable. Retry",
|
||||
"operatorStatus": "Availability: {name}",
|
||||
"presenceHelp": "Online means an active connection to the support platform, across tabs and devices. It does not mean the operator is accepting new requests.",
|
||||
"online": "On the platform",
|
||||
"offline": "Not connected",
|
||||
"workingStatus": "Working status",
|
||||
"statusHelp": "Your status is saved across sessions. Busy, break, unavailable, and active vacation stop new assignments; signing in does not reset them.",
|
||||
"statusSaved": "Availability saved",
|
||||
"saveFailed": "Could not save changes. Your input is preserved.",
|
||||
"loadFailed": "Could not load availability",
|
||||
"saveStatus": "Save status",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel",
|
||||
"retry": "Retry",
|
||||
"refresh": "Refresh",
|
||||
"discardTitle": "Discard unsaved changes?",
|
||||
"discardDescription": "Your changes have not been saved. Leave without saving them?",
|
||||
"discard": "Discard changes",
|
||||
"vacationStart": "Vacation starts",
|
||||
"vacationEnd": "Vacation ends (exclusive)",
|
||||
"vacationTimezone": "Enter dates in your browser timezone: {timezone}.",
|
||||
"vacationExact": "Exact UTC interval: {start} → {end}.",
|
||||
"vacationHelp": "During this interval, new assignments stop even if you sign in. Outside the interval, the saved return status applies: {status}. Existing conversations are not transferred automatically.",
|
||||
"cancelVacationHelp": "Saving a different status cancels the scheduled vacation.",
|
||||
"invalidVacation": "Enter valid start and end times, with the end after the start. Times skipped by daylight saving are not valid.",
|
||||
"calendar": "Support calendar",
|
||||
"editCalendar": "Edit calendar",
|
||||
"saveCalendar": "Save calendar",
|
||||
"calendarSaved": "Support calendar saved",
|
||||
"readOnly": "You have read-only access.",
|
||||
"deploymentDefault": "Deployment defaults",
|
||||
"selectScope": "Select default or team",
|
||||
"deploymentTimezone": "Default IANA timezone",
|
||||
"teamTimezone": "Team timezone override",
|
||||
"timezoneHelp": "Use an IANA name, such as Europe/Moscow or America/New_York, not a fixed UTC offset.",
|
||||
"inheritTimezoneHelp": "Leave blank to inherit the deployment timezone. A timezone override does not change stored dated shifts.",
|
||||
"effectiveTimezone": "Effective timezone: {timezone}",
|
||||
"scheduleMode": "Calendar mode",
|
||||
"legacy": "Existing dated shifts",
|
||||
"recurring": "Weekly hours and exceptions",
|
||||
"legacyHelp": "Existing dated shifts are preserved and are not reinterpreted in the new timezone. Weekly hours apply only after explicitly selecting and saving the recurring mode.",
|
||||
"viewDatedShifts": "View dated shifts",
|
||||
"legacyActive": "Existing dated shifts remain the source of working hours. No weekly hours are silently created.",
|
||||
"confirmCalendarTitle": "Apply this support calendar?",
|
||||
"confirmRecurring": "Switch to weekly hours and date exceptions for this scope? Existing dated shifts will remain stored but will no longer determine availability in this mode.",
|
||||
"confirmCalendar": "These settings change when new requests can be assigned. Teams inheriting the default timezone are affected by a default timezone change. Dated shifts remain unchanged.",
|
||||
"weeklyHours": "Weekly hours",
|
||||
"hoursHelp": "Add multiple periods for split shifts. An end earlier than the start continues into the next day. Equal start and end is invalid. Days without periods are closed. Use 00:00–24:00 for a full day.",
|
||||
"noWeeklyHours": "No weekly hours. In recurring mode, only explicit open-date exceptions can open support.",
|
||||
"addPeriod": "Add period",
|
||||
"removePeriod": "Remove period",
|
||||
"day": "Day",
|
||||
"start": "Start",
|
||||
"end": "End",
|
||||
"exceptions": "Holidays and date exceptions",
|
||||
"exceptionsHelp": "A date exception replaces that local day's weekly hours. Closed dates block the whole day, including an overnight period from the previous day.",
|
||||
"noExceptions": "No date exceptions",
|
||||
"addException": "Add date exception",
|
||||
"removeException": "Remove date exception",
|
||||
"date": "Date",
|
||||
"closedDay": "Closed all day",
|
||||
"preview": "Calendar preview",
|
||||
"previewHelp": "Preview the current form without saving it. Enter the check time in your browser timezone ({timezone}), or leave it blank for now. Results use the calendar timezone.",
|
||||
"previewAt": "Check time (optional)",
|
||||
"runPreview": "Check calendar",
|
||||
"previewing": "Checking…",
|
||||
"previewFailed": "Could not preview the calendar",
|
||||
"previewStale": "The form changed. Run the preview again.",
|
||||
"scheduleOpen": "The schedule is open at this time.",
|
||||
"scheduleClosed": "The schedule is closed at this time.",
|
||||
"nextOpening": "Next scheduled opening: {time}",
|
||||
"noNextOpening": "No scheduled opening found in the preview window.",
|
||||
"notPresencePromise": "Scheduled hours do not guarantee that an operator will be connected or available.",
|
||||
"invalidTimezone": "Enter a valid IANA timezone. Deployment defaults cannot be blank.",
|
||||
"invalidHours": "Use valid HH:mm intervals with different start and end times. An open exception needs at least one interval.",
|
||||
"invalidExceptionDate": "Each exception needs a valid, unique date.",
|
||||
"invalidPreviewTime": "Enter a valid check time in your browser timezone.",
|
||||
"noPermission": "You do not have permission to view calendar settings. You can still manage your own working status.",
|
||||
"noTeams": "No teams are available for calendar configuration.",
|
||||
"publicChecking": "Checking human support availability…",
|
||||
"publicNextOpening": "Next scheduled opening: {time}. Operator presence is not guaranteed.",
|
||||
"offlineDeprecated": "Legacy setting, retained for compatibility. It does not allow new assignments to disconnected operators.",
|
||||
"manageStatus": "Manage availability",
|
||||
"statusSeparate": "Working status and scheduled vacation are managed separately from the operator profile.",
|
||||
"status": {
|
||||
"ready": "Ready to accept",
|
||||
"busy": "Busy",
|
||||
"break": "On break",
|
||||
"unavailable": "Unavailable",
|
||||
"vacation": "On vacation",
|
||||
"unknown": "Unknown status"
|
||||
},
|
||||
"reason": {
|
||||
"eligible": "Eligible for new assignments",
|
||||
"profile_disabled": "Operator profile is administratively disabled",
|
||||
"user_disabled": "Account is disabled",
|
||||
"team_disabled": "Team is disabled",
|
||||
"outside_hours": "Outside scheduled working hours",
|
||||
"offline": "No active connection to the support platform",
|
||||
"busy": "Busy: new assignments are paused",
|
||||
"break": "On break: new assignments are paused",
|
||||
"unavailable": "Unavailable for new assignments",
|
||||
"vacation": "Scheduled vacation is active",
|
||||
"auto_assign_disabled": "Automatic assignment is disabled",
|
||||
"capacity": "Concurrent conversation limit reached",
|
||||
"unknown": "Eligibility could not be determined"
|
||||
},
|
||||
"public": {
|
||||
"available": "Human support is available now",
|
||||
"outside_hours": "Human support is outside scheduled hours",
|
||||
"temporarily_absent": "No operator is currently available for a new request",
|
||||
"capacity": "Operators are currently at capacity",
|
||||
"unknown": "Human support availability is unknown"
|
||||
},
|
||||
"weekday": {
|
||||
"1": "Monday",
|
||||
"2": "Tuesday",
|
||||
"3": "Wednesday",
|
||||
"4": "Thursday",
|
||||
"5": "Friday",
|
||||
"6": "Saturday",
|
||||
"7": "Sunday"
|
||||
},
|
||||
"dstOccurrence": "{field}: this time occurs twice. Choose the exact UTC instant.",
|
||||
"chooseOccurrence": "Choose occurrence",
|
||||
"datedShiftsNotice": "These dated shifts affect availability only for teams using the existing dated-shift mode. In weekly mode they remain stored but inactive; switching back reactivates them unchanged."
|
||||
}
|
||||
}
|
||||
|
||||
2
web/messages/en-US.json.license
Normal file
2
web/messages/en-US.json.license
Normal file
@@ -0,0 +1,2 @@
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
SPDX-FileComment: Modified in the independent hardening fork. See docs/hardening/README.md and MODIFIED_FILES.md for source attribution and change notes.
|
||||
@@ -26,7 +26,11 @@
|
||||
"actions": "Действия",
|
||||
"name": "Название",
|
||||
"saving": "Сохранение...",
|
||||
"reset": "Сбросить"
|
||||
"reset": "Сбросить",
|
||||
"operationFailed": "Не удалось выполнить операцию. Повторите попытку.",
|
||||
"loadFailed": "Не удалось загрузить данные. Повторите попытку.",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Отключено"
|
||||
},
|
||||
"language": {
|
||||
"enUS": "English",
|
||||
@@ -531,9 +535,9 @@
|
||||
"messageSummary": "[Сообщение]",
|
||||
"closedTitle": "Диалог завершён",
|
||||
"closedDescription": "Этот диалог поддержки закрыт. Спасибо, что обратились к нам.",
|
||||
"connecting": "Подключение",
|
||||
"connected": "Поддержка на связи",
|
||||
"disconnected": "Нет связи",
|
||||
"connecting": "Подключение чата",
|
||||
"connected": "Чат подключён",
|
||||
"disconnected": "Чат отключён",
|
||||
"retry": "Переподключиться",
|
||||
"moreActions": "Другие действия",
|
||||
"minimize": "Свернуть окно",
|
||||
@@ -583,7 +587,12 @@
|
||||
"startFirstRequest": "Создайте первое обращение",
|
||||
"openRequestCount": "Открыто {open} из {total}",
|
||||
"loadConversationsFailed": "Не удалось загрузить список обращений",
|
||||
"createConversationFailed": "Не удалось создать обращение"
|
||||
"createConversationFailed": "Не удалось создать обращение",
|
||||
"dismissedTitle": "Окно чата закрыто",
|
||||
"dismissedDescription": "Ваше обращение не завершено. Вы можете вернуться и продолжить разговор.",
|
||||
"returnToChat": "Вернуться в чат",
|
||||
"sendUnavailable": "Диалог недоступен. Вернитесь к нему или повторите попытку перед отправкой.",
|
||||
"sendInProgress": "Сообщение уже отправляется."
|
||||
},
|
||||
"dashboardHome": {
|
||||
"rangeToday": "Сегодня",
|
||||
@@ -2493,7 +2502,23 @@
|
||||
"debugAnswer": "Отладочный ответ",
|
||||
"answerResult": "Результат ответа",
|
||||
"searchHits": "Совпадения поиска",
|
||||
"hitsSummary": "Совпадений: {count} · {latency}мс"
|
||||
"hitsSummary": "Совпадений: {count} · {latency}мс",
|
||||
"unknownType": "Неизвестный тип",
|
||||
"unknownStatus": "Неизвестный статус",
|
||||
"indexFailureSummary": "Не удалось выполнить индексацию. Проверьте настройки индексации и повторите попытку.",
|
||||
"requestId": "Идентификатор запроса",
|
||||
"rerank": "Переранжирование",
|
||||
"topScore": "Лучшая оценка",
|
||||
"promptTokens": "Входные токены",
|
||||
"completionTokens": "Выходные токены",
|
||||
"traceData": "Данные трассировки",
|
||||
"chunkNumber": "Фрагмент №{number}",
|
||||
"providerValue": "Стратегия: {value}",
|
||||
"scoreValue": "Оценка: {value}",
|
||||
"rerankValue": "Оценка переранжирования: {value}",
|
||||
"historyRecordedNotice": "Прошлые ответы и фрагменты показаны в исходном виде. Смена языка интерфейса не изменяет историю.",
|
||||
"faqNumber": "FAQ №{number}",
|
||||
"documentNumber": "Документ №{number}"
|
||||
},
|
||||
"scaffold": {
|
||||
"moduleBoard": "Доска поставки модулей",
|
||||
@@ -3370,8 +3395,8 @@
|
||||
"statusReady": "Готово к приёму обращений",
|
||||
"statusNeedsSetup": "Требуется настройка (готово {ready} из {total} шагов)",
|
||||
"stepsProgress": "Готово {ready} из {total} шагов",
|
||||
"quickSetupAll": "Настроить всё в 1 клик",
|
||||
"quickSetupRunning": "Применение настроек...",
|
||||
"quickSetupAll": "Проверить недостающие настройки",
|
||||
"quickSetupRunning": "Подготовка плана...",
|
||||
"quickFixSuccess": "Настройки применены, проверка маршрутизации обновлена",
|
||||
"refresh": "Проверить снова",
|
||||
"openSettings": "Открыть настройки",
|
||||
@@ -3394,12 +3419,12 @@
|
||||
"description": "Требуется как минимум одна активная команда для объединения операторов и привязки расписания смен."
|
||||
},
|
||||
"schedules": {
|
||||
"title": "Расписания смен (активны сейчас)",
|
||||
"description": "Команде нужно активное расписание на текущее время, иначе обращения остаются в очереди нерабочих часов."
|
||||
"title": "Рабочее время поддержки",
|
||||
"description": "У команды сейчас должно быть рабочее время по выбранному календарю: датированным сменам или недельному расписанию. Вне рабочих часов обращения остаются в очереди."
|
||||
},
|
||||
"operators": {
|
||||
"title": "Профили операторов и доступная емкость",
|
||||
"description": "Операторы должны быть активны, состоять в команде, включить автоназначение, иметь статус «Свободен» и доступный лимит диалогов."
|
||||
"description": "Для назначения нужны активные учётная запись и профиль, рабочее время команды, подключение к платформе, готовность принимать обращения, отсутствие действующего отпуска и включённое автоназначение. Лимит одновременных обращений не должен быть достигнут."
|
||||
},
|
||||
"ai_agents": {
|
||||
"title": "AI-агенты и передача человеку",
|
||||
@@ -3433,7 +3458,15 @@
|
||||
"channel_no_agent": "Канал не подключен к AI-агенту.",
|
||||
"channel_agent_disabled": "Канал указывает на отключенного или удаленного AI-агента.",
|
||||
"channel_zero_rollout": "У канала охват AI-агента равен 0%.",
|
||||
"no_end_to_end_path": "Ни один канал не может доставить запрос до доступного оператора прямо сейчас."
|
||||
"no_end_to_end_path": "Ни один канал не может доставить запрос до доступного оператора прямо сейчас.",
|
||||
"agent_unpublished": "У агента нет опубликованной версии. Опубликуйте его в настройках перед использованием в канале.",
|
||||
"channel_agent_unpublished": "Канал связан с неопубликованным агентом. Опубликуйте его или выберите опубликованного агента.",
|
||||
"operator_offline": "У оператора нет активного подключения к платформе поддержки.",
|
||||
"operator_break": "Оператор на перерыве и не может получать новые назначения.",
|
||||
"operator_unavailable": "Оператор недоступен для новых назначений.",
|
||||
"operator_vacation": "У оператора сейчас действует запланированный отпуск.",
|
||||
"operator_outside_hours": "Оператор сейчас вне рабочего времени по расписанию.",
|
||||
"operator_availability_unavailable": "Не удалось определить текущую доступность оператора."
|
||||
},
|
||||
"operatorBlockers": {
|
||||
"not_authenticated": "Вы не вошли в систему.",
|
||||
@@ -3443,25 +3476,31 @@
|
||||
"team_off_hours": "Ваша команда сейчас не на смене (нет активного расписания).",
|
||||
"auto_assign_disabled": "Автоматическое назначение диалогов выключено в вашем профиле.",
|
||||
"status_busy": "Ваш статус установлен в «Занят».",
|
||||
"at_capacity": "Вы достигли лимита одновременных диалогов."
|
||||
"at_capacity": "Вы достигли лимита одновременных диалогов.",
|
||||
"offline": "Нет активного подключения к платформе поддержки.",
|
||||
"break": "Перерыв: новые назначения приостановлены.",
|
||||
"unavailable": "Недоступен для новых назначений.",
|
||||
"vacation": "Действует запланированный отпуск.",
|
||||
"availability_unavailable": "Не удалось определить текущую доступность.",
|
||||
"user_disabled": "Учётная запись отключена или удалена."
|
||||
},
|
||||
"quickFixLabels": {
|
||||
"quick_setup_all": "Настроить всё в 1 клик",
|
||||
"ensure_team_and_schedule": "Создать/включить команду и расписание 24/7",
|
||||
"quick_setup_all": "Проверить недостающие настройки",
|
||||
"ensure_team_and_schedule": "Проверить настройку группы",
|
||||
"enable_my_operator_profile": "Включить мой приём и статус «Свободен»",
|
||||
"fix_ai_agent_handoff": "Настроить передачу оператору и привязать команды",
|
||||
"ensure_default_channel": "Создать/исправить веб-канал по умолчанию"
|
||||
},
|
||||
"simulator": {
|
||||
"title": "Симулятор маршрутизации к оператору",
|
||||
"description": "Протестируйте путь входящего сообщения клиента через канал и узнайте результат на каждом узле маршрутизации.",
|
||||
"description": "Проверка текущей доступности маршрута только с оператором без изменений. Поведение ИИ и частичный запуск не моделируются; сообщения не отправляются.",
|
||||
"selectChannelPlaceholder": "Выберите канал для симуляции...",
|
||||
"runSimulation": "Запустить симуляцию",
|
||||
"running": "Симуляция...",
|
||||
"outcomeSuccess": "Запрос ДОЙДЁТ до оператора: {operator}",
|
||||
"outcomeBlocked": "Запрос НЕ ДОЙДЁТ до оператора",
|
||||
"outcomeSuccess": "Сейчас доступен оператор: {operator}",
|
||||
"outcomeBlocked": "Передача оператору не подтверждена",
|
||||
"outcomes": {
|
||||
"assigned_to_operator": "Успех: обращение назначено свободному оператору.",
|
||||
"assigned_to_operator": "Для маршрута только с оператором сейчас есть подходящий сотрудник. Это проверка без изменений, не гарантия доставки.",
|
||||
"blocked_no_channel": "Заблокировано: каналы не созданы или запрошенный канал не найден.",
|
||||
"blocked_channel_disabled": "Заблокировано: данный канал отключен.",
|
||||
"blocked_zero_rollout": "Заблокировано: охват AI-агента в канале равен 0%.",
|
||||
@@ -3469,7 +3508,10 @@
|
||||
"blocked_ai_only_mode": "Заблокировано: AI-агент работает в режиме «Только ИИ» и не переключает на человека.",
|
||||
"blocked_agent_no_teams": "Заблокировано: у AI-агента не указаны активные команды для перевода.",
|
||||
"queued_off_hours_no_schedule": "В очереди: у команд сейчас нет активного расписания смен (нерабочие часы).",
|
||||
"queued_no_eligible_operator": "В очереди: смена активна, но нет свободных операторов с доступной емкостью."
|
||||
"queued_no_eligible_operator": "В очереди: смена активна, но нет свободных операторов с доступной емкостью.",
|
||||
"ai_execution_not_simulated": "Поведение ИИ не моделируется; передача оператору не подтверждена.",
|
||||
"partial_rollout_not_simulated": "Частичный запуск не моделируется; передача оператору не подтверждена.",
|
||||
"blocked_agent_unpublished": "Маршрутизация к оператору не подтверждена для неопубликованного агента. Сначала опубликуйте его."
|
||||
},
|
||||
"hops": {
|
||||
"channel": "Канал",
|
||||
@@ -3491,7 +3533,10 @@
|
||||
"blocked_agent_no_teams": "Агенту не назначены активные команды",
|
||||
"queued_off_hours_no_schedule": "Нет активного расписания на текущее время (нерабочие часы)",
|
||||
"queued_no_eligible_operator": "В активных командах нет свободных операторов с доступной емкостью",
|
||||
"assigned_to_operator": "Назначено подходящему оператору"
|
||||
"assigned_to_operator": "Назначено подходящему оператору",
|
||||
"ai_execution_not_simulated": "Поведение ИИ не моделируется; передача оператору не подтверждена.",
|
||||
"partial_rollout_not_simulated": "Частичный запуск не моделируется; передача оператору не подтверждена.",
|
||||
"blocked_agent_unpublished": "Маршрутизация к оператору не подтверждена для неопубликованного агента. Сначала опубликуйте его."
|
||||
},
|
||||
"candidateTableTitle": "Оценка кандидатов-операторов",
|
||||
"colOperator": "Оператор",
|
||||
@@ -3505,12 +3550,19 @@
|
||||
"eligibleBadge": "Подходит",
|
||||
"skipReasons": {
|
||||
"profile_disabled": "Профиль отключен",
|
||||
"user_disabled": "Аккаунт пользователя отключен",
|
||||
"user_disabled": "Учётная запись отключена или удалена.",
|
||||
"team_not_in_agent": "Команда не входит в настройки агента",
|
||||
"team_off_hours": "Вне расписания команды",
|
||||
"auto_assign_off": "Автоназначение выключено",
|
||||
"status_busy": "Статус «Занят»",
|
||||
"at_capacity": "Лимит диалогов исчерпан"
|
||||
"at_capacity": "Лимит диалогов исчерпан",
|
||||
"offline": "Нет активного подключения к платформе поддержки.",
|
||||
"break": "Перерыв: новые назначения приостановлены.",
|
||||
"unavailable": "Недоступен для новых назначений.",
|
||||
"vacation": "Действует запланированный отпуск.",
|
||||
"availability_unavailable": "Не удалось определить текущую доступность.",
|
||||
"auto_assign_disabled": "Автоматическое назначение отключено.",
|
||||
"no_team": "Нет назначенной активной команды."
|
||||
}
|
||||
},
|
||||
"banner": {
|
||||
@@ -3520,6 +3572,185 @@
|
||||
"headerIndicator": {
|
||||
"readyLabel": "Приём включен",
|
||||
"blockedLabel": "Приём заблокирован ({count})"
|
||||
},
|
||||
"preview": {
|
||||
"review": "Просмотреть изменения",
|
||||
"title": "Проверка изменений перед применением",
|
||||
"description": "Изменятся только указанные записи и поля. Смены не добавляются. Остальные настройки и доли запуска сохранятся. После сохранения ожидающие диалоги могут быть распределены.",
|
||||
"noChanges": "Безопасных автоматических изменений нет. Выберите группу, маршрут или рабочие часы в соответствующих настройках.",
|
||||
"apply": "Применить проверенные изменения",
|
||||
"create": "Создать",
|
||||
"update": "Изменить",
|
||||
"notApplied": "Проверенные изменения не были применены.",
|
||||
"manageSchedules": "Настроить рабочие часы"
|
||||
},
|
||||
"repairFields": {
|
||||
"name": "Название",
|
||||
"leader_user_id": "ID руководителя группы",
|
||||
"status": "Статус (0 включён, 1 отключён)",
|
||||
"user_id": "ID пользователя",
|
||||
"team_id": "ID группы / название новой группы",
|
||||
"team_ids": "Группы маршрутизации",
|
||||
"service_status": "Доступность (0 свободен, 1 занят)",
|
||||
"auto_assign_enabled": "Автоматическое назначение",
|
||||
"max_concurrent_count": "Лимит одновременных диалогов",
|
||||
"priority_level": "Приоритет назначения",
|
||||
"receive_offline_message": "Принимать сообщения офлайн",
|
||||
"service_mode": "Режим (1 только ИИ, 2 только оператор, 3 сначала ИИ)",
|
||||
"rollout_percent": "Доля запуска агента (%)",
|
||||
"handoff_mode": "Режим передачи (2 очередь группы)",
|
||||
"fallback_mode": "Резервный режим",
|
||||
"auto_handoff_on_failure": "Передавать оператору при сбое",
|
||||
"reply_timeout_seconds": "Тайм-аут ответа (сек.)",
|
||||
"max_steps": "Максимум шагов ИИ",
|
||||
"channel_id": "Идентификатор канала",
|
||||
"channel_type": "Тип канала",
|
||||
"ai_agent_id": "ID агента / название нового агента",
|
||||
"ai_agent_rollout_percent": "Доля запуска канала (%)",
|
||||
"config_json": "Настройки канала"
|
||||
},
|
||||
"repairWarnings": {
|
||||
"schedules_unchanged": "Рабочие часы не изменены. Настройте смены явно: время вне смены не является ошибкой настройки.",
|
||||
"active_team_required": "Выберите или включите группу в настройках перед связыванием новых записей.",
|
||||
"disabled_configuration_preserved": "Выбранная конфигурация отключена или имеет нулевую долю запуска и оставлена без изменений.",
|
||||
"agent_team_selection_required": "Выберите нужные группы в настройках агента. Группы не добавлялись автоматически.",
|
||||
"channel_selection_required": "Выберите агента и долю запуска в настройках канала. Существующая маршрутизация сохранена.",
|
||||
"existing_configuration_preserved": "Существующие настройки сохранены. Проверьте оставшиеся проблемы готовности в соответствующих настройках.",
|
||||
"dispatch_failed": "Изменения сохранены, но распределение ожидающих диалогов не удалось. Проверьте очередь, не применяйте исправление повторно.",
|
||||
"readiness_refresh_failed": "Не удалось обновить диагностику. Загрузите её снова: сохранённые изменения не отменены.",
|
||||
"agent_publication_required": "Опубликуйте нужного агента в его настройках перед созданием канала. Мастер не публикует агентов и не обходит это требование."
|
||||
}
|
||||
},
|
||||
"supportAvailability": {
|
||||
"title": "Доступность поддержки",
|
||||
"description": "Настройте часы работы и часовые пояса. Подключение, рабочий статус и возможность принимать обращения учитываются отдельно.",
|
||||
"loading": "Загрузка доступности…",
|
||||
"myStatus": "Моя доступность",
|
||||
"retryStatus": "Статус неизвестен. Повторить",
|
||||
"operatorStatus": "Доступность: {name}",
|
||||
"presenceHelp": "«На платформе» означает активное подключение к платформе поддержки с учётом вкладок и устройств. Это не означает готовность принимать новые обращения.",
|
||||
"online": "На платформе",
|
||||
"offline": "Не подключён",
|
||||
"workingStatus": "Рабочий статус",
|
||||
"statusHelp": "Статус сохраняется между сеансами. Занятость, перерыв, недоступность и действующий отпуск блокируют новые назначения. Вход в систему их не сбрасывает.",
|
||||
"statusSaved": "Доступность сохранена",
|
||||
"saveFailed": "Не удалось сохранить изменения. Введённые данные сохранены в форме.",
|
||||
"loadFailed": "Не удалось загрузить доступность",
|
||||
"saveStatus": "Сохранить статус",
|
||||
"saving": "Сохранение…",
|
||||
"cancel": "Отмена",
|
||||
"retry": "Повторить",
|
||||
"refresh": "Обновить",
|
||||
"discardTitle": "Отменить несохранённые изменения?",
|
||||
"discardDescription": "Изменения ещё не сохранены. Выйти без сохранения?",
|
||||
"discard": "Отменить изменения",
|
||||
"vacationStart": "Начало отпуска",
|
||||
"vacationEnd": "Окончание отпуска (не включительно)",
|
||||
"vacationTimezone": "Введите даты в часовом поясе браузера: {timezone}.",
|
||||
"vacationExact": "Точный интервал UTC: {start} → {end}.",
|
||||
"vacationHelp": "В этот период новые назначения отключены, даже если вы войдёте в систему. Вне периода действует сохранённый статус: {status}. Текущие обращения автоматически не передаются.",
|
||||
"cancelVacationHelp": "Сохранение другого статуса отменит запланированный отпуск.",
|
||||
"invalidVacation": "Укажите корректные начало и окончание, причём окончание должно быть позже начала. Время, пропущенное при переводе часов, недопустимо.",
|
||||
"calendar": "Календарь поддержки",
|
||||
"editCalendar": "Изменить календарь",
|
||||
"saveCalendar": "Сохранить календарь",
|
||||
"calendarSaved": "Календарь поддержки сохранён",
|
||||
"readOnly": "У вас доступ только для просмотра.",
|
||||
"deploymentDefault": "Настройки по умолчанию",
|
||||
"selectScope": "Выберите общие настройки или команду",
|
||||
"deploymentTimezone": "Часовой пояс IANA по умолчанию",
|
||||
"teamTimezone": "Часовой пояс команды",
|
||||
"timezoneHelp": "Используйте имя IANA, например Europe/Moscow или America/New_York, а не фиксированное смещение UTC.",
|
||||
"inheritTimezoneHelp": "Оставьте пустым для наследования общего часового пояса. Переопределение не меняет сохранённые датированные смены.",
|
||||
"effectiveTimezone": "Применяемый часовой пояс: {timezone}",
|
||||
"scheduleMode": "Режим календаря",
|
||||
"legacy": "Существующие датированные смены",
|
||||
"recurring": "Недельное расписание и исключения",
|
||||
"legacyHelp": "Существующие датированные смены сохраняются и не пересчитываются в новый часовой пояс. Недельное расписание применяется только после явного выбора и сохранения этого режима.",
|
||||
"viewDatedShifts": "Открыть датированные смены",
|
||||
"legacyActive": "Часы работы по-прежнему определяются существующими датированными сменами. Недельное расписание автоматически не создаётся.",
|
||||
"confirmCalendarTitle": "Применить календарь поддержки?",
|
||||
"confirmRecurring": "Переключить выбранную область на недельное расписание и исключения? Датированные смены останутся сохранёнными, но в этом режиме не будут определять доступность.",
|
||||
"confirmCalendar": "Эти настройки меняют время назначения новых обращений. Изменение общего часового пояса затронет команды, которые его наследуют. Датированные смены останутся без изменений.",
|
||||
"weeklyHours": "Недельное расписание",
|
||||
"hoursHelp": "Добавьте несколько интервалов для смен с перерывом. Окончание раньше начала означает переход на следующий день. Одинаковые начало и конец недопустимы. Дни без интервалов закрыты. Для полного дня используйте 00:00–24:00.",
|
||||
"noWeeklyHours": "Недельных интервалов нет. В этом режиме поддержка открывается только по явно заданным исключениям.",
|
||||
"addPeriod": "Добавить интервал",
|
||||
"removePeriod": "Удалить интервал",
|
||||
"day": "День",
|
||||
"start": "Начало",
|
||||
"end": "Окончание",
|
||||
"exceptions": "Праздники и исключения по датам",
|
||||
"exceptionsHelp": "Исключение заменяет недельное расписание на эту местную дату. Закрытая дата блокирует весь день, включая ночной интервал с предыдущего дня.",
|
||||
"noExceptions": "Исключений по датам нет",
|
||||
"addException": "Добавить исключение",
|
||||
"removeException": "Удалить исключение",
|
||||
"date": "Дата",
|
||||
"closedDay": "Закрыто весь день",
|
||||
"preview": "Проверка календаря",
|
||||
"previewHelp": "Проверьте форму без сохранения. Введите момент проверки в часовом поясе браузера ({timezone}) или оставьте пустым для текущего момента. Результат показан в часовом поясе календаря.",
|
||||
"previewAt": "Момент проверки (необязательно)",
|
||||
"runPreview": "Проверить календарь",
|
||||
"previewing": "Проверка…",
|
||||
"previewFailed": "Не удалось проверить календарь",
|
||||
"previewStale": "Форма изменилась. Повторите проверку.",
|
||||
"scheduleOpen": "В этот момент расписание открыто.",
|
||||
"scheduleClosed": "В этот момент расписание закрыто.",
|
||||
"nextOpening": "Следующее открытие по расписанию: {time}",
|
||||
"noNextOpening": "В периоде проверки не найдено следующего открытия.",
|
||||
"notPresencePromise": "Часы по расписанию не гарантируют подключение или доступность оператора.",
|
||||
"invalidTimezone": "Укажите корректный часовой пояс IANA. Общий часовой пояс не может быть пустым.",
|
||||
"invalidHours": "Используйте интервалы ЧЧ:мм с разными началом и окончанием. Для открытого исключения нужен хотя бы один интервал.",
|
||||
"invalidExceptionDate": "Для каждого исключения нужна корректная, неповторяющаяся дата.",
|
||||
"invalidPreviewTime": "Укажите корректное время проверки в часовом поясе браузера.",
|
||||
"noPermission": "У вас нет прав на просмотр календаря. Свой рабочий статус можно изменить отдельно.",
|
||||
"noTeams": "Нет команд для настройки календаря.",
|
||||
"publicChecking": "Проверяем доступность операторов…",
|
||||
"publicNextOpening": "Следующее открытие по расписанию: {time}. Наличие оператора не гарантируется.",
|
||||
"offlineDeprecated": "Устаревшая настройка сохранена для совместимости. Она не разрешает новые назначения отключённым операторам.",
|
||||
"manageStatus": "Изменить доступность",
|
||||
"statusSeparate": "Рабочий статус и отпуск настраиваются отдельно от профиля оператора.",
|
||||
"status": {
|
||||
"ready": "Готов принимать",
|
||||
"busy": "Занят",
|
||||
"break": "Перерыв",
|
||||
"unavailable": "Недоступен",
|
||||
"vacation": "В отпуске",
|
||||
"unknown": "Неизвестный статус"
|
||||
},
|
||||
"reason": {
|
||||
"eligible": "Может принимать новые назначения",
|
||||
"profile_disabled": "Профиль оператора отключён администратором",
|
||||
"user_disabled": "Учётная запись отключена",
|
||||
"team_disabled": "Команда отключена",
|
||||
"outside_hours": "Вне рабочего времени по расписанию",
|
||||
"offline": "Нет активного подключения к платформе поддержки",
|
||||
"busy": "Занят: новые назначения приостановлены",
|
||||
"break": "Перерыв: новые назначения приостановлены",
|
||||
"unavailable": "Недоступен для новых назначений",
|
||||
"vacation": "Действует запланированный отпуск",
|
||||
"auto_assign_disabled": "Автоматическое назначение отключено",
|
||||
"capacity": "Достигнут лимит одновременных обращений",
|
||||
"unknown": "Не удалось определить возможность назначения"
|
||||
},
|
||||
"public": {
|
||||
"available": "Операторы сейчас доступны",
|
||||
"outside_hours": "Сейчас нерабочее время поддержки",
|
||||
"temporarily_absent": "Сейчас нет оператора для нового обращения",
|
||||
"capacity": "Операторы сейчас полностью загружены",
|
||||
"unknown": "Доступность операторов неизвестна"
|
||||
},
|
||||
"weekday": {
|
||||
"1": "Понедельник",
|
||||
"2": "Вторник",
|
||||
"3": "Среда",
|
||||
"4": "Четверг",
|
||||
"5": "Пятница",
|
||||
"6": "Суббота",
|
||||
"7": "Воскресенье"
|
||||
},
|
||||
"dstOccurrence": "{field}: это время встречается дважды. Выберите точный момент UTC.",
|
||||
"chooseOccurrence": "Выберите вариант времени",
|
||||
"datedShiftsNotice": "Эти датированные смены определяют доступность только в режиме существующих смен. В недельном режиме они сохраняются, но не действуют. При возврате они вновь применяются без изменений."
|
||||
}
|
||||
}
|
||||
|
||||
2
web/messages/ru-RU.json.license
Normal file
2
web/messages/ru-RU.json.license
Normal file
@@ -0,0 +1,2 @@
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
SPDX-FileComment: Modified in the independent hardening fork. See docs/hardening/README.md and MODIFIED_FILES.md for source attribution and change notes.
|
||||
@@ -26,7 +26,11 @@
|
||||
"actions": "操作",
|
||||
"name": "名称",
|
||||
"saving": "保存中...",
|
||||
"reset": "重置"
|
||||
"reset": "重置",
|
||||
"operationFailed": "操作失败,请重试。",
|
||||
"loadFailed": "加载数据失败,请重试。",
|
||||
"enabled": "启用",
|
||||
"disabled": "禁用"
|
||||
},
|
||||
"language": {
|
||||
"enUS": "English (英文)",
|
||||
@@ -531,9 +535,9 @@
|
||||
"messageSummary": "[消息]",
|
||||
"closedTitle": "会话已结束",
|
||||
"closedDescription": "当前客服会话已关闭,感谢您的使用。",
|
||||
"connecting": "连接中",
|
||||
"connected": "在线服务",
|
||||
"disconnected": "连接已断开",
|
||||
"connecting": "聊天连接中",
|
||||
"connected": "聊天已连接",
|
||||
"disconnected": "聊天已断开",
|
||||
"retry": "重新连接",
|
||||
"moreActions": "更多操作",
|
||||
"minimize": "收起窗口",
|
||||
@@ -583,7 +587,12 @@
|
||||
"startFirstRequest": "开始第一个请求",
|
||||
"openRequestCount": "进行中 {open} / 共 {total}",
|
||||
"loadConversationsFailed": "加载请求列表失败",
|
||||
"createConversationFailed": "创建请求失败"
|
||||
"createConversationFailed": "创建请求失败",
|
||||
"dismissedTitle": "聊天窗口已关闭",
|
||||
"dismissedDescription": "您的支持请求尚未结束,您可以返回继续对话。",
|
||||
"returnToChat": "返回聊天",
|
||||
"sendUnavailable": "当前对话不可用,请返回对话或重试后再发送。",
|
||||
"sendInProgress": "正在发送消息,请稍候。"
|
||||
},
|
||||
"dashboardHome": {
|
||||
"rangeToday": "今天",
|
||||
@@ -2493,7 +2502,23 @@
|
||||
"debugAnswer": "调试问答",
|
||||
"answerResult": "回答结果",
|
||||
"searchHits": "检索命中",
|
||||
"hitsSummary": "命中 {count} 条 · {latency}ms"
|
||||
"hitsSummary": "命中 {count} 条 · {latency}ms",
|
||||
"unknownType": "未知类型",
|
||||
"unknownStatus": "未知状态",
|
||||
"indexFailureSummary": "索引失败。请检查索引配置后重试。",
|
||||
"requestId": "请求 ID",
|
||||
"rerank": "重排序",
|
||||
"topScore": "最高得分",
|
||||
"promptTokens": "输入 Token",
|
||||
"completionTokens": "输出 Token",
|
||||
"traceData": "追踪数据",
|
||||
"chunkNumber": "分块 #{number}",
|
||||
"providerValue": "策略:{value}",
|
||||
"scoreValue": "得分:{value}",
|
||||
"rerankValue": "重排序得分:{value}",
|
||||
"historyRecordedNotice": "历史回答和片段按记录时的原文显示。切换界面语言不会改写历史。",
|
||||
"faqNumber": "FAQ #{number}",
|
||||
"documentNumber": "文档 #{number}"
|
||||
},
|
||||
"scaffold": {
|
||||
"moduleBoard": "模块推进看板",
|
||||
@@ -3370,8 +3395,8 @@
|
||||
"statusReady": "已就绪,可正常接待请求",
|
||||
"statusNeedsSetup": "配置未就绪(已完成 {ready}/{total} 步)",
|
||||
"stepsProgress": "已就绪 {ready} / {total} 步",
|
||||
"quickSetupAll": "一键完成全部基础配置",
|
||||
"quickSetupRunning": "正在应用配置...",
|
||||
"quickSetupAll": "预览缺失的默认配置",
|
||||
"quickSetupRunning": "正在准备预览…",
|
||||
"quickFixSuccess": "配置已自动修复并重新检测",
|
||||
"refresh": "重新检测",
|
||||
"openSettings": "前往设置",
|
||||
@@ -3394,12 +3419,12 @@
|
||||
"description": "至少需要一个处于启用状态的人工客服组,用于聚合坐席人员并配置值班排班。"
|
||||
},
|
||||
"schedules": {
|
||||
"title": "实时值班排班",
|
||||
"description": "客服组必须拥有覆盖当前时段的有效值班排班;非工作时间段的请求将排队等待。"
|
||||
"title": "当前支持工作时间",
|
||||
"description": "团队当前必须处于所选日历的工作时间内,可使用日期班次或每周时间模式。非工作时间的请求保留在队列中。"
|
||||
},
|
||||
"operators": {
|
||||
"title": "坐席档案与服务容量",
|
||||
"description": "坐席需启用档案、归属有效客服组、开启自动分配、处于空闲状态且并发会话未达上限。"
|
||||
"description": "接待人员需要已启用的账户和资料、处于团队工作时间内、与平台保持活动连接、状态为可接待、未处于休假期间、启用自动分配且有可用容量。"
|
||||
},
|
||||
"ai_agents": {
|
||||
"title": "AI 智能体与人工转接",
|
||||
@@ -3433,7 +3458,15 @@
|
||||
"channel_no_agent": "接入渠道未关联 AI 智能体。",
|
||||
"channel_agent_disabled": "接入渠道关联的 AI 智能体已被禁用或不存在。",
|
||||
"channel_zero_rollout": "接入渠道的 AI 智能体分流比例为 0%。",
|
||||
"no_end_to_end_path": "当前没有任何渠道能成功连通到空闲可用的人工坐席。"
|
||||
"no_end_to_end_path": "当前没有任何渠道能成功连通到空闲可用的人工坐席。",
|
||||
"agent_unpublished": "Agent 尚无已发布版本,请先在 Agent 设置中发布后再用于渠道。",
|
||||
"channel_agent_unpublished": "渠道指向未发布的 Agent。请发布该 Agent,或明确选择已发布的 Agent。",
|
||||
"operator_offline": "接待人员没有与支持平台的活动连接。",
|
||||
"operator_break": "接待人员正在休息,无法接收新分配。",
|
||||
"operator_unavailable": "接待人员无法接收新分配。",
|
||||
"operator_vacation": "接待人员当前处于计划休假期间。",
|
||||
"operator_outside_hours": "接待人员当前不在计划工作时间内。",
|
||||
"operator_availability_unavailable": "无法确定接待人员当前的可用性。"
|
||||
},
|
||||
"operatorBlockers": {
|
||||
"not_authenticated": "您尚未登录系统。",
|
||||
@@ -3443,25 +3476,31 @@
|
||||
"team_off_hours": "您所在的客服组当前处于非工作时段(无生效排班)。",
|
||||
"auto_assign_disabled": "您的坐席档案未开启自动分配。",
|
||||
"status_busy": "您的服务状态为「忙碌」。",
|
||||
"at_capacity": "您的当前接待会话量已达上限。"
|
||||
"at_capacity": "您的当前接待会话量已达上限。",
|
||||
"offline": "没有与支持平台的活动连接。",
|
||||
"break": "休息中,已暂停新分配。",
|
||||
"unavailable": "不可接收新分配。",
|
||||
"vacation": "处于计划休假期间。",
|
||||
"availability_unavailable": "无法确定当前可用性。",
|
||||
"user_disabled": "账户已禁用或删除。"
|
||||
},
|
||||
"quickFixLabels": {
|
||||
"quick_setup_all": "一键完成全部基础配置",
|
||||
"ensure_team_and_schedule": "创建/启用客服组及 24/7 排班",
|
||||
"quick_setup_all": "预览缺失的默认配置",
|
||||
"ensure_team_and_schedule": "预览客服组设置",
|
||||
"enable_my_operator_profile": "启用我的坐席并设为空闲",
|
||||
"fix_ai_agent_handoff": "修复 AI 转人工模式并绑定客服组",
|
||||
"ensure_default_channel": "创建/修复默认 Web 渠道"
|
||||
},
|
||||
"simulator": {
|
||||
"title": "人工坐席路由模拟器",
|
||||
"description": "模拟客户通过指定渠道进线后的完整路由流转过程,逐级查看每个节点的调度决策。",
|
||||
"description": "只读检查当前仅人工路由的接待条件。不模拟 AI 行为或部分灰度,不会发送消息。",
|
||||
"selectChannelPlaceholder": "选择要模拟的渠道...",
|
||||
"runSimulation": "开始模拟分发",
|
||||
"running": "正在模拟...",
|
||||
"outcomeSuccess": "请求将成功分配给坐席:{operator}",
|
||||
"outcomeBlocked": "请求无法到达人工坐席",
|
||||
"outcomeSuccess": "当前符合条件的客服:{operator}",
|
||||
"outcomeBlocked": "未确认可送达人工客服",
|
||||
"outcomes": {
|
||||
"assigned_to_operator": "成功:会话将分配给处于空闲状态的就绪坐席。",
|
||||
"assigned_to_operator": "仅人工路由当前有符合条件的客服。这是只读检查,不保证实际送达。",
|
||||
"blocked_no_channel": "受阻:系统中没有可用渠道或指定的渠道不存在。",
|
||||
"blocked_channel_disabled": "受阻:该接入渠道已被禁用。",
|
||||
"blocked_zero_rollout": "受阻:该渠道的 AI 智能体分流比例为 0%。",
|
||||
@@ -3469,7 +3508,10 @@
|
||||
"blocked_ai_only_mode": "受阻:AI 智能体为纯 AI 模式,禁止转接人工。",
|
||||
"blocked_agent_no_teams": "受阻:AI 智能体未配置有效的转接客服组。",
|
||||
"queued_off_hours_no_schedule": "排队中:目标客服组当前无生效排班(非工作时间)。",
|
||||
"queued_no_eligible_operator": "排队中:当前处于工作排班内,但暂无可接待空闲坐席。"
|
||||
"queued_no_eligible_operator": "排队中:当前处于工作排班内,但暂无可接待空闲坐席。",
|
||||
"ai_execution_not_simulated": "未模拟 AI 行为,无法确认是否到达人工客服。",
|
||||
"partial_rollout_not_simulated": "未模拟部分灰度,无法确认是否到达人工客服。",
|
||||
"blocked_agent_unpublished": "未确认未发布 Agent 的人工路由,请先发布 Agent。"
|
||||
},
|
||||
"hops": {
|
||||
"channel": "接入渠道",
|
||||
@@ -3491,7 +3533,10 @@
|
||||
"blocked_agent_no_teams": "AI 智能体未关联有效客服组",
|
||||
"queued_off_hours_no_schedule": "当前时刻无生效中的工作排班(非工作时段)",
|
||||
"queued_no_eligible_operator": "当前客服组内暂无空闲且容量充足的坐席",
|
||||
"assigned_to_operator": "成功分配给符合条件的坐席"
|
||||
"assigned_to_operator": "成功分配给符合条件的坐席",
|
||||
"ai_execution_not_simulated": "未模拟 AI 行为,无法确认是否到达人工客服。",
|
||||
"partial_rollout_not_simulated": "未模拟部分灰度,无法确认是否到达人工客服。",
|
||||
"blocked_agent_unpublished": "未确认未发布 Agent 的人工路由,请先发布 Agent。"
|
||||
},
|
||||
"candidateTableTitle": "参评候选坐席明细",
|
||||
"colOperator": "坐席",
|
||||
@@ -3505,12 +3550,19 @@
|
||||
"eligibleBadge": "符合条件",
|
||||
"skipReasons": {
|
||||
"profile_disabled": "档案已停用",
|
||||
"user_disabled": "账号已停用",
|
||||
"user_disabled": "账户已禁用或删除。",
|
||||
"team_not_in_agent": "客服组未在智能体转接范围内",
|
||||
"team_off_hours": "客服组处于非值班时段",
|
||||
"auto_assign_off": "自动分配已关闭",
|
||||
"status_busy": "处于忙碌状态",
|
||||
"at_capacity": "接待量已达上限"
|
||||
"at_capacity": "接待量已达上限",
|
||||
"offline": "没有与支持平台的活动连接。",
|
||||
"break": "休息中,已暂停新分配。",
|
||||
"unavailable": "不可接收新分配。",
|
||||
"vacation": "处于计划休假期间。",
|
||||
"availability_unavailable": "无法确定当前可用性。",
|
||||
"auto_assign_disabled": "自动分配已禁用。",
|
||||
"no_team": "未分配已启用的团队。"
|
||||
}
|
||||
},
|
||||
"banner": {
|
||||
@@ -3520,6 +3572,185 @@
|
||||
"headerIndicator": {
|
||||
"readyLabel": "接单就绪",
|
||||
"blockedLabel": "接单受阻 ({count})"
|
||||
},
|
||||
"preview": {
|
||||
"review": "预览设置更改",
|
||||
"title": "应用前确认具体更改",
|
||||
"description": "仅更改下列具名记录和字段,不会添加排班。未列出的配置及灰度比例保持不变。更改提交后可能分配等待中的会话。",
|
||||
"noChanges": "没有可安全自动应用的更改。请在对应设置中选择客服组、路由或工作时段。",
|
||||
"apply": "应用已确认的更改",
|
||||
"create": "创建",
|
||||
"update": "更新",
|
||||
"notApplied": "未应用已确认的更改。",
|
||||
"manageSchedules": "管理明确的工作时段"
|
||||
},
|
||||
"repairFields": {
|
||||
"name": "名称",
|
||||
"leader_user_id": "组长 ID",
|
||||
"status": "启用状态(0 启用,1 禁用)",
|
||||
"user_id": "用户 ID",
|
||||
"team_id": "客服组 ID / 新组名称",
|
||||
"team_ids": "路由客服组",
|
||||
"service_status": "接待状态(0 空闲,1 忙碌)",
|
||||
"auto_assign_enabled": "自动分配",
|
||||
"max_concurrent_count": "并发会话上限",
|
||||
"priority_level": "分配优先级",
|
||||
"receive_offline_message": "接收离线消息",
|
||||
"service_mode": "服务模式(1 仅 AI,2 仅人工,3 AI 优先)",
|
||||
"rollout_percent": "Agent 灰度比例(%)",
|
||||
"handoff_mode": "转人工模式(2 客服组队列)",
|
||||
"fallback_mode": "回退模式",
|
||||
"auto_handoff_on_failure": "失败时自动转人工",
|
||||
"reply_timeout_seconds": "回复超时(秒)",
|
||||
"max_steps": "AI 最大步骤数",
|
||||
"channel_id": "渠道标识",
|
||||
"channel_type": "渠道类型",
|
||||
"ai_agent_id": "Agent ID / 新 Agent 名称",
|
||||
"ai_agent_rollout_percent": "渠道灰度比例(%)",
|
||||
"config_json": "渠道配置"
|
||||
},
|
||||
"repairWarnings": {
|
||||
"schedules_unchanged": "未更改工作时段。请明确配置排班;非值班时段不是配置错误。",
|
||||
"active_team_required": "请先在客服组设置中选择或启用客服组,再关联新记录。",
|
||||
"disabled_configuration_preserved": "所选配置已禁用或灰度比例为零,已保持不变。",
|
||||
"agent_team_selection_required": "请在 Agent 设置中选择目标客服组;未自动添加任何组。",
|
||||
"channel_selection_required": "请在渠道设置中选择目标 Agent 和灰度比例。已保留现有渠道路由。",
|
||||
"existing_configuration_preserved": "已保留现有配置。请在对应设置中检查剩余的就绪问题。",
|
||||
"dispatch_failed": "更改已保存,但等待会话分配失败。请检查队列,不要重复应用修复。",
|
||||
"readiness_refresh_failed": "就绪状态刷新失败。请重新加载诊断以查看当前状态;已提交的更改未回滚。",
|
||||
"agent_publication_required": "创建渠道前,请先在 Agent 设置中发布目标 Agent。设置向导不会发布 Agent 或绕过发布要求。"
|
||||
}
|
||||
},
|
||||
"supportAvailability": {
|
||||
"title": "支持可用性",
|
||||
"description": "配置支持时间和时区。连接、工作状态和接待资格分别计算。",
|
||||
"loading": "正在加载可用性…",
|
||||
"myStatus": "我的可用性",
|
||||
"retryStatus": "状态不可用,请重试",
|
||||
"operatorStatus": "可用性:{name}",
|
||||
"presenceHelp": "在线表示至少一个标签页或设备仍与支持平台连接,并不表示可以接待新请求。",
|
||||
"online": "平台在线",
|
||||
"offline": "未连接",
|
||||
"workingStatus": "工作状态",
|
||||
"statusHelp": "状态在会话间保留。忙碌、休息、不可用和休假期间停止新分配,登录不会重置状态。",
|
||||
"statusSaved": "可用性已保存",
|
||||
"saveFailed": "无法保存更改,表单输入已保留。",
|
||||
"loadFailed": "无法加载可用性",
|
||||
"saveStatus": "保存状态",
|
||||
"saving": "正在保存…",
|
||||
"cancel": "取消",
|
||||
"retry": "重试",
|
||||
"refresh": "刷新",
|
||||
"discardTitle": "放弃未保存的更改?",
|
||||
"discardDescription": "更改尚未保存。是否放弃并离开?",
|
||||
"discard": "放弃更改",
|
||||
"vacationStart": "休假开始",
|
||||
"vacationEnd": "休假结束(不含结束时刻)",
|
||||
"vacationTimezone": "请按浏览器时区输入日期:{timezone}。",
|
||||
"vacationExact": "精确 UTC 时段:{start} → {end}。",
|
||||
"vacationHelp": "此时段内即使登录也不会收到新分配。时段外恢复已保存的状态:{status}。现有会话不会自动转移。",
|
||||
"cancelVacationHelp": "保存其他状态将取消已安排的休假。",
|
||||
"invalidVacation": "请输入有效的开始和结束时间,结束时间必须晚于开始时间。夏令时跳过的时间无效。",
|
||||
"calendar": "支持日历",
|
||||
"editCalendar": "编辑日历",
|
||||
"saveCalendar": "保存日历",
|
||||
"calendarSaved": "支持日历已保存",
|
||||
"readOnly": "您只有查看权限。",
|
||||
"deploymentDefault": "部署默认设置",
|
||||
"selectScope": "选择默认设置或团队",
|
||||
"deploymentTimezone": "默认 IANA 时区",
|
||||
"teamTimezone": "团队时区覆盖",
|
||||
"timezoneHelp": "请使用 Europe/Moscow 或 America/New_York 等 IANA 名称,而不是固定 UTC 偏移量。",
|
||||
"inheritTimezoneHelp": "留空以继承部署时区。时区覆盖不会修改已保存的日期班次。",
|
||||
"effectiveTimezone": "生效时区:{timezone}",
|
||||
"scheduleMode": "日历模式",
|
||||
"legacy": "现有日期班次",
|
||||
"recurring": "每周时间和例外",
|
||||
"legacyHelp": "现有日期班次会保留,不会按新时区重新解释。只有明确选择并保存每周模式后,每周时间才会生效。",
|
||||
"viewDatedShifts": "查看日期班次",
|
||||
"legacyActive": "工作时间仍由现有日期班次决定,不会自动创建每周时间。",
|
||||
"confirmCalendarTitle": "应用此支持日历?",
|
||||
"confirmRecurring": "将此范围切换到每周时间和日期例外?现有日期班次会保留,但在此模式下不再决定可用性。",
|
||||
"confirmCalendar": "这些设置会改变新请求可分配的时间。默认时区变更会影响继承该时区的团队,日期班次保持不变。",
|
||||
"weeklyHours": "每周工作时间",
|
||||
"hoursHelp": "分段班次可添加多个时段。结束早于开始表示跨到次日。起止时间不能相同,没有时段的日期为休息日。 全天请使用 00:00–24:00。",
|
||||
"noWeeklyHours": "没有每周工作时间。在每周模式下,仅明确设为开放的日期例外可开放支持。",
|
||||
"addPeriod": "添加时段",
|
||||
"removePeriod": "删除时段",
|
||||
"day": "星期",
|
||||
"start": "开始",
|
||||
"end": "结束",
|
||||
"exceptions": "节假日和日期例外",
|
||||
"exceptionsHelp": "日期例外替代该本地日期的每周时间。关闭日期会阻止全天接待,包括前一天跨夜延续的时段。",
|
||||
"noExceptions": "没有日期例外",
|
||||
"addException": "添加日期例外",
|
||||
"removeException": "删除日期例外",
|
||||
"date": "日期",
|
||||
"closedDay": "全天关闭",
|
||||
"preview": "日历预览",
|
||||
"previewHelp": "无需保存即可预览表单。按浏览器时区({timezone})输入检查时间,留空则使用当前时间。结果按日历时区显示。",
|
||||
"previewAt": "检查时间(可选)",
|
||||
"runPreview": "检查日历",
|
||||
"previewing": "正在检查…",
|
||||
"previewFailed": "无法预览日历",
|
||||
"previewStale": "表单已更改,请重新预览。",
|
||||
"scheduleOpen": "此时日历处于开放时段。",
|
||||
"scheduleClosed": "此时日历处于关闭时段。",
|
||||
"nextOpening": "下次计划开放时间:{time}",
|
||||
"noNextOpening": "预览范围内未找到下次计划开放时间。",
|
||||
"notPresencePromise": "计划工作时间不保证届时有接待人员在线或可用。",
|
||||
"invalidTimezone": "请输入有效的 IANA 时区。部署默认时区不能为空。",
|
||||
"invalidHours": "请使用有效的 HH:mm 时段,起止时间不能相同。开放日期例外至少需要一个时段。",
|
||||
"invalidExceptionDate": "每个例外必须有有效且不重复的日期。",
|
||||
"invalidPreviewTime": "请按浏览器时区输入有效的检查时间。",
|
||||
"noPermission": "您无权查看日历设置,但仍可管理自己的工作状态。",
|
||||
"noTeams": "没有可配置日历的团队。",
|
||||
"publicChecking": "正在检查人工支持可用性…",
|
||||
"publicNextOpening": "下次计划开放时间:{time},不保证届时有接待人员在线。",
|
||||
"offlineDeprecated": "此旧设置为兼容性而保留,不允许向未连接的接待人员分配新请求。",
|
||||
"manageStatus": "管理可用性",
|
||||
"statusSeparate": "工作状态和计划休假与接待人员资料分别管理。",
|
||||
"status": {
|
||||
"ready": "可接待",
|
||||
"busy": "忙碌",
|
||||
"break": "休息中",
|
||||
"unavailable": "不可用",
|
||||
"vacation": "休假中",
|
||||
"unknown": "未知状态"
|
||||
},
|
||||
"reason": {
|
||||
"eligible": "可接收新分配",
|
||||
"profile_disabled": "接待人员资料已被管理员禁用",
|
||||
"user_disabled": "账户已禁用",
|
||||
"team_disabled": "团队已禁用",
|
||||
"outside_hours": "不在计划工作时间内",
|
||||
"offline": "没有与支持平台的活动连接",
|
||||
"busy": "忙碌:已暂停新分配",
|
||||
"break": "休息中:已暂停新分配",
|
||||
"unavailable": "不可接收新分配",
|
||||
"vacation": "处于计划休假期间",
|
||||
"auto_assign_disabled": "自动分配已禁用",
|
||||
"capacity": "已达到同时接待会话上限",
|
||||
"unknown": "无法确定接待资格"
|
||||
},
|
||||
"public": {
|
||||
"available": "人工支持当前可用",
|
||||
"outside_hours": "当前不在人工支持计划工作时间内",
|
||||
"temporarily_absent": "当前没有接待人员可处理新请求",
|
||||
"capacity": "接待人员当前已满负荷",
|
||||
"unknown": "人工支持可用性未知"
|
||||
},
|
||||
"weekday": {
|
||||
"1": "星期一",
|
||||
"2": "星期二",
|
||||
"3": "星期三",
|
||||
"4": "星期四",
|
||||
"5": "星期五",
|
||||
"6": "星期六",
|
||||
"7": "星期日"
|
||||
},
|
||||
"dstOccurrence": "{field}:此时间出现两次。请选择准确的 UTC 时刻。",
|
||||
"chooseOccurrence": "选择时间实例",
|
||||
"datedShiftsNotice": "这些日期班次仅对使用现有日期班次模式的团队生效。在每周模式下它们会保留但不生效;切回后将原样重新生效。"
|
||||
}
|
||||
}
|
||||
|
||||
2
web/messages/zh-CN.json.license
Normal file
2
web/messages/zh-CN.json.license
Normal file
@@ -0,0 +1,2 @@
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
SPDX-FileComment: Modified in the independent hardening fork. See docs/hardening/README.md and MODIFIED_FILES.md for source attribution and change notes.
|
||||
Reference in New Issue
Block a user