feat: implement channel runtime connectors
This commit is contained in:
@ -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';
|
||||
Reference in New Issue
Block a user