bunnyquery 1.7.0 → 1.8.1
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/bunnyquery.css +12 -0
- package/bunnyquery.js +337 -41
- package/dist/engine.cjs +351 -35
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +214 -12
- package/dist/engine.d.ts +214 -12
- package/dist/engine.mjs +340 -36
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/ai_agent.ts +80 -0
- package/src/engine/budget.ts +113 -7
- package/src/engine/history.ts +58 -18
- package/src/engine/host.ts +6 -0
- package/src/engine/index.ts +9 -0
- package/src/engine/indexing_groups.ts +50 -11
- package/src/engine/links.ts +35 -1
- package/src/engine/prompts/chat_system_prompt.ts +3 -0
- package/src/engine/requests.ts +11 -2
- package/src/engine/session.ts +220 -6
- package/src/engine/time.ts +34 -0
- package/styles/chat.css +12 -0
package/package.json
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ai_agent` service option, parsed and serialized in ONE place.
|
|
3
|
+
*
|
|
4
|
+
* Stored on the skapi service record as up to three '#'-delimited segments:
|
|
5
|
+
*
|
|
6
|
+
* none AI chat disabled
|
|
7
|
+
* claude platform chosen, no model saved yet
|
|
8
|
+
* claude#claude-sonnet-4-6 platform + model
|
|
9
|
+
* claude#claude-sonnet-4-6#400000 platform + model + context-window override
|
|
10
|
+
*
|
|
11
|
+
* The third segment is new and optional, so every value written before it
|
|
12
|
+
* existed parses unchanged with `contextWindow: null`. Nothing writes a third
|
|
13
|
+
* segment without a model, because the window is meaningless without one.
|
|
14
|
+
*
|
|
15
|
+
* This lived as four separate copies (agent.vue, dbfile.vue, service.vue, and
|
|
16
|
+
* the widget's index.js), which is how the format drifts. New callers should
|
|
17
|
+
* import from here rather than re-deriving the split.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type AiAgentPlatform = 'claude' | 'openai' | null;
|
|
21
|
+
|
|
22
|
+
export type ParsedAiAgent = {
|
|
23
|
+
/** null when unset or explicitly 'none'. */
|
|
24
|
+
platform: AiAgentPlatform;
|
|
25
|
+
/** '' when no model has been saved. */
|
|
26
|
+
model: string;
|
|
27
|
+
/** Per-project context-window override in tokens, or null to use the model's. */
|
|
28
|
+
contextWindow: number | null;
|
|
29
|
+
/** True when a real platform is configured (i.e. not unset and not 'none'). */
|
|
30
|
+
hasPlatform: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function normalizePlatform(raw: string): AiAgentPlatform {
|
|
34
|
+
var p = (raw || '').trim().toLowerCase();
|
|
35
|
+
return p === 'claude' || p === 'openai' ? p : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseAiAgentValue(value: string | null | undefined): ParsedAiAgent {
|
|
39
|
+
var raw = (value || '').trim();
|
|
40
|
+
if (!raw || raw.toLowerCase() === 'none') {
|
|
41
|
+
return { platform: null, model: '', contextWindow: null, hasPlatform: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Split into at most three parts so a '#' inside a model id (none today, but
|
|
45
|
+
// the delimiter should not be load-bearing on the tail) cannot shift fields.
|
|
46
|
+
var firstHash = raw.indexOf('#');
|
|
47
|
+
if (firstHash === -1) {
|
|
48
|
+
var only = normalizePlatform(raw);
|
|
49
|
+
return { platform: only, model: '', contextWindow: null, hasPlatform: !!only };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
var platform = normalizePlatform(raw.slice(0, firstHash));
|
|
53
|
+
var rest = raw.slice(firstHash + 1);
|
|
54
|
+
var secondHash = rest.indexOf('#');
|
|
55
|
+
var model = (secondHash === -1 ? rest : rest.slice(0, secondHash)).trim();
|
|
56
|
+
var windowRaw = secondHash === -1 ? '' : rest.slice(secondHash + 1).trim();
|
|
57
|
+
|
|
58
|
+
var parsedWindow = windowRaw ? Number(windowRaw) : NaN;
|
|
59
|
+
var contextWindow = Number.isFinite(parsedWindow) && parsedWindow > 0
|
|
60
|
+
? Math.floor(parsedWindow)
|
|
61
|
+
: null;
|
|
62
|
+
|
|
63
|
+
return { platform: platform, model: model, contextWindow: contextWindow, hasPlatform: !!platform };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildAiAgentValue(
|
|
67
|
+
platform: string | null | undefined,
|
|
68
|
+
model?: string | null,
|
|
69
|
+
contextWindow?: number | null,
|
|
70
|
+
): string {
|
|
71
|
+
var p = (platform || '').trim().toLowerCase();
|
|
72
|
+
if (!p || p === 'none') return 'none';
|
|
73
|
+
|
|
74
|
+
var m = (model || '').trim();
|
|
75
|
+
if (!m) return p;
|
|
76
|
+
|
|
77
|
+
var n = Number(contextWindow);
|
|
78
|
+
if (!Number.isFinite(n) || n <= 0) return p + '#' + m;
|
|
79
|
+
return p + '#' + m + '#' + Math.floor(n);
|
|
80
|
+
}
|
package/src/engine/budget.ts
CHANGED
|
@@ -7,15 +7,76 @@
|
|
|
7
7
|
import { sanitizeAttachmentLinksForHistory } from './links';
|
|
8
8
|
|
|
9
9
|
export var CONTEXT_WINDOW_DEFAULT: Record<string, number> = { claude: 200000, openai: 128000 };
|
|
10
|
+
// Exact model ids first, then family keys (see getContextWindow: a suffixed id
|
|
11
|
+
// falls back to its family rather than to the platform default). Claude figures
|
|
12
|
+
// come from Anthropic's published model table; `max_input_tokens` from
|
|
13
|
+
// /v1/models overrides all of this at runtime when a listing has been seen.
|
|
14
|
+
// 'claude-opus-4-7' read 200000 here, which was wrong by 5x, and the default
|
|
15
|
+
// Claude model 'claude-sonnet-4-6' was absent entirely so it fell through to
|
|
16
|
+
// CONTEXT_WINDOW_DEFAULT.claude. Neither was observable, because the history
|
|
17
|
+
// budget and the Claude per-request cap below bind long before the window does.
|
|
10
18
|
export var CONTEXT_WINDOW_BY_MODEL: Record<string, number> = {
|
|
11
|
-
|
|
19
|
+
// exact ids
|
|
20
|
+
'claude-opus-5': 1000000, 'claude-opus-4-8': 1000000, 'claude-opus-4-7': 1000000,
|
|
21
|
+
'claude-sonnet-5': 1000000, 'claude-sonnet-4-6': 1000000, 'claude-sonnet-4': 200000,
|
|
22
|
+
'claude-haiku-4-5': 200000, 'gpt-5.4': 128000, 'gpt-5.6-luna': 128000,
|
|
23
|
+
// family keys
|
|
24
|
+
'claude-opus': 1000000, 'claude-sonnet': 1000000, 'claude-haiku': 200000,
|
|
25
|
+
'gpt-5.6': 128000, 'gpt-5': 128000,
|
|
12
26
|
};
|
|
27
|
+
|
|
28
|
+
// Context windows reported by a provider's own models listing, keyed by model id.
|
|
29
|
+
// Anthropic's GET /v1/models returns `max_input_tokens` per model, which is the
|
|
30
|
+
// authoritative context window; OpenAI's listing carries no equivalent field, so
|
|
31
|
+
// OpenAI models resolve from the table above. Populated by
|
|
32
|
+
// registerModelContextWindows() when a client fetches its model list.
|
|
33
|
+
var apiReportedContextWindows: Record<string, number> = {};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Record context windows from a provider models listing. Accepts the raw list
|
|
37
|
+
* items and reads `max_input_tokens` (Anthropic); items without it are skipped,
|
|
38
|
+
* so passing an OpenAI listing is a no-op rather than an error.
|
|
39
|
+
*/
|
|
40
|
+
export function registerModelContextWindows(models: Array<{ id?: string; max_input_tokens?: number }> | null | undefined): void {
|
|
41
|
+
if (!Array.isArray(models)) return;
|
|
42
|
+
for (var i = 0; i < models.length; i++) {
|
|
43
|
+
var m = models[i];
|
|
44
|
+
var id = (m && m.id ? String(m.id) : '').trim().toLowerCase();
|
|
45
|
+
var reported = m ? Number(m.max_input_tokens) : NaN;
|
|
46
|
+
if (id && Number.isFinite(reported) && reported > 0) {
|
|
47
|
+
apiReportedContextWindows[id] = Math.floor(reported);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Per-project override, keyed by service id. Set from the project settings. */
|
|
53
|
+
var projectContextWindows: Record<string, number> = {};
|
|
54
|
+
|
|
55
|
+
export function setProjectContextWindow(serviceId: string, tokens: number | null | undefined): void {
|
|
56
|
+
var key = (serviceId || '').trim();
|
|
57
|
+
if (!key) return;
|
|
58
|
+
var n = Number(tokens);
|
|
59
|
+
if (Number.isFinite(n) && n > 0) projectContextWindows[key] = Math.floor(n);
|
|
60
|
+
else delete projectContextWindows[key];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getProjectContextWindow(serviceId: string): number | null {
|
|
64
|
+
var key = (serviceId || '').trim();
|
|
65
|
+
return key && projectContextWindows[key] ? projectContextWindows[key] : null;
|
|
66
|
+
}
|
|
13
67
|
export var OUTPUT_TOKEN_RESERVE = 22000;
|
|
14
68
|
export var TOOL_AND_RESPONSE_BUFFER = 4000;
|
|
15
69
|
export var MIN_INPUT_TOKEN_BUDGET = 8000;
|
|
16
70
|
export var CLAUDE_PER_REQUEST_INPUT_CAP = 28000;
|
|
17
71
|
export var MAX_HISTORY_MESSAGES = 20;
|
|
18
72
|
export var HISTORY_TOKEN_BUDGET = 8000;
|
|
73
|
+
// Ratios that scale the two ceilings above off the resolved context window.
|
|
74
|
+
// Calibrated to reproduce the previous fixed values at the default windows:
|
|
75
|
+
// claude 200000 -> cap 27840 (was 28000, so the 28000 floor holds), and
|
|
76
|
+
// openai 128000 -> history 8160 (~the previous 8000). Both are floored at the
|
|
77
|
+
// old constants, so these can only ever raise a budget, never lower one.
|
|
78
|
+
export var CLAUDE_INPUT_CAP_RATIO = 0.16;
|
|
79
|
+
export var HISTORY_BUDGET_RATIO = 0.08;
|
|
19
80
|
|
|
20
81
|
export function estimateTextTokens(text: string): number {
|
|
21
82
|
return Math.ceil((text || '').length / 3);
|
|
@@ -25,9 +86,34 @@ export function estimateMessageTokens(msg: { role: string; content: string }): n
|
|
|
25
86
|
return estimateTextTokens(msg.content) + estimateTextTokens(msg.role) + 6;
|
|
26
87
|
}
|
|
27
88
|
|
|
28
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Resolve a model's context window, most specific source first:
|
|
91
|
+
* 1. per-project override (project settings)
|
|
92
|
+
* 2. the provider's own models listing (Anthropic `max_input_tokens`)
|
|
93
|
+
* 3. an exact entry in CONTEXT_WINDOW_BY_MODEL
|
|
94
|
+
* 4. a family entry, by dropping trailing '-' segments off the id
|
|
95
|
+
* 5. the platform default
|
|
96
|
+
*
|
|
97
|
+
* Step 4 is why a new or suffixed id no longer drops straight to the platform
|
|
98
|
+
* default: 'gpt-5.6-luna' resolves via 'gpt-5.6', and a dated Claude snapshot
|
|
99
|
+
* such as 'claude-opus-4-7-20260101' resolves via 'claude-opus-4-7'. The walk
|
|
100
|
+
* stops at the first hit, so a more specific entry always wins over its family.
|
|
101
|
+
*/
|
|
102
|
+
export function getContextWindow(platform: string, model?: string, serviceId?: string): number {
|
|
103
|
+
var override = serviceId ? getProjectContextWindow(serviceId) : null;
|
|
104
|
+
if (override) return override;
|
|
105
|
+
|
|
29
106
|
var normalized = (model || '').trim().toLowerCase();
|
|
30
|
-
if (normalized
|
|
107
|
+
if (normalized) {
|
|
108
|
+
if (apiReportedContextWindows[normalized]) return apiReportedContextWindows[normalized];
|
|
109
|
+
if (CONTEXT_WINDOW_BY_MODEL[normalized]) return CONTEXT_WINDOW_BY_MODEL[normalized];
|
|
110
|
+
|
|
111
|
+
var parts = normalized.split('-');
|
|
112
|
+
for (var end = parts.length - 1; end > 0; end--) {
|
|
113
|
+
var family = parts.slice(0, end).join('-');
|
|
114
|
+
if (CONTEXT_WINDOW_BY_MODEL[family]) return CONTEXT_WINDOW_BY_MODEL[family];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
31
117
|
return CONTEXT_WINDOW_DEFAULT[platform];
|
|
32
118
|
}
|
|
33
119
|
|
|
@@ -46,14 +132,34 @@ export type BoundedChatOptions = {
|
|
|
46
132
|
};
|
|
47
133
|
|
|
48
134
|
export function buildBoundedChatMessages(options: BoundedChatOptions) {
|
|
49
|
-
var contextWindow = getContextWindow(options.platform, options.model);
|
|
135
|
+
var contextWindow = getContextWindow(options.platform, options.model, options.serviceId);
|
|
50
136
|
var contextBasedBudget = Math.max(MIN_INPUT_TOKEN_BUDGET,
|
|
51
137
|
contextWindow - OUTPUT_TOKEN_RESERVE - TOOL_AND_RESPONSE_BUFFER);
|
|
138
|
+
// Scaling is gated on an EXPLICIT per-project window set from project
|
|
139
|
+
// settings. Without one, the fixed ceilings apply exactly as before, so every
|
|
140
|
+
// existing project keeps byte-identical behavior and nobody's token spend
|
|
141
|
+
// moves because a table value was corrected. With one, the Claude per-request
|
|
142
|
+
// cap and the history budget scale off the window, so raising it genuinely
|
|
143
|
+
// sends more history instead of being absorbed by a hardcoded ceiling.
|
|
144
|
+
// Both derive from contextBasedBudget (pre-Claude-cap) so the two platforms
|
|
145
|
+
// scale symmetrically rather than the Claude cap compounding the ratio down.
|
|
146
|
+
var scaled = !!(options.serviceId && getProjectContextWindow(options.serviceId));
|
|
147
|
+
var claudeInputCap = scaled
|
|
148
|
+
? Math.max(CLAUDE_PER_REQUEST_INPUT_CAP, Math.round(contextBasedBudget * CLAUDE_INPUT_CAP_RATIO))
|
|
149
|
+
: CLAUDE_PER_REQUEST_INPUT_CAP;
|
|
52
150
|
var availableInputBudget = options.platform === 'claude'
|
|
53
|
-
? Math.min(contextBasedBudget,
|
|
151
|
+
? Math.min(contextBasedBudget, claudeInputCap) : contextBasedBudget;
|
|
54
152
|
var systemCost = estimateTextTokens(options.systemPrompt) + 12;
|
|
55
|
-
var
|
|
56
|
-
|
|
153
|
+
var historyAllowance = scaled
|
|
154
|
+
? Math.max(HISTORY_TOKEN_BUDGET, Math.round(contextBasedBudget * HISTORY_BUDGET_RATIO))
|
|
155
|
+
: HISTORY_TOKEN_BUDGET;
|
|
156
|
+
var budgetForHistory = Math.max(1000, Math.min(historyAllowance, availableInputBudget - systemCost));
|
|
157
|
+
// The message count scales alongside the token budget; otherwise 20 messages
|
|
158
|
+
// is a second ceiling that swallows the extra budget on a raised window.
|
|
159
|
+
var maxHistoryMessages = scaled
|
|
160
|
+
? Math.max(MAX_HISTORY_MESSAGES, Math.round(MAX_HISTORY_MESSAGES * (budgetForHistory / HISTORY_TOKEN_BUDGET)))
|
|
161
|
+
: MAX_HISTORY_MESSAGES;
|
|
162
|
+
var windowed = options.history.slice(-maxHistoryMessages);
|
|
57
163
|
var latestIndex = windowed.length - 1;
|
|
58
164
|
var trimmed = windowed.map(function (m, i) {
|
|
59
165
|
if (i === latestIndex) return m;
|
package/src/engine/history.ts
CHANGED
|
@@ -40,6 +40,46 @@ export function extractLastUserTextFromRequest(requestBody: any): string {
|
|
|
40
40
|
return '';
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** The two openings an indexing prompt can have. A bg-queue item that starts with
|
|
44
|
+
* neither is an ordinary chat that happened to be routed onto that queue. */
|
|
45
|
+
export function isIndexingRequestText(userText: any): boolean {
|
|
46
|
+
if (typeof userText !== 'string') return false;
|
|
47
|
+
return userText.indexOf('A new file has just been uploaded') === 0 || userText.indexOf('CONTINUE indexing') === 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type IndexingRequestRef = {
|
|
51
|
+
name: string;
|
|
52
|
+
path?: string;
|
|
53
|
+
mime?: string;
|
|
54
|
+
size?: number;
|
|
55
|
+
/** A CONTINUE pass rather than the run's first. */
|
|
56
|
+
continued: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The file an indexing prompt is about, read back out of the prompt itself.
|
|
61
|
+
*
|
|
62
|
+
* The prompt is the only description of the pass that survives on the server, so
|
|
63
|
+
* this is how BOTH a history rebuild and a worker-minted pass the client never
|
|
64
|
+
* dispatched (ChatSession._adoptWorkerIndexingPasses) recover the file. Shared so
|
|
65
|
+
* the two produce the same `_indexFile`, which is what makes them group together.
|
|
66
|
+
*/
|
|
67
|
+
export function parseIndexingRequestText(userText: any): IndexingRequestRef | null {
|
|
68
|
+
if (typeof userText !== 'string' || !userText) return null;
|
|
69
|
+
var nameMatch = userText.match(/^- name: (.+)$/m);
|
|
70
|
+
if (!nameMatch) return null;
|
|
71
|
+
var mimeMatch = userText.match(/^- mime type: (.+)$/m);
|
|
72
|
+
var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
|
|
73
|
+
var pathMatch = userText.match(/^- storage path: (.+)$/m);
|
|
74
|
+
return {
|
|
75
|
+
name: nameMatch[1].trim(),
|
|
76
|
+
path: pathMatch ? pathMatch[1].trim() : undefined,
|
|
77
|
+
mime: mimeMatch ? mimeMatch[1].trim() : undefined,
|
|
78
|
+
size: sizeMatch ? Number(sizeMatch[1]) : undefined,
|
|
79
|
+
continued: userText.indexOf('CONTINUE indexing') === 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
43
83
|
export type MapHistoryOptions = {
|
|
44
84
|
clearedAt: number;
|
|
45
85
|
serviceId: string;
|
|
@@ -64,6 +104,13 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
64
104
|
var assistantText = isPending ? '' : ((extractAssistantText(response) || '').trim() || '');
|
|
65
105
|
var isErrorResponse = !isPending && (isFailed || isErrorResponseBody(response));
|
|
66
106
|
var serverItemId = item && typeof item.id === 'string' && item.id ? item.id : undefined;
|
|
107
|
+
// A USER bubble shows when the request was made (`created`); an ASSISTANT
|
|
108
|
+
// bubble shows when its response landed (`updated`). Fall back to the other
|
|
109
|
+
// if one is missing (older records only carried `updated`).
|
|
110
|
+
var createdTs = Number(item && item.created);
|
|
111
|
+
var updatedTs = Number(item && item.updated);
|
|
112
|
+
var userTs = isFinite(createdTs) && createdTs > 0 ? createdTs : (isFinite(updatedTs) && updatedTs > 0 ? updatedTs : undefined);
|
|
113
|
+
var replyTs = isFinite(updatedTs) && updatedTs > 0 ? updatedTs : (isFinite(createdTs) && createdTs > 0 ? createdTs : undefined);
|
|
67
114
|
|
|
68
115
|
if (userText) {
|
|
69
116
|
var displayContent;
|
|
@@ -71,31 +118,21 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
71
118
|
// alongside the formatted label so grouping never has to parse it back.
|
|
72
119
|
var indexFile: any = undefined;
|
|
73
120
|
if (item._isBgTask) {
|
|
74
|
-
var
|
|
75
|
-
if (
|
|
76
|
-
var mimeMatch = userText.match(/^- mime type: (.+)$/m);
|
|
77
|
-
var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
|
|
78
|
-
var pathMatch = userText.match(/^- storage path: (.+)$/m);
|
|
121
|
+
var ref = parseIndexingRequestText(userText);
|
|
122
|
+
if (ref) {
|
|
79
123
|
// A CONTINUE pass ("CONTINUE indexing …") gets the compact
|
|
80
124
|
// continuation label; a first pass ("A new file …") gets the full
|
|
81
125
|
// one. Mirrors agent.vue's mapHistoryListToMessages so a big file's
|
|
82
126
|
// windows read as progress, not the same task repeating.
|
|
83
|
-
var isContinuePass = userText.indexOf('CONTINUE indexing') === 0;
|
|
84
127
|
displayContent = opts.formatIndexingLabel(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
128
|
+
ref.name,
|
|
129
|
+
ref.mime || '',
|
|
130
|
+
typeof ref.size === 'number' ? ref.size : null,
|
|
131
|
+
ref.path,
|
|
89
132
|
false,
|
|
90
|
-
|
|
133
|
+
ref.continued
|
|
91
134
|
);
|
|
92
|
-
indexFile =
|
|
93
|
-
name: nameMatch[1].trim(),
|
|
94
|
-
path: pathMatch ? pathMatch[1].trim() : undefined,
|
|
95
|
-
mime: mimeMatch ? mimeMatch[1].trim() : undefined,
|
|
96
|
-
size: sizeMatch ? Number(sizeMatch[1]) : undefined,
|
|
97
|
-
continued: isContinuePass,
|
|
98
|
-
};
|
|
135
|
+
indexFile = ref;
|
|
99
136
|
} else {
|
|
100
137
|
displayContent = userText;
|
|
101
138
|
}
|
|
@@ -110,6 +147,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
110
147
|
if (indexFile) userMsg._indexFile = indexFile;
|
|
111
148
|
if (item._isOnBgQueue) userMsg._useBgQueue = true;
|
|
112
149
|
if (serverItemId !== undefined) userMsg._serverItemId = serverItemId;
|
|
150
|
+
if (userTs !== undefined) userMsg._ts = userTs;
|
|
113
151
|
mapped.push(userMsg);
|
|
114
152
|
}
|
|
115
153
|
if (isCancelledItem) { /* no assistant bubble */ }
|
|
@@ -123,6 +161,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
123
161
|
var em: any = { role: 'assistant', content: getErrorMessage(response), isError: true };
|
|
124
162
|
if (item._isBgTask) em.isBackgroundTask = true;
|
|
125
163
|
if (serverItemId !== undefined) em._serverItemId = serverItemId;
|
|
164
|
+
if (replyTs !== undefined) em._ts = replyTs;
|
|
126
165
|
mapped.push(em);
|
|
127
166
|
} else if (assistantText) {
|
|
128
167
|
// Safe db-only sanitize (forAssistant) so a volatile db url the model
|
|
@@ -130,6 +169,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
130
169
|
var okm: any = { role: 'assistant', content: sanitizeAttachmentLinksForHistory(assistantText, opts.serviceId, true) };
|
|
131
170
|
if (item._isBgTask) okm.isBackgroundTask = true;
|
|
132
171
|
if (serverItemId !== undefined) okm._serverItemId = serverItemId;
|
|
172
|
+
if (replyTs !== undefined) okm._ts = replyTs;
|
|
133
173
|
mapped.push(okm);
|
|
134
174
|
}
|
|
135
175
|
});
|
package/src/engine/host.ts
CHANGED
|
@@ -67,6 +67,12 @@ export interface ChatMessage {
|
|
|
67
67
|
_localId?: string;
|
|
68
68
|
_cancelling?: boolean;
|
|
69
69
|
_cancelError?: string;
|
|
70
|
+
/** Epoch ms shown as small text under the bubble. From the request history a
|
|
71
|
+
* USER bubble carries the request's `created` time and an ASSISTANT bubble the
|
|
72
|
+
* `updated` (response) time; a live bubble is stamped with the wall clock when
|
|
73
|
+
* it is created, then reconciled to the server value on the next history load.
|
|
74
|
+
* Absent while a turn is still pending, so no time shows on a "Thinking" bubble. */
|
|
75
|
+
_ts?: number;
|
|
70
76
|
// History cache key (`serviceId#platform`) this bubble was created under.
|
|
71
77
|
// Stamped on LOCALLY-created bubbles only (the optimistic user message and
|
|
72
78
|
// its "Thinking..." placeholder); server-mapped bubbles are identified by
|
package/src/engine/index.ts
CHANGED
|
@@ -46,11 +46,20 @@ export * from './prompts';
|
|
|
46
46
|
export { getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError } from './errors';
|
|
47
47
|
export * from './budget';
|
|
48
48
|
export * from './links';
|
|
49
|
+
export * from './time';
|
|
50
|
+
export * from './ai_agent';
|
|
49
51
|
export {
|
|
50
52
|
filterListByClearHorizon,
|
|
51
53
|
normalizeTextContent,
|
|
52
54
|
extractLastUserTextFromRequest,
|
|
53
55
|
mapHistoryListToMessages,
|
|
56
|
+
// The indexing-prompt reader. Exported because the prompt is the only record
|
|
57
|
+
// of what a pass is about, so anything that meets a pass without a bubble
|
|
58
|
+
// (a history rebuild, an adopted worker pass, agent.vue's own mapper) has to
|
|
59
|
+
// read it the same way or the two will not group together.
|
|
60
|
+
isIndexingRequestText,
|
|
61
|
+
parseIndexingRequestText,
|
|
62
|
+
type IndexingRequestRef,
|
|
54
63
|
type MapHistoryOptions,
|
|
55
64
|
} from './history';
|
|
56
65
|
|
|
@@ -7,12 +7,34 @@
|
|
|
7
7
|
* repeating forever, and any real question the user asks in between gets buried.
|
|
8
8
|
*
|
|
9
9
|
* buildChatDisplayList turns the flat message array into a DISPLAY list in which
|
|
10
|
-
* every message belonging to one file (however far apart
|
|
11
|
-
* else is interleaved between them) is represented by a single
|
|
12
|
-
* rendered at the position of that
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* every message belonging to one run of one file (however far apart the passes
|
|
11
|
+
* sit, and whatever else is interleaved between them) is represented by a single
|
|
12
|
+
* group entry, rendered at the position of that run's FIRST loaded pass.
|
|
13
|
+
*
|
|
14
|
+
* First, not newest. The message array is ordered by request CREATION time, and
|
|
15
|
+
* a run's later passes are created one at a time as the previous one resolves —
|
|
16
|
+
* by the client for the paged text path, and by the WORKER itself for the
|
|
17
|
+
* rendered-page (PDF) path, which the client only learns about on its next
|
|
18
|
+
* first-page history fetch. So a pass is routinely created minutes after the
|
|
19
|
+
* upload, on a queue that runs in parallel with the foreground chat. Anchoring
|
|
20
|
+
* at the newest pass meant one such late pass dragged the whole run — every
|
|
21
|
+
* earlier pass with it — below any question the user had asked in the meantime,
|
|
22
|
+
* and made a run that had visibly finished before the question render after it.
|
|
23
|
+
* Anchoring at the first pass puts the row where the run actually began, which
|
|
24
|
+
* is a position no later pass can change:
|
|
25
|
+
* - a new pass never relocates the row, so nothing under the reader shifts;
|
|
26
|
+
* - a question asked after the upload always renders below the row, which is
|
|
27
|
+
* the order it happened in.
|
|
28
|
+
* The cost is that a long-running index does not follow the conversation to the
|
|
29
|
+
* bottom: once the user has chatted past the upload, the spinner and the Stop
|
|
30
|
+
* button sit above their newest turns. That is a scroll away, and the row no
|
|
31
|
+
* longer lies about when the work started.
|
|
32
|
+
*
|
|
33
|
+
* Paging older history is the one thing that CAN move the row: an older page
|
|
34
|
+
* carrying earlier passes of a run already on screen moves it up into that page.
|
|
35
|
+
* That happens at most once per run, only for a run whose start was never
|
|
36
|
+
* loaded (`mayHaveOlder`), and it is the same event that already re-derives
|
|
37
|
+
* `runKey` — so the view treats it as a new row either way.
|
|
16
38
|
*
|
|
17
39
|
* The group deliberately reports no authoritative pass TOTAL. History is paged
|
|
18
40
|
* newest-first, so any total computed from loaded messages is a lower bound that
|
|
@@ -37,9 +59,10 @@ export type IndexingGroup = {
|
|
|
37
59
|
* Monday and re-indexed on Wednesday is two runs, and collapsing them into
|
|
38
60
|
* one row erased Monday's from Monday's place in the conversation, claimed
|
|
39
61
|
* its passes for Wednesday, and let Monday's failure be overwritten by
|
|
40
|
-
* Wednesday's success.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
62
|
+
* Wednesday's success. Named after the run's FIRST loaded pass (see where it
|
|
63
|
+
* is assigned below), so passes appended to the run and other runs appearing
|
|
64
|
+
* on either side of it never rename a row already on screen. This is the
|
|
65
|
+
* render key and the expansion key. */
|
|
43
66
|
runKey: string;
|
|
44
67
|
name: string;
|
|
45
68
|
path?: string;
|
|
@@ -65,8 +88,16 @@ export type IndexingGroup = {
|
|
|
65
88
|
/** The file's first pass is not among the loaded messages, so earlier passes
|
|
66
89
|
* exist in history that has not been paged in yet. */
|
|
67
90
|
mayHaveOlder: boolean;
|
|
68
|
-
/** Position in the source array this collapsed row renders at
|
|
91
|
+
/** Position in the source array this collapsed row renders at: the index of
|
|
92
|
+
* the run's FIRST loaded pass (see the file docstring for why not the last). */
|
|
69
93
|
anchorIndex: number;
|
|
94
|
+
/** Identity of the turn at `anchorIndex` — its server item id, or its local id
|
|
95
|
+
* while it has none, or `''` when it has neither. The views stamp this on the
|
|
96
|
+
* row (`data-row-pos`) so the scroll anchor can tell a row that RELOCATED (an
|
|
97
|
+
* older page moved the run's start) from one that merely gained a pass. They
|
|
98
|
+
* must not re-derive it from `members`: which member the row renders at is
|
|
99
|
+
* this module's decision, and the two silently disagreed once already. */
|
|
100
|
+
anchorId: string;
|
|
70
101
|
};
|
|
71
102
|
|
|
72
103
|
export type DisplayEntry =
|
|
@@ -230,7 +261,10 @@ export function buildChatDisplayList(
|
|
|
230
261
|
cancellableIds: [],
|
|
231
262
|
cancelling: false,
|
|
232
263
|
mayHaveOlder: false,
|
|
264
|
+
// The run's first loaded pass, and never re-stamped: see the file
|
|
265
|
+
// docstring. `anchorId` is filled in once every member is known.
|
|
233
266
|
anchorIndex: i,
|
|
267
|
+
anchorId: '',
|
|
234
268
|
};
|
|
235
269
|
order.push(runId);
|
|
236
270
|
}
|
|
@@ -246,7 +280,6 @@ export function buildChatDisplayList(
|
|
|
246
280
|
g.passCount++;
|
|
247
281
|
}
|
|
248
282
|
g.members.push({ msg: msg, index: i });
|
|
249
|
-
g.anchorIndex = i;
|
|
250
283
|
runOfIndex[i] = runId;
|
|
251
284
|
if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
|
|
252
285
|
if (ref && ref.name) keyByName[ref.name] = g.key;
|
|
@@ -334,6 +367,12 @@ export function buildChatDisplayList(
|
|
|
334
367
|
if (pref && !pref.continued) { sawFirstPass = true; break; }
|
|
335
368
|
}
|
|
336
369
|
grp.mayHaveOlder = !sawFirstPass && hasMoreHistory;
|
|
370
|
+
// Where the row renders, and WHICH turn that is. Derived from the same
|
|
371
|
+
// member so the two can never drift apart; a group always has a member
|
|
372
|
+
// (it is created by the first one).
|
|
373
|
+
var anchor = grp.members[0];
|
|
374
|
+
grp.anchorIndex = anchor.index;
|
|
375
|
+
grp.anchorId = anchor.msg._serverItemId || anchor.msg._localId || '';
|
|
337
376
|
}
|
|
338
377
|
|
|
339
378
|
var out: DisplayEntry[] = [];
|
package/src/engine/links.ts
CHANGED
|
@@ -183,6 +183,33 @@ export function repairUrlWhitespace(href: string): string {
|
|
|
183
183
|
return href.trim().replace(/\s/g, '%20');
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* A model reproducing a URL sometimes HTML-escapes its `&` query separators as
|
|
188
|
+
* `&` (or the numeric `&` / `&`). Left in the href that escaping
|
|
189
|
+
* survives the client's own escapeAttr -> v-html/innerHTML decode round-trip and
|
|
190
|
+
* reaches the browser LITERALLY, so a presigned S3 URL is navigated with its
|
|
191
|
+
* parameters named `amp;Signature`, `amp;Expires`, `amp;response-content-type`,
|
|
192
|
+
* ... — the real params vanish, the signature can't be located, and S3 rejects
|
|
193
|
+
* it (the "링크가 안되" dead export link). Undo just that entity escaping.
|
|
194
|
+
*
|
|
195
|
+
* This is a no-op on a clean URL: a valid link carries a raw `&` between params
|
|
196
|
+
* and percent-encodes (`%26`) any literal ampersand that is data, so a real URL
|
|
197
|
+
* never contains `&` to begin with. Mirrors repairUrlWhitespace: it repairs
|
|
198
|
+
* model damage, not the URL. The loop collapses a doubly-escaped `&amp;` too.
|
|
199
|
+
*/
|
|
200
|
+
export function repairUrlEntities(href: string): string {
|
|
201
|
+
if (!href || href.indexOf('&') === -1) return href;
|
|
202
|
+
var out = href, prev = '';
|
|
203
|
+
while (out !== prev) {
|
|
204
|
+
prev = out;
|
|
205
|
+
out = out
|
|
206
|
+
.replace(/&/gi, '&')
|
|
207
|
+
.replace(/�*38;/g, '&')
|
|
208
|
+
.replace(/�*26;/gi, '&');
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
186
213
|
/**
|
|
187
214
|
* Trim punctuation and unmatched wrappers that cling to a token in prose.
|
|
188
215
|
* `src::a/b.pdf).` -> `src::a/b.pdf`, while a balanced `file (v2).pdf` is kept.
|
|
@@ -280,8 +307,11 @@ export function classifyInlineLink(
|
|
|
280
307
|
// source into the storage-path branch, where it became a chip pointing at
|
|
281
308
|
// this project for someone else's file.
|
|
282
309
|
if (srcIsUrl && !isDbHost(rawPath) && !readExpiredAttachmentHref(rawPath)) {
|
|
310
|
+
// decode any model-introduced `&` in the URL (tail stays keyed on
|
|
311
|
+
// the raw match length, so it is left untouched)
|
|
312
|
+
var srcUrl = repairUrlEntities(rawPath);
|
|
283
313
|
return {
|
|
284
|
-
part: { type: 'link', label: truncateLabelForDisplay(
|
|
314
|
+
part: { type: 'link', label: truncateLabelForDisplay(srcUrl), fullLabel: srcUrl, href: srcUrl, expired: false },
|
|
285
315
|
tail: tail,
|
|
286
316
|
};
|
|
287
317
|
}
|
|
@@ -337,6 +367,10 @@ export function classifyInlineLink(
|
|
|
337
367
|
// [label](url) and bare urls.
|
|
338
368
|
var originalHref = g3 || g6 || '';
|
|
339
369
|
if (!originalHref) return null;
|
|
370
|
+
// A model that reproduces the URL may have HTML-escaped its `&` separators;
|
|
371
|
+
// decode them now so every downstream check and the final href see a clean
|
|
372
|
+
// URL (otherwise a presigned link navigates with `&` and 403s).
|
|
373
|
+
originalHref = repairUrlEntities(originalHref);
|
|
340
374
|
// A bare url swallows the punctuation that ends the sentence it sits in, so
|
|
341
375
|
// `see https://host/a.pdf.` linked to `a.pdf.` and 404'd. Trim it and hand the
|
|
342
376
|
// trimmed text back as `tail`, exactly as the src:: branch does.
|
|
@@ -26,6 +26,9 @@ export function buildChatSystemPrompt(params: ChatSystemPromptParams): string {
|
|
|
26
26
|
You are a dedicated assistant for the project ID: "${formattedServiceId}".
|
|
27
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
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
|
+
Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records that usually share a table. So a question about the data almost always spans many records across several files. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "없어?", "하나도 없나?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY relevant table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset.
|
|
30
|
+
Never assert absence from a partial read. Do not say "there is no X", "none", "not found", or "아니요, 없습니다" until a complete scan has come back empty. If you have not finished scanning every relevant table and file, keep querying instead of guessing. A confident "no" that later turns out wrong is worse than telling the user you are still checking.
|
|
31
|
+
Embedded values: a search term is often stored inside a larger string. A merchant "GODADDY" appears as "DNH*GODADDY#4070277042", and a card as "4140****2941". Server-side index and tag filters match only exact values or leading prefixes, not substrings, so filtering on such a field silently drops rows. When the value you are looking for may be embedded, do not trust a narrow filter to be complete. Fetch the full set with fetch_all and match the substring yourself.
|
|
29
32
|
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
33
|
- 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
34
|
- Most attached files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) have ALREADY had their text extracted on the server and inlined in the same message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for those files. A "[skapi: ...]" note in that block means the file could not be extracted.
|
package/src/engine/requests.ts
CHANGED
|
@@ -833,8 +833,16 @@ export const INDEXING_COMPLETE_MARKER = 'INDEXING_COMPLETE';
|
|
|
833
833
|
// a small cap suffices.
|
|
834
834
|
export const MAX_INDEXING_RESUME_PASSES = 6;
|
|
835
835
|
|
|
836
|
+
/**
|
|
837
|
+
* `queue` narrows the fetch to one processing chain; `status` narrows it to items
|
|
838
|
+
* in one state. Passing both is how the client asks "is there still unresolved
|
|
839
|
+
* work on the background-indexing queue?" without pulling a page of chat history
|
|
840
|
+
* (see ChatSession._adoptWorkerIndexingPasses) — the server answers that from a
|
|
841
|
+
* status-keyed index, so the reply carries only the live items, not the bodies of
|
|
842
|
+
* everything already finished.
|
|
843
|
+
*/
|
|
836
844
|
export async function getChatHistory(
|
|
837
|
-
params: { service?: string; owner?: string; platform: 'claude' | 'openai'; queue?: string },
|
|
845
|
+
params: { service?: string; owner?: string; platform: 'claude' | 'openai'; queue?: string; status?: 'pending' | 'running' | 'resolved' | 'failed' },
|
|
838
846
|
fetchOptions: Record<string, any>,
|
|
839
847
|
) {
|
|
840
848
|
const url =
|
|
@@ -848,10 +856,11 @@ export async function getChatHistory(
|
|
|
848
856
|
},
|
|
849
857
|
{ service: params.service, owner: params.owner },
|
|
850
858
|
params.queue ? { queue: params.queue } : {},
|
|
859
|
+
params.status ? { status: params.status } : {},
|
|
851
860
|
);
|
|
852
861
|
|
|
853
862
|
return chatEngineConfig().clientSecretRequestHistory(
|
|
854
|
-
p as { url: string; method: 'POST'; queue?: string },
|
|
863
|
+
p as { url: string; method: 'POST'; queue?: string; status?: string },
|
|
855
864
|
Object.assign({ ascending: false }, fetchOptions),
|
|
856
865
|
);
|
|
857
866
|
}
|