@coffer-org/plugin-webchat 7.0.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ export default definePlugin({
5
5
  id: 'webchat',
6
6
  version: '1.0.0',
7
7
  dependsOn: [],
8
+ label: 'webchat.plugin.label',
9
+ description: 'webchat.plugin.description',
8
10
  settings: defineSettings({
9
11
  label: 'webchat.settings.label',
10
12
  fields: {
@@ -1,11 +1,19 @@
1
1
  import { type ActionCaller } from '@coffer-org/server/plugin-hooks';
2
- import { type AgentDescriptor } from '@coffer-org/server/orchestrator';
3
- import { type ThreadSelection } from './chain-store.ts';
2
+ import { type AgentDescriptor, type ConnectorCapabilities } from '@coffer-org/server/orchestrator';
3
+ import { type Conversation as ThreadSelection } from '@coffer-org/server/conversation-store';
4
+ export interface StreamCtx {
5
+ caller: ActionCaller;
6
+ emit: (event: string, data: unknown) => void;
7
+ signal: AbortSignal;
8
+ }
4
9
  export interface ThreadSummary {
5
10
  convId: string;
6
11
  title: string;
7
12
  lastTs: number;
8
13
  count: number;
14
+ ownerName: string | null;
15
+ mine: boolean;
16
+ private: boolean;
9
17
  }
10
18
  export interface HistoryMsg {
11
19
  msgId: string;
@@ -38,3 +46,11 @@ export declare function agentsAction(body: Record<string, unknown>, caller: Acti
38
46
  export declare function selectAgentAction(body: Record<string, unknown>, caller: ActionCaller): Promise<{
39
47
  selection: ThreadSelection;
40
48
  }>;
49
+ export declare function channelAction(body: Record<string, unknown>, ctx: StreamCtx): Promise<void>;
50
+ export interface SetVisibilityDeps {
51
+ capabilities?: ConnectorCapabilities;
52
+ }
53
+ export declare function setVisibilityAction(body: Record<string, unknown>, caller: ActionCaller, deps?: SetVisibilityDeps): Promise<{
54
+ visibility: 'private' | null;
55
+ }>;
56
+ export declare function stopAction(body: Record<string, unknown>, caller: ActionCaller): Promise<void>;
@@ -1,6 +1,12 @@
1
1
  import { HttpError } from '@coffer-org/server/plugin-hooks';
2
- import { listAgentCatalog, getDefaultAgentId, listRegisteredAgents, getConversationStarters, } from '@coffer-org/server/orchestrator';
3
- import { chatIdFor, conversations, history, getSelection, setSelection } from "./chain-store.js";
2
+ import { SIDECAR_ROLES } from '@coffer-org/server/conversation-store';
3
+ import { listUsers } from '@coffer-org/server/auth-store';
4
+ import { abortTurn } from '@coffer-org/server/turn-gate';
5
+ import { listAgentCatalog, getDefaultAgentId, listRegisteredAgents, getConversationStarters, mayBePrivate, mayRead, } from '@coffer-org/server/orchestrator';
6
+ import { getConversation, setConversation, listConversations, readConversation, } from '@coffer-org/server/conversation-store';
7
+ import { mayWrite } from '@coffer-org/server/orchestrator';
8
+ import { CAPABILITIES } from "./send.js";
9
+ import { subscribe, evictWhere } from "./channel-registry.js";
4
10
  const TITLE_MAX = 60;
5
11
  function parseSuggestions(text) {
6
12
  try {
@@ -16,14 +22,18 @@ function title(firstUserText) {
16
22
  return t.length <= TITLE_MAX ? t : `${t.slice(0, TITLE_MAX - 1)}…`;
17
23
  }
18
24
  export async function threadsAction(_body, caller) {
19
- const chats = await conversations(caller.id);
20
- const prefixLen = `u:${caller.id}:`.length;
25
+ const chats = await listConversations(caller.id).then((rows) => rows.map((r) => ({ ...r, convId: r.id })));
26
+ const users = await listUsers();
27
+ const nameById = new Map(users.map((u) => [String(u.id), u.displayName]));
21
28
  return {
22
29
  threads: chats.map((c) => ({
23
- convId: c.chatId.slice(prefixLen),
24
- title: title(c.firstUserText),
30
+ convId: c.convId,
31
+ title: c.title ?? title(c.firstUserText),
25
32
  lastTs: c.lastTs,
26
33
  count: c.count,
34
+ ownerName: c.owner === null ? null : (nameById.get(c.owner) ?? null),
35
+ mine: c.owner === caller.id,
36
+ private: c.visibility === 'private',
27
37
  })),
28
38
  };
29
39
  }
@@ -33,15 +43,27 @@ function requireConvId(body) {
33
43
  throw new HttpError(400, 'missing required field: convId');
34
44
  return convId;
35
45
  }
46
+ const EMPTY_SELECTION = {
47
+ id: '',
48
+ agentId: null,
49
+ presetId: null,
50
+ title: null,
51
+ owner: null,
52
+ visibility: null,
53
+ updatedAt: '',
54
+ };
36
55
  export async function historyAction(body, caller) {
37
56
  const convId = requireConvId(body);
38
- const rows = await history(chatIdFor(caller.id, convId));
57
+ const conversationId = await openConversation(convId, caller.id, 'read');
58
+ if (conversationId === null)
59
+ return { convId, messages: [], headMsgId: null };
60
+ const rows = await readConversation(conversationId);
39
61
  const reasoningByBot = new Map(rows.filter((m) => m.role === 'reasoning').map((m) => [m.msgId.slice(0, -2), m.text]));
40
62
  const suggestionsByBot = new Map(rows
41
63
  .filter((m) => m.role === 'suggestions')
42
64
  .map((m) => [m.msgId.slice(0, -2), parseSuggestions(m.text)])
43
65
  .filter((entry) => entry[1] !== null));
44
- const visible = rows.filter((m) => m.role !== 'reasoning' && m.role !== 'suggestions');
66
+ const visible = rows.filter((m) => !SIDECAR_ROLES.includes(m.role));
45
67
  return {
46
68
  convId,
47
69
  messages: visible.map((m) => ({
@@ -58,16 +80,19 @@ export async function historyAction(body, caller) {
58
80
  }
59
81
  export async function agentsAction(body, caller) {
60
82
  const convId = requireConvId(body);
83
+ const conversationId = await openConversation(convId, caller.id, 'read');
61
84
  return {
62
85
  agents: await listAgentCatalog(),
63
86
  defaultAgentId: getDefaultAgentId() ?? null,
64
- selection: await getSelection(chatIdFor(caller.id, convId)),
87
+ selection: conversationId === null ? EMPTY_SELECTION : await getConversation(conversationId),
65
88
  starters: await getConversationStarters(),
66
89
  };
67
90
  }
68
91
  export async function selectAgentAction(body, caller) {
69
92
  const convId = requireConvId(body);
70
- const chatId = chatIdFor(caller.id, convId);
93
+ const conversationId = await openConversation(convId, caller.id, 'write');
94
+ if (conversationId === null)
95
+ return { selection: EMPTY_SELECTION };
71
96
  const patch = {};
72
97
  if ('agentId' in body) {
73
98
  const agentId = typeof body['agentId'] === 'string' && body['agentId'] ? body['agentId'] : null;
@@ -77,6 +102,60 @@ export async function selectAgentAction(body, caller) {
77
102
  }
78
103
  if ('presetId' in body)
79
104
  patch.presetId = typeof body['presetId'] === 'string' && body['presetId'] ? body['presetId'] : null;
80
- await setSelection(chatId, patch);
81
- return { selection: await getSelection(chatId) };
105
+ await setConversation(conversationId, patch);
106
+ return { selection: await getConversation(conversationId) };
107
+ }
108
+ export async function channelAction(body, ctx) {
109
+ const convId = requireConvId(body);
110
+ const conversationId = await openConversation(convId, ctx.caller.id, 'read');
111
+ if (conversationId === null)
112
+ return;
113
+ let resolveHeld;
114
+ const held = new Promise((resolve) => {
115
+ resolveHeld = resolve;
116
+ });
117
+ const off = subscribe(conversationId, {
118
+ viewerId: ctx.caller.id,
119
+ emit: ctx.emit,
120
+ close: () => resolveHeld(),
121
+ });
122
+ try {
123
+ if (!ctx.signal.aborted) {
124
+ ctx.signal.addEventListener('abort', () => resolveHeld(), { once: true });
125
+ await held;
126
+ }
127
+ }
128
+ finally {
129
+ off();
130
+ }
131
+ }
132
+ export async function setVisibilityAction(body, caller, deps = {}) {
133
+ const convId = requireConvId(body);
134
+ const makePrivate = body['private'] === true;
135
+ const conversationId = await openConversation(convId, caller.id, 'write');
136
+ if (conversationId === null)
137
+ return { visibility: null };
138
+ const selection = await getConversation(conversationId);
139
+ if (selection.owner !== caller.id)
140
+ return { visibility: selection.visibility };
141
+ if (makePrivate && !mayBePrivate(deps.capabilities ?? CAPABILITIES))
142
+ return { visibility: selection.visibility };
143
+ const visibility = makePrivate ? 'private' : null;
144
+ await setConversation(conversationId, { visibility });
145
+ evictWhere(conversationId, (viewerId) => !mayRead({ owner: selection.owner, visibility }, viewerId));
146
+ return { visibility };
147
+ }
148
+ export async function stopAction(body, caller) {
149
+ const convId = requireConvId(body);
150
+ const conversationId = await openConversation(convId, caller.id, 'write');
151
+ if (conversationId === null)
152
+ return;
153
+ abortTurn('webchat', conversationId);
154
+ }
155
+ async function openConversation(convId, viewerId, need) {
156
+ if (!convId || convId.includes(':'))
157
+ return null;
158
+ const conv = await getConversation(convId);
159
+ const allowed = need === 'read' ? mayRead(conv, viewerId) : mayWrite(conv, viewerId);
160
+ return allowed ? convId : null;
82
161
  }
@@ -1,9 +1,8 @@
1
- import { type ThreadChat, type StoredMsg } from '@coffer-org/server/thread-store';
1
+ import { type StoredMsg } from '@coffer-org/server/thread-store';
2
2
  import { type ThreadSelection } from '@coffer-org/server/thread-state';
3
- import type { ConvMessage } from '@coffer-org/server/orchestrator';
3
+ import type { ConvMessage, ContextFact } from '@coffer-org/server/orchestrator';
4
4
  export declare const CONNECTOR = "webchat";
5
5
  export declare function chatIdFor(userId: string, convId: string): string;
6
- export declare function chatPrefixFor(userId: string): string;
7
6
  export declare function recordUser(m: {
8
7
  chatId: string;
9
8
  msgId: string;
@@ -34,13 +33,30 @@ export declare function recordSuggestions(m: {
34
33
  suggestions: string[];
35
34
  now: number;
36
35
  }): Promise<boolean>;
36
+ export declare function recordContext(m: {
37
+ chatId: string;
38
+ userMsgId: string;
39
+ facts: ContextFact[];
40
+ ts: number;
41
+ }): Promise<void>;
42
+ export declare function userTurnCount(chatId: string): Promise<number>;
37
43
  export declare function buildChain(headMsgId: string, opts: {
38
44
  chatId: string;
39
45
  maxDepth?: number;
40
46
  }): Promise<ConvMessage[]>;
41
47
  export declare function history(chatId: string, limit?: number): Promise<StoredMsg[]>;
42
- export declare function conversations(userId: string): Promise<ThreadChat[]>;
48
+ export interface ConversationSummary {
49
+ convId: string;
50
+ lastTs: number;
51
+ count: number;
52
+ firstUserText: string;
53
+ title: string | null;
54
+ owner: string | null;
55
+ visibility: 'private' | null;
56
+ }
57
+ export declare function conversations(viewerId: string): Promise<ConversationSummary[]>;
43
58
  export type { ThreadSelection };
59
+ export declare function openConversation(convId: string, viewerId: string, need: 'read' | 'write'): Promise<string | null>;
44
60
  export declare function getSelection(chatId: string): Promise<ThreadSelection>;
45
61
  export declare function setSelection(chatId: string, patch: Partial<ThreadSelection>): Promise<void>;
46
62
  export declare function readSelectionForTurn(chatId: string): Promise<ThreadSelection>;
@@ -1,5 +1,6 @@
1
- import { getThreadMessage, putThreadMessage, listThreadMessages, listThreadChats, } from '@coffer-org/server/thread-store';
2
- import { getThreadState, setThreadState, readAndTouchThreadState, } from '@coffer-org/server/thread-state';
1
+ import { getThreadMessage, putThreadMessage, listThreadMessages, listAllThreadChats, countUserTurns, SIDECAR_ROLES, } from '@coffer-org/server/thread-store';
2
+ import { getThreadState, getThreadStates, setThreadState, readAndTouchThreadState, findChatByConvId, } from '@coffer-org/server/thread-state';
3
+ import { CONNECTOR_FACT_NAME, mayRead, mayWrite } from '@coffer-org/server/orchestrator';
3
4
  import { getLogger } from '@coffer-org/sdk/logger';
4
5
  const log = getLogger('webchat');
5
6
  export const CONNECTOR = 'webchat';
@@ -7,8 +8,10 @@ const DEFAULT_MAX_DEPTH = 40;
7
8
  export function chatIdFor(userId, convId) {
8
9
  return `u:${userId}:${convId}`;
9
10
  }
10
- export function chatPrefixFor(userId) {
11
- return `u:${userId}:`;
11
+ function convIdOf(chatId) {
12
+ const first = chatId.indexOf(':');
13
+ const second = first === -1 ? -1 : chatId.indexOf(':', first + 1);
14
+ return second === -1 ? null : chatId.slice(second + 1);
12
15
  }
13
16
  export async function recordUser(m) {
14
17
  await putThreadMessage({
@@ -65,17 +68,46 @@ export async function recordSuggestions(m) {
65
68
  });
66
69
  return true;
67
70
  }
71
+ export async function recordContext(m) {
72
+ await putThreadMessage({
73
+ connector: CONNECTOR,
74
+ chatId: m.chatId,
75
+ msgId: `${m.userMsgId}~c`,
76
+ role: 'context',
77
+ text: JSON.stringify(m.facts),
78
+ ts: m.ts,
79
+ replyToId: null,
80
+ });
81
+ }
82
+ function parseFacts(text) {
83
+ try {
84
+ const parsed = JSON.parse(text);
85
+ return Array.isArray(parsed)
86
+ ? parsed.filter((f) => typeof f?.name === 'string' &&
87
+ CONNECTOR_FACT_NAME.test(f.name) &&
88
+ typeof f.value === 'string' &&
89
+ Object.entries(f.attrs ?? {}).every(([k, v]) => CONNECTOR_FACT_NAME.test(k) && typeof v === 'string'))
90
+ : [];
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ }
96
+ export function userTurnCount(chatId) {
97
+ return countUserTurns(CONNECTOR, chatId);
98
+ }
68
99
  export async function buildChain(headMsgId, opts) {
69
100
  const max = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
70
101
  const acc = [];
102
+ let turns = 0;
71
103
  let cur = headMsgId;
72
104
  const seen = new Set();
73
- while (cur && acc.length < max && !seen.has(cur)) {
105
+ while (cur && turns < max && !seen.has(cur)) {
74
106
  seen.add(cur);
75
107
  const stored = await getThreadMessage(CONNECTOR, opts.chatId, cur);
76
108
  if (!stored)
77
109
  break;
78
- if (stored.role !== 'reasoning' && stored.role !== 'suggestions') {
110
+ if (!SIDECAR_ROLES.includes(stored.role)) {
79
111
  acc.push({
80
112
  role: stored.role,
81
113
  content: stored.text,
@@ -84,10 +116,17 @@ export async function buildChain(headMsgId, opts) {
84
116
  msgId: stored.msgId,
85
117
  ts: stored.ts,
86
118
  });
119
+ turns += 1;
120
+ if (stored.role === 'user') {
121
+ const ctx = await getThreadMessage(CONNECTOR, opts.chatId, `${stored.msgId}~c`);
122
+ const facts = ctx ? parseFacts(ctx.text) : [];
123
+ if (facts.length)
124
+ acc[acc.length - 1].context = facts;
125
+ }
87
126
  }
88
127
  cur = stored.replyToId;
89
128
  }
90
- if (acc.length >= max)
129
+ if (turns >= max)
91
130
  log.info(`chain capped at ${max} for ${headMsgId}`);
92
131
  while (acc.length && acc.at(-1)?.role !== 'user')
93
132
  acc.pop();
@@ -96,8 +135,46 @@ export async function buildChain(headMsgId, opts) {
96
135
  export function history(chatId, limit) {
97
136
  return listThreadMessages(CONNECTOR, chatId, limit);
98
137
  }
99
- export function conversations(userId) {
100
- return listThreadChats(CONNECTOR, chatPrefixFor(userId));
138
+ export async function conversations(viewerId) {
139
+ const chats = await listAllThreadChats(CONNECTOR);
140
+ const states = await getThreadStates(CONNECTOR, chats.map((c) => c.chatId));
141
+ const out = [];
142
+ for (const c of chats) {
143
+ const access = states.get(c.chatId) ?? {
144
+ agentId: null,
145
+ presetId: null,
146
+ title: null,
147
+ owner: null,
148
+ visibility: null,
149
+ };
150
+ if (!mayRead(access, viewerId))
151
+ continue;
152
+ const convId = convIdOf(c.chatId);
153
+ if (convId === null)
154
+ continue;
155
+ out.push({
156
+ convId,
157
+ lastTs: c.lastTs,
158
+ count: c.count,
159
+ firstUserText: c.firstUserText,
160
+ title: access.title,
161
+ owner: access.owner,
162
+ visibility: access.visibility,
163
+ });
164
+ }
165
+ return out;
166
+ }
167
+ export async function openConversation(convId, viewerId, need) {
168
+ if (!convId || convId.includes(':'))
169
+ return null;
170
+ const lookup = await findChatByConvId(CONNECTOR, convId);
171
+ if (lookup.found === 'ambiguous')
172
+ return null;
173
+ if (lookup.found === 'none')
174
+ return chatIdFor(viewerId, convId);
175
+ const access = await getThreadState(CONNECTOR, lookup.chatId);
176
+ const allowed = need === 'read' ? mayRead(access, viewerId) : mayWrite(access, viewerId);
177
+ return allowed ? lookup.chatId : null;
101
178
  }
102
179
  export async function getSelection(chatId) {
103
180
  return getThreadState(CONNECTOR, chatId);
@@ -0,0 +1,16 @@
1
+ export interface Subscriber {
2
+ viewerId: string;
3
+ emit(event: string, data: unknown): void;
4
+ close(): void;
5
+ }
6
+ export type ChannelCallback = (frame: {
7
+ event: string;
8
+ data: unknown;
9
+ }) => void;
10
+ export declare function subscribe(conversationId: string, sub: Subscriber | ChannelCallback): () => void;
11
+ export declare function subscribeAs(conversationId: string, viewerId: string, cb: ChannelCallback): Promise<() => void>;
12
+ export declare function broadcast(conversationId: string, event: string, data: unknown): void;
13
+ export declare function evictWhere(conversationId: string, drop: (viewerId: string) => boolean): void;
14
+ export declare function subscriberCount(conversationId: string): number;
15
+ export declare function initFanout(): () => void;
16
+ export declare function teardownFanout(): void;
@@ -0,0 +1,126 @@
1
+ import { getLogger } from '@coffer-org/sdk/logger';
2
+ import { getConversation, onMessage, conversationEmitter, HIDDEN_ROLES } from '@coffer-org/server/conversation-store';
3
+ import { mayRead } from '@coffer-org/server/orchestrator';
4
+ const log = getLogger('webchat');
5
+ const channels = new Map();
6
+ export function subscribe(conversationId, sub) {
7
+ if (typeof sub === 'function') {
8
+ return subscribe(conversationId, {
9
+ viewerId: '*',
10
+ emit: (event, data) => sub({ event, data }),
11
+ close: () => { },
12
+ });
13
+ }
14
+ let set = channels.get(conversationId);
15
+ if (!set) {
16
+ set = new Set();
17
+ channels.set(conversationId, set);
18
+ }
19
+ set.add(sub);
20
+ return () => {
21
+ const current = channels.get(conversationId);
22
+ if (!current)
23
+ return;
24
+ current.delete(sub);
25
+ if (current.size === 0)
26
+ channels.delete(conversationId);
27
+ };
28
+ }
29
+ export async function subscribeAs(conversationId, viewerId, cb) {
30
+ if (viewerId !== '*') {
31
+ try {
32
+ const conv = await getConversation(conversationId);
33
+ if (conv && !mayRead(conv, viewerId)) {
34
+ return () => { };
35
+ }
36
+ }
37
+ catch {
38
+ }
39
+ }
40
+ return subscribe(conversationId, {
41
+ viewerId,
42
+ emit: (event, data) => cb({ event, data }),
43
+ close: () => { },
44
+ });
45
+ }
46
+ export function broadcast(conversationId, event, data) {
47
+ const set = channels.get(conversationId);
48
+ if (!set)
49
+ return;
50
+ for (const sub of set) {
51
+ try {
52
+ sub.emit(event, data);
53
+ }
54
+ catch (err) {
55
+ log.warn(`channel emit failed for viewer ${sub.viewerId} on ${conversationId}: ${err instanceof Error ? err.message : String(err)}`);
56
+ }
57
+ }
58
+ }
59
+ export function evictWhere(conversationId, drop) {
60
+ const set = channels.get(conversationId);
61
+ if (!set)
62
+ return;
63
+ for (const sub of set) {
64
+ if (!drop(sub.viewerId))
65
+ continue;
66
+ set.delete(sub);
67
+ sub.close();
68
+ }
69
+ if (set.size === 0)
70
+ channels.delete(conversationId);
71
+ }
72
+ export function subscriberCount(conversationId) {
73
+ return channels.get(conversationId)?.size ?? 0;
74
+ }
75
+ let unsubscribeMessage;
76
+ let unsubscribeConversation;
77
+ export function initFanout() {
78
+ if (!unsubscribeMessage) {
79
+ unsubscribeMessage = onMessage((m) => {
80
+ if (HIDDEN_ROLES.includes(m.role))
81
+ return;
82
+ if (m.role === 'assistant') {
83
+ broadcast(m.conversationId, 'message', { msgId: m.msgId, text: m.text });
84
+ }
85
+ else if (m.role === 'user') {
86
+ broadcast(m.conversationId, 'user', {
87
+ msgId: m.msgId,
88
+ text: m.text,
89
+ ts: m.ts,
90
+ ...(m.attachments?.length ? { attachments: m.attachments } : {}),
91
+ });
92
+ }
93
+ });
94
+ }
95
+ if (!unsubscribeConversation) {
96
+ const handler = (e) => {
97
+ if (e instanceof CustomEvent) {
98
+ const { id, patch } = e.detail;
99
+ if (patch && (patch.visibility !== undefined || patch.owner !== undefined)) {
100
+ void (async () => {
101
+ try {
102
+ const conv = await getConversation(id);
103
+ evictWhere(id, (viewerId) => !mayRead(conv, viewerId));
104
+ }
105
+ catch {
106
+ }
107
+ })();
108
+ }
109
+ }
110
+ };
111
+ conversationEmitter.addEventListener('conversation', handler);
112
+ unsubscribeConversation = () => {
113
+ conversationEmitter.removeEventListener('conversation', handler);
114
+ };
115
+ }
116
+ return () => {
117
+ teardownFanout();
118
+ };
119
+ }
120
+ export function teardownFanout() {
121
+ unsubscribeMessage?.();
122
+ unsubscribeMessage = undefined;
123
+ unsubscribeConversation?.();
124
+ unsubscribeConversation = undefined;
125
+ }
126
+ initFanout();
@@ -1,6 +1,5 @@
1
1
  export function policy() {
2
2
  return {
3
- accessPassword: '',
4
3
  triggerPrefix: '',
5
4
  replyWindow: 1800,
6
5
  };
@@ -1,5 +1,5 @@
1
1
  import type { Connector } from '@coffer-org/server/orchestrator';
2
- import { recordAssistant as storeAssistant, recordReasoning as storeReasoning, recordSuggestions as storeSuggestions } from './chain-store.ts';
2
+ import { setConversation as storeSelection } from '@coffer-org/server/conversation-store';
3
3
  import type { ReasoningDisplay } from './settings.ts';
4
4
  export interface StreamingConnector {
5
5
  connector: Connector;
@@ -7,12 +7,9 @@ export interface StreamingConnector {
7
7
  suggestions(): string[] | null;
8
8
  }
9
9
  export interface StreamingConnectorOpts {
10
- chatId: string;
11
- botMsgId: string;
12
- emit: (event: string, data: unknown) => void;
10
+ conversationId?: string;
11
+ botMsgId?: string;
13
12
  display: ReasoningDisplay;
14
- record?: typeof storeAssistant;
15
- recordReasoning?: typeof storeReasoning;
16
- recordSuggestions?: typeof storeSuggestions;
13
+ setSelection?: typeof storeSelection;
17
14
  }
18
15
  export declare function makeStreamingConnector(opts: StreamingConnectorOpts): StreamingConnector;
@@ -1,76 +1,70 @@
1
- import { makeLiveChannel, plainRender } from '@coffer-org/server/orchestrator';
2
- import { recordAssistant as storeAssistant, recordReasoning as storeReasoning, recordSuggestions as storeSuggestions, } from "./chain-store.js";
1
+ import { makeLiveSink, plainRender } from '@coffer-org/server/orchestrator';
2
+ import { setConversation as storeSelection } from '@coffer-org/server/conversation-store';
3
+ import { broadcast } from "./channel-registry.js";
4
+ import { getLogger } from '@coffer-org/sdk/logger';
5
+ const log = getLogger('webchat');
3
6
  const WEBCHAT_THROTTLE_MS = 250;
4
7
  const WEBCHAT_MAX = 100_000;
5
8
  export function makeStreamingConnector(opts) {
6
- const record = opts.record ?? storeAssistant;
7
- const recordReasoningFn = opts.recordReasoning ?? storeReasoning;
8
- const recordSuggestionsFn = opts.recordSuggestions ?? storeSuggestions;
9
+ const setSelectionFn = opts.setSelection ?? storeSelection;
9
10
  let recordedId = null;
10
11
  let lastSuggestions = null;
12
+ let hasAnswer = false;
11
13
  const connector = {
12
14
  id: 'webchat',
13
- reply(_chatId, _ctx) {
14
- const ch = makeLiveChannel({
15
+ open(envelope, msgId) {
16
+ const activeBotMsgId = msgId ?? opts.botMsgId ?? '';
17
+ const conversationId = envelope.conversationId ?? opts.conversationId ?? '';
18
+ const inner = makeLiveSink({
15
19
  ops: {
16
20
  send: async (text) => {
17
- opts.emit('message', { msgId: opts.botMsgId, text });
18
- return opts.botMsgId;
21
+ broadcast(conversationId, 'delta', { msgId: activeBotMsgId, text });
22
+ return activeBotMsgId;
19
23
  },
20
- edit: async (msgId, text) => {
21
- opts.emit('delta', { msgId, text });
24
+ edit: async (id, text) => {
25
+ broadcast(conversationId, 'delta', { msgId: id, text });
22
26
  },
23
27
  },
24
28
  throttleMs: WEBCHAT_THROTTLE_MS,
25
29
  maxLength: WEBCHAT_MAX,
26
30
  render: plainRender(WEBCHAT_MAX),
27
31
  });
32
+ let afterwordWork = Promise.resolve();
28
33
  return {
29
- ...ch,
30
- async finish(r) {
31
- lastSuggestions = r.suggestions;
32
- return ch.finish(r);
33
- },
34
- updateReasoning(acc) {
35
- if (opts.display === 'off')
34
+ emit(e) {
35
+ if (e.kind === 'reasoning') {
36
+ if (opts.display !== 'off')
37
+ broadcast(conversationId, 'reasoning', { msgId: activeBotMsgId, text: e.text });
38
+ return;
39
+ }
40
+ if (e.kind === 'suggestions') {
41
+ lastSuggestions = e.items;
42
+ return;
43
+ }
44
+ if (e.kind === 'title') {
45
+ afterwordWork = afterwordWork.then(() => setSelectionFn(conversationId, { title: e.text }).catch((err) => {
46
+ log.warn(`setSelection(title) failed: ${err instanceof Error ? err.message : String(err)}`);
47
+ }));
36
48
  return;
37
- opts.emit('reasoning', { msgId: opts.botMsgId, text: acc });
49
+ }
50
+ if (e.kind === 'answer') {
51
+ hasAnswer = true;
52
+ recordedId = activeBotMsgId;
53
+ }
54
+ inner.emit(e);
55
+ },
56
+ async done() {
57
+ await inner.done();
58
+ await afterwordWork;
38
59
  },
39
60
  };
40
61
  },
41
- async recordAssistant(m) {
42
- const now = Math.floor(Date.now() / 1000);
43
- const wrote = await record({
44
- chatId: opts.chatId,
45
- parentMsgId: m.parentMsgId,
46
- botMsgId: m.botMsgId,
47
- text: m.text,
48
- now,
49
- });
50
- recordedId = wrote ? m.botMsgId : null;
51
- if (wrote && opts.display !== 'off' && m.reasoning && m.botMsgId) {
52
- await recordReasoningFn({
53
- chatId: opts.chatId,
54
- botMsgId: m.botMsgId,
55
- parentMsgId: m.parentMsgId,
56
- text: m.reasoning,
57
- now,
58
- });
59
- }
60
- if (wrote && lastSuggestions?.length && m.botMsgId) {
61
- await recordSuggestionsFn({
62
- chatId: opts.chatId,
63
- botMsgId: m.botMsgId,
64
- parentMsgId: m.parentMsgId,
65
- suggestions: lastSuggestions,
66
- now,
67
- });
68
- }
62
+ async bindMessage(_envelope, _msgId, _externalId) {
69
63
  },
70
64
  };
71
65
  return {
72
66
  connector,
73
- recorded: () => recordedId,
67
+ recorded: () => (hasAnswer ? recordedId : null),
74
68
  suggestions: () => lastSuggestions,
75
69
  };
76
70
  }
@@ -1,2 +1 @@
1
- export declare const WEB_FORMAT: string;
2
1
  export declare function webChannelSystem(): Promise<string>;