@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.
@@ -1,15 +1,6 @@
1
1
  import { frontendInstructions } from '@coffer-org/server/frontend-agent';
2
- export const WEB_FORMAT = [
3
- 'CRITICAL OUTPUT FORMAT — MARKDOWN ONLY. Format the reply strictly as Markdown (GFM). NEVER emit raw HTML tags — they are stripped before rendering and their content may be lost.',
4
- 'Inline: **bold**, *italic*, `inline code`, ~~strikethrough~~. Links: [text](https://…).',
5
- 'Blocks: blank-line-separated paragraphs, `#`–`###` headings, `>` blockquotes, `---` separators.',
6
- 'Lists: `- item` for bullets, `1. item` for numbered steps. Nest with two spaces.',
7
- 'Tables render as real tables — use GFM pipe syntax with a header separator row.',
8
- 'Code blocks: triple backticks with a language tag (```python) — highlighted on render.',
9
- 'The reply is read in a narrow chat panel (~360px) — keep it short and skimmable: short paragraphs, lists over prose, narrow tables.',
10
- 'Use Unicode symbols and emoji to structure replies: arrows (→), status marks (✅ ⚠️ ❌ ⏳), topic emoji (📅 💰 🏥 🛒 🌱 …).',
11
- ].join('\n');
2
+ import { NEUTRAL_FORMAT } from '@coffer-org/markdown';
12
3
  export async function webChannelSystem() {
13
4
  const site = await frontendInstructions('');
14
- return [WEB_FORMAT, site].filter(Boolean).join('\n\n');
5
+ return [NEUTRAL_FORMAT, site].filter(Boolean).join('\n\n');
15
6
  }
@@ -1,7 +1,6 @@
1
1
  import type { PluginHooks } from '@coffer-org/server/plugin-hooks';
2
2
  export type { ThreadSummary, HistoryMsg } from './actions.ts';
3
- export { WEB_FORMAT } from './format.ts';
4
3
  export { makeStreamingConnector } from './connector.ts';
5
- export { chatIdFor, chatPrefixFor, recordUser, recordAssistant, buildChain, getSelection, setSelection, } from './chain-store.ts';
4
+ export { getConversation as getSelection, setConversation as setSelection, } from '@coffer-org/server/conversation-store';
6
5
  export { sendAction, pageContext } from './send.ts';
7
6
  export declare const serverHooks: PluginHooks;
@@ -1,31 +1,34 @@
1
- import { pruneThreadMessages } from '@coffer-org/server/thread-store';
2
- import { pruneThreadState } from '@coffer-org/server/thread-state';
3
- import { threadsAction, historyAction, agentsAction, selectAgentAction } from "./actions.js";
1
+ import { pruneConversations } from '@coffer-org/server/conversation-store';
2
+ import { threadsAction, historyAction, agentsAction, selectAgentAction, setVisibilityAction, channelAction, stopAction, } from "./actions.js";
4
3
  import { sendAction } from "./send.js";
5
- import { CONNECTOR } from "./chain-store.js";
6
4
  import { registerConnector } from '@coffer-org/server/orchestrator';
5
+ import { initFanout, teardownFanout } from "./channel-registry.js";
7
6
  const THREAD_TTL_MS = Number(process.env['WEBCHAT_THREAD_TTL_MS'] ?? 30 * 86_400_000);
8
7
  let unregisterConnector;
9
- export { WEB_FORMAT } from "./format.js";
10
8
  export { makeStreamingConnector } from "./connector.js";
11
- export { chatIdFor, chatPrefixFor, recordUser, recordAssistant, buildChain, getSelection, setSelection, } from "./chain-store.js";
9
+ export { getConversation as getSelection, setConversation as setSelection, } from '@coffer-org/server/conversation-store';
12
10
  export { sendAction, pageContext } from "./send.js";
