insika 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Insika web widget.
|
|
2
|
+
//
|
|
3
|
+
// One <script> tag, no framework, no build step, no dependency:
|
|
4
|
+
//
|
|
5
|
+
// <script src="https://agents.example.com/channels/web/asset/widget.js"
|
|
6
|
+
// data-agent="support" data-title="Ask us anything" defer></script>
|
|
7
|
+
//
|
|
8
|
+
// Everything it needs it reads off its own tag: the agent, the chrome text, and
|
|
9
|
+
// the engine's base URL (derived from this file's own src, so an adopter never
|
|
10
|
+
// configures a host twice). Theming is a block of CSS custom properties — set
|
|
11
|
+
// them on :root and they win, because they are declared on the widget root with
|
|
12
|
+
// var(..., fallback).
|
|
13
|
+
//
|
|
14
|
+
// The one design decision worth stating: a message POST is NEVER retried. It is
|
|
15
|
+
// the request that runs a turn, so re-sending it costs a second LLM call and can
|
|
16
|
+
// put a second answer in front of the customer. A dropped stream shows what
|
|
17
|
+
// arrived plus a failure note, and the person decides whether to ask again. The
|
|
18
|
+
// backoff below is for minting a session — the only call here that is safe to
|
|
19
|
+
// repeat.
|
|
20
|
+
(function () {
|
|
21
|
+
"use strict";
|
|
22
|
+
|
|
23
|
+
var script = document.currentScript;
|
|
24
|
+
if (!script) return;
|
|
25
|
+
|
|
26
|
+
var base = new URL(script.src, window.location.href).href.replace(/\/channels\/[^/]+\/asset\/[^/]*$/, "");
|
|
27
|
+
var channel = (script.src.match(/\/channels\/([^/]+)\/asset\//) || [])[1] || "web";
|
|
28
|
+
var agent = script.dataset.agent || "";
|
|
29
|
+
var title = script.dataset.title || "Chat";
|
|
30
|
+
var placeholder = script.dataset.placeholder || "Type a message…";
|
|
31
|
+
var greeting = script.dataset.greeting || "";
|
|
32
|
+
var storageKey = "insika:" + channel + ":" + agent + ":session";
|
|
33
|
+
|
|
34
|
+
if (!agent) {
|
|
35
|
+
console.error("[insika] the widget needs data-agent on its <script> tag");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- chrome ---------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
var CSS = [
|
|
42
|
+
".insika-w{position:fixed;right:var(--insika-offset,20px);bottom:var(--insika-offset,20px);z-index:2147483000;",
|
|
43
|
+
"font-family:var(--insika-font,system-ui,-apple-system,Segoe UI,Roboto,sans-serif);font-size:15px;line-height:1.5}",
|
|
44
|
+
".insika-b{width:56px;height:56px;border-radius:50%;border:0;cursor:pointer;display:block;margin-left:auto;",
|
|
45
|
+
"background:var(--insika-accent,#1f2937);color:var(--insika-on-accent,#fff);font-size:24px;",
|
|
46
|
+
"box-shadow:0 6px 24px rgba(0,0,0,.22)}",
|
|
47
|
+
".insika-p{display:none;flex-direction:column;width:min(380px,calc(100vw - 40px));height:min(560px,calc(100vh - 120px));",
|
|
48
|
+
"margin-bottom:12px;border-radius:14px;overflow:hidden;background:var(--insika-bg,#fff);color:var(--insika-fg,#111827);",
|
|
49
|
+
"border:1px solid var(--insika-border,rgba(0,0,0,.12));box-shadow:0 12px 48px rgba(0,0,0,.24)}",
|
|
50
|
+
".insika-w.open .insika-p{display:flex}",
|
|
51
|
+
".insika-h{padding:12px 14px;font-weight:600;background:var(--insika-accent,#1f2937);color:var(--insika-on-accent,#fff);",
|
|
52
|
+
"display:flex;align-items:center;justify-content:space-between}",
|
|
53
|
+
".insika-x{background:none;border:0;color:inherit;font-size:20px;cursor:pointer;line-height:1;padding:0 2px}",
|
|
54
|
+
".insika-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}",
|
|
55
|
+
".insika-m{max-width:85%;padding:9px 12px;border-radius:12px;white-space:pre-wrap;overflow-wrap:anywhere}",
|
|
56
|
+
".insika-m.user{align-self:flex-end;background:var(--insika-accent,#1f2937);color:var(--insika-on-accent,#fff)}",
|
|
57
|
+
".insika-m.agent{align-self:flex-start;background:var(--insika-muted,#f3f4f6);color:var(--insika-fg,#111827)}",
|
|
58
|
+
".insika-m.note{align-self:center;font-size:13px;opacity:.7;background:none;padding:2px}",
|
|
59
|
+
".insika-f{display:flex;gap:8px;padding:10px;border-top:1px solid var(--insika-border,rgba(0,0,0,.12))}",
|
|
60
|
+
".insika-f input{flex:1;min-width:0;padding:9px 11px;border-radius:9px;font:inherit;color:inherit;background:transparent;",
|
|
61
|
+
"border:1px solid var(--insika-border,rgba(0,0,0,.18))}",
|
|
62
|
+
".insika-f button{padding:9px 14px;border:0;border-radius:9px;cursor:pointer;font:inherit;",
|
|
63
|
+
"background:var(--insika-accent,#1f2937);color:var(--insika-on-accent,#fff)}",
|
|
64
|
+
".insika-f button[disabled]{opacity:.5;cursor:default}"
|
|
65
|
+
].join("");
|
|
66
|
+
|
|
67
|
+
var style = document.createElement("style");
|
|
68
|
+
style.textContent = CSS;
|
|
69
|
+
document.head.appendChild(style);
|
|
70
|
+
|
|
71
|
+
var root = el("div", "insika-w");
|
|
72
|
+
var panel = el("div", "insika-p");
|
|
73
|
+
var header = el("div", "insika-h");
|
|
74
|
+
var log = el("div", "insika-log");
|
|
75
|
+
var form = document.createElement("form");
|
|
76
|
+
var input = document.createElement("input");
|
|
77
|
+
var send = document.createElement("button");
|
|
78
|
+
var bubble = el("button", "insika-b");
|
|
79
|
+
|
|
80
|
+
header.appendChild(text("span", title));
|
|
81
|
+
var close = el("button", "insika-x");
|
|
82
|
+
close.type = "button";
|
|
83
|
+
close.textContent = "×";
|
|
84
|
+
close.setAttribute("aria-label", "Close");
|
|
85
|
+
header.appendChild(close);
|
|
86
|
+
|
|
87
|
+
form.className = "insika-f";
|
|
88
|
+
input.type = "text";
|
|
89
|
+
input.placeholder = placeholder;
|
|
90
|
+
input.setAttribute("aria-label", placeholder);
|
|
91
|
+
send.type = "submit";
|
|
92
|
+
send.textContent = "Send";
|
|
93
|
+
form.appendChild(input);
|
|
94
|
+
form.appendChild(send);
|
|
95
|
+
|
|
96
|
+
panel.appendChild(header);
|
|
97
|
+
panel.appendChild(log);
|
|
98
|
+
panel.appendChild(form);
|
|
99
|
+
|
|
100
|
+
bubble.type = "button";
|
|
101
|
+
bubble.textContent = "💬";
|
|
102
|
+
bubble.setAttribute("aria-label", title);
|
|
103
|
+
|
|
104
|
+
root.appendChild(panel);
|
|
105
|
+
root.appendChild(bubble);
|
|
106
|
+
document.body.appendChild(root);
|
|
107
|
+
|
|
108
|
+
if (greeting) say("agent", greeting);
|
|
109
|
+
|
|
110
|
+
bubble.addEventListener("click", toggle);
|
|
111
|
+
close.addEventListener("click", toggle);
|
|
112
|
+
form.addEventListener("submit", function (e) {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
var value = input.value.trim();
|
|
115
|
+
if (!value || busy) return;
|
|
116
|
+
input.value = "";
|
|
117
|
+
ask(value);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function toggle() {
|
|
121
|
+
root.classList.toggle("open");
|
|
122
|
+
if (root.classList.contains("open")) input.focus();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- the turn -------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
var busy = false;
|
|
128
|
+
|
|
129
|
+
function ask(message) {
|
|
130
|
+
busy = true;
|
|
131
|
+
send.disabled = true;
|
|
132
|
+
say("user", message);
|
|
133
|
+
|
|
134
|
+
var bubbleEl = null;
|
|
135
|
+
var note = null;
|
|
136
|
+
|
|
137
|
+
session()
|
|
138
|
+
.then(function (id) {
|
|
139
|
+
return fetch(base + "/channels/" + channel + "/messages", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "content-type": "application/json" },
|
|
142
|
+
body: JSON.stringify({ agent: agent, session_id: id, message: message })
|
|
143
|
+
});
|
|
144
|
+
})
|
|
145
|
+
.then(function (res) {
|
|
146
|
+
// An unknown session id (the store was wiped, the id expired) is the one
|
|
147
|
+
// recoverable failure: drop ours and let the next message mint a new one.
|
|
148
|
+
if (res.status === 404) {
|
|
149
|
+
forget();
|
|
150
|
+
throw new Error("session expired — please send that again");
|
|
151
|
+
}
|
|
152
|
+
if (!res.ok || !res.body) throw new Error("the assistant is unavailable right now");
|
|
153
|
+
return read(res.body.getReader(), function (event, data) {
|
|
154
|
+
if (event === "delta" && data.delta) {
|
|
155
|
+
if (note) { note.remove(); note = null; }
|
|
156
|
+
if (!bubbleEl) bubbleEl = say("agent", "");
|
|
157
|
+
bubbleEl.textContent += data.delta;
|
|
158
|
+
log.scrollTop = log.scrollHeight;
|
|
159
|
+
} else if (event === "working" && !bubbleEl && !note) {
|
|
160
|
+
note = say("note", "working…");
|
|
161
|
+
} else if (event === "error") {
|
|
162
|
+
if (note) { note.remove(); note = null; }
|
|
163
|
+
throw new Error(data.message || "something went wrong");
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
})
|
|
167
|
+
.catch(function (err) {
|
|
168
|
+
if (note) note.remove();
|
|
169
|
+
say("note", err.message || "something went wrong");
|
|
170
|
+
})
|
|
171
|
+
.finally(function () {
|
|
172
|
+
busy = false;
|
|
173
|
+
send.disabled = false;
|
|
174
|
+
input.focus();
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The engine issues the session id; we only ever store the one it gave us
|
|
179
|
+
// (a client that proposes its own id on a public endpoint is
|
|
180
|
+
// one enumeration away from reading someone else's conversation).
|
|
181
|
+
function session() {
|
|
182
|
+
var saved = remembered;
|
|
183
|
+
if (saved) return Promise.resolve(saved);
|
|
184
|
+
|
|
185
|
+
return mint(0).then(function (id) {
|
|
186
|
+
remember(id);
|
|
187
|
+
return id;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// localStorage THROWS (not returns null) in a sandboxed iframe, in Safari's
|
|
192
|
+
// private mode and wherever site data is blocked. Reading it unguarded would
|
|
193
|
+
// throw out of `ask` before the .catch is attached, leaving the send button
|
|
194
|
+
// disabled forever. So the conversation degrades to in-memory instead: it lives
|
|
195
|
+
// for the page, which is worse than remembering and much better than frozen.
|
|
196
|
+
var remembered = read_storage();
|
|
197
|
+
|
|
198
|
+
function read_storage() {
|
|
199
|
+
try { return localStorage.getItem(storageKey); } catch (e) { return null; }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function remember(id) {
|
|
203
|
+
remembered = id;
|
|
204
|
+
try { localStorage.setItem(storageKey, id); } catch (e) { /* in-memory only */ }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function forget() {
|
|
208
|
+
remembered = null;
|
|
209
|
+
try { localStorage.removeItem(storageKey); } catch (e) { /* in-memory only */ }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 1s → 30s backoff, one attempt in flight, the same discipline the Studio's
|
|
213
|
+
// live transcript uses. Safe to repeat because minting has no side effect the
|
|
214
|
+
// customer can see: the worst case is an unused empty session.
|
|
215
|
+
function mint(attempt) {
|
|
216
|
+
return fetch(base + "/channels/" + channel + "/sessions", { method: "POST" })
|
|
217
|
+
.then(function (res) {
|
|
218
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
219
|
+
return res.json();
|
|
220
|
+
})
|
|
221
|
+
.then(function (body) {
|
|
222
|
+
if (!body.session_id) throw new Error("no session id");
|
|
223
|
+
return body.session_id;
|
|
224
|
+
})
|
|
225
|
+
.catch(function (err) {
|
|
226
|
+
if (attempt >= 4) throw new Error("cannot reach the assistant right now");
|
|
227
|
+
var wait = Math.min(30000, 1000 * Math.pow(2, attempt));
|
|
228
|
+
return delay(wait).then(function () { return mint(attempt + 1); });
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Minimal SSE reader over fetch — EventSource cannot POST, and the turn is a
|
|
233
|
+
// POST. Frames are `event: <name>\ndata: <json>\n\n`; anything else is skipped,
|
|
234
|
+
// so a frame added later cannot break an old widget.
|
|
235
|
+
function read(reader, onEvent) {
|
|
236
|
+
var decoder = new TextDecoder();
|
|
237
|
+
var buffer = "";
|
|
238
|
+
|
|
239
|
+
return reader.read().then(function step(chunk) {
|
|
240
|
+
if (chunk.done) return;
|
|
241
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
242
|
+
|
|
243
|
+
var parts = buffer.split("\n\n");
|
|
244
|
+
buffer = parts.pop();
|
|
245
|
+
parts.forEach(function (frame) {
|
|
246
|
+
var name = (frame.match(/^event: (.+)$/m) || [])[1];
|
|
247
|
+
var raw = (frame.match(/^data: (.*)$/m) || [])[1];
|
|
248
|
+
if (!name) return;
|
|
249
|
+
var data = {};
|
|
250
|
+
try { data = raw ? JSON.parse(raw) : {}; } catch (e) { return; }
|
|
251
|
+
onEvent(name, data);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return reader.read().then(step);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- helpers --------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
function say(kind, content) {
|
|
261
|
+
var node = el("div", "insika-m " + kind);
|
|
262
|
+
node.textContent = content;
|
|
263
|
+
log.appendChild(node);
|
|
264
|
+
log.scrollTop = log.scrollHeight;
|
|
265
|
+
return node;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function el(tag, className) {
|
|
269
|
+
var node = document.createElement(tag);
|
|
270
|
+
node.className = className;
|
|
271
|
+
return node;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function text(tag, content) {
|
|
275
|
+
var node = document.createElement(tag);
|
|
276
|
+
node.textContent = content;
|
|
277
|
+
return node;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function delay(ms) {
|
|
281
|
+
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
|
282
|
+
}
|
|
283
|
+
})();
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
module Channels
|
|
9
|
+
# The web widget — the FIRST Shape A channel, and the adoption
|
|
10
|
+
# claim behind the whole RFC: an adopter pastes one tag into their site and has
|
|
11
|
+
# a working agent, with no backend of their own and no build step.
|
|
12
|
+
#
|
|
13
|
+
# <script src="https://agents.example.com/channels/web/asset/widget.js"
|
|
14
|
+
# data-agent="support" data-title="Ask us anything" defer></script>
|
|
15
|
+
#
|
|
16
|
+
# Shape A means the reply comes back on the request's own stream, so there is
|
|
17
|
+
# no outbox, no claim and no `deliver` here — the three routes and a static
|
|
18
|
+
# asset are the entire surface:
|
|
19
|
+
#
|
|
20
|
+
# POST /channels/web/sessions mint an opaque session id (the engine's)
|
|
21
|
+
# POST /channels/web/messages the turn, answered as SSE on this connection
|
|
22
|
+
# GET /channels/web/asset/widget.js
|
|
23
|
+
#
|
|
24
|
+
# What makes this channel different from the relay is that it is PUBLIC: the
|
|
25
|
+
# caller is an anonymous browser, so there is no shared secret to check and the
|
|
26
|
+
# money faucet is real. Three things stand in for the missing credential, and
|
|
27
|
+
# all three are refusals, never grants (R2):
|
|
28
|
+
#
|
|
29
|
+
# · an AGENT allowlist — a visitor addresses the agents the operator published
|
|
30
|
+
# to the widget, not every agent in the deployment;
|
|
31
|
+
# · an ORIGIN allowlist — exact match, no wildcards, and no "allow all" value;
|
|
32
|
+
# · a mandatory chat RATE LIMIT — the channel answers `:disabled`
|
|
33
|
+
# until one is configured, because a public endpoint with an LLM behind it
|
|
34
|
+
# and no ceiling is an unmetered bill waiting to happen.
|
|
35
|
+
#
|
|
36
|
+
# R1/R2 hold: this object translates and refuses, and does nothing else. No
|
|
37
|
+
# Executor, no store, no RubyLLM — the rate-limit probe is injected as a plain
|
|
38
|
+
# callable so the channel never learns what a profile or a settings store is.
|
|
39
|
+
class Web
|
|
40
|
+
DEFAULT_ID = "web"
|
|
41
|
+
|
|
42
|
+
# The static files this channel serves, by request name. A closed map, not a
|
|
43
|
+
# directory listing: `asset/:f` takes a name from the URL, and anything that
|
|
44
|
+
# resolves a path from user input is a traversal waiting to be found.
|
|
45
|
+
ASSETS = {
|
|
46
|
+
"widget.js" => { file: "web/widget.js", content_type: "application/javascript; charset=utf-8" }
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# An unversioned URL cannot be cached for a year — the next release would
|
|
50
|
+
# never reach a browser that already has it. Short max-age + an ETag instead:
|
|
51
|
+
# the common case is a 304 with no body, and an upgrade lands within minutes.
|
|
52
|
+
# (A deliberate narrowing of's "long-cache versioned URL": the install
|
|
53
|
+
# snippet in the RFC has no version in it, so there is nothing to bust.)
|
|
54
|
+
ASSET_CACHE_CONTROL = "public, max-age=300"
|
|
55
|
+
|
|
56
|
+
attr_reader :id
|
|
57
|
+
|
|
58
|
+
# The bundled widget as an operator configures it. BOTH allowlists are the
|
|
59
|
+
# switch: a widget with no origins can be embedded nowhere, and one with no
|
|
60
|
+
# agents can address nothing, so either one missing means the operator has not
|
|
61
|
+
# actually asked for this channel. -> Web | nil.
|
|
62
|
+
def self.from_env(env = ENV, id: DEFAULT_ID, chat_rate_limit: nil)
|
|
63
|
+
origins = csv(Insika::EnvSchema.read("INSIKA_WIDGET_ORIGINS", env))
|
|
64
|
+
agents = csv(Insika::EnvSchema.read("INSIKA_WIDGET_AGENTS", env))
|
|
65
|
+
return nil if origins.empty? || agents.empty?
|
|
66
|
+
|
|
67
|
+
new(origins: origins, agents: agents, id: id, chat_rate_limit: chat_rate_limit)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The probe's gate needs, asked exactly the way `EdgeLimiter` asks it at
|
|
71
|
+
# turn time: the per-agent override first, the platform default second. Built
|
|
72
|
+
# here so both composition roots wire the gate identically, and returned as a
|
|
73
|
+
# lambda so the channel itself stays store-free.
|
|
74
|
+
#
|
|
75
|
+
# Resolved on every check rather than at boot: an operator who removes the
|
|
76
|
+
# limit tomorrow closes the widget, instead of leaving it open because it was
|
|
77
|
+
# configured correctly once.
|
|
78
|
+
def self.limit_resolver(profiles:, settings_store: nil)
|
|
79
|
+
lambda do |agent_id|
|
|
80
|
+
limits = profiles&.fetch(agent_id.to_s)&.limits || {}
|
|
81
|
+
next limits[:chat_rate_limit] if limits.key?(:chat_rate_limit)
|
|
82
|
+
|
|
83
|
+
((settings_store&.get || {})["edge"] || {})["chat_rate_limit"]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.csv(value) = value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
88
|
+
|
|
89
|
+
# origins: exact-match origin allowlist (`https://shop.example`). No
|
|
90
|
+
# wildcard, no "*" — CORS is a browser courtesy and a value
|
|
91
|
+
# that means "anyone" would only make it look like a control.
|
|
92
|
+
# agents: agent ids a visitor may address.
|
|
93
|
+
# chat_rate_limit: callable(agent_id) -> the effective limit, or nil. Absent ⇒
|
|
94
|
+
# the channel is `:disabled`, which is the fail-closed
|
|
95
|
+
# reading of "no ceiling configured".
|
|
96
|
+
def initialize(origins:, agents:, id: DEFAULT_ID, chat_rate_limit: nil)
|
|
97
|
+
@id = id.to_s
|
|
98
|
+
@origins = Array(origins).map(&:to_s)
|
|
99
|
+
@agents = Array(agents).map(&:to_s)
|
|
100
|
+
@chat_rate_limit = chat_rate_limit
|
|
101
|
+
@assets = {}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# -> :ok | :unauthorized | :disabled. Same verdict vocabulary as every other
|
|
105
|
+
# channel (no Rack triple in `lib/`), and the same fail-closed default.
|
|
106
|
+
#
|
|
107
|
+
# A request with NO `Origin` is not a browser, so there is nothing for CORS to
|
|
108
|
+
# protect and refusing it would be theatre — curl can always set any origin it
|
|
109
|
+
# likes. The rate limit is what actually defends this route, which is why it
|
|
110
|
+
# is checked here and not left to the operator's memory.
|
|
111
|
+
def authenticate(req)
|
|
112
|
+
return :disabled if @agents.empty?
|
|
113
|
+
return :disabled unless rate_limited?
|
|
114
|
+
|
|
115
|
+
origin = req.get_header("HTTP_ORIGIN").to_s
|
|
116
|
+
return :ok if origin.empty?
|
|
117
|
+
|
|
118
|
+
allowed_origin?(origin) ? :ok : :unauthorized
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Is a chat rate limit configured for EVERY agent the widget publishes? All of
|
|
122
|
+
# them and not just the one being addressed: the check runs before the body is
|
|
123
|
+
# parsed, and "the widget is open for agent A but closed for agent B" is a
|
|
124
|
+
# posture nobody can hold in their head.
|
|
125
|
+
def rate_limited?
|
|
126
|
+
return false if @chat_rate_limit.nil?
|
|
127
|
+
|
|
128
|
+
!@agents.empty? && @agents.all? { |agent| @chat_rate_limit.call(agent).to_i.positive? }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# POST /channels/web/messages — STRING keys in, because this body comes off
|
|
132
|
+
# the public internet and nothing in it should become a symbol.
|
|
133
|
+
#
|
|
134
|
+
# { "agent": "support", "session_id": "web:8f3c…", "message": "oi" }
|
|
135
|
+
#
|
|
136
|
+
# The agent allowlist is checked HERE, which is a refusal and not a grant: the
|
|
137
|
+
# profile still decides what that agent may do.
|
|
138
|
+
def parse(_req, body:)
|
|
139
|
+
body = body.is_a?(Hash) ? body : {}
|
|
140
|
+
agent = body["agent"].to_s.strip
|
|
141
|
+
session_id = body["session_id"].to_s.strip
|
|
142
|
+
message = body["message"].to_s
|
|
143
|
+
|
|
144
|
+
raise Insika::ValidationError, "agent is required" if agent.empty?
|
|
145
|
+
raise Insika::ValidationError, "agent '#{agent}' is not published to the widget" unless @agents.include?(agent)
|
|
146
|
+
raise Insika::ValidationError, "session_id is required" if session_id.empty?
|
|
147
|
+
raise Insika::ValidationError, "message is required" if message.strip.empty?
|
|
148
|
+
|
|
149
|
+
{ agent: agent, session_id: session_id, message: message }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
#'s hard rule for a public channel: the ENGINE issues the id and the
|
|
153
|
+
# client never proposes one. A visitor-supplied session id on an anonymous
|
|
154
|
+
# endpoint is session hijacking by enumeration, so there is no create-on-write
|
|
155
|
+
# path — `POST /messages` with an unknown id is a 404, not a new conversation.
|
|
156
|
+
def mint_session_id = "#{@id}:#{SecureRandom.hex(16)}"
|
|
157
|
+
|
|
158
|
+
# Turn Event -> SSE frame | nil. Four frames, which is the whole widget
|
|
159
|
+
# protocol: what to type, what to say while a tool runs, and how it ended.
|
|
160
|
+
#
|
|
161
|
+
# `:intermediate` and `:thinking` are deliberately absent. `:content` is the
|
|
162
|
+
# ANSWER — the model's narration on the way there is internal, and a
|
|
163
|
+
# widget that rendered it would show the customer the engine thinking out loud.
|
|
164
|
+
def frame_for(event)
|
|
165
|
+
case event.type
|
|
166
|
+
when :content then sse("delta", { delta: event.data[:delta].to_s })
|
|
167
|
+
when :tool_call then sse("working", { name: event.data[:name].to_s })
|
|
168
|
+
when :task_completed then sse("done", {})
|
|
169
|
+
when :task_failed then sse("error", { message: event.data[:message].to_s })
|
|
170
|
+
when :task_cancelled then sse("error", { message: "task cancelled" })
|
|
171
|
+
when :error then sse("error", { message: event.data[:message].to_s })
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# GET /channels/web/asset/:f -> { content_type:, body:, etag: } | nil.
|
|
176
|
+
# Read once and memoized: the file ships with the gem and cannot change under
|
|
177
|
+
# a running process.
|
|
178
|
+
def asset(name)
|
|
179
|
+
entry = ASSETS[name.to_s]
|
|
180
|
+
return nil if entry.nil?
|
|
181
|
+
|
|
182
|
+
@assets[name.to_s] ||= begin
|
|
183
|
+
body = File.read(File.expand_path(entry[:file], __dir__))
|
|
184
|
+
{ content_type: entry[:content_type], body: body,
|
|
185
|
+
etag: %("#{Digest::SHA256.hexdigest(body)[0, 16]}"),
|
|
186
|
+
cache_control: ASSET_CACHE_CONTROL }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# -> the CORS headers for this request's origin. An origin that is not on the
|
|
191
|
+
# list simply gets none, and the browser refuses the response itself — the
|
|
192
|
+
# engine does not have to pretend that is an authorization decision.
|
|
193
|
+
def cors_headers(origin)
|
|
194
|
+
o = origin.to_s
|
|
195
|
+
return { "vary" => "origin" } unless allowed_origin?(o)
|
|
196
|
+
|
|
197
|
+
{ "access-control-allow-origin" => o,
|
|
198
|
+
"vary" => "origin",
|
|
199
|
+
"access-control-allow-headers" => "content-type",
|
|
200
|
+
"access-control-allow-methods" => "POST, OPTIONS",
|
|
201
|
+
"access-control-max-age" => "600" }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def allowed_origin?(origin) = !origin.empty? && @origins.include?(origin)
|
|
207
|
+
|
|
208
|
+
def sse(name, data) = "event: #{name}\ndata: #{JSON.generate(data)}\n\n"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Channels
|
|
7
|
+
# A webhook as a Shape B "channel": the recipient of operator ALERTS
|
|
8
|
+
# (WS6). One instance per configured URL, registered in the ChannelRegistry
|
|
9
|
+
# so ChannelDelivery's outbox+claim+retry pipeline delivers the alert the
|
|
10
|
+
# same way it delivers a chat answer — at-most-once, bounded retry, boot
|
|
11
|
+
# sweep. Deliberately NOT Slack/CRM/anything: it POSTs the event as JSON and
|
|
12
|
+
# the consumer interprets it (the engine transports, it does not integrate).
|
|
13
|
+
#
|
|
14
|
+
# The target URL is operator configuration, so the POST crosses the SAME
|
|
15
|
+
# egress guard the Relay applies: https-only (fails closed), private/
|
|
16
|
+
# loopback/metadata targets and DNS-rebindable hosts blocked. Without it the
|
|
17
|
+
# `alerts.webhook` URL is an SSRF vector — a URL pointed at cloud metadata
|
|
18
|
+
# or an internal API exfiltrates alert events out of the boundary (WS6).
|
|
19
|
+
class Webhook
|
|
20
|
+
def initialize(url, http:, allow_http: false, allow_private: false)
|
|
21
|
+
@http = http
|
|
22
|
+
@allow_http = allow_http
|
|
23
|
+
@allow_private = allow_private
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The ChannelDelivery contract: -> HTTP status (200..299 = delivered).
|
|
27
|
+
# Every failure becomes a DeliveryError so the bounded retry records it.
|
|
28
|
+
def deliver(payload, to:, delivery_id: nil)
|
|
29
|
+
if (reason = egress_violation(to))
|
|
30
|
+
raise Insika::DeliveryError, "webhook egress blocked for #{to}: #{reason}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
result = @http.request(
|
|
34
|
+
method: :post, url: to,
|
|
35
|
+
headers: { "content-type" => "application/json" },
|
|
36
|
+
body: JSON.generate(payload)
|
|
37
|
+
)
|
|
38
|
+
result[:status].to_i
|
|
39
|
+
rescue Insika::DeliveryError
|
|
40
|
+
raise
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
raise Insika::DeliveryError, "webhook: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Resolved on EVERY delivery, not once at registration: a host that
|
|
48
|
+
# answered a public address yesterday can answer 169.254.169.254 today,
|
|
49
|
+
# and this POST carries the operator's alerts.
|
|
50
|
+
def egress_violation(url)
|
|
51
|
+
Insika::EgressGuard.violation(url, allow_http: @allow_http,
|
|
52
|
+
allow_private: @allow_private)
|
|
53
|
+
rescue URI::InvalidURIError
|
|
54
|
+
"invalid URL"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|