feat: implement channel runtime connectors

This commit is contained in:
2026-06-03 16:22:44 +08:00
parent ee972441f5
commit c3d84b904a
105 changed files with 15621 additions and 322 deletions

View File

@ -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>

View File

@ -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;

View File

@ -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

View File

@ -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;

View File

@ -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={{

View File

@ -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} />
)}

View File

@ -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>

View File

@ -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>

View File

@ -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)
// ---------------------------------------------------------------------------

View 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$/);
});
});

View File

@ -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();
});
});

View File

@ -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') {

View 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');
});
});

View File

@ -0,0 +1,5 @@
export const containedLongTextClass = 'contained-long-text';
export const containedPreservedLongTextClass = 'contained-preserved-long-text';
export const containedJsonTextClass = 'contained-json-text';

View File

@ -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: {

View File

@ -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;