illuma-agents 1.0.8 → 1.0.10
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/LICENSE +1 -5
- package/dist/cjs/common/enum.cjs +1 -2
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/events.cjs +11 -0
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +2 -1
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/instrumentation.cjs +3 -1
- package/dist/cjs/instrumentation.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/types.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +79 -2
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/tools.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +99 -0
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -0
- package/dist/cjs/llm/fake.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +102 -0
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/utils/index.cjs +87 -1
- package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs +175 -1
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/llm/providers.cjs +13 -16
- package/dist/cjs/llm/providers.cjs.map +1 -1
- package/dist/cjs/llm/text.cjs.map +1 -1
- package/dist/cjs/messages/core.cjs +14 -14
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/messages/ids.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +18 -1
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/splitStream.cjs.map +1 -1
- package/dist/cjs/stream.cjs +24 -1
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +20 -1
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/handlers.cjs +29 -25
- package/dist/cjs/tools/handlers.cjs.map +1 -1
- package/dist/cjs/tools/search/anthropic.cjs.map +1 -1
- package/dist/cjs/tools/search/content.cjs.map +1 -1
- package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
- package/dist/cjs/tools/search/format.cjs.map +1 -1
- package/dist/cjs/tools/search/highlights.cjs.map +1 -1
- package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
- package/dist/cjs/tools/search/schema.cjs +27 -25
- package/dist/cjs/tools/search/schema.cjs.map +1 -1
- package/dist/cjs/tools/search/search.cjs +6 -1
- package/dist/cjs/tools/search/search.cjs.map +1 -1
- package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +182 -35
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/search/utils.cjs.map +1 -1
- package/dist/cjs/utils/graph.cjs.map +1 -1
- package/dist/cjs/utils/llm.cjs +0 -1
- package/dist/cjs/utils/llm.cjs.map +1 -1
- package/dist/cjs/utils/misc.cjs.map +1 -1
- package/dist/cjs/utils/run.cjs.map +1 -1
- package/dist/cjs/utils/title.cjs +7 -7
- package/dist/cjs/utils/title.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +1 -2
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/events.mjs +11 -0
- package/dist/esm/events.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +2 -1
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/instrumentation.mjs +3 -1
- package/dist/esm/instrumentation.mjs.map +1 -1
- package/dist/esm/llm/anthropic/types.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +79 -2
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/tools.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +97 -0
- package/dist/esm/llm/bedrock/index.mjs.map +1 -0
- package/dist/esm/llm/fake.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +103 -1
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openai/utils/index.mjs +88 -2
- package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs +175 -1
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/llm/providers.mjs +2 -5
- package/dist/esm/llm/providers.mjs.map +1 -1
- package/dist/esm/llm/text.mjs.map +1 -1
- package/dist/esm/messages/core.mjs +14 -14
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/messages/ids.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +18 -1
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/splitStream.mjs.map +1 -1
- package/dist/esm/stream.mjs +24 -1
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +20 -1
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/handlers.mjs +30 -26
- package/dist/esm/tools/handlers.mjs.map +1 -1
- package/dist/esm/tools/search/anthropic.mjs.map +1 -1
- package/dist/esm/tools/search/content.mjs.map +1 -1
- package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
- package/dist/esm/tools/search/format.mjs.map +1 -1
- package/dist/esm/tools/search/highlights.mjs.map +1 -1
- package/dist/esm/tools/search/rerankers.mjs.map +1 -1
- package/dist/esm/tools/search/schema.mjs +27 -25
- package/dist/esm/tools/search/schema.mjs.map +1 -1
- package/dist/esm/tools/search/search.mjs +6 -1
- package/dist/esm/tools/search/search.mjs.map +1 -1
- package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +182 -35
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/search/utils.mjs.map +1 -1
- package/dist/esm/utils/graph.mjs.map +1 -1
- package/dist/esm/utils/llm.mjs +0 -1
- package/dist/esm/utils/llm.mjs.map +1 -1
- package/dist/esm/utils/misc.mjs.map +1 -1
- package/dist/esm/utils/run.mjs.map +1 -1
- package/dist/esm/utils/title.mjs +7 -7
- package/dist/esm/utils/title.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +1 -2
- package/dist/types/llm/bedrock/index.d.ts +36 -0
- package/dist/types/llm/openai/index.d.ts +1 -0
- package/dist/types/llm/openai/utils/index.d.ts +10 -1
- package/dist/types/llm/openrouter/index.d.ts +4 -1
- package/dist/types/tools/search/types.d.ts +2 -0
- package/dist/types/types/llm.d.ts +3 -8
- package/package.json +16 -12
- package/src/common/enum.ts +1 -2
- package/src/common/index.ts +1 -1
- package/src/events.ts +11 -0
- package/src/graphs/Graph.ts +2 -1
- package/src/instrumentation.ts +25 -22
- package/src/llm/anthropic/llm.spec.ts +1442 -1442
- package/src/llm/anthropic/types.ts +140 -140
- package/src/llm/anthropic/utils/message_inputs.ts +757 -660
- package/src/llm/anthropic/utils/output_parsers.ts +133 -133
- package/src/llm/anthropic/utils/tools.ts +29 -29
- package/src/llm/bedrock/index.ts +128 -0
- package/src/llm/fake.ts +133 -133
- package/src/llm/google/llm.spec.ts +3 -1
- package/src/llm/google/utils/tools.ts +160 -160
- package/src/llm/openai/index.ts +126 -0
- package/src/llm/openai/types.ts +24 -24
- package/src/llm/openai/utils/index.ts +116 -1
- package/src/llm/openai/utils/isReasoningModel.test.ts +90 -90
- package/src/llm/openrouter/index.ts +222 -1
- package/src/llm/providers.ts +2 -7
- package/src/llm/text.ts +94 -94
- package/src/messages/core.ts +463 -463
- package/src/messages/formatAgentMessages.tools.test.ts +400 -400
- package/src/messages/formatMessage.test.ts +693 -693
- package/src/messages/ids.ts +26 -26
- package/src/messages/prune.ts +567 -567
- package/src/messages/shiftIndexTokenCountMap.test.ts +81 -81
- package/src/mockStream.ts +98 -98
- package/src/prompts/collab.ts +5 -5
- package/src/prompts/index.ts +1 -1
- package/src/prompts/taskmanager.ts +61 -61
- package/src/run.ts +22 -4
- package/src/scripts/ant_web_search_edge_case.ts +162 -0
- package/src/scripts/ant_web_search_error_edge_case.ts +148 -0
- package/src/scripts/args.ts +48 -48
- package/src/scripts/caching.ts +123 -123
- package/src/scripts/code_exec_files.ts +193 -193
- package/src/scripts/empty_input.ts +137 -137
- package/src/scripts/memory.ts +97 -97
- package/src/scripts/test-tools-before-handoff.ts +1 -5
- package/src/scripts/thinking.ts +149 -149
- package/src/scripts/tools.ts +1 -4
- package/src/specs/anthropic.simple.test.ts +67 -0
- package/src/specs/spec.utils.ts +3 -3
- package/src/specs/token-distribution-edge-case.test.ts +316 -316
- package/src/specs/tool-error.test.ts +193 -193
- package/src/splitStream.test.ts +691 -691
- package/src/splitStream.ts +234 -234
- package/src/stream.test.ts +94 -94
- package/src/stream.ts +30 -1
- package/src/tools/ToolNode.ts +24 -1
- package/src/tools/handlers.ts +32 -28
- package/src/tools/search/anthropic.ts +51 -51
- package/src/tools/search/content.test.ts +173 -173
- package/src/tools/search/content.ts +147 -147
- package/src/tools/search/direct-url.test.ts +530 -0
- package/src/tools/search/firecrawl.ts +210 -210
- package/src/tools/search/format.ts +250 -250
- package/src/tools/search/highlights.ts +320 -320
- package/src/tools/search/index.ts +2 -2
- package/src/tools/search/jina-reranker.test.ts +126 -126
- package/src/tools/search/output.md +2775 -2775
- package/src/tools/search/rerankers.ts +242 -242
- package/src/tools/search/schema.ts +65 -63
- package/src/tools/search/search.ts +766 -759
- package/src/tools/search/serper-scraper.ts +155 -155
- package/src/tools/search/test.html +883 -883
- package/src/tools/search/test.md +642 -642
- package/src/tools/search/test.ts +159 -159
- package/src/tools/search/tool.ts +641 -471
- package/src/tools/search/types.ts +689 -687
- package/src/tools/search/utils.ts +79 -79
- package/src/types/index.ts +6 -6
- package/src/types/llm.ts +2 -8
- package/src/utils/graph.ts +10 -10
- package/src/utils/llm.ts +26 -27
- package/src/utils/llmConfig.ts +13 -5
- package/src/utils/logging.ts +48 -48
- package/src/utils/misc.ts +57 -57
- package/src/utils/run.ts +100 -100
- package/src/utils/title.ts +165 -165
- package/dist/cjs/llm/ollama/index.cjs +0 -70
- package/dist/cjs/llm/ollama/index.cjs.map +0 -1
- package/dist/cjs/llm/ollama/utils.cjs +0 -158
- package/dist/cjs/llm/ollama/utils.cjs.map +0 -1
- package/dist/esm/llm/ollama/index.mjs +0 -68
- package/dist/esm/llm/ollama/index.mjs.map +0 -1
- package/dist/esm/llm/ollama/utils.mjs +0 -155
- package/dist/esm/llm/ollama/utils.mjs.map +0 -1
- package/dist/types/llm/ollama/index.d.ts +0 -8
- package/dist/types/llm/ollama/utils.d.ts +0 -7
- package/src/llm/ollama/index.ts +0 -92
- package/src/llm/ollama/utils.ts +0 -193
- package/src/proto/CollabGraph.ts +0 -269
- package/src/proto/TaskManager.ts +0 -243
- package/src/proto/collab.ts +0 -200
- package/src/proto/collab_design.ts +0 -184
- package/src/proto/collab_design_v2.ts +0 -224
- package/src/proto/collab_design_v3.ts +0 -255
- package/src/proto/collab_design_v4.ts +0 -220
- package/src/proto/collab_design_v5.ts +0 -251
- package/src/proto/collab_graph.ts +0 -181
- package/src/proto/collab_original.ts +0 -123
- package/src/proto/example.ts +0 -93
- package/src/proto/example_new.ts +0 -68
- package/src/proto/example_old.ts +0 -201
- package/src/proto/example_test.ts +0 -152
- package/src/proto/example_test_anthropic.ts +0 -100
- package/src/proto/log_stream.ts +0 -202
- package/src/proto/main_collab_community_event.ts +0 -133
- package/src/proto/main_collab_design_v2.ts +0 -96
- package/src/proto/main_collab_design_v4.ts +0 -100
- package/src/proto/main_collab_design_v5.ts +0 -135
- package/src/proto/main_collab_global_analysis.ts +0 -122
- package/src/proto/main_collab_hackathon_event.ts +0 -153
- package/src/proto/main_collab_space_mission.ts +0 -153
- package/src/proto/main_philosophy.ts +0 -210
- package/src/proto/original_script.ts +0 -126
- package/src/proto/standard.ts +0 -100
- package/src/proto/stream.ts +0 -56
- package/src/proto/tasks.ts +0 -118
- package/src/proto/tools/global_analysis_tools.ts +0 -86
- package/src/proto/tools/space_mission_tools.ts +0 -60
- package/src/proto/vertexai.ts +0 -54
- package/src/scripts/image.ts +0 -178
package/src/stream.ts
CHANGED
|
@@ -107,6 +107,25 @@ export function getChunkContent({
|
|
|
107
107
|
| undefined
|
|
108
108
|
)?.summary?.[0]?.text;
|
|
109
109
|
}
|
|
110
|
+
if (
|
|
111
|
+
provider === Providers.OPENROUTER &&
|
|
112
|
+
chunk?.additional_kwargs?.reasoning_details != null &&
|
|
113
|
+
Array.isArray(chunk.additional_kwargs.reasoning_details)
|
|
114
|
+
) {
|
|
115
|
+
// Extract text from reasoning_details array (for Gemini, DeepSeek, etc.)
|
|
116
|
+
const textEntries = chunk.additional_kwargs.reasoning_details
|
|
117
|
+
.filter(
|
|
118
|
+
(detail) =>
|
|
119
|
+
detail.type === 'reasoning.text' &&
|
|
120
|
+
detail.text != null &&
|
|
121
|
+
detail.text !== ''
|
|
122
|
+
)
|
|
123
|
+
.map((detail) => detail.text)
|
|
124
|
+
.join('');
|
|
125
|
+
if (textEntries) {
|
|
126
|
+
return textEntries;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
110
129
|
return (
|
|
111
130
|
((chunk?.additional_kwargs?.[reasoningKey] as string | undefined) ?? '') ||
|
|
112
131
|
chunk?.content
|
|
@@ -155,7 +174,10 @@ export class ChatModelStreamHandler implements t.EventHandler {
|
|
|
155
174
|
chunk.tool_calls.length > 0 &&
|
|
156
175
|
chunk.tool_calls.every(
|
|
157
176
|
(tc) =>
|
|
158
|
-
tc.id != null &&
|
|
177
|
+
tc.id != null &&
|
|
178
|
+
tc.id !== '' &&
|
|
179
|
+
(tc as Partial<ToolCall>).name != null &&
|
|
180
|
+
tc.name !== ''
|
|
159
181
|
)
|
|
160
182
|
) {
|
|
161
183
|
hasToolCalls = true;
|
|
@@ -352,6 +374,13 @@ hasToolCallChunks: ${hasToolCallChunks}
|
|
|
352
374
|
reasoning_content.summary[0].text
|
|
353
375
|
) {
|
|
354
376
|
reasoning_content = 'valid';
|
|
377
|
+
} else if (
|
|
378
|
+
agentContext.provider === Providers.OPENROUTER &&
|
|
379
|
+
chunk.additional_kwargs?.reasoning_details != null &&
|
|
380
|
+
Array.isArray(chunk.additional_kwargs.reasoning_details) &&
|
|
381
|
+
chunk.additional_kwargs.reasoning_details.length > 0
|
|
382
|
+
) {
|
|
383
|
+
reasoning_content = 'valid';
|
|
355
384
|
}
|
|
356
385
|
if (
|
|
357
386
|
reasoning_content != null &&
|
package/src/tools/ToolNode.ts
CHANGED
|
@@ -87,6 +87,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
87
87
|
{ ...call, args, type: 'tool_call', stepId, turn },
|
|
88
88
|
config
|
|
89
89
|
);
|
|
90
|
+
|
|
91
|
+
// Debug logging for image generation
|
|
92
|
+
if (call.name === 'image_generation') {
|
|
93
|
+
console.log('[ToolNode] image_generation output:', {
|
|
94
|
+
isBaseMessage: isBaseMessage(output),
|
|
95
|
+
messageType: isBaseMessage(output) ? output._getType() : 'not a message',
|
|
96
|
+
isCommand: isCommand(output),
|
|
97
|
+
hasArtifact: isBaseMessage(output) && (output as ToolMessage).artifact !== undefined,
|
|
98
|
+
outputType: typeof output,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
90
102
|
if (
|
|
91
103
|
(isBaseMessage(output) && output._getType() === 'tool') ||
|
|
92
104
|
isCommand(output)
|
|
@@ -201,7 +213,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
201
213
|
|
|
202
214
|
outputs = await Promise.all(
|
|
203
215
|
aiMessage.tool_calls
|
|
204
|
-
?.filter((call) =>
|
|
216
|
+
?.filter((call) => {
|
|
217
|
+
/**
|
|
218
|
+
* Filter out:
|
|
219
|
+
* 1. Already processed tool calls (present in toolMessageIds)
|
|
220
|
+
* 2. Server tool calls (e.g., web_search with IDs starting with 'srvtoolu_')
|
|
221
|
+
* which are executed by the provider's API and don't require invocation
|
|
222
|
+
*/
|
|
223
|
+
return (
|
|
224
|
+
(call.id == null || !toolMessageIds.has(call.id)) &&
|
|
225
|
+
!(call.id?.startsWith('srvtoolu_') ?? false)
|
|
226
|
+
);
|
|
227
|
+
})
|
|
205
228
|
.map((call) => this.runTool(call, config)) ?? []
|
|
206
229
|
);
|
|
207
230
|
}
|
package/src/tools/handlers.ts
CHANGED
|
@@ -9,7 +9,6 @@ import type { AgentContext } from '@/agents/AgentContext';
|
|
|
9
9
|
import type * as t from '@/types';
|
|
10
10
|
import {
|
|
11
11
|
ToolCallTypes,
|
|
12
|
-
ContentTypes,
|
|
13
12
|
GraphEvents,
|
|
14
13
|
StepTypes,
|
|
15
14
|
Providers,
|
|
@@ -87,22 +86,33 @@ export async function handleToolCallChunks({
|
|
|
87
86
|
const alreadyDispatched =
|
|
88
87
|
prevRunStep?.type === StepTypes.MESSAGE_CREATION &&
|
|
89
88
|
graph.messageStepHasToolCalls.has(prevStepId);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
89
|
+
|
|
90
|
+
if (prevRunStep?.type === StepTypes.TOOL_CALLS) {
|
|
91
|
+
/**
|
|
92
|
+
* If previous step is already a tool_calls step, use that step ID
|
|
93
|
+
* This ensures tool call deltas are dispatched to the correct step
|
|
94
|
+
*/
|
|
95
|
+
stepId = prevStepId;
|
|
96
|
+
} else if (
|
|
97
|
+
!alreadyDispatched &&
|
|
98
|
+
prevRunStep?.type === StepTypes.MESSAGE_CREATION
|
|
99
|
+
) {
|
|
100
|
+
/**
|
|
101
|
+
* Create tool_calls step as soon as we receive the first tool call chunk
|
|
102
|
+
* This ensures deltas are always associated with the correct step
|
|
103
|
+
*
|
|
104
|
+
* NOTE: We do NOT dispatch an empty text block here because:
|
|
105
|
+
* - Empty text blocks cause providers (Anthropic, Bedrock) to reject messages
|
|
106
|
+
* - The tool_calls themselves are sufficient for the step
|
|
107
|
+
* - Empty content with tool_call_ids gets stored in conversation history
|
|
108
|
+
* and causes "messages must have non-empty content" errors on replay
|
|
109
|
+
*/
|
|
100
110
|
graph.messageStepHasToolCalls.set(prevStepId, true);
|
|
101
111
|
stepId = await graph.dispatchRunStep(
|
|
102
112
|
stepKey,
|
|
103
113
|
{
|
|
104
114
|
type: StepTypes.TOOL_CALLS,
|
|
105
|
-
tool_calls,
|
|
115
|
+
tool_calls: tool_calls ?? [],
|
|
106
116
|
},
|
|
107
117
|
metadata
|
|
108
118
|
);
|
|
@@ -149,26 +159,21 @@ export const handleToolCalls = async (
|
|
|
149
159
|
// no previous step
|
|
150
160
|
}
|
|
151
161
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
},
|
|
162
|
-
],
|
|
163
|
-
});
|
|
164
|
-
};
|
|
162
|
+
/**
|
|
163
|
+
* NOTE: We do NOT dispatch empty text blocks with tool_call_ids because:
|
|
164
|
+
* - Empty text blocks cause providers (Anthropic, Bedrock) to reject messages
|
|
165
|
+
* - They get stored in conversation history and cause errors on replay:
|
|
166
|
+
* "messages must have non-empty content" (Anthropic)
|
|
167
|
+
* "The content field in the Message object is empty" (Bedrock)
|
|
168
|
+
* - The tool_calls themselves are sufficient
|
|
169
|
+
*/
|
|
170
|
+
|
|
165
171
|
/* If the previous step exists and is a message creation */
|
|
166
172
|
if (
|
|
167
173
|
prevStepId &&
|
|
168
174
|
prevRunStep &&
|
|
169
175
|
prevRunStep.type === StepTypes.MESSAGE_CREATION
|
|
170
176
|
) {
|
|
171
|
-
await dispatchToolCallIds(prevStepId);
|
|
172
177
|
graph.messageStepHasToolCalls.set(prevStepId, true);
|
|
173
178
|
/* If the previous step doesn't exist or is not a message creation */
|
|
174
179
|
} else if (
|
|
@@ -186,8 +191,7 @@ export const handleToolCalls = async (
|
|
|
186
191
|
},
|
|
187
192
|
metadata
|
|
188
193
|
);
|
|
189
|
-
|
|
190
|
-
graph.messageStepHasToolCalls.set(prevStepId, true);
|
|
194
|
+
graph.messageStepHasToolCalls.set(stepId, true);
|
|
191
195
|
}
|
|
192
196
|
|
|
193
197
|
await graph.dispatchRunStep(
|
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
AnthropicTextBlockParam,
|
|
3
|
-
AnthropicWebSearchResultBlockParam,
|
|
4
|
-
} from '@/llm/anthropic/types';
|
|
5
|
-
import type { SearchResultData, ProcessedOrganic } from './types';
|
|
6
|
-
import { getAttribution } from './utils';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Coerces Anthropic web search results to the SearchResultData format
|
|
10
|
-
* @param results - Array of Anthropic web search results
|
|
11
|
-
* @param turn - The turn number to associate with these results
|
|
12
|
-
* @returns SearchResultData with minimal ProcessedOrganic items
|
|
13
|
-
*/
|
|
14
|
-
export function coerceAnthropicSearchResults({
|
|
15
|
-
results,
|
|
16
|
-
turn = 0,
|
|
17
|
-
}: {
|
|
18
|
-
results: (AnthropicTextBlockParam | AnthropicWebSearchResultBlockParam)[];
|
|
19
|
-
turn?: number;
|
|
20
|
-
}): SearchResultData {
|
|
21
|
-
const organic: ProcessedOrganic[] = results
|
|
22
|
-
.filter((result) => result.type === 'web_search_result')
|
|
23
|
-
.map((result, index) => ({
|
|
24
|
-
link: result.url,
|
|
25
|
-
position: index + 1,
|
|
26
|
-
title: result.title,
|
|
27
|
-
date: result.page_age ?? undefined,
|
|
28
|
-
attribution: getAttribution(result.url),
|
|
29
|
-
}));
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
turn,
|
|
33
|
-
organic,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Helper function to check if an object is an Anthropic web search result
|
|
39
|
-
*/
|
|
40
|
-
export function isAnthropicWebSearchResult(
|
|
41
|
-
obj: unknown
|
|
42
|
-
): obj is AnthropicWebSearchResultBlockParam {
|
|
43
|
-
return (
|
|
44
|
-
typeof obj === 'object' &&
|
|
45
|
-
obj !== null &&
|
|
46
|
-
'type' in obj &&
|
|
47
|
-
obj.type === 'web_search_result' &&
|
|
48
|
-
'url' in obj &&
|
|
49
|
-
typeof (obj as Record<string, unknown>).url === 'string'
|
|
50
|
-
);
|
|
51
|
-
}
|
|
1
|
+
import type {
|
|
2
|
+
AnthropicTextBlockParam,
|
|
3
|
+
AnthropicWebSearchResultBlockParam,
|
|
4
|
+
} from '@/llm/anthropic/types';
|
|
5
|
+
import type { SearchResultData, ProcessedOrganic } from './types';
|
|
6
|
+
import { getAttribution } from './utils';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Coerces Anthropic web search results to the SearchResultData format
|
|
10
|
+
* @param results - Array of Anthropic web search results
|
|
11
|
+
* @param turn - The turn number to associate with these results
|
|
12
|
+
* @returns SearchResultData with minimal ProcessedOrganic items
|
|
13
|
+
*/
|
|
14
|
+
export function coerceAnthropicSearchResults({
|
|
15
|
+
results,
|
|
16
|
+
turn = 0,
|
|
17
|
+
}: {
|
|
18
|
+
results: (AnthropicTextBlockParam | AnthropicWebSearchResultBlockParam)[];
|
|
19
|
+
turn?: number;
|
|
20
|
+
}): SearchResultData {
|
|
21
|
+
const organic: ProcessedOrganic[] = results
|
|
22
|
+
.filter((result) => result.type === 'web_search_result')
|
|
23
|
+
.map((result, index) => ({
|
|
24
|
+
link: result.url,
|
|
25
|
+
position: index + 1,
|
|
26
|
+
title: result.title,
|
|
27
|
+
date: result.page_age ?? undefined,
|
|
28
|
+
attribution: getAttribution(result.url),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
turn,
|
|
33
|
+
organic,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Helper function to check if an object is an Anthropic web search result
|
|
39
|
+
*/
|
|
40
|
+
export function isAnthropicWebSearchResult(
|
|
41
|
+
obj: unknown
|
|
42
|
+
): obj is AnthropicWebSearchResultBlockParam {
|
|
43
|
+
return (
|
|
44
|
+
typeof obj === 'object' &&
|
|
45
|
+
obj !== null &&
|
|
46
|
+
'type' in obj &&
|
|
47
|
+
obj.type === 'web_search_result' &&
|
|
48
|
+
'url' in obj &&
|
|
49
|
+
typeof (obj as Record<string, unknown>).url === 'string'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -1,173 +1,173 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
-
/* eslint-disable no-console */
|
|
3
|
-
// content.test.ts
|
|
4
|
-
import * as fs from 'fs';
|
|
5
|
-
import { processContent } from './content';
|
|
6
|
-
|
|
7
|
-
describe('Link Processor', () => {
|
|
8
|
-
afterAll(() => {
|
|
9
|
-
if (fs.existsSync('./temp.html')) {
|
|
10
|
-
fs.unlinkSync('./temp.html');
|
|
11
|
-
}
|
|
12
|
-
if (fs.existsSync('./temp.md')) {
|
|
13
|
-
fs.unlinkSync('./temp.md');
|
|
14
|
-
}
|
|
15
|
-
});
|
|
16
|
-
// Basic functionality tests
|
|
17
|
-
test('should replace basic links with references', () => {
|
|
18
|
-
const html = `
|
|
19
|
-
<p>Test with <a href="https://example.com/link" title="Example">a link</a></p>
|
|
20
|
-
<p>And an <img src="https://example.com/img.jpg" alt="image"></p>
|
|
21
|
-
<p>Plus a <video src="https://example.com/video.mp4"></video></p>
|
|
22
|
-
`;
|
|
23
|
-
|
|
24
|
-
const markdown = `
|
|
25
|
-
Test with [a link](https://example.com/link "Example")
|
|
26
|
-
And an 
|
|
27
|
-
Plus a [video](https://example.com/video.mp4)
|
|
28
|
-
`;
|
|
29
|
-
|
|
30
|
-
const result = processContent(html, markdown);
|
|
31
|
-
|
|
32
|
-
expect(result.links.length).toBe(1);
|
|
33
|
-
expect(result.images.length).toBe(1);
|
|
34
|
-
expect(result.videos.length).toBe(1);
|
|
35
|
-
expect(result.markdown).toContain('link#1');
|
|
36
|
-
expect(result.markdown).toContain('image#1');
|
|
37
|
-
expect(result.markdown).toContain('video#1');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
// Edge case tests
|
|
41
|
-
test('should handle links with parentheses and special characters', () => {
|
|
42
|
-
const html = `
|
|
43
|
-
<a href="https://example.com/page(1).html" title="Parens">Link with parens</a>
|
|
44
|
-
<a href="https://example.com/path?query=test¶m=value">Link with query</a>
|
|
45
|
-
`;
|
|
46
|
-
|
|
47
|
-
const markdown = `
|
|
48
|
-
[Link with parens](https://example.com/page(1).html "Parens")
|
|
49
|
-
[Link with query](https://example.com/path?query=test¶m=value)
|
|
50
|
-
`;
|
|
51
|
-
|
|
52
|
-
const result = processContent(html, markdown);
|
|
53
|
-
|
|
54
|
-
expect(result.links.length).toBe(2);
|
|
55
|
-
expect(result.markdown).toContain('link#1');
|
|
56
|
-
expect(result.markdown).toContain('link#2');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// Performance test with large files
|
|
60
|
-
test('should process large files efficiently', () => {
|
|
61
|
-
const html = fs.readFileSync('src/tools/search/test.html', 'utf-8');
|
|
62
|
-
const markdown = fs.readFileSync('src/tools/search/test.md', 'utf-8');
|
|
63
|
-
|
|
64
|
-
// const largeHtml = generateLargeHtml(1000); // 1000 links
|
|
65
|
-
// fs.writeFileSync('./temp.html', largeHtml);
|
|
66
|
-
|
|
67
|
-
// const largeMd = generateLargeMarkdown(1000); // 1000 links
|
|
68
|
-
// fs.writeFileSync('./temp.md', largeMd);
|
|
69
|
-
|
|
70
|
-
// const html = fs.readFileSync('./temp.html', 'utf-8');
|
|
71
|
-
// const markdown = fs.readFileSync('./temp.md', 'utf-8');
|
|
72
|
-
|
|
73
|
-
// Measure time taken to process
|
|
74
|
-
const startTime = process.hrtime();
|
|
75
|
-
const result = processContent(html, markdown);
|
|
76
|
-
const elapsed = process.hrtime(startTime);
|
|
77
|
-
const timeInMs = elapsed[0] * 1000 + elapsed[1] / 1000000;
|
|
78
|
-
|
|
79
|
-
console.log(
|
|
80
|
-
`Processed ${result.links.length} links, ${result.images.length} images, and ${result.videos.length} videos in ${timeInMs.toFixed(2)}ms`
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
// Basic validations for large file processing
|
|
84
|
-
expect(result.links.length).toBeGreaterThan(0);
|
|
85
|
-
expect(result.markdown).toContain('link#');
|
|
86
|
-
|
|
87
|
-
// Check if all links were replaced (sample check)
|
|
88
|
-
expect(result.markdown).not.toContain('https://example.com/link');
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
// Memory usage test
|
|
92
|
-
test('should have reasonable memory usage', () => {
|
|
93
|
-
const html = fs.readFileSync('src/tools/search/test.html', 'utf-8');
|
|
94
|
-
const markdown = fs.readFileSync('src/tools/search/test.md', 'utf-8');
|
|
95
|
-
|
|
96
|
-
const beforeMem = process.memoryUsage();
|
|
97
|
-
processContent(html, markdown);
|
|
98
|
-
const afterMem = process.memoryUsage();
|
|
99
|
-
|
|
100
|
-
const heapUsed = (afterMem.heapUsed - beforeMem.heapUsed) / 1024 / 1024; // MB
|
|
101
|
-
|
|
102
|
-
console.log(`Memory used: ${heapUsed.toFixed(2)} MB`);
|
|
103
|
-
|
|
104
|
-
// This is a loose check - actual thresholds depend on your environment
|
|
105
|
-
expect(heapUsed).toBeLessThan(100); // Should use less than 100MB additional heap
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// Real-world file test (if available)
|
|
109
|
-
test('should process real-world Wikipedia content', () => {
|
|
110
|
-
// Try to find real-world test files if they exist
|
|
111
|
-
const wikiHtml = 'src/tools/search/test.html';
|
|
112
|
-
const wikiMd = 'src/tools/search/test.md';
|
|
113
|
-
|
|
114
|
-
if (fs.existsSync(wikiHtml) && fs.existsSync(wikiMd)) {
|
|
115
|
-
const html = fs.readFileSync(wikiHtml, 'utf-8');
|
|
116
|
-
const markdown = fs.readFileSync(wikiMd, 'utf-8');
|
|
117
|
-
|
|
118
|
-
const result = processContent(html, markdown);
|
|
119
|
-
|
|
120
|
-
console.log(
|
|
121
|
-
`Processed ${result.links.length} Wikipedia links, ${result.images.length} images, and ${result.videos.length} videos`
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
expect(result.links.length).toBeGreaterThan(10); // Wikipedia articles typically have many links
|
|
125
|
-
expect(result.markdown).not.toMatch(/\]\(https?:\/\/[^\s")]+\)/); // No regular URLs should remain
|
|
126
|
-
} else {
|
|
127
|
-
console.log('Wikipedia test files not found, skipping this test');
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// Helper function to generate large HTML test data
|
|
133
|
-
function generateLargeHtml(linkCount: number): string {
|
|
134
|
-
let html = '<html><body>';
|
|
135
|
-
|
|
136
|
-
for (let i = 1; i <= linkCount; i++) {
|
|
137
|
-
html += `<p>Paragraph ${i} with <a href="https://example.com/link${i}" title="Link ${i}">link ${i}</a>`;
|
|
138
|
-
|
|
139
|
-
if (i % 10 === 0) {
|
|
140
|
-
html += ` and <img src="https://example.com/image${i / 10}.jpg" alt="Image ${i / 10}">`;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (i % 50 === 0) {
|
|
144
|
-
html += ` and <video src="https://example.com/video${i / 50}.mp4" title="Video ${i / 50}"></video>`;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
html += '</p>';
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
html += '</body></html>';
|
|
151
|
-
return html;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Helper function to generate large Markdown test data */
|
|
155
|
-
function generateLargeMarkdown(linkCount: number): string {
|
|
156
|
-
let markdown = '# Test Document\n\n';
|
|
157
|
-
|
|
158
|
-
for (let i = 1; i <= linkCount; i++) {
|
|
159
|
-
markdown += `Paragraph ${i} with [link ${i}](https://example.com/link${i} "Link ${i}")`;
|
|
160
|
-
|
|
161
|
-
if (i % 10 === 0) {
|
|
162
|
-
markdown += ` and `;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (i % 50 === 0) {
|
|
166
|
-
markdown += ` and [Video ${i / 50}](https://example.com/video${i / 50}.mp4 "Video ${i / 50}")`;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
markdown += '\n\n';
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return markdown;
|
|
173
|
-
}
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
+
/* eslint-disable no-console */
|
|
3
|
+
// content.test.ts
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import { processContent } from './content';
|
|
6
|
+
|
|
7
|
+
describe('Link Processor', () => {
|
|
8
|
+
afterAll(() => {
|
|
9
|
+
if (fs.existsSync('./temp.html')) {
|
|
10
|
+
fs.unlinkSync('./temp.html');
|
|
11
|
+
}
|
|
12
|
+
if (fs.existsSync('./temp.md')) {
|
|
13
|
+
fs.unlinkSync('./temp.md');
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
// Basic functionality tests
|
|
17
|
+
test('should replace basic links with references', () => {
|
|
18
|
+
const html = `
|
|
19
|
+
<p>Test with <a href="https://example.com/link" title="Example">a link</a></p>
|
|
20
|
+
<p>And an <img src="https://example.com/img.jpg" alt="image"></p>
|
|
21
|
+
<p>Plus a <video src="https://example.com/video.mp4"></video></p>
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const markdown = `
|
|
25
|
+
Test with [a link](https://example.com/link "Example")
|
|
26
|
+
And an 
|
|
27
|
+
Plus a [video](https://example.com/video.mp4)
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const result = processContent(html, markdown);
|
|
31
|
+
|
|
32
|
+
expect(result.links.length).toBe(1);
|
|
33
|
+
expect(result.images.length).toBe(1);
|
|
34
|
+
expect(result.videos.length).toBe(1);
|
|
35
|
+
expect(result.markdown).toContain('link#1');
|
|
36
|
+
expect(result.markdown).toContain('image#1');
|
|
37
|
+
expect(result.markdown).toContain('video#1');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Edge case tests
|
|
41
|
+
test('should handle links with parentheses and special characters', () => {
|
|
42
|
+
const html = `
|
|
43
|
+
<a href="https://example.com/page(1).html" title="Parens">Link with parens</a>
|
|
44
|
+
<a href="https://example.com/path?query=test¶m=value">Link with query</a>
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const markdown = `
|
|
48
|
+
[Link with parens](https://example.com/page(1).html "Parens")
|
|
49
|
+
[Link with query](https://example.com/path?query=test¶m=value)
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const result = processContent(html, markdown);
|
|
53
|
+
|
|
54
|
+
expect(result.links.length).toBe(2);
|
|
55
|
+
expect(result.markdown).toContain('link#1');
|
|
56
|
+
expect(result.markdown).toContain('link#2');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Performance test with large files
|
|
60
|
+
test('should process large files efficiently', () => {
|
|
61
|
+
const html = fs.readFileSync('src/tools/search/test.html', 'utf-8');
|
|
62
|
+
const markdown = fs.readFileSync('src/tools/search/test.md', 'utf-8');
|
|
63
|
+
|
|
64
|
+
// const largeHtml = generateLargeHtml(1000); // 1000 links
|
|
65
|
+
// fs.writeFileSync('./temp.html', largeHtml);
|
|
66
|
+
|
|
67
|
+
// const largeMd = generateLargeMarkdown(1000); // 1000 links
|
|
68
|
+
// fs.writeFileSync('./temp.md', largeMd);
|
|
69
|
+
|
|
70
|
+
// const html = fs.readFileSync('./temp.html', 'utf-8');
|
|
71
|
+
// const markdown = fs.readFileSync('./temp.md', 'utf-8');
|
|
72
|
+
|
|
73
|
+
// Measure time taken to process
|
|
74
|
+
const startTime = process.hrtime();
|
|
75
|
+
const result = processContent(html, markdown);
|
|
76
|
+
const elapsed = process.hrtime(startTime);
|
|
77
|
+
const timeInMs = elapsed[0] * 1000 + elapsed[1] / 1000000;
|
|
78
|
+
|
|
79
|
+
console.log(
|
|
80
|
+
`Processed ${result.links.length} links, ${result.images.length} images, and ${result.videos.length} videos in ${timeInMs.toFixed(2)}ms`
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Basic validations for large file processing
|
|
84
|
+
expect(result.links.length).toBeGreaterThan(0);
|
|
85
|
+
expect(result.markdown).toContain('link#');
|
|
86
|
+
|
|
87
|
+
// Check if all links were replaced (sample check)
|
|
88
|
+
expect(result.markdown).not.toContain('https://example.com/link');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Memory usage test
|
|
92
|
+
test('should have reasonable memory usage', () => {
|
|
93
|
+
const html = fs.readFileSync('src/tools/search/test.html', 'utf-8');
|
|
94
|
+
const markdown = fs.readFileSync('src/tools/search/test.md', 'utf-8');
|
|
95
|
+
|
|
96
|
+
const beforeMem = process.memoryUsage();
|
|
97
|
+
processContent(html, markdown);
|
|
98
|
+
const afterMem = process.memoryUsage();
|
|
99
|
+
|
|
100
|
+
const heapUsed = (afterMem.heapUsed - beforeMem.heapUsed) / 1024 / 1024; // MB
|
|
101
|
+
|
|
102
|
+
console.log(`Memory used: ${heapUsed.toFixed(2)} MB`);
|
|
103
|
+
|
|
104
|
+
// This is a loose check - actual thresholds depend on your environment
|
|
105
|
+
expect(heapUsed).toBeLessThan(100); // Should use less than 100MB additional heap
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Real-world file test (if available)
|
|
109
|
+
test('should process real-world Wikipedia content', () => {
|
|
110
|
+
// Try to find real-world test files if they exist
|
|
111
|
+
const wikiHtml = 'src/tools/search/test.html';
|
|
112
|
+
const wikiMd = 'src/tools/search/test.md';
|
|
113
|
+
|
|
114
|
+
if (fs.existsSync(wikiHtml) && fs.existsSync(wikiMd)) {
|
|
115
|
+
const html = fs.readFileSync(wikiHtml, 'utf-8');
|
|
116
|
+
const markdown = fs.readFileSync(wikiMd, 'utf-8');
|
|
117
|
+
|
|
118
|
+
const result = processContent(html, markdown);
|
|
119
|
+
|
|
120
|
+
console.log(
|
|
121
|
+
`Processed ${result.links.length} Wikipedia links, ${result.images.length} images, and ${result.videos.length} videos`
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(result.links.length).toBeGreaterThan(10); // Wikipedia articles typically have many links
|
|
125
|
+
expect(result.markdown).not.toMatch(/\]\(https?:\/\/[^\s")]+\)/); // No regular URLs should remain
|
|
126
|
+
} else {
|
|
127
|
+
console.log('Wikipedia test files not found, skipping this test');
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Helper function to generate large HTML test data
|
|
133
|
+
function generateLargeHtml(linkCount: number): string {
|
|
134
|
+
let html = '<html><body>';
|
|
135
|
+
|
|
136
|
+
for (let i = 1; i <= linkCount; i++) {
|
|
137
|
+
html += `<p>Paragraph ${i} with <a href="https://example.com/link${i}" title="Link ${i}">link ${i}</a>`;
|
|
138
|
+
|
|
139
|
+
if (i % 10 === 0) {
|
|
140
|
+
html += ` and <img src="https://example.com/image${i / 10}.jpg" alt="Image ${i / 10}">`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (i % 50 === 0) {
|
|
144
|
+
html += ` and <video src="https://example.com/video${i / 50}.mp4" title="Video ${i / 50}"></video>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
html += '</p>';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
html += '</body></html>';
|
|
151
|
+
return html;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Helper function to generate large Markdown test data */
|
|
155
|
+
function generateLargeMarkdown(linkCount: number): string {
|
|
156
|
+
let markdown = '# Test Document\n\n';
|
|
157
|
+
|
|
158
|
+
for (let i = 1; i <= linkCount; i++) {
|
|
159
|
+
markdown += `Paragraph ${i} with [link ${i}](https://example.com/link${i} "Link ${i}")`;
|
|
160
|
+
|
|
161
|
+
if (i % 10 === 0) {
|
|
162
|
+
markdown += ` and `;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (i % 50 === 0) {
|
|
166
|
+
markdown += ` and [Video ${i / 50}](https://example.com/video${i / 50}.mp4 "Video ${i / 50}")`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
markdown += '\n\n';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return markdown;
|
|
173
|
+
}
|