feat: implement channel runtime connectors
This commit is contained in:
@ -10,6 +10,7 @@ import type { ChatLogEvent, ChatLogSession } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { containedJsonTextClass } from '@/lib/text-wrapping';
|
||||
|
||||
function eventLabel(event: ChatLogEvent): string {
|
||||
return event.event_type || event.role || 'event';
|
||||
@ -175,7 +176,7 @@ export default function LogsPage() {
|
||||
return (
|
||||
<div
|
||||
key={`${event.message_id ?? index}:${event.event_type}`}
|
||||
className="rounded-lg border border-border bg-background"
|
||||
className="min-w-0 max-w-full overflow-hidden rounded-lg border border-border bg-background"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
@ -188,7 +189,7 @@ export default function LogsPage() {
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
|
||||
</div>
|
||||
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap break-words p-3 text-xs leading-5 text-foreground">
|
||||
<pre className={`max-h-[520px] overflow-auto p-3 text-xs leading-5 text-foreground ${containedJsonTextClass}`}>
|
||||
{body || formatPayload(event)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@ -19,7 +19,12 @@ import {
|
||||
uploadFile,
|
||||
wsManager,
|
||||
} from '@/lib/api';
|
||||
import { mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages';
|
||||
import {
|
||||
getSessionRefreshIntervalMs,
|
||||
mergeServerWithPendingUsers,
|
||||
shouldDisplayChatMessage,
|
||||
shouldMergePendingUsers,
|
||||
} from '@/lib/chat-messages';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { buildSessionProgressView } from '@/lib/session-progress';
|
||||
@ -47,6 +52,10 @@ function loadThinkingModePreference(): boolean {
|
||||
return stored == null ? false : stored !== 'false';
|
||||
}
|
||||
|
||||
function isDocumentHidden(): boolean {
|
||||
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const {
|
||||
@ -78,6 +87,7 @@ export default function ChatPage() {
|
||||
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
||||
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
||||
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
|
||||
const [documentHidden, setDocumentHidden] = useState(isDocumentHidden);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messageViewportRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@ -247,14 +257,26 @@ export default function ChatPage() {
|
||||
}, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isThinking) {
|
||||
const intervalMs = getSessionRefreshIntervalMs({ isLoading, isThinking, documentHidden });
|
||||
if (intervalMs == null) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
loadSessionMessages(useChatStore.getState().sessionId);
|
||||
}, 1500);
|
||||
const currentSessionId = useChatStore.getState().sessionId;
|
||||
void loadSessionMessages(currentSessionId);
|
||||
void loadSessions();
|
||||
}, intervalMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [isLoading, isThinking, loadSessionMessages]);
|
||||
}, [documentHidden, isLoading, isThinking, loadSessionMessages, loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const updateVisibility = () => setDocumentHidden(isDocumentHidden());
|
||||
document.addEventListener('visibilitychange', updateVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', updateVisibility);
|
||||
}, []);
|
||||
|
||||
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
|
||||
const viewport = messageViewportRef.current;
|
||||
|
||||
@ -73,6 +73,7 @@ import type {
|
||||
} from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapping';
|
||||
|
||||
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
|
||||
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
|
||||
@ -1094,7 +1095,7 @@ function ReadableFact({
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="break-words text-sm leading-5">{value || '-'}</div>
|
||||
<div className={`text-sm leading-5 ${containedLongTextClass}`}>{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1119,12 +1120,12 @@ function MetricTile({
|
||||
|
||||
function RawDetails({ title, payload }: { title: string; payload: unknown }) {
|
||||
return (
|
||||
<details className="mt-3 rounded-md border border-border bg-white">
|
||||
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-white">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{title}
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</summary>
|
||||
<pre className="max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5">
|
||||
<pre className={`max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5 ${containedJsonTextClass}`}>
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -88,6 +88,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.contained-long-text {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.contained-preserved-long-text {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.contained-json-text {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/* Override Tailwind Typography table defaults for markdown rendering */
|
||||
.prose table {
|
||||
margin-top: 0;
|
||||
|
||||
@ -3,9 +3,11 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { containedLongTextClass } from '@/lib/text-wrapping';
|
||||
|
||||
export function MarkdownContent({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<div className={`prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-code:[overflow-wrap:anywhere] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] prose-pre:whitespace-pre-wrap prose-pre:[overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${containedLongTextClass}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
|
||||
@ -12,6 +12,7 @@ import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||
|
||||
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
|
||||
@ -66,7 +67,7 @@ function MessageBubble({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[88%] px-4 py-3 ${
|
||||
className={`min-w-0 max-w-[88%] px-4 py-3 ${
|
||||
isUser
|
||||
? 'rounded-[28px] bg-primary text-primary-foreground'
|
||||
: 'rounded-none bg-transparent text-[#1D1715]'
|
||||
@ -92,14 +93,14 @@ function MessageBubble({
|
||||
key={att.file_id}
|
||||
href={fileUrl}
|
||||
download={att.name}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${
|
||||
className={`flex min-w-0 items-center gap-2 px-3 py-2 rounded-md text-sm ${
|
||||
isUser
|
||||
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{att.name}</span>
|
||||
<span className="min-w-0 truncate">{att.name}</span>
|
||||
{att.size && (
|
||||
<span className="text-xs opacity-70 flex-shrink-0">
|
||||
{att.size > 1024 * 1024
|
||||
@ -114,7 +115,7 @@ function MessageBubble({
|
||||
)}
|
||||
|
||||
{isUser ? (
|
||||
<p className="text-sm whitespace-pre-wrap">{textContent}</p>
|
||||
<p className={`text-sm ${containedPreservedLongTextClass}`}>{textContent}</p>
|
||||
) : (
|
||||
<MarkdownContent content={textContent} />
|
||||
)}
|
||||
|
||||
@ -11,6 +11,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||
|
||||
export type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
|
||||
|
||||
@ -177,7 +178,7 @@ export function TaskAcceptanceControls({
|
||||
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
||||
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)}
|
||||
</div>
|
||||
{recordedFeedback.comment ? <p className="mt-2 whitespace-pre-wrap text-muted-foreground">{String(recordedFeedback.comment)}</p> : null}
|
||||
{recordedFeedback.comment ? <p className={`mt-2 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(recordedFeedback.comment)}</p> : null}
|
||||
{recordedFeedback.created_at ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
|
||||
) : null}
|
||||
@ -229,7 +230,7 @@ export function TaskAcceptanceControls({
|
||||
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
|
||||
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className={`text-xs text-muted-foreground ${containedPreservedLongTextClass}`}>
|
||||
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
||||
<span className="font-mono">{runId || '-'}</span>
|
||||
<span className="mx-1">·</span>
|
||||
|
||||
@ -24,6 +24,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import { containedJsonTextClass, containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||
import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types';
|
||||
|
||||
import { TaskAcceptanceControls, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
||||
@ -146,14 +147,14 @@ function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
|
||||
const versions = historyVersions(card.details);
|
||||
|
||||
return (
|
||||
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
|
||||
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
|
||||
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
|
||||
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{versions.map((version, index) => (
|
||||
<div key={String(version.runId || index)} className="rounded-md border border-border bg-background p-3">
|
||||
<div key={String(version.runId || index)} className="min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-background p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">
|
||||
{pickAppText(locale, `第 ${index + 1} 轮结果`, `Version ${index + 1}`)}
|
||||
@ -162,9 +163,9 @@ function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
|
||||
{renderHistoryStatus(version, locale)}
|
||||
</Badge>
|
||||
</div>
|
||||
{version.result ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{String(version.result)}</p> : null}
|
||||
{version.result ? <p className={`mt-2 text-sm leading-6 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(version.result)}</p> : null}
|
||||
{version.comment ? (
|
||||
<div className="mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground">
|
||||
<div className={`mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground ${containedLongTextClass}`}>
|
||||
{pickAppText(locale, '修改意见', 'Revision note')}: {String(version.comment)}
|
||||
</div>
|
||||
) : null}
|
||||
@ -181,7 +182,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
|
||||
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
|
||||
|
||||
return (
|
||||
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="rounded-md scroll-mt-28">
|
||||
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="scroll-mt-28 overflow-hidden rounded-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
@ -197,7 +198,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{card.actorName ? <span>{card.actorName}</span> : null}
|
||||
{card.actorName ? <span className={containedLongTextClass}>{card.actorName}</span> : null}
|
||||
<span>{formatTaskRuntimeTime(card.createdAt, locale)}</span>
|
||||
{card.runId ? <span className="font-mono">{card.runId.slice(0, 8)}</span> : null}
|
||||
</div>
|
||||
@ -213,7 +214,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{card.summary ? <p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{card.summary}</p> : null}
|
||||
{card.summary ? <p className={`mt-3 text-sm leading-6 text-muted-foreground ${containedPreservedLongTextClass}`}>{card.summary}</p> : null}
|
||||
|
||||
{shouldRenderResultAcceptance ? (
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
@ -222,11 +223,11 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
|
||||
) : null}
|
||||
|
||||
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
|
||||
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
|
||||
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
|
||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
|
||||
{pickAppText(locale, '详情 JSON', 'Details JSON')}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-5 text-muted-foreground">
|
||||
<pre className={`mt-2 max-h-72 overflow-auto text-[11px] leading-5 text-muted-foreground ${containedJsonTextClass}`}>
|
||||
{detailsJson(card.details)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
@ -8,6 +8,12 @@ import type {
|
||||
ChatLogsResponse,
|
||||
BackendTask,
|
||||
ChatMessage,
|
||||
ChannelConfigDetail,
|
||||
ChannelConfigPayload,
|
||||
ChannelConnectorDescriptor,
|
||||
ConnectorSessionResponse,
|
||||
ConnectorSessionStartPayload,
|
||||
ChannelEventRecord,
|
||||
CronJob,
|
||||
FileAttachment,
|
||||
NotificationDetail,
|
||||
@ -638,6 +644,53 @@ export async function updateProviderConfig(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChannelConfig(channelId: string): Promise<ChannelConfigDetail> {
|
||||
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/config`);
|
||||
}
|
||||
|
||||
export async function updateChannelConfig(
|
||||
channelId: string,
|
||||
payload: ChannelConfigPayload
|
||||
): Promise<{ ok: boolean; channel_id: string; restart_required: boolean; channel: ChannelConfigDetail }> {
|
||||
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/config`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listChannelEvents(channelId: string, limit: number = 100): Promise<ChannelEventRecord[]> {
|
||||
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/events?limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function listChannelConnectors(): Promise<ChannelConnectorDescriptor[]> {
|
||||
return fetchJSON('/api/channel-connectors');
|
||||
}
|
||||
|
||||
export async function startChannelConnectorSession(
|
||||
payload: ConnectorSessionStartPayload
|
||||
): Promise<ConnectorSessionResponse> {
|
||||
return fetchJSON('/api/channel-connector-sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
kind: payload.kind,
|
||||
displayName: payload.displayName,
|
||||
ownerUserId: payload.ownerUserId,
|
||||
options: payload.options || {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChannelConnectorSession(sessionId: string): Promise<ConnectorSessionResponse> {
|
||||
return fetchJSON(`/api/channel-connector-sessions/${encodeURIComponent(sessionId)}`);
|
||||
}
|
||||
|
||||
export async function restartRuntime(): Promise<{ ok: boolean; restarting: boolean }> {
|
||||
return fetchJSON('/api/runtime/restart', {
|
||||
method: 'POST',
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cron (proxied)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
81
app-instance/frontend/lib/channel-connectors.test.ts
Normal file
81
app-instance/frontend/lib/channel-connectors.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getChannelConnectorSession,
|
||||
listChannelConnectors,
|
||||
startChannelConnectorSession,
|
||||
} from '@/lib/api';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.clear();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockJsonResponse(body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(body),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
function firstFetchCall(fetchMock: any): [unknown, RequestInit] {
|
||||
return fetchMock.mock.calls[0] as [unknown, RequestInit];
|
||||
}
|
||||
|
||||
describe('channel connector api', () => {
|
||||
it('lists available channel connectors', async () => {
|
||||
const fetchMock = vi.fn(() => mockJsonResponse([{ kind: 'weixin', displayName: 'Weixin' }]));
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const connectors = await listChannelConnectors();
|
||||
|
||||
expect(connectors).toEqual([{ kind: 'weixin', displayName: 'Weixin' }]);
|
||||
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connectors$/);
|
||||
});
|
||||
|
||||
it('starts a connector session with options', async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
mockJsonResponse({
|
||||
session: { sessionId: 'cs_1', kind: 'weixin', status: 'qr_ready' },
|
||||
connection: { connection_id: 'conn_1', kind: 'weixin', status: 'pairing' },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const response = await startChannelConnectorSession({
|
||||
kind: 'weixin',
|
||||
displayName: 'Weixin Main',
|
||||
options: { mode: 'qr' },
|
||||
});
|
||||
|
||||
expect(response.session.sessionId).toBe('cs_1');
|
||||
const [, request] = firstFetchCall(fetchMock);
|
||||
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connector-sessions$/);
|
||||
expect(request).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ kind: 'weixin', displayName: 'Weixin Main', options: { mode: 'qr' } }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('polls a connector session by id', async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
mockJsonResponse({
|
||||
session: { sessionId: 'cs_1', kind: 'weixin', status: 'connected' },
|
||||
connection: { connection_id: 'conn_1', kind: 'weixin', status: 'connected' },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const response = await getChannelConnectorSession('cs_1');
|
||||
|
||||
expect(response.connection?.status).toBe('connected');
|
||||
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connector-sessions\/cs_1$/);
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTaskCardMessageIndexes, mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages';
|
||||
import {
|
||||
getSessionRefreshIntervalMs,
|
||||
getTaskCardMessageIndexes,
|
||||
mergeServerWithPendingUsers,
|
||||
shouldDisplayChatMessage,
|
||||
shouldMergePendingUsers,
|
||||
} from '@/lib/chat-messages';
|
||||
import type { ChatMessage } from '@/types';
|
||||
|
||||
describe('chat message helpers', () => {
|
||||
@ -98,4 +104,11 @@ describe('chat message helpers', () => {
|
||||
expect(shouldDisplayChatMessage({ role: 'assistant', content: 'Final answer.', task_id: 'task-1', run_id: 'run-1' })).toBe(true);
|
||||
expect(shouldDisplayChatMessage({ role: 'user', content: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps polling idle visible chats so external channel messages appear', () => {
|
||||
expect(getSessionRefreshIntervalMs({ isLoading: true, isThinking: false, documentHidden: false })).toBe(1500);
|
||||
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: true, documentHidden: false })).toBe(1500);
|
||||
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: false, documentHidden: false })).toBe(5000);
|
||||
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: false, documentHidden: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,26 @@
|
||||
import type { ChatMessage } from '@/types';
|
||||
|
||||
const INVISIBLE_CONTENT_CHARS = /[\u200B-\u200D\uFEFF]/g;
|
||||
export const CHAT_WAITING_REFRESH_INTERVAL_MS = 1500;
|
||||
export const CHAT_IDLE_REFRESH_INTERVAL_MS = 5000;
|
||||
|
||||
export function getSessionRefreshIntervalMs({
|
||||
isLoading,
|
||||
isThinking,
|
||||
documentHidden,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
isThinking: boolean;
|
||||
documentHidden: boolean;
|
||||
}): number | null {
|
||||
if (documentHidden) {
|
||||
return null;
|
||||
}
|
||||
if (isLoading || isThinking) {
|
||||
return CHAT_WAITING_REFRESH_INTERVAL_MS;
|
||||
}
|
||||
return CHAT_IDLE_REFRESH_INTERVAL_MS;
|
||||
}
|
||||
|
||||
export function normalizedMessageText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
|
||||
22
app-instance/frontend/lib/text-wrapping.test.ts
Normal file
22
app-instance/frontend/lib/text-wrapping.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { containedJsonTextClass, containedLongTextClass } from './text-wrapping';
|
||||
|
||||
const globalsCss = readFileSync(join(process.cwd(), 'app/globals.css'), 'utf8');
|
||||
|
||||
describe('contained long text classes', () => {
|
||||
it('keeps long plain text inside its container', () => {
|
||||
expect(containedLongTextClass).toBe('contained-long-text');
|
||||
expect(globalsCss).toContain('.contained-long-text');
|
||||
expect(globalsCss).toContain('overflow-wrap: anywhere');
|
||||
expect(globalsCss).toContain('word-break: break-word');
|
||||
});
|
||||
|
||||
it('keeps long JSON and monospace output inside its container', () => {
|
||||
expect(containedJsonTextClass).toBe('contained-json-text');
|
||||
expect(globalsCss).toContain('.contained-json-text');
|
||||
expect(globalsCss).toContain('white-space: pre-wrap');
|
||||
});
|
||||
});
|
||||
5
app-instance/frontend/lib/text-wrapping.ts
Normal file
5
app-instance/frontend/lib/text-wrapping.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const containedLongTextClass = 'contained-long-text';
|
||||
|
||||
export const containedPreservedLongTextClass = 'contained-preserved-long-text';
|
||||
|
||||
export const containedJsonTextClass = 'contained-json-text';
|
||||
@ -6,6 +6,7 @@ const config: Config = {
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./lib/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
@ -148,9 +148,116 @@ export interface AgentConfigPayload {
|
||||
max_tool_iterations: number;
|
||||
}
|
||||
|
||||
export interface ChannelStatus {
|
||||
name: string;
|
||||
export interface ChannelConfigDetail {
|
||||
channel_id: string;
|
||||
enabled: boolean;
|
||||
kind: string;
|
||||
mode: string;
|
||||
account_id: string;
|
||||
display_name: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ChannelConfigPayload {
|
||||
enabled: boolean;
|
||||
kind: string;
|
||||
mode: string;
|
||||
account_id?: string;
|
||||
display_name?: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ChannelStatus {
|
||||
channel_id: string;
|
||||
name?: string;
|
||||
kind: string;
|
||||
mode: string;
|
||||
display_name: string;
|
||||
enabled: boolean;
|
||||
state: 'configured' | 'disabled' | 'starting' | 'running' | 'degraded' | 'error' | 'stopped';
|
||||
account_id: string;
|
||||
last_error?: string | null;
|
||||
last_event_at?: string | null;
|
||||
started_at?: string | null;
|
||||
capabilities: string[];
|
||||
webhook_url?: string | null;
|
||||
websocket_url?: string | null;
|
||||
connected_peers?: number;
|
||||
}
|
||||
|
||||
export interface ChannelEventRecord {
|
||||
event_id: string;
|
||||
channel_id: string;
|
||||
kind: string;
|
||||
session_id?: string | null;
|
||||
message_id?: string | null;
|
||||
run_id?: string | null;
|
||||
status: string;
|
||||
error?: string | null;
|
||||
text_preview?: string | null;
|
||||
text_length?: number;
|
||||
created_at: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChannelConnectorDescriptor {
|
||||
kind: string;
|
||||
displayName?: string;
|
||||
display_name?: string;
|
||||
authType?: string;
|
||||
auth_type?: string;
|
||||
providerId?: string;
|
||||
provider_id?: string;
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
export interface ChannelConnectionView {
|
||||
connection_id: string;
|
||||
owner_user_id?: string | null;
|
||||
channel_id: string;
|
||||
kind: string;
|
||||
mode: string;
|
||||
display_name: string;
|
||||
account_id: string;
|
||||
status: string;
|
||||
auth_type: string;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
capabilities?: string[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
last_seen_at?: string | null;
|
||||
last_error?: string | null;
|
||||
}
|
||||
|
||||
export interface ConnectorSessionView {
|
||||
sessionId: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
qrCode?: string | null;
|
||||
qrImage?: string | null;
|
||||
instructions?: string[];
|
||||
accountId?: string | null;
|
||||
displayName?: string | null;
|
||||
error?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ConnectorSessionResponse {
|
||||
session: ConnectorSessionView;
|
||||
connection?: ChannelConnectionView | null;
|
||||
}
|
||||
|
||||
export interface ConnectorSessionStartPayload {
|
||||
kind: string;
|
||||
displayName?: string;
|
||||
ownerUserId?: string;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RuntimeControls {
|
||||
self_restart: boolean;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
@ -165,6 +272,7 @@ export interface SystemStatus {
|
||||
max_tool_iterations: number;
|
||||
providers: ProviderStatus[];
|
||||
channels: ChannelStatus[];
|
||||
runtime_controls?: RuntimeControls;
|
||||
cron: {
|
||||
enabled: boolean;
|
||||
jobs: number;
|
||||
|
||||
Reference in New Issue
Block a user