bunnyquery 1.2.3 → 1.3.2
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/README.md +78 -8
- package/bunnyquery.css +229 -165
- package/bunnyquery.js +4625 -3542
- package/dist/engine.cjs +1973 -0
- package/dist/engine.cjs.map +1 -0
- package/dist/engine.d.mts +496 -0
- package/dist/engine.d.ts +496 -0
- package/dist/engine.mjs +1914 -0
- package/dist/engine.mjs.map +1 -0
- package/package.json +24 -3
- package/src/engine/attachments.ts +0 -0
- package/src/engine/budget.ts +76 -0
- package/src/engine/config.ts +49 -0
- package/src/engine/errors.ts +58 -0
- package/src/engine/history.ts +117 -0
- package/src/engine/host.ts +110 -0
- package/src/engine/index.ts +79 -0
- package/src/engine/links.ts +74 -0
- package/src/engine/office.ts +105 -0
- package/src/engine/prompts/chat_system_prompt.ts +51 -0
- package/src/engine/prompts/index.ts +14 -0
- package/src/engine/prompts/indexing_system_prompt.ts +37 -0
- package/src/engine/prompts/indexing_user_message.ts +62 -0
- package/src/engine/requests.ts +682 -0
- package/src/engine/session.ts +927 -0
- package/src/widget.css +881 -0
- package/styles/chat.css +194 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine configuration / dependency injection.
|
|
3
|
+
*
|
|
4
|
+
* The engine is framework- and transport-agnostic: it never imports a skapi
|
|
5
|
+
* instance or `import.meta.env`. Each consumer calls `configureChatEngine()`
|
|
6
|
+
* once at startup to inject the skapi transport functions, the MCP base URL,
|
|
7
|
+
* and (optionally) the `poll` value to attach to clientSecretRequest.
|
|
8
|
+
*
|
|
9
|
+
* Why `poll` is configurable: agent.vue uses the npm-bundled skapi-js and OMITS
|
|
10
|
+
* `poll` (its clientSecretRequest auto-resolves with the final body), whereas
|
|
11
|
+
* the BunnyQuery widget uses the deployed skapi-js@latest and must pass
|
|
12
|
+
* `poll: 0` to get the early ack + a manual `.poll()` handle (needed for queued-
|
|
13
|
+
* send cancel). So the request builders include `poll` only when it is set.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ChatEngineConfig {
|
|
17
|
+
/** skapi.clientSecretRequest, bound to the consumer's skapi instance. */
|
|
18
|
+
clientSecretRequest: (opts: any) => Promise<any>;
|
|
19
|
+
/** skapi.clientSecretRequestHistory, bound to the consumer's skapi instance. */
|
|
20
|
+
clientSecretRequestHistory: (params: any, fetchOptions: any) => Promise<any>;
|
|
21
|
+
/** MCP server base URL (prod vs dev resolved by the consumer). */
|
|
22
|
+
mcpBaseUrl: string;
|
|
23
|
+
/**
|
|
24
|
+
* Value to attach as `poll` on every clientSecretRequest. When `undefined`
|
|
25
|
+
* the `poll` key is omitted entirely (agent.vue). BunnyQuery sets `0`.
|
|
26
|
+
*/
|
|
27
|
+
poll?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let _config: ChatEngineConfig | null = null;
|
|
31
|
+
|
|
32
|
+
export function configureChatEngine(config: ChatEngineConfig): void {
|
|
33
|
+
_config = config;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function chatEngineConfig(): ChatEngineConfig {
|
|
37
|
+
if (!_config) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'[chat-engine] configureChatEngine() must be called before using the engine.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return _config;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Spread helper: `{ ...pollOpt() }` adds `poll` only when configured. */
|
|
46
|
+
export function pollOpt(): { poll?: number } {
|
|
47
|
+
const p = _config?.poll;
|
|
48
|
+
return p === undefined ? {} : { poll: p };
|
|
49
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error detection + message extraction (pure). Moved verbatim from the
|
|
3
|
+
* agent.vue / bunnyquery chatbox so both consumers share one implementation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function getErrorMessage(input: any): string {
|
|
7
|
+
if (!input) return 'Something went wrong.';
|
|
8
|
+
if (typeof input === 'string') return input;
|
|
9
|
+
if (input.error && input.error.message) return input.error.message;
|
|
10
|
+
if (input.body && input.body.error && input.body.error.message) return input.body.error.message;
|
|
11
|
+
if (input.body && typeof input.body.message === 'string') return input.body.message;
|
|
12
|
+
if (input.message) return input.message;
|
|
13
|
+
return 'Something went wrong.';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isErrorResponseBody(response: any): boolean {
|
|
17
|
+
if (!response || typeof response !== 'object') return false;
|
|
18
|
+
if (typeof response.status_code === 'number' && response.status_code >= 400) return true;
|
|
19
|
+
if (response.type === 'error') return true;
|
|
20
|
+
if (response.error && (response.error.message || response.error.type)) return true;
|
|
21
|
+
var body = response.body;
|
|
22
|
+
if (body && typeof body === 'object') {
|
|
23
|
+
if (body.type === 'error') return true;
|
|
24
|
+
if (body.error && (body.error.message || body.error.type)) return true;
|
|
25
|
+
}
|
|
26
|
+
if (typeof response.message === 'string' && response.message.length) {
|
|
27
|
+
var hasClaude = Array.isArray(response.content);
|
|
28
|
+
var hasOpenAI =
|
|
29
|
+
typeof response.output_text === 'string' ||
|
|
30
|
+
Array.isArray(response.output) ||
|
|
31
|
+
Array.isArray(response.choices);
|
|
32
|
+
if (!hasClaude && !hasOpenAI) return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isAuthExpiredError(input: any): boolean {
|
|
38
|
+
if (!input) return false;
|
|
39
|
+
var blobs: string[] = [];
|
|
40
|
+
var push = function (v: any) { if (typeof v === 'string' && v) blobs.push(v); };
|
|
41
|
+
if (typeof input === 'string') push(input);
|
|
42
|
+
else {
|
|
43
|
+
push(input.message); push(input.code);
|
|
44
|
+
if (input.error) { push(input.error.message); push(input.error.code); push(input.error.type); }
|
|
45
|
+
if (input.body) {
|
|
46
|
+
push(input.body.message);
|
|
47
|
+
if (input.body.error) { push(input.body.error.message); push(input.body.error.code); push(input.body.error.type); }
|
|
48
|
+
}
|
|
49
|
+
if (typeof input.status === 'number' && input.status === 401) return true;
|
|
50
|
+
if (typeof input.status_code === 'number' && input.status_code === 401) return true;
|
|
51
|
+
}
|
|
52
|
+
var hay = blobs.join(' | ').toLowerCase();
|
|
53
|
+
if (!hay) return false;
|
|
54
|
+
return hay.indexOf('token has expired') !== -1 || hay.indexOf('token is expired') !== -1 ||
|
|
55
|
+
hay.indexOf('expired_token') !== -1 || hay.indexOf('invalid_token') !== -1 ||
|
|
56
|
+
hay.indexOf('unauthorized') !== -1 || hay.indexOf('not authorized') !== -1 ||
|
|
57
|
+
(hay.indexOf('invalid_request') !== -1 && hay.indexOf('token') !== -1);
|
|
58
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History mapping (pure). Moved verbatim from the chatbox. The clear-horizon
|
|
3
|
+
* timestamp and the "Indexing: …" display label are INJECTED (clearedAt param,
|
|
4
|
+
* formatIndexingLabel callback) so the engine touches neither localStorage nor
|
|
5
|
+
* view-specific display formatting. serviceId is passed for link sanitization.
|
|
6
|
+
*/
|
|
7
|
+
import { extractClaudeText, extractOpenAIText } from './requests';
|
|
8
|
+
import { isErrorResponseBody, getErrorMessage } from './errors';
|
|
9
|
+
import { sanitizeAttachmentLinksForHistory } from './links';
|
|
10
|
+
|
|
11
|
+
export function filterListByClearHorizon(list: any[], clearedAt: number): any[] {
|
|
12
|
+
if (!clearedAt) return list;
|
|
13
|
+
return list.filter(function (item) {
|
|
14
|
+
var updated = Number(item && item.updated);
|
|
15
|
+
return isFinite(updated) && updated > clearedAt;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizeTextContent(content: any): string {
|
|
20
|
+
if (typeof content === 'string') return content;
|
|
21
|
+
if (Array.isArray(content)) {
|
|
22
|
+
return content.map(function (part: any) {
|
|
23
|
+
if (typeof part === 'string') return part;
|
|
24
|
+
if (part && (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text')) return part.text || '';
|
|
25
|
+
return '';
|
|
26
|
+
}).join('\n').trim();
|
|
27
|
+
}
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function extractLastUserTextFromRequest(requestBody: any): string {
|
|
32
|
+
var arr = requestBody && Array.isArray(requestBody.messages) ? requestBody.messages
|
|
33
|
+
: (requestBody && Array.isArray(requestBody.input) ? requestBody.input : []);
|
|
34
|
+
for (var i = arr.length - 1; i >= 0; i--) {
|
|
35
|
+
if (arr[i] && arr[i].role === 'user') {
|
|
36
|
+
var t = normalizeTextContent(arr[i].content);
|
|
37
|
+
if (t) return t;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type MapHistoryOptions = {
|
|
44
|
+
clearedAt: number;
|
|
45
|
+
serviceId: string;
|
|
46
|
+
/** View-side display formatter for "Indexing:/Reindexing: …" bubbles. */
|
|
47
|
+
formatIndexingLabel: (name: string, mime?: string, size?: number | null, storagePath?: string) => string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'openai', opts: MapHistoryOptions) {
|
|
51
|
+
var mapped: any[] = [], runningItemIds: string[] = [];
|
|
52
|
+
var extractAssistantText = platform === 'openai' ? extractOpenAIText : extractClaudeText;
|
|
53
|
+
var filtered = filterListByClearHorizon(list, opts.clearedAt);
|
|
54
|
+
filtered.slice().reverse().forEach(function (item) {
|
|
55
|
+
var requestBody = item && item.request_body;
|
|
56
|
+
var isInProcess = item && item.status === 'running';
|
|
57
|
+
var isQueued = item && item.status === 'pending';
|
|
58
|
+
var isCancelledItem = item && item.status === 'cancelled';
|
|
59
|
+
var isPending = isInProcess || isQueued;
|
|
60
|
+
var isFailed = item && item.status === 'failed';
|
|
61
|
+
var response = isFailed ? (item.error != null ? item.error : item.response_body)
|
|
62
|
+
: (item && item.response_body != null ? item.response_body : item && item.error);
|
|
63
|
+
var userText = extractLastUserTextFromRequest(requestBody);
|
|
64
|
+
var assistantText = isPending ? '' : ((extractAssistantText(response) || '').trim() || '');
|
|
65
|
+
var isErrorResponse = !isPending && (isFailed || isErrorResponseBody(response));
|
|
66
|
+
var serverItemId = item && typeof item.id === 'string' && item.id ? item.id : undefined;
|
|
67
|
+
|
|
68
|
+
if (userText) {
|
|
69
|
+
var displayContent;
|
|
70
|
+
if (item._isBgTask) {
|
|
71
|
+
var nameMatch = userText.match(/^- name: (.+)$/m);
|
|
72
|
+
if (nameMatch) {
|
|
73
|
+
var mimeMatch = userText.match(/^- mime type: (.+)$/m);
|
|
74
|
+
var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
|
|
75
|
+
var pathMatch = userText.match(/^- storage path: (.+)$/m);
|
|
76
|
+
displayContent = opts.formatIndexingLabel(
|
|
77
|
+
nameMatch[1].trim(),
|
|
78
|
+
mimeMatch ? mimeMatch[1].trim() : '',
|
|
79
|
+
sizeMatch ? Number(sizeMatch[1]) : null,
|
|
80
|
+
pathMatch ? pathMatch[1].trim() : undefined
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
displayContent = userText;
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
displayContent = sanitizeAttachmentLinksForHistory(userText, opts.serviceId);
|
|
87
|
+
}
|
|
88
|
+
var userMsg: any = { role: 'user', content: displayContent };
|
|
89
|
+
if (isInProcess) userMsg.isPendingInProcess = true;
|
|
90
|
+
if (isQueued) userMsg.isPendingQueued = true;
|
|
91
|
+
if (isCancelledItem) userMsg.isCancelled = true;
|
|
92
|
+
if (item._isBgTask) userMsg.isBackgroundTask = true;
|
|
93
|
+
if (item._isOnBgQueue) userMsg._useBgQueue = true;
|
|
94
|
+
if (serverItemId !== undefined) userMsg._serverItemId = serverItemId;
|
|
95
|
+
mapped.push(userMsg);
|
|
96
|
+
}
|
|
97
|
+
if (isCancelledItem) { /* no assistant bubble */ }
|
|
98
|
+
else if (isInProcess) {
|
|
99
|
+
var ph: any = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true };
|
|
100
|
+
if (item._isBgTask) ph.isBackgroundTask = true;
|
|
101
|
+
if (serverItemId !== undefined) { ph._serverItemId = serverItemId; runningItemIds.push(serverItemId); }
|
|
102
|
+
mapped.push(ph);
|
|
103
|
+
} else if (isQueued) { /* no assistant placeholder */ }
|
|
104
|
+
else if (isErrorResponse) {
|
|
105
|
+
var em: any = { role: 'assistant', content: getErrorMessage(response), isError: true };
|
|
106
|
+
if (item._isBgTask) em.isBackgroundTask = true;
|
|
107
|
+
if (serverItemId !== undefined) em._serverItemId = serverItemId;
|
|
108
|
+
mapped.push(em);
|
|
109
|
+
} else if (assistantText) {
|
|
110
|
+
var okm: any = { role: 'assistant', content: assistantText };
|
|
111
|
+
if (item._isBgTask) okm.isBackgroundTask = true;
|
|
112
|
+
if (serverItemId !== undefined) okm._serverItemId = serverItemId;
|
|
113
|
+
mapped.push(okm);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
return { messages: mapped, runningItemIds: runningItemIds };
|
|
117
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatSession host adapter + state types.
|
|
3
|
+
*
|
|
4
|
+
* ChatSession is DOM-free and Vue-free; the consumer (bunnyquery widget or the
|
|
5
|
+
* agent.vue chatbox) implements `ChatHost` to bridge identity, rendering, scroll,
|
|
6
|
+
* and the skapi cancel/refresh surface. Everything the session needs that would
|
|
7
|
+
* otherwise touch the DOM or a framework goes through a host hook.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ChatIdentity {
|
|
11
|
+
serviceId: string;
|
|
12
|
+
owner: string;
|
|
13
|
+
/** Per-user queue name (falls back to serviceId). */
|
|
14
|
+
userId: string;
|
|
15
|
+
platform: 'claude' | 'openai' | 'none';
|
|
16
|
+
model?: string;
|
|
17
|
+
serviceName?: string;
|
|
18
|
+
serviceDescription?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ChatMessage {
|
|
22
|
+
role: 'user' | 'assistant';
|
|
23
|
+
content: string; // raw markdown — never HTML (the view parses for display)
|
|
24
|
+
isPending?: boolean;
|
|
25
|
+
isPendingInProcess?: boolean;
|
|
26
|
+
isPendingQueued?: boolean;
|
|
27
|
+
isPendingOlder?: boolean;
|
|
28
|
+
isSendingToServer?: boolean;
|
|
29
|
+
isCancelled?: boolean;
|
|
30
|
+
isError?: boolean;
|
|
31
|
+
isBackgroundTask?: boolean;
|
|
32
|
+
_useBgQueue?: boolean;
|
|
33
|
+
_serverItemId?: string;
|
|
34
|
+
_localId?: string;
|
|
35
|
+
_cancelling?: boolean;
|
|
36
|
+
_cancelError?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ChatState {
|
|
40
|
+
messages: ChatMessage[];
|
|
41
|
+
/** Pending/uploaded attachment objects (view-shaped); the engine mutates
|
|
42
|
+
* status/progress during upload, the view renders them. */
|
|
43
|
+
attachments: any[];
|
|
44
|
+
uploadingAttachments: boolean;
|
|
45
|
+
sending: boolean;
|
|
46
|
+
typing: boolean;
|
|
47
|
+
typingAbort: boolean;
|
|
48
|
+
loadingHistory: boolean;
|
|
49
|
+
loadingOlderHistory: boolean;
|
|
50
|
+
historyEndOfList: boolean;
|
|
51
|
+
historyStartKeyHistory: string[];
|
|
52
|
+
historyRequestToken: number;
|
|
53
|
+
gateRefreshToken: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ChatHost {
|
|
57
|
+
/** Read live (platform/model/name can change between sends). */
|
|
58
|
+
getIdentity(): ChatIdentity;
|
|
59
|
+
/** The chat system prompt (consumer-built; agent.vue uses a formatted id). */
|
|
60
|
+
buildSystemPrompt(): string;
|
|
61
|
+
|
|
62
|
+
// --- render / scroll (the ONLY view surface) ---
|
|
63
|
+
/** Re-render the whole message list (coalesced). */
|
|
64
|
+
notify(): void;
|
|
65
|
+
/** Re-render a single message bubble in place (typewriter ticks). */
|
|
66
|
+
refreshMessageBubble(idx: number): void;
|
|
67
|
+
scrollToBottom(smooth?: boolean): Promise<void> | void;
|
|
68
|
+
/** Scroll only if the user is pinned to the bottom (does not force-pin). */
|
|
69
|
+
scrollToBottomIfSticky(smooth?: boolean): Promise<void> | void;
|
|
70
|
+
|
|
71
|
+
// --- skapi surface beyond configureChatEngine() ---
|
|
72
|
+
cancelRequest(opts: {
|
|
73
|
+
url: string; method: string; id: string; queue: string; service: string; owner: string;
|
|
74
|
+
}): Promise<{ removed?: boolean; message?: string } | any>;
|
|
75
|
+
refreshSession(): Promise<boolean>;
|
|
76
|
+
|
|
77
|
+
// --- bg-indexing display ---
|
|
78
|
+
/** Build the "Indexing:/Reindexing: …" label (view-side display formatting). */
|
|
79
|
+
formatIndexingLabel(name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean): string;
|
|
80
|
+
/** drainBgTaskQueue is a no-op until the chat view is mounted. */
|
|
81
|
+
isViewMounted(): boolean;
|
|
82
|
+
|
|
83
|
+
/** Clear-horizon timestamp (localStorage, per service#platform) — view-owned. */
|
|
84
|
+
getClearedAt(): number;
|
|
85
|
+
|
|
86
|
+
// --- attachment upload I/O (consumer-specific bytes path: agent.vue uses the
|
|
87
|
+
// Service class, bunnyquery uses get-signed-url). The session owns the
|
|
88
|
+
// upload ORCHESTRATION (per-member loop, overwrite/reindex flow, indexing,
|
|
89
|
+
// status lifecycle); these hooks do the actual I/O + chip rendering. ---
|
|
90
|
+
uploadFile(args: {
|
|
91
|
+
file: File;
|
|
92
|
+
storagePath: string;
|
|
93
|
+
checkExistence: boolean;
|
|
94
|
+
onProgress?: (p: any) => void;
|
|
95
|
+
setAbort?: (abort: () => void) => void;
|
|
96
|
+
}): Promise<any>;
|
|
97
|
+
/** Mint a temporary CDN URL for a stored file. */
|
|
98
|
+
getTemporaryUrl(storagePath: string): Promise<string>;
|
|
99
|
+
/** Map a relative path to the consumer's db storage key (e.g. uid-prefixed). */
|
|
100
|
+
storagePathFor(relPath: string): string;
|
|
101
|
+
getMimeType(name: string): string | null;
|
|
102
|
+
/** Non-dismissible "file exists" prompt → keep+reindex vs overwrite. */
|
|
103
|
+
promptOverwrite(filename: string): Promise<'overwrite' | 'reindex'>;
|
|
104
|
+
/** Clear the "apply to all" overwrite choice at the start of a batch. */
|
|
105
|
+
resetOverwriteBatch(): void;
|
|
106
|
+
/** Re-render the attachment chip row (progress / status). */
|
|
107
|
+
renderAttachmentChips(): void;
|
|
108
|
+
/** Enable/disable composer controls during an upload batch. */
|
|
109
|
+
updateComposerControls(): void;
|
|
110
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @skapi/chat-engine — framework-agnostic chat engine.
|
|
3
|
+
*
|
|
4
|
+
* Tier-1 (this barrel): pure transport/logic shared by agent.vue and the
|
|
5
|
+
* BunnyQuery widget. DOM-free and Vue-free. Consumers inject the skapi
|
|
6
|
+
* transport + MCP base URL via configureChatEngine() and (for markdown / DOM
|
|
7
|
+
* rendering) keep their own view layer.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
configureChatEngine,
|
|
12
|
+
chatEngineConfig,
|
|
13
|
+
type ChatEngineConfig,
|
|
14
|
+
} from './config';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
isOfficeFile,
|
|
18
|
+
makeExtractPlaceholder,
|
|
19
|
+
composeUserMessage,
|
|
20
|
+
type ExtractDirective,
|
|
21
|
+
type ComposedUserMessage,
|
|
22
|
+
} from './office';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
groupAttachmentFailures,
|
|
26
|
+
type AttachmentFailureGroup,
|
|
27
|
+
} from './attachments';
|
|
28
|
+
|
|
29
|
+
export * from './prompts';
|
|
30
|
+
|
|
31
|
+
// Pure helpers (Tier-1.5): error detection, token budgeting, link/path
|
|
32
|
+
// normalization, and history mapping — shared so both consumers stay identical.
|
|
33
|
+
export { getErrorMessage, isErrorResponseBody, isAuthExpiredError } from './errors';
|
|
34
|
+
export * from './budget';
|
|
35
|
+
export * from './links';
|
|
36
|
+
export {
|
|
37
|
+
filterListByClearHorizon,
|
|
38
|
+
normalizeTextContent,
|
|
39
|
+
extractLastUserTextFromRequest,
|
|
40
|
+
mapHistoryListToMessages,
|
|
41
|
+
type MapHistoryOptions,
|
|
42
|
+
} from './history';
|
|
43
|
+
|
|
44
|
+
// Tier-2: the stateful chat orchestration (queue/poll/cancel, typewriter,
|
|
45
|
+
// bg-task drain, resolution). DOM-free; the consumer implements ChatHost.
|
|
46
|
+
export { ChatSession } from './session';
|
|
47
|
+
export type { ChatHost, ChatIdentity, ChatState, ChatMessage } from './host';
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
// constants
|
|
51
|
+
POLL_INTERVAL,
|
|
52
|
+
BG_INDEXING_QUEUE_SUFFIX,
|
|
53
|
+
MCP_NAME,
|
|
54
|
+
DEFAULT_CLAUDE_MODEL,
|
|
55
|
+
DEFAULT_OPENAI_MODEL,
|
|
56
|
+
// request builders + dispatch
|
|
57
|
+
callClaudeWithMcp,
|
|
58
|
+
callClaudeWithPublicMcp,
|
|
59
|
+
callOpenAIWithPublicMcp,
|
|
60
|
+
notifyAgentSaveAttachment,
|
|
61
|
+
listClaudeModels,
|
|
62
|
+
listOpenAIModels,
|
|
63
|
+
getChatHistory,
|
|
64
|
+
// response extraction
|
|
65
|
+
extractClaudeText,
|
|
66
|
+
extractOpenAIText,
|
|
67
|
+
// content transforms
|
|
68
|
+
transformContentWithImages,
|
|
69
|
+
transformContentWithOpenAIImages,
|
|
70
|
+
// types
|
|
71
|
+
type ClaudeRole,
|
|
72
|
+
type ClaudeMessage,
|
|
73
|
+
type OpenAIMessage,
|
|
74
|
+
type ClaudeMcpToolConfig,
|
|
75
|
+
type ClaudeMcpServerRequest,
|
|
76
|
+
type CallClaudeWithMcpParams,
|
|
77
|
+
type AttachmentSaveInfo,
|
|
78
|
+
type BgTaskEntry,
|
|
79
|
+
} from './requests';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure link/path helpers (no DOM, no marked). Moved verbatim from the chatbox.
|
|
3
|
+
* `serviceId` is passed as a PARAMETER (the original read it from a global) so
|
|
4
|
+
* the engine stays consumer-agnostic. The HTML-emitting helpers
|
|
5
|
+
* (buildLinkPartFromGroups, linkToAnchorHtml, fileToAnchorHtml, parseMsgParts*)
|
|
6
|
+
* stay in each VIEW — only these pure pieces move here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export var EXPIRED_ATTACHMENT_URL_HOST = '_expired_.url';
|
|
10
|
+
export var EXPIRED_ATTACHMENT_URL_ORIGIN = 'https://' + EXPIRED_ATTACHMENT_URL_HOST;
|
|
11
|
+
export var LINK_LABEL_MAX_DISPLAY_CHARS = 32;
|
|
12
|
+
|
|
13
|
+
export function createInlineLinkRegex(): RegExp {
|
|
14
|
+
return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]+|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]+|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function safeDecodeURIComponent(v: string): string {
|
|
18
|
+
try { return decodeURIComponent(v); } catch (e) { return v; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function encodePathSegments(path: string): string {
|
|
22
|
+
return path.split('/').filter(Boolean).map(function (s) { return encodeURIComponent(s); }).join('/');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeAttachmentPathCandidate(value: string): string {
|
|
26
|
+
return safeDecodeURIComponent((value || '').trim()).replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function extractRemotePathFromAttachmentHref(href: string, serviceId: string): string | null {
|
|
30
|
+
try {
|
|
31
|
+
var parsed = new URL(href);
|
|
32
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
|
33
|
+
var path = normalizeAttachmentPathCandidate(parsed.pathname || '');
|
|
34
|
+
var segs = path.split('/').filter(Boolean);
|
|
35
|
+
if (!segs.length) return null;
|
|
36
|
+
var HEX = /^[a-f0-9]{32,}$/i;
|
|
37
|
+
var sid = serviceId || '';
|
|
38
|
+
var start = 0;
|
|
39
|
+
while (start < segs.length) {
|
|
40
|
+
var seg = segs[start];
|
|
41
|
+
if (seg === sid || HEX.test(seg)) { start++; continue; }
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
var real = segs.slice(start).join('/');
|
|
45
|
+
return real || null;
|
|
46
|
+
} catch (e) { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getExpiredAttachmentVisiblePath(remotePath: string, fallback?: string): string {
|
|
50
|
+
var n = normalizeAttachmentPathCandidate(remotePath);
|
|
51
|
+
if (n) return n;
|
|
52
|
+
return normalizeAttachmentPathCandidate(fallback || 'file') || 'file';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildDisplayExpiredAttachmentHref(remotePath: string, fallback?: string): string {
|
|
56
|
+
return EXPIRED_ATTACHMENT_URL_ORIGIN + '/' + encodePathSegments(getExpiredAttachmentVisiblePath(remotePath, fallback));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function sanitizeAttachmentLinksForHistory(content: string, serviceId: string): string {
|
|
60
|
+
if (!content || content.indexOf('Attached files:') === -1) return content;
|
|
61
|
+
return content.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, function (_m: string, label: string, href: string) {
|
|
62
|
+
var remotePath = extractRemotePathFromAttachmentHref(href, serviceId);
|
|
63
|
+
var labelPath = normalizeAttachmentPathCandidate(label);
|
|
64
|
+
var fullPath = remotePath || labelPath;
|
|
65
|
+
if (!fullPath) return '[' + label + '](' + EXPIRED_ATTACHMENT_URL_ORIGIN + '/file)';
|
|
66
|
+
return '[' + label + '](' + buildDisplayExpiredAttachmentHref(fullPath, label) + ')';
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function truncateLabelForDisplay(label: string): string {
|
|
71
|
+
if (!label) return label;
|
|
72
|
+
if (label.length <= LINK_LABEL_MAX_DISPLAY_CHARS) return label;
|
|
73
|
+
return '…' + label.slice(label.length - (LINK_LABEL_MAX_DISPLAY_CHARS - 1));
|
|
74
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Office-file server-side extraction helpers.
|
|
3
|
+
*
|
|
4
|
+
* Office documents (Microsoft .docx/.xlsx/.pptx, Hancom .hwpx, etc.) can't be
|
|
5
|
+
* read by web_fetch (binary/zip). The proxy worker downloads them from db
|
|
6
|
+
* storage, extracts their text server-side, and substitutes that text for a
|
|
7
|
+
* placeholder token in the request body (carried under the reserved
|
|
8
|
+
* `_skapi_extract` key, which the producer strips before the upstream call).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// A directive telling the proxy worker which office file to download + extract
|
|
12
|
+
// and which placeholder token in the request body to substitute.
|
|
13
|
+
export type ExtractDirective = {
|
|
14
|
+
/** db storage path of the file, e.g. "folder/report.docx" (also the src:: value). */
|
|
15
|
+
path: string;
|
|
16
|
+
/** The exact token in the request body to replace with the extracted text. */
|
|
17
|
+
placeholder: string;
|
|
18
|
+
/** Original filename — informational (server logs only). */
|
|
19
|
+
name?: string;
|
|
20
|
+
/** MIME type — informational (server logs only). */
|
|
21
|
+
mime?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Office formats whose text the model cannot read via web_fetch. OOXML
|
|
25
|
+
// (.docx/.xlsx/.pptx) and Hancom .hwpx are extracted server-side; the other
|
|
26
|
+
// (legacy/macro/binary) extensions are still flagged so the worker returns a
|
|
27
|
+
// graceful note instead of the model fetching binary garbage.
|
|
28
|
+
const OFFICE_FILE_EXTENSIONS = new Set([
|
|
29
|
+
'doc', 'docx', 'docm',
|
|
30
|
+
'xls', 'xlsx', 'xlsm',
|
|
31
|
+
'ppt', 'pptx', 'pptm',
|
|
32
|
+
'hwp', 'hwpx',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export function isOfficeFile(name?: string, mime?: string): boolean {
|
|
36
|
+
const ext = (name || '').split('.').pop()?.toLowerCase() || '';
|
|
37
|
+
if (OFFICE_FILE_EXTENSIONS.has(ext)) return true;
|
|
38
|
+
const m = (mime || '').toLowerCase();
|
|
39
|
+
return (
|
|
40
|
+
m.includes('officedocument') ||
|
|
41
|
+
m.includes('hwp') ||
|
|
42
|
+
m === 'application/msword' ||
|
|
43
|
+
m === 'application/vnd.ms-excel' ||
|
|
44
|
+
m === 'application/vnd.ms-powerpoint'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Monotonic counter so placeholders are unique even for same-named files in one
|
|
49
|
+
// request. Token shape must match the worker's _EXTRACT_PLACEHOLDER_RE:
|
|
50
|
+
// {{SKAPI_FILE_CONTENT::<id>}}.
|
|
51
|
+
let _extractPlaceholderSeq = 0;
|
|
52
|
+
export function makeExtractPlaceholder(seed: string): string {
|
|
53
|
+
_extractPlaceholderSeq += 1;
|
|
54
|
+
const slug = (seed || 'file').replace(/[^a-zA-Z0-9]+/g, '_').slice(-48);
|
|
55
|
+
return `{{SKAPI_FILE_CONTENT::${slug}-${_extractPlaceholderSeq}}}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ComposedUserMessage {
|
|
59
|
+
/** Clean display/history copy (attachment links, NO extraction placeholders). */
|
|
60
|
+
composed: string;
|
|
61
|
+
/** LLM-bound copy — `composed` plus inline office-extraction placeholders. */
|
|
62
|
+
composedForLlm: string;
|
|
63
|
+
/** Office-extraction directives for the proxy worker (undefined if no office files). */
|
|
64
|
+
extractContent?: ExtractDirective[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Compose the user's chat message from the typed text + uploaded attachment URLs.
|
|
68
|
+
// Identical for every consumer (agent.vue + the BunnyQuery widget): appends a
|
|
69
|
+
// markdown "Attached files" link block, and for office files
|
|
70
|
+
// (.docx/.xlsx/.pptx/.hwpx) adds inline extraction placeholders to the LLM copy
|
|
71
|
+
// ONLY (the proxy worker substitutes their text server-side; the display/history
|
|
72
|
+
// copy stays clean so stale tokens never accumulate across replayed turns).
|
|
73
|
+
// The directive `path` is the db storage path (uid-prefixed where applicable),
|
|
74
|
+
// NOT the link label — it falls back to the label when no storagePath is given
|
|
75
|
+
// (agent.vue uploads FLAT, so storagePath === name there).
|
|
76
|
+
export function composeUserMessage(
|
|
77
|
+
text: string,
|
|
78
|
+
attachmentUrls: Array<{ name: string; url: string; storagePath?: string }>,
|
|
79
|
+
): ComposedUserMessage {
|
|
80
|
+
let composed = text;
|
|
81
|
+
if (attachmentUrls.length > 0) {
|
|
82
|
+
const lines = attachmentUrls.map((u) => `- [${u.name}](${u.url})`);
|
|
83
|
+
composed = `${text}\n\nAttached files:\n${lines.join('\n')}`;
|
|
84
|
+
}
|
|
85
|
+
let composedForLlm = composed;
|
|
86
|
+
let extractContent: ExtractDirective[] | undefined;
|
|
87
|
+
if (attachmentUrls.length > 0) {
|
|
88
|
+
const officeFiles = attachmentUrls.filter((u) => isOfficeFile(u.name));
|
|
89
|
+
if (officeFiles.length > 0) {
|
|
90
|
+
const directives: ExtractDirective[] = [];
|
|
91
|
+
const sections = officeFiles.map((u) => {
|
|
92
|
+
const storagePath = u.storagePath || u.name;
|
|
93
|
+
const placeholder = makeExtractPlaceholder(storagePath);
|
|
94
|
+
directives.push({ path: storagePath, placeholder, name: u.name });
|
|
95
|
+
return `===== ${u.name} =====\n----- BEGIN FILE CONTENT -----\n${placeholder}\n----- END FILE CONTENT -----`;
|
|
96
|
+
});
|
|
97
|
+
extractContent = directives;
|
|
98
|
+
composedForLlm =
|
|
99
|
+
`${composed}\n\nExtracted content of attached office files ` +
|
|
100
|
+
`(read inline below; do NOT fetch their URLs):\n\n` +
|
|
101
|
+
sections.join('\n\n');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { composed, composedForLlm, extractContent };
|
|
105
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BASE PROMPT — Chat assistant
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* System prompt sent on every chat turn. Rebuilt fresh on every send because
|
|
5
|
+
* the project name/description can change at any time.
|
|
6
|
+
*
|
|
7
|
+
* The `${...}` placeholders are filled from the live project (service):
|
|
8
|
+
* formattedServiceId -> the project ID the assistant is scoped to
|
|
9
|
+
* serviceName -> project display name (only added if a description exists)
|
|
10
|
+
* serviceDescription -> project description (only added if present)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type ChatSystemPromptParams = {
|
|
14
|
+
/** The project/service ID this assistant is scoped to (formatted form). */
|
|
15
|
+
formattedServiceId: string;
|
|
16
|
+
/** Project display name. Only appended when a description is also present. */
|
|
17
|
+
serviceName?: string;
|
|
18
|
+
/** Project description. When present, name + description are appended. */
|
|
19
|
+
serviceDescription?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function buildChatSystemPrompt(params: ChatSystemPromptParams): string {
|
|
23
|
+
const { formattedServiceId, serviceName, serviceDescription } = params;
|
|
24
|
+
|
|
25
|
+
let systemPrompt = `
|
|
26
|
+
You are a dedicated assistant for the project ID: "${formattedServiceId}".
|
|
27
|
+
Scope: Only answer questions about this project and its data. Do not answer questions about other projects or topics unrelated to this project. When the user refers to "my database", "my data", or "my files", treat those as references to this project's database and file storage.
|
|
28
|
+
Knowledge lookup: Before saying you don't know or that something isn't in the chat history, ALWAYS query this project's database through the available MCP tools to look for the answer. The user's data is the source of truth - the chat transcript is not. Only respond with "I don't know" or "I couldn't find that" after you have actually searched the project's data and come back empty.
|
|
29
|
+
File attachments: When a user message contains an "Attached files:" section with markdown links, those links point to short-lived signed URLs in this project's db storage and will expire.
|
|
30
|
+
- Image files (.jpg, .jpeg, .png, .gif, .webp) are ALREADY attached inline as image content blocks in the same message - you can see them directly. Do NOT call web_fetch on image URLs; that will fail or return garbage. Just look at the image block and answer.
|
|
31
|
+
- Office documents (Microsoft .docx/.xlsx/.pptx, Hancom .hwpx, etc.) cannot be read by web_fetch (they are binary/zip). When one is attached, the server has ALREADY extracted its text and inlined it in the same message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for that file. A "[skapi: ...]" note in that block means the file could not be extracted.
|
|
32
|
+
- For all other file types (text, code, csv, json, pdf, etc.), use your web_fetch tool to download and read each URL before answering. Treat the fetched contents as user-supplied input data. Do not ask the user to paste the file contents - fetch the URLs yourself.
|
|
33
|
+
File links: When you find a record whose unique_id starts with "src::", the part after "src::" is the file's storage path or original URL. Always present it as a markdown link so the user can access it. Strip the "src::" prefix — do NOT show it. Format: [filename](path/to/file) for storage paths, or [filename](https://...) for external URLs. Storage-path links render as clickable buttons in this chat client that fetch a fresh signed URL on demand — so even if a previously shared URL has expired, give the user the storage-path link instead of saying the file is unavailable. Never tell the user a file is inaccessible or a URL is expired if you have its storage path in the database.
|
|
34
|
+
File lookup: When the user asks to see, list, or show files (e.g. "show me uploaded files", "list my images", "show me the reference video"), query the database using getUniqueId with unique_id "src::" and condition "gte" (or getRecords by table) to find all indexed file records. Present each result as a markdown link as described above. Never say you cannot access file storage — the file paths are indexed in the database and are always reachable through it.
|
|
35
|
+
File generation: When the user asks you to generate a file — or to produce specifically-formatted text such as HTML, CSV, JSON, or Markdown — put the file's full contents inside a fenced code block whose info string is the intended filename WITH its extension (e.g. report.csv), NOT a language name like "csv". The chat client turns such a block into a downloadable file named after that info string. Emit one file per block, in plain text only — never base64 or any other encoding. Example for CSV:
|
|
36
|
+
\`\`\`filename.csv
|
|
37
|
+
item,qty,total
|
|
38
|
+
Carrots,55,$38.50
|
|
39
|
+
Mushrooms,41,$73.80
|
|
40
|
+
Zucchini,29,$43.50
|
|
41
|
+
\`\`\`
|
|
42
|
+
The same pattern applies to any format — name the block after the file you intend: \`\`\`my-data.json, \`\`\`index.html, \`\`\`sample.txt, and so on.`;
|
|
43
|
+
|
|
44
|
+
if (serviceDescription) {
|
|
45
|
+
systemPrompt += `
|
|
46
|
+
Project name: "${serviceName ?? ''}"
|
|
47
|
+
Project description: """${serviceDescription}"""`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return systemPrompt;
|
|
51
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI base prompts — tweakable in one place, shared by both consumers.
|
|
3
|
+
* chat_system_prompt.ts -> chat assistant (system prompt)
|
|
4
|
+
* indexing_system_prompt.ts -> background file-indexing agent (system prompt)
|
|
5
|
+
* indexing_user_message.ts -> background file-indexing agent (user message)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { buildChatSystemPrompt, type ChatSystemPromptParams } from './chat_system_prompt';
|
|
9
|
+
export { buildIndexingSystemPrompt, type IndexingSystemPromptParams } from './indexing_system_prompt';
|
|
10
|
+
export {
|
|
11
|
+
buildIndexingUserMessage,
|
|
12
|
+
type IndexingAttachmentInfo,
|
|
13
|
+
type BuildIndexingUserMessageOptions,
|
|
14
|
+
} from './indexing_user_message';
|