13
11
  export const serverHooks = {
14
12
  init: () => {
15
13
  unregisterConnector = registerConnector({ id: 'webchat' });
14
+ initFanout();
16
15
  },
17
16
  teardown: () => {
18
17
  unregisterConnector?.();
19
18
  unregisterConnector = undefined;
19
+ teardownFanout();
20
20
  },
21
21
  userActions: {
22
22
  threads: threadsAction,
23
23
  history: historyAction,
24
24
  agents: agentsAction,
25
25
  selectAgent: selectAgentAction,
26
+ setVisibility: (body, caller) => setVisibilityAction(body, caller),
27
+ send: (body, caller) => sendAction(body, caller),
28
+ stop: (body, caller) => stopAction(body, caller),
26
29
  },
27
30
  streamActions: {
28
- send: (body, ctx) => sendAction(body, ctx),
31
+ channel: (body, ctx) => channelAction(body, ctx),
29
32
  },
30
33
  backgroundTasks: [
31
34
  {
@@ -33,8 +36,7 @@ export const serverHooks = {
33
36
  intervalMs: 86_400_000,
34
37
  run: async () => {
35
38
  const cutoff = new Date(Date.now() - THREAD_TTL_MS);
36
- await pruneThreadMessages(CONNECTOR, Math.floor(cutoff.getTime() / 1000));
37
- await pruneThreadState(CONNECTOR, cutoff.toISOString());
39
+ await pruneConversations(Math.floor(cutoff.getTime() / 1000), cutoff.toISOString());
38
40
  },
39
41
  },
40
42
  ],
@@ -1,12 +1,15 @@
1
1
  import { handleIncoming } from '@coffer-org/server/orchestrator';
2
- import type { ActionCaller } from '@coffer-org/server/plugin-hooks';
2
+ import { type ActionCaller } from '@coffer-org/server/plugin-hooks';
3
+ import { conversationEmitter } from '@coffer-org/server/conversation-store';
4
+ import type { ConnectorCapabilities, ContextFact } from '@coffer-org/server/orchestrator';
5
+ export { conversationEmitter };
6
+ export declare const CAPABILITIES: ConnectorCapabilities;
3
7
  export interface SendDeps {
4
8
  handleIncoming?: typeof handleIncoming;
5
9
  }
6
- export interface StreamCtx {
7
- caller: ActionCaller;
8
- emit: (event: string, data: unknown) => void;
9
- signal: AbortSignal;
10
+ export declare function pageContext(ctx: unknown): ContextFact[];
11
+ export interface SendResult {
12
+ msgId: string | null;
13
+ botMsgId: string | null;
10
14
  }
11
- export declare function pageContext(ctx: unknown): string;
12
- export declare function sendAction(body: Record<string, unknown>, ctx: StreamCtx, deps?: SendDeps): Promise<void>;
15
+ export declare function sendAction(body: Record<string, unknown>, caller: ActionCaller, deps?: SendDeps): Promise<SendResult>;
@@ -1,65 +1,115 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { handleIncoming, liveAgentId } from '@coffer-org/server/orchestrator';
3
- import { chatIdFor, recordUser, buildChain, history, readSelectionForTurn } from "./chain-store.js";
3
+ import { beginTurn } from '@coffer-org/server/turn-gate';
4
+ import { HttpError } from '@coffer-org/server/plugin-hooks';
5
+ import { lastMessageId, conversationEmitter, onMessage, } from '@coffer-org/server/conversation-store';
6
+ import { getConversation, setConversation, readAndTouchConversation, } from '@coffer-org/server/conversation-store';
7
+ import { mayWrite, mayRead } from '@coffer-org/server/orchestrator';
4
8
  import { policy } from "./config.js";
5
9
  import { makeStreamingConnector } from "./connector.js";
10
+ import { broadcast, initFanout } from "./channel-registry.js";
6
11
  import { webChannelSystem } from "./format.js";
7
12
  import { loadReasoningDisplay } from "./settings.js";
13
+ import { getLogger } from '@coffer-org/sdk/logger';
14
+ const log = getLogger('webchat');
15
+ initFanout();
16
+ export { conversationEmitter };
17
+ export const CAPABILITIES = {
18
+ events: ['delta', 'reasoning', 'segment', 'suggestions', 'title'],
19
+ privateChats: true,
20
+ };
8
21
  export function pageContext(ctx) {
9
22
  if (typeof ctx !== 'object' || ctx === null)
10
- return '';
23
+ return [];
11
24
  const c = ctx;
12
25
  const path = typeof c['path'] === 'string' ? c['path'] : '';
13
26
  if (!path)
14
- return '';
27
+ return [];
15
28
  const library = typeof c['library'] === 'string' ? c['library'] : '';
16
- const type = typeof c['type'] === 'string' ? c['type'] : '';
29
+ const shelf = typeof c['type'] === 'string' ? c['type'] : '';
17
30
  const id = typeof c['id'] === 'string' ? c['id'] : '';
18
- const where = library && type && id ? `record ${library}/${type}/${id}` : library && type ? `the ${library}/${type} list` : path;
19
- return `CURRENT PAGE the user is looking at ${where} (SPA path ${path}). When they say "this", "here", or "this record", they most likely mean it. Do not mention this note unless it is relevant.`;
31
+ const title = typeof c['title'] === 'string' ? c['title'] : '';
32
+ if (library && shelf && id)
33
+ return [{ name: 'record', value: title, attrs: { library, shelf, id } }];
34
+ if (library && shelf)
35
+ return [{ name: 'page', value: '', attrs: { library, shelf } }];
36
+ return [{ name: 'page', value: path }];
20
37
  }
21
- export async function sendAction(body, ctx, deps = {}) {
38
+ export async function sendAction(body, caller, deps = {}) {
22
39
  const doHandle = deps.handleIncoming ?? handleIncoming;
23
40
  const convId = typeof body['convId'] === 'string' ? body['convId'] : '';
24
41
  const text = typeof body['text'] === 'string' ? body['text'] : '';
25
- if (!convId) {
26
- ctx.emit('error', { message: 'missing required field: convId' });
27
- return;
42
+ if (!convId)
43
+ throw new HttpError(400, 'missing required field: convId');
44
+ if (!text.trim())
45
+ throw new HttpError(400, 'missing required field: text');
46
+ const conversationId = await openConversation(convId, caller.id, 'write');
47
+ if (conversationId === null) {
48
+ return { msgId: null, botMsgId: null };
28
49
  }
29
- if (!text.trim()) {
30
- ctx.emit('error', { message: 'missing required field: text' });
31
- return;
50
+ const selection = await readAndTouchConversation(conversationId);
51
+ if (selection.owner === null) {
52
+ await setConversation(conversationId, { owner: caller.id });
32
53
  }
33
- const chatId = chatIdFor(ctx.caller.id, convId);
34
- const selection = await readSelectionForTurn(chatId);
35
54
  const agentId = liveAgentId(selection.agentId);
36
55
  const msgId = randomUUID();
37
56
  const botMsgId = randomUUID();
38
- const nowSec = Math.floor(Date.now() / 1000);
39
57
  const attachments = parseAttachments(body['attachments']);
40
58
  const explicitReplyTo = typeof body['replyTo'] === 'string' && body['replyTo'] ? body['replyTo'] : null;
41
- const replyTo = explicitReplyTo ?? (await history(chatId, 1)).at(-1)?.msgId ?? null;
42
- await recordUser({ chatId, msgId, sender: ctx.caller.id, text, attachments, ts: nowSec, replyToId: replyTo });
43
- const messages = await buildChain(msgId, { chatId });
59
+ const replyTo = explicitReplyTo ?? (await lastMessageId(conversationId));
60
+ let userMsgId = msgId;
61
+ let userMsgResolved = false;
62
+ let resolveUserMsg;
63
+ const userMsgPromise = new Promise((resolve) => {
64
+ resolveUserMsg = resolve;
65
+ });
66
+ const unsubUserMsg = onMessage((m) => {
67
+ if (!userMsgResolved && m.conversationId === conversationId && m.role === 'user') {
68
+ userMsgResolved = true;
69
+ resolveUserMsg(m.msgId);
70
+ }
71
+ });
44
72
  const { connector, recorded, suggestions } = makeStreamingConnector({
45
- chatId,
73
+ conversationId,
46
74
  botMsgId,
47
- emit: ctx.emit,
48
75
  display: await loadReasoningDisplay(),
49
76
  });
50
77
  const turnContext = pageContext(body['context']);
51
- await doHandle(connector, {
52
- connectorId: 'webchat',
53
- channelSystem: await webChannelSystem(),
54
- ...(turnContext ? { turnContext } : {}),
55
- ...(agentId ? { agentId } : {}),
56
- ...(selection.presetId ? { presetId: selection.presetId } : {}),
57
- chatId,
58
- sender: { id: ctx.caller.id },
59
- messages,
60
- supportsSuggestions: true,
61
- }, { policy: policy() });
62
- ctx.emit('done', { msgId: recorded(), parentMsgId: msgId, suggestions: suggestions() });
78
+ const gate = await beginTurn('webchat', conversationId, { supersede: true });
79
+ const turnPromise = (async () => {
80
+ try {
81
+ await doHandle(connector, {
82
+ envelope: { connectorId: 'webchat', conversationId, turnId: msgId },
83
+ sender: { userId: Number(caller.id) },
84
+ message: {
85
+ text,
86
+ ...(attachments?.length ? { attachments } : {}),
87
+ replyToMsgId: replyTo,
88
+ },
89
+ capabilities: CAPABILITIES,
90
+ systemPrompt: { channel: [await webChannelSystem()] },
91
+ ...(turnContext.length ? { turnContext } : {}),
92
+ ...(agentId ? { agentId } : {}),
93
+ ...(selection.presetId ? { presetId: selection.presetId } : {}),
94
+ signal: gate.signal,
95
+ }, { policy: policy() });
96
+ broadcast(conversationId, 'done', {
97
+ msgId: recorded() ?? botMsgId,
98
+ parentMsgId: userMsgId,
99
+ suggestions: suggestions(),
100
+ });
101
+ }
102
+ catch (err) {
103
+ log.error(`sendAction: handleIncoming threw for chat ${conversationId}: ${err instanceof Error ? err.message : String(err)}`);
104
+ broadcast(conversationId, 'error', { message: null, parentMsgId: userMsgId });
105
+ }
106
+ finally {
107
+ gate.end();
108
+ }
109
+ })();
110
+ userMsgId = await Promise.race([userMsgPromise, turnPromise.then(() => msgId)]);
111
+ unsubUserMsg();
112
+ return { msgId: userMsgId, botMsgId };
63
113
  }
64
114
  function parseAttachments(value) {
65
115
  if (!Array.isArray(value))
@@ -73,11 +123,18 @@ function parseAttachments(value) {
73
123
  return [
74
124
  {
75
125
  name: r['name'],
76
- ...(typeof r['mime'] === 'string' ? { mime: r['mime'] } : {}),
77
- ...(typeof r['size'] === 'number' ? { size: r['size'] } : {}),
78
- ...(typeof r['label'] === 'string' ? { label: r['label'] } : {}),
126
+ ...(typeof r['mime'] === 'string' && r['mime'] ? { mime: r['mime'] } : {}),
127
+ ...(typeof r['size'] === 'number' && Number.isFinite(r['size']) ? { size: r['size'] } : {}),
128
+ ...(typeof r['label'] === 'string' && r['label'] ? { label: r['label'] } : {}),
79
129
  },
80
130
  ];
81
131
  });
82
132
  return refs.length ? refs : undefined;
83
133
  }
134
+ async function openConversation(convId, viewerId, need) {
135
+ if (!convId || convId.includes(':'))
136
+ return null;
137
+ const conv = await getConversation(convId);
138
+ const allowed = need === 'read' ? mayRead(conv, viewerId) : mayWrite(conv, viewerId);
139
+ return allowed ? convId : null;
140
+ }