tina4ruby 3.13.111 → 3.13.113
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 +66 -0
- data/lib/tina4/ai_client.rb +330 -68
- data/lib/tina4/api.rb +189 -0
- data/lib/tina4/dev_admin.rb +10 -0
- data/lib/tina4/rack_app.rb +186 -169
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 964e92e8030ced5a8f4af3934fb9bb7e49df0056d795c5025d5176aed2fa06d2
|
|
4
|
+
data.tar.gz: 39fa0f71a1b3219bbaa4382f136c1fb69a4f4760436d75339a50d6906ba5a8e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ceab487af3f411513fb2fbd3a057e15a04560c40ff4268d7663d51ebb62c7b955ab2584d17183e82a0194967ce5baa67f7c9465d62b207a6490aa2741f0aeaf
|
|
7
|
+
data.tar.gz: 68564196c7aadba28370621f5d613d8700c0a0e5b137ebbed4f871cb7ce6393a4cd535dd531c2455c5db730f3079088805f16d375441d22c390e64db218fcf6d
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,72 @@ number means the same thing everywhere.
|
|
|
6
6
|
**The authoritative release notes for every shipped version live in the documentation:**
|
|
7
7
|
https://tina4.com/ruby/36-releases
|
|
8
8
|
|
|
9
|
+
## 3.13.113
|
|
10
|
+
|
|
11
|
+
Feature: streaming and multimodal AI, plus reusable `Api.stream` primitives
|
|
12
|
+
(ADR-0060).
|
|
13
|
+
|
|
14
|
+
### Api.stream primitives
|
|
15
|
+
|
|
16
|
+
- `Tina4::API#stream_bytes(path, ...)` streams the response body as raw
|
|
17
|
+
chunks in transport order. Pass a block or take an Enumerator.
|
|
18
|
+
- `Tina4::API#stream_lines(path, ...)` yields one UTF-8 String per LF- or
|
|
19
|
+
CRLF-terminated line; a trailing line without a newline is yielded on EOF;
|
|
20
|
+
multibyte sequences that split across chunks are buffered.
|
|
21
|
+
- `Tina4::API#stream_sse(path, ...)` frames SSE events into
|
|
22
|
+
`{data:, event:, id:, retry:}` Hashes; blank line separates events;
|
|
23
|
+
`:` comment lines are dropped; multi-line `data:` fields are joined with
|
|
24
|
+
`\n`; the OpenAI `[DONE]` sentinel is delivered as the last event and the
|
|
25
|
+
iterator ends. Each primitive is layered on the one below it.
|
|
26
|
+
- `Tina4::APIStreamError` (with `#status`) is raised when a stream opens
|
|
27
|
+
with a non-2xx HTTP status (pre-stream error, no bytes yielded).
|
|
28
|
+
|
|
29
|
+
### Ai.chat streaming: typed events (breaking)
|
|
30
|
+
|
|
31
|
+
- `Tina4::Ai.chat(messages, stream: true)` now yields typed event Hashes:
|
|
32
|
+
`{ type: :text_delta, text: ... }`, `{ type: :tool_call, id:, name:, args: }`,
|
|
33
|
+
`{ type: :done, finish_reason:, usage: nil }`, or
|
|
34
|
+
`{ type: :error, message:, code: nil }`. Text deltas fire per chunk;
|
|
35
|
+
OpenAI `tool_calls` fragments and Anthropic `input_json_delta` fragments
|
|
36
|
+
are aggregated and emitted as a single `tool_call` event; `done` fires
|
|
37
|
+
exactly once on the terminal wire signal; `error` replaces `done` on a
|
|
38
|
+
mid-stream failure. Malformed tool-call arguments JSON raises
|
|
39
|
+
`Tina4::AiParseError`.
|
|
40
|
+
- The stream path is implemented on top of `Tina4::API#stream_sse`; there is
|
|
41
|
+
one SSE reader for the framework, shared between `Api` and `Ai`.
|
|
42
|
+
- Migration for 3.13.101–3.13.112 callers: `for chunk in stream` becomes
|
|
43
|
+
`for event in stream; event[:text] if event[:type] == :text_delta; end`.
|
|
44
|
+
No shim (feedback_no_aliases, feedback_breaking_changes).
|
|
45
|
+
|
|
46
|
+
### Ai.chat multimodal content
|
|
47
|
+
|
|
48
|
+
- `message[:content]` accepts a String OR an Array of parts:
|
|
49
|
+
`{ type: "text", text: String }` and
|
|
50
|
+
`{ type: "image", source: "data:<mime>;base64,<payload>" }` or
|
|
51
|
+
`{ type: "image", source: "https://..." }` (also with symbol keys).
|
|
52
|
+
- Parts are translated per provider before the request is sent:
|
|
53
|
+
OpenAI / local get `{ type: "image_url", image_url: { url } }`;
|
|
54
|
+
Anthropic gets `{ type: "image", source: { type: "base64", media_type, data } }`
|
|
55
|
+
for data URIs or `{ type: "image", source: { type: "url", url } }` for
|
|
56
|
+
https URLs.
|
|
57
|
+
- Malformed parts (missing `text` or `source`, unknown `type`, non-string
|
|
58
|
+
values, a `data:` URI without `;base64,`, a plain `http:` URL) raise
|
|
59
|
+
`Tina4::AiConfigError` before any request is sent.
|
|
60
|
+
|
|
61
|
+
### Tests
|
|
62
|
+
|
|
63
|
+
- New `spec/api_stream_contract_spec.rb` with a real local TCP fixture
|
|
64
|
+
server exercising chunked transport, LF/CRLF line splitting, trailing
|
|
65
|
+
lines, multibyte-across-chunk-boundary buffering, and every SSE framing
|
|
66
|
+
case (single/multi-line/named/comment/blank/[DONE]/retry). Verifies the
|
|
67
|
+
request body reaches the server and that a transport drop raises.
|
|
68
|
+
- Extended `spec/ai_client_contract_spec.rb` with `/stream-openai-tools`,
|
|
69
|
+
`/stream-anthropic-tools`, `/stream-midstream-drop`, and
|
|
70
|
+
`/multimodal-echo` cases proving typed events, tool-call aggregation on
|
|
71
|
+
both providers, one-and-only-one `done`, mid-stream error semantics,
|
|
72
|
+
no-retry-after-first-event, and the OpenAI/Anthropic body shapes for
|
|
73
|
+
multimodal parts.
|
|
74
|
+
|
|
9
75
|
## 3.13.107
|
|
10
76
|
|
|
11
77
|
Feature: RBAC role and permission guards (parity across all four frameworks).
|
data/lib/tina4/ai_client.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "net/http"
|
|
5
5
|
require "uri"
|
|
6
|
+
require "openssl"
|
|
6
7
|
|
|
7
8
|
module Tina4
|
|
8
9
|
class AiError < StandardError; end
|
|
@@ -21,16 +22,32 @@ module Tina4
|
|
|
21
22
|
|
|
22
23
|
ChatResponse = Struct.new(:text, :model, :usage, :finish_reason, :raw, keyword_init: true)
|
|
23
24
|
|
|
24
|
-
# Zero-dependency app-facing AI client
|
|
25
|
+
# Zero-dependency app-facing AI client. ADR-0053 defined the base contract
|
|
26
|
+
# (chat / complete / embed with a normalised, provider-neutral response
|
|
27
|
+
# shape); ADR-0060 extended it with typed streaming events and multimodal
|
|
28
|
+
# content parts. Both live here so the AI surface is a single file.
|
|
25
29
|
class Ai
|
|
26
30
|
PROVIDERS = %w[local openai anthropic].freeze
|
|
27
31
|
|
|
28
32
|
class << self
|
|
29
|
-
|
|
33
|
+
# chat(stream: false) still returns a ChatResponse (ADR-0053).
|
|
34
|
+
# chat(stream: true) returns an Enumerator of typed events (ADR-0060):
|
|
35
|
+
#
|
|
36
|
+
# { type: :text_delta, text: "..." }
|
|
37
|
+
# { type: :tool_call, id: "...", name: "...", args: {...} }
|
|
38
|
+
# { type: :done, finish_reason: "...", usage: {...} | nil }
|
|
39
|
+
# { type: :error, message: "...", code: "..." | nil }
|
|
40
|
+
#
|
|
41
|
+
# The typed events replace the ADR-0053 string-only stream shape. That
|
|
42
|
+
# was a deliberately breaking change (see ADR-0060 §7) so an agent loop
|
|
43
|
+
# could observe tool_calls and finish_reason without hand-rolled SSE
|
|
44
|
+
# parsing per app.
|
|
45
|
+
def chat(messages, model: nil, temperature: nil, max_tokens: nil,
|
|
46
|
+
stream: false, timeout: nil, provider: nil)
|
|
30
47
|
validate_messages(messages)
|
|
31
48
|
config = resolve_config("chat", model, timeout, provider)
|
|
32
49
|
body = chat_body(config, messages, temperature, max_tokens, stream)
|
|
33
|
-
return
|
|
50
|
+
return stream_events(config, headers(config), body) if stream
|
|
34
51
|
|
|
35
52
|
normalize_chat(config[:provider], request_json(config, headers(config), body))
|
|
36
53
|
end
|
|
@@ -67,14 +84,61 @@ module Tina4
|
|
|
67
84
|
|
|
68
85
|
private
|
|
69
86
|
|
|
87
|
+
# ── validation ─────────────────────────────────────────────────────────
|
|
88
|
+
|
|
70
89
|
def validate_messages(messages)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
unless messages.is_a?(Array) && !messages.empty?
|
|
91
|
+
raise AiConfigError, "AI messages must be a non-empty list"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
messages.each do |message|
|
|
95
|
+
raise AiConfigError, "AI messages must be objects" unless message.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
role = (message[:role] || message["role"]).to_s
|
|
98
|
+
unless %w[system user assistant].include?(role)
|
|
99
|
+
raise AiConfigError, "AI messages must contain supported roles"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
content = message.key?(:content) ? message[:content] : message["content"]
|
|
103
|
+
validate_content(content)
|
|
74
104
|
end
|
|
75
|
-
raise AiConfigError, "AI messages must contain supported roles and string content" unless valid
|
|
76
105
|
end
|
|
77
106
|
|
|
107
|
+
def validate_content(content)
|
|
108
|
+
return if content.is_a?(String)
|
|
109
|
+
|
|
110
|
+
unless content.is_a?(Array) && !content.empty?
|
|
111
|
+
raise AiConfigError, "AI message content must be a string or a non-empty list of parts"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
content.each { |part| validate_content_part(part) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def validate_content_part(part)
|
|
118
|
+
raise AiConfigError, "AI content parts must be objects" unless part.is_a?(Hash)
|
|
119
|
+
|
|
120
|
+
type = (part[:type] || part["type"]).to_s
|
|
121
|
+
case type
|
|
122
|
+
when "text"
|
|
123
|
+
text = part.key?(:text) ? part[:text] : part["text"]
|
|
124
|
+
raise AiConfigError, "AI text content part must have a string text field" unless text.is_a?(String)
|
|
125
|
+
when "image"
|
|
126
|
+
source = part.key?(:source) ? part[:source] : part["source"]
|
|
127
|
+
raise AiConfigError, "AI image content part must have a string source field" unless source.is_a?(String)
|
|
128
|
+
|
|
129
|
+
if source.start_with?("data:")
|
|
130
|
+
# data:<media_type>;base64,<payload> — enforce the base64 marker
|
|
131
|
+
raise AiConfigError, "AI image data URI must be base64-encoded" unless source.include?(";base64,")
|
|
132
|
+
elsif !source.start_with?("https://")
|
|
133
|
+
raise AiConfigError, "AI image source must be a data:<mime>;base64,<data> URI or an https:// URL"
|
|
134
|
+
end
|
|
135
|
+
else
|
|
136
|
+
raise AiConfigError, "AI content part type must be 'text' or 'image'"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ── configuration ──────────────────────────────────────────────────────
|
|
141
|
+
|
|
78
142
|
def number(name, default, minimum)
|
|
79
143
|
value = Float(ENV.fetch(name, default.to_s))
|
|
80
144
|
raise AiConfigError, "#{name} must be at least #{minimum}" if value < minimum
|
|
@@ -142,20 +206,80 @@ module Tina4
|
|
|
142
206
|
result
|
|
143
207
|
end
|
|
144
208
|
|
|
209
|
+
# ── request body ───────────────────────────────────────────────────────
|
|
210
|
+
|
|
145
211
|
def chat_body(config, messages, temperature, max_tokens, stream)
|
|
146
|
-
|
|
212
|
+
provider = config[:provider]
|
|
213
|
+
normalized = messages.map do |message|
|
|
214
|
+
role = (message[:role] || message["role"]).to_s
|
|
215
|
+
content = message.key?(:content) ? message[:content] : message["content"]
|
|
216
|
+
{ role: role, content: translate_content(provider, role, content) }
|
|
217
|
+
end
|
|
147
218
|
body = { model: config[:model], messages: normalized, stream: stream }
|
|
148
219
|
body[:temperature] = temperature unless temperature.nil?
|
|
149
220
|
body[:max_tokens] = max_tokens unless max_tokens.nil?
|
|
150
|
-
if
|
|
151
|
-
|
|
221
|
+
if provider == "anthropic"
|
|
222
|
+
system_texts = normalized.select { |message| message[:role] == "system" }.map { |message| system_text(message[:content]) }
|
|
152
223
|
body[:messages] = normalized.reject { |message| message[:role] == "system" }
|
|
153
224
|
body[:max_tokens] = max_tokens || 1024
|
|
154
|
-
body[:system] =
|
|
225
|
+
body[:system] = system_texts.join("\n\n") unless system_texts.empty?
|
|
155
226
|
end
|
|
156
227
|
body
|
|
157
228
|
end
|
|
158
229
|
|
|
230
|
+
# Anthropic's `system` field is a plain string. If the caller passed a
|
|
231
|
+
# parts array on a system message, concatenate the text parts (image
|
|
232
|
+
# parts on the system role are silently dropped — Anthropic rejects them
|
|
233
|
+
# outright, and validation already ran, so we don't raise a second time).
|
|
234
|
+
def system_text(content)
|
|
235
|
+
return content.to_s unless content.is_a?(Array)
|
|
236
|
+
|
|
237
|
+
content.select { |part| (part[:type] || part["type"]).to_s == "text" }
|
|
238
|
+
.map { |part| part[:text] || part["text"] }.join("\n\n")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Translate parts to each provider's native shape. Strings pass through
|
|
242
|
+
# unchanged. See ADR-0060 §Public surface.
|
|
243
|
+
def translate_content(provider, role, content)
|
|
244
|
+
return content if content.is_a?(String)
|
|
245
|
+
return system_text(content) if provider == "anthropic" && role == "system"
|
|
246
|
+
|
|
247
|
+
content.map do |part|
|
|
248
|
+
type = (part[:type] || part["type"]).to_s
|
|
249
|
+
case type
|
|
250
|
+
when "text"
|
|
251
|
+
{ type: "text", text: part[:text] || part["text"] }
|
|
252
|
+
when "image"
|
|
253
|
+
translate_image_part(provider, part[:source] || part["source"])
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def translate_image_part(provider, source)
|
|
259
|
+
if provider == "anthropic"
|
|
260
|
+
if source.start_with?("data:")
|
|
261
|
+
media_type, payload = split_data_uri(source)
|
|
262
|
+
{ type: "image", source: { type: "base64", media_type: media_type, data: payload } }
|
|
263
|
+
else
|
|
264
|
+
{ type: "image", source: { type: "url", url: source } }
|
|
265
|
+
end
|
|
266
|
+
else
|
|
267
|
+
# openai and local both accept the OpenAI image_url shape
|
|
268
|
+
{ type: "image_url", image_url: { url: source } }
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def split_data_uri(source)
|
|
273
|
+
# "data:image/png;base64,<payload>"
|
|
274
|
+
header, payload = source.split(",", 2)
|
|
275
|
+
meta = header.to_s.sub(/\Adata:/, "")
|
|
276
|
+
media_type = meta.split(";").first.to_s
|
|
277
|
+
media_type = "application/octet-stream" if media_type.empty?
|
|
278
|
+
[media_type, payload.to_s]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# ── non-streaming request path ─────────────────────────────────────────
|
|
282
|
+
|
|
159
283
|
def http_request(config, deadline, request_headers, body)
|
|
160
284
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
161
285
|
raise AiTimeoutError, "AI total request timeout expired" unless remaining.positive?
|
|
@@ -213,10 +337,13 @@ module Tina4
|
|
|
213
337
|
sleep(delay) if delay.positive?
|
|
214
338
|
end
|
|
215
339
|
|
|
340
|
+
# ── response normalisation ─────────────────────────────────────────────
|
|
341
|
+
|
|
216
342
|
def normalize_chat(provider, raw)
|
|
217
343
|
if provider == "anthropic"
|
|
218
344
|
parts = raw.fetch("content").select { |item| item.fetch("type", "text") == "text" }.map { |item| item.fetch("text") }
|
|
219
345
|
raise KeyError if parts.empty?
|
|
346
|
+
|
|
220
347
|
prompt = raw.fetch("usage", {}).fetch("input_tokens", 0).to_i
|
|
221
348
|
completion = raw.fetch("usage", {}).fetch("output_tokens", 0).to_i
|
|
222
349
|
return ChatResponse.new(text: parts.join, model: raw.fetch("model", "").to_s,
|
|
@@ -226,6 +353,7 @@ module Tina4
|
|
|
226
353
|
choice = raw.fetch("choices").fetch(0)
|
|
227
354
|
text = choice.fetch("message").fetch("content")
|
|
228
355
|
raise TypeError unless text.is_a?(String)
|
|
356
|
+
|
|
229
357
|
usage = raw.fetch("usage", {})
|
|
230
358
|
ChatResponse.new(text: text, model: raw.fetch("model", "").to_s,
|
|
231
359
|
usage: { prompt_tokens: usage.fetch("prompt_tokens", 0).to_i,
|
|
@@ -236,75 +364,209 @@ module Tina4
|
|
|
236
364
|
raise AiParseError, "AI provider returned a malformed chat response"
|
|
237
365
|
end
|
|
238
366
|
|
|
239
|
-
|
|
240
|
-
return [true, nil] if data == "[DONE]"
|
|
367
|
+
# ── streaming (ADR-0060 typed events, built on Api#stream_sse) ─────────
|
|
241
368
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
369
|
+
# Split the resolved provider URL into (origin, request_uri) so we can
|
|
370
|
+
# hand an Api client the origin and a path -- Api#build_uri concatenates
|
|
371
|
+
# them without further munging.
|
|
372
|
+
def split_url(url)
|
|
373
|
+
uri = URI.parse(url)
|
|
374
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
375
|
+
origin += ":#{uri.port}" if uri.port
|
|
376
|
+
[origin, uri.request_uri]
|
|
377
|
+
end
|
|
249
378
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
379
|
+
def stream_events(config, request_headers, body)
|
|
380
|
+
Enumerator.new do |yielder|
|
|
381
|
+
stream_pump(config, request_headers, body, yielder)
|
|
382
|
+
end
|
|
253
383
|
end
|
|
254
384
|
|
|
255
|
-
def
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
385
|
+
def stream_pump(config, request_headers, body, yielder)
|
|
386
|
+
provider = config[:provider]
|
|
387
|
+
origin, request_path = split_url(config[:url])
|
|
388
|
+
api = Tina4::API.new(origin, timeout: config[:total_timeout])
|
|
389
|
+
stream_headers = request_headers.merge("Accept" => "text/event-stream")
|
|
390
|
+
request_body_json = JSON.generate(body)
|
|
391
|
+
|
|
392
|
+
state = {
|
|
393
|
+
text_delta_seen: false,
|
|
394
|
+
done_emitted: false,
|
|
395
|
+
error_emitted: false,
|
|
396
|
+
finish_reason: nil,
|
|
397
|
+
openai_tool_calls: {}, # index -> { id:, name:, args_fragments: [] }
|
|
398
|
+
anthropic_tool_blocks: {} # index -> { id:, name:, args_fragments: [] }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
begin
|
|
402
|
+
catch(:tina4_ai_stream_end) do
|
|
403
|
+
api.stream_sse(
|
|
404
|
+
request_path,
|
|
405
|
+
method: "POST",
|
|
406
|
+
body: request_body_json,
|
|
407
|
+
headers: stream_headers,
|
|
408
|
+
content_type: "application/json",
|
|
409
|
+
timeout: config[:total_timeout],
|
|
410
|
+
connect_timeout: config[:connect_timeout]
|
|
411
|
+
) do |sse|
|
|
412
|
+
handle_sse_event(provider, sse, yielder, state)
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# No terminal done/error was emitted -- treat as mid-stream failure.
|
|
417
|
+
emit_error(yielder, state, "AI provider stream ended before terminal event") unless state[:done_emitted] || state[:error_emitted]
|
|
418
|
+
rescue Tina4::APIStreamError => e
|
|
419
|
+
# Pre-stream HTTP error (status arrived before any bytes). Contract:
|
|
420
|
+
# raise, not error-event. Body is not surfaced (never leak secrets).
|
|
421
|
+
raise AiHTTPError.new("AI provider returned HTTP #{e.status}", e.status)
|
|
422
|
+
rescue Net::OpenTimeout
|
|
423
|
+
raise AiTimeoutError, "AI connection timeout expired"
|
|
424
|
+
rescue Net::ReadTimeout, Timeout::Error
|
|
425
|
+
if state[:text_delta_seen]
|
|
426
|
+
emit_error(yielder, state, "AI total request timeout expired")
|
|
427
|
+
else
|
|
428
|
+
raise AiTimeoutError, "AI total request timeout expired"
|
|
429
|
+
end
|
|
430
|
+
rescue AiParseError
|
|
431
|
+
raise
|
|
432
|
+
rescue SocketError, EOFError, IOError, SystemCallError => e
|
|
433
|
+
if state[:text_delta_seen]
|
|
434
|
+
emit_error(yielder, state, "AI transport failed (#{e.class.name})")
|
|
435
|
+
else
|
|
436
|
+
raise AiHTTPError, "AI transport failed (#{e.class.name})"
|
|
263
437
|
end
|
|
264
438
|
end
|
|
265
439
|
end
|
|
266
440
|
|
|
267
|
-
def
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
441
|
+
def handle_sse_event(provider, sse, yielder, state)
|
|
442
|
+
data = sse[:data]
|
|
443
|
+
return if data.nil?
|
|
444
|
+
|
|
445
|
+
if data == "[DONE]"
|
|
446
|
+
# OpenAI sentinel -- flush any pending tool_calls, then emit :done.
|
|
447
|
+
flush_openai_tool_calls(yielder, state)
|
|
448
|
+
emit_done(yielder, state)
|
|
449
|
+
throw :tina4_ai_stream_end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
return if data.empty?
|
|
453
|
+
|
|
454
|
+
parsed = begin
|
|
455
|
+
JSON.parse(data)
|
|
456
|
+
rescue JSON::ParserError
|
|
457
|
+
raise AiParseError, "AI provider returned malformed stream data"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
if provider == "anthropic"
|
|
461
|
+
handle_anthropic_event(parsed, yielder, state)
|
|
462
|
+
else
|
|
463
|
+
handle_openai_event(parsed, yielder, state)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def handle_openai_event(parsed, yielder, state)
|
|
468
|
+
choice = (parsed["choices"] || []).first
|
|
469
|
+
return unless choice
|
|
470
|
+
|
|
471
|
+
delta = choice["delta"] || {}
|
|
472
|
+
content = delta["content"]
|
|
473
|
+
if content.is_a?(String) && !content.empty?
|
|
474
|
+
yielder << { type: :text_delta, text: content }
|
|
475
|
+
state[:text_delta_seen] = true
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
(delta["tool_calls"] || []).each do |tool_call|
|
|
479
|
+
index = tool_call["index"] || 0
|
|
480
|
+
entry = state[:openai_tool_calls][index] ||= { id: nil, name: nil, args_fragments: [] }
|
|
481
|
+
entry[:id] = tool_call["id"] if tool_call["id"]
|
|
482
|
+
function = tool_call["function"] || {}
|
|
483
|
+
entry[:name] = function["name"] if function["name"]
|
|
484
|
+
entry[:args_fragments] << function["arguments"] if function["arguments"].is_a?(String)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
reason = choice["finish_reason"]
|
|
488
|
+
return unless reason
|
|
489
|
+
|
|
490
|
+
state[:finish_reason] = reason
|
|
491
|
+
# The definitive trigger to emit aggregated tool_calls. Some providers
|
|
492
|
+
# send finish_reason=stop even when tool_calls were streamed, so flush
|
|
493
|
+
# on any finish_reason -- the flush is a no-op when no calls buffered.
|
|
494
|
+
flush_openai_tool_calls(yielder, state)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def handle_anthropic_event(parsed, yielder, state)
|
|
498
|
+
case parsed["type"]
|
|
499
|
+
when "content_block_start"
|
|
500
|
+
block = parsed["content_block"] || {}
|
|
501
|
+
if block["type"] == "tool_use"
|
|
502
|
+
state[:anthropic_tool_blocks][parsed["index"]] = {
|
|
503
|
+
id: block["id"], name: block["name"], args_fragments: []
|
|
504
|
+
}
|
|
505
|
+
end
|
|
506
|
+
when "content_block_delta"
|
|
507
|
+
delta = parsed["delta"] || {}
|
|
508
|
+
case delta["type"]
|
|
509
|
+
when "text_delta"
|
|
510
|
+
text = delta["text"]
|
|
511
|
+
if text.is_a?(String) && !text.empty?
|
|
512
|
+
yielder << { type: :text_delta, text: text }
|
|
513
|
+
state[:text_delta_seen] = true
|
|
304
514
|
end
|
|
515
|
+
when "input_json_delta"
|
|
516
|
+
entry = state[:anthropic_tool_blocks][parsed["index"]]
|
|
517
|
+
entry[:args_fragments] << (delta["partial_json"] || "") if entry
|
|
305
518
|
end
|
|
519
|
+
when "content_block_stop"
|
|
520
|
+
entry = state[:anthropic_tool_blocks].delete(parsed["index"])
|
|
521
|
+
emit_anthropic_tool_call(yielder, entry) if entry
|
|
522
|
+
when "message_delta"
|
|
523
|
+
reason = (parsed["delta"] || {})["stop_reason"]
|
|
524
|
+
state[:finish_reason] = reason if reason
|
|
525
|
+
when "message_stop"
|
|
526
|
+
emit_done(yielder, state)
|
|
527
|
+
throw :tina4_ai_stream_end
|
|
306
528
|
end
|
|
307
529
|
end
|
|
530
|
+
|
|
531
|
+
def flush_openai_tool_calls(yielder, state)
|
|
532
|
+
state[:openai_tool_calls].keys.sort.each do |index|
|
|
533
|
+
entry = state[:openai_tool_calls][index]
|
|
534
|
+
joined = entry[:args_fragments].join
|
|
535
|
+
args = parse_tool_args(joined)
|
|
536
|
+
yielder << { type: :tool_call, id: entry[:id].to_s, name: entry[:name].to_s, args: args }
|
|
537
|
+
end
|
|
538
|
+
state[:openai_tool_calls].clear
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def emit_anthropic_tool_call(yielder, entry)
|
|
542
|
+
joined = entry[:args_fragments].join
|
|
543
|
+
args = parse_tool_args(joined)
|
|
544
|
+
yielder << { type: :tool_call, id: entry[:id].to_s, name: entry[:name].to_s, args: args }
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def parse_tool_args(joined)
|
|
548
|
+
return {} if joined.nil? || joined.empty?
|
|
549
|
+
|
|
550
|
+
JSON.parse(joined)
|
|
551
|
+
rescue JSON::ParserError
|
|
552
|
+
raise AiParseError, "AI provider returned malformed tool_call arguments JSON"
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def emit_done(yielder, state)
|
|
556
|
+
return if state[:done_emitted] || state[:error_emitted]
|
|
557
|
+
|
|
558
|
+
yielder << { type: :done, finish_reason: state[:finish_reason] || "stop" }
|
|
559
|
+
state[:done_emitted] = true
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def emit_error(yielder, state, message, code: nil)
|
|
563
|
+
return if state[:done_emitted] || state[:error_emitted]
|
|
564
|
+
|
|
565
|
+
payload = { type: :error, message: message }
|
|
566
|
+
payload[:code] = code if code
|
|
567
|
+
yielder << payload
|
|
568
|
+
state[:error_emitted] = true
|
|
569
|
+
end
|
|
308
570
|
end
|
|
309
571
|
end
|
|
310
572
|
end
|
data/lib/tina4/api.rb
CHANGED
|
@@ -302,8 +302,182 @@ module Tina4
|
|
|
302
302
|
end
|
|
303
303
|
end
|
|
304
304
|
|
|
305
|
+
# ── Streaming primitives (ADR-0060 / 3.13.113) ─────────────────────────────
|
|
306
|
+
#
|
|
307
|
+
# Three cooperating primitives, each layered on the one below:
|
|
308
|
+
# * stream_bytes — raw response body chunks in the order the transport
|
|
309
|
+
# delivers them (no buffering, no framing).
|
|
310
|
+
# * stream_lines — one String per LF- or CRLF-terminated line, decoded as
|
|
311
|
+
# UTF-8. A trailing line without a newline is yielded on
|
|
312
|
+
# EOF. An incomplete UTF-8 sequence at a chunk boundary
|
|
313
|
+
# is buffered across chunks.
|
|
314
|
+
# * stream_sse — SSE-framed events {data:, event:, id:, retry:}. Blank
|
|
315
|
+
# line separates events; ":" comment lines are dropped;
|
|
316
|
+
# multi-line data: fields are joined with "\n"; the
|
|
317
|
+
# OpenAI [DONE] sentinel arrives as the last event
|
|
318
|
+
# (data == "[DONE]") and the iterator ends on the next
|
|
319
|
+
# EOF (the caller decides how to treat it).
|
|
320
|
+
#
|
|
321
|
+
# Ruby idiom: pass a block, OR call without a block to get an Enumerator.
|
|
322
|
+
# Every keyword arg (method:, body:, headers:, content_type:, timeout:,
|
|
323
|
+
# connect_timeout:) is optional and matches the send_request defaults.
|
|
324
|
+
# Aborting the iterator (break, StopIteration on Enumerator#next, GC) closes
|
|
325
|
+
# the underlying socket cleanly — Net::HTTP's block form releases it on any
|
|
326
|
+
# exit from the block.
|
|
327
|
+
#
|
|
328
|
+
# These are the SAME primitives Tina4::Ai.chat(stream: true) uses under the
|
|
329
|
+
# hood (ADR-0060 rule 5). Application code that streams HTTP anywhere (LLMs,
|
|
330
|
+
# log tails, event feeds, chunked downloads) reaches for these instead of
|
|
331
|
+
# hand-rolling a Net::HTTP + line-buffer + SSE-frame reader per app.
|
|
332
|
+
|
|
333
|
+
def stream_bytes(path, method: "GET", body: nil, headers: {},
|
|
334
|
+
content_type: nil, timeout: nil, connect_timeout: nil, &block)
|
|
335
|
+
unless block_given?
|
|
336
|
+
return Enumerator.new do |y|
|
|
337
|
+
stream_bytes(path, method: method, body: body, headers: headers,
|
|
338
|
+
content_type: content_type, timeout: timeout,
|
|
339
|
+
connect_timeout: connect_timeout) { |chunk| y << chunk }
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
open_stream(path, method, body, headers, content_type, timeout, connect_timeout, &block)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def stream_lines(path, method: "GET", body: nil, headers: {},
|
|
346
|
+
content_type: nil, timeout: nil, connect_timeout: nil, &block)
|
|
347
|
+
unless block_given?
|
|
348
|
+
return Enumerator.new do |y|
|
|
349
|
+
stream_lines(path, method: method, body: body, headers: headers,
|
|
350
|
+
content_type: content_type, timeout: timeout,
|
|
351
|
+
connect_timeout: connect_timeout) { |line| y << line }
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
buffer = String.new(encoding: Encoding::BINARY)
|
|
355
|
+
stream_bytes(path, method: method, body: body, headers: headers,
|
|
356
|
+
content_type: content_type, timeout: timeout,
|
|
357
|
+
connect_timeout: connect_timeout) do |chunk|
|
|
358
|
+
buffer << chunk.b
|
|
359
|
+
while (index = buffer.index("\n".b))
|
|
360
|
+
raw = buffer.byteslice(0, index)
|
|
361
|
+
raw = raw.byteslice(0, raw.bytesize - 1) if raw.bytesize.positive? && raw.getbyte(raw.bytesize - 1) == 13
|
|
362
|
+
buffer = buffer.byteslice(index + 1, buffer.bytesize - index - 1) || String.new(encoding: Encoding::BINARY)
|
|
363
|
+
block.call(decode_utf8(raw))
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
block.call(decode_utf8(buffer)) unless buffer.empty?
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def stream_sse(path, method: "GET", body: nil, headers: {},
|
|
370
|
+
content_type: nil, timeout: nil, connect_timeout: nil, &block)
|
|
371
|
+
unless block_given?
|
|
372
|
+
return Enumerator.new do |y|
|
|
373
|
+
stream_sse(path, method: method, body: body, headers: headers,
|
|
374
|
+
content_type: content_type, timeout: timeout,
|
|
375
|
+
connect_timeout: connect_timeout) { |event| y << event }
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
data_parts = []
|
|
379
|
+
event_name = nil
|
|
380
|
+
event_id = nil
|
|
381
|
+
retry_ms = nil
|
|
382
|
+
dispatch = lambda do
|
|
383
|
+
return if data_parts.empty? && event_name.nil? && event_id.nil? && retry_ms.nil?
|
|
384
|
+
|
|
385
|
+
payload = { data: data_parts.join("\n") }
|
|
386
|
+
payload[:event] = event_name if event_name
|
|
387
|
+
payload[:id] = event_id if event_id
|
|
388
|
+
payload[:retry] = retry_ms if retry_ms
|
|
389
|
+
block.call(payload)
|
|
390
|
+
data_parts = []
|
|
391
|
+
event_name = nil
|
|
392
|
+
event_id = nil
|
|
393
|
+
retry_ms = nil
|
|
394
|
+
end
|
|
395
|
+
stream_lines(path, method: method, body: body, headers: headers,
|
|
396
|
+
content_type: content_type, timeout: timeout,
|
|
397
|
+
connect_timeout: connect_timeout) do |line|
|
|
398
|
+
if line.empty?
|
|
399
|
+
dispatch.call
|
|
400
|
+
elsif line.start_with?(":")
|
|
401
|
+
# comment — ignored per the SSE spec
|
|
402
|
+
else
|
|
403
|
+
field, sep, value = line.partition(":")
|
|
404
|
+
field = line if sep.empty?
|
|
405
|
+
value = "" if sep.empty?
|
|
406
|
+
value = value[1..] if value.start_with?(" ")
|
|
407
|
+
case field
|
|
408
|
+
when "data" then data_parts << value
|
|
409
|
+
when "event" then event_name = value
|
|
410
|
+
when "id" then event_id = value
|
|
411
|
+
when "retry"
|
|
412
|
+
begin
|
|
413
|
+
retry_ms = Integer(value)
|
|
414
|
+
rescue ArgumentError, TypeError
|
|
415
|
+
retry_ms = nil
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
dispatch.call
|
|
421
|
+
end
|
|
422
|
+
|
|
305
423
|
private
|
|
306
424
|
|
|
425
|
+
# Force UTF-8 on a raw line, tolerating a mid-multibyte tail (which
|
|
426
|
+
# stream_lines already buffered across chunks — the trailing-EOF flush is
|
|
427
|
+
# the only place an incomplete sequence can slip through, and we mark it
|
|
428
|
+
# replaceable rather than crash the whole stream).
|
|
429
|
+
def decode_utf8(bytes)
|
|
430
|
+
string = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
431
|
+
string.valid_encoding? ? string : string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Open a single streaming HTTP request and yield body chunks to the block.
|
|
435
|
+
# Non-2xx responses raise APIStreamError (with the status code); a transport
|
|
436
|
+
# failure (DNS, connection refused, mid-stream drop) also raises. Net::HTTP's
|
|
437
|
+
# block form releases the socket when this method returns for any reason —
|
|
438
|
+
# including a break/StopIteration propagated up from the caller.
|
|
439
|
+
def open_stream(path, method, body, headers, content_type, timeout, connect_timeout)
|
|
440
|
+
uri = build_uri(path)
|
|
441
|
+
request_class = case method.to_s.upcase
|
|
442
|
+
when "GET" then Net::HTTP::Get
|
|
443
|
+
when "POST" then Net::HTTP::Post
|
|
444
|
+
when "PUT" then Net::HTTP::Put
|
|
445
|
+
when "PATCH" then Net::HTTP::Patch
|
|
446
|
+
when "DELETE" then Net::HTTP::Delete
|
|
447
|
+
when "HEAD" then Net::HTTP::Head
|
|
448
|
+
else raise ArgumentError, "unsupported stream method: #{method}"
|
|
449
|
+
end
|
|
450
|
+
request = request_class.new(uri)
|
|
451
|
+
apply_headers(request, headers || {})
|
|
452
|
+
cookie = cookie_header
|
|
453
|
+
request["Cookie"] = cookie if @cookies_enabled && cookie
|
|
454
|
+
if body
|
|
455
|
+
request.body = body.is_a?(String) ? body : JSON.generate(body)
|
|
456
|
+
request["Content-Type"] = content_type if content_type
|
|
457
|
+
elsif content_type
|
|
458
|
+
request["Content-Type"] = content_type
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
462
|
+
http.use_ssl = uri.scheme == "https"
|
|
463
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @verify_ssl == false
|
|
464
|
+
http.open_timeout = connect_timeout || @timeout
|
|
465
|
+
http.read_timeout = timeout || @timeout
|
|
466
|
+
http.write_timeout = (timeout || @timeout) if http.respond_to?(:write_timeout=)
|
|
467
|
+
|
|
468
|
+
http.start do |conn|
|
|
469
|
+
conn.request(request) do |response|
|
|
470
|
+
status = response.code.to_i
|
|
471
|
+
unless (200..299).cover?(status)
|
|
472
|
+
response.read_body { |_chunk| } # drain the error body
|
|
473
|
+
raise APIStreamError.new("stream returned HTTP #{status}", status)
|
|
474
|
+
end
|
|
475
|
+
store_cookies(response.get_fields("Set-Cookie"))
|
|
476
|
+
response.read_body { |chunk| yield chunk }
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
307
481
|
def build_uri(path, params = {})
|
|
308
482
|
url = "#{@base_url}#{path}"
|
|
309
483
|
uri = URI.parse(url)
|
|
@@ -614,6 +788,21 @@ module Tina4
|
|
|
614
788
|
end
|
|
615
789
|
end
|
|
616
790
|
|
|
791
|
+
# Raised when a streaming request (stream_bytes / stream_lines / stream_sse)
|
|
792
|
+
# opens successfully but the HTTP status is not 2xx. The buffered `execute`
|
|
793
|
+
# path folds an error status into an APIResponse; a streaming caller has no
|
|
794
|
+
# response object to inspect, so the error is raised at the moment the status
|
|
795
|
+
# is known -- before any bytes are yielded (the pre-stream error contract of
|
|
796
|
+
# ADR-0060). Carries the status code for the caller to switch on.
|
|
797
|
+
class APIStreamError < StandardError
|
|
798
|
+
attr_reader :status
|
|
799
|
+
|
|
800
|
+
def initialize(message, status = nil)
|
|
801
|
+
super(message)
|
|
802
|
+
@status = status
|
|
803
|
+
end
|
|
804
|
+
end
|
|
805
|
+
|
|
617
806
|
class APIResponse
|
|
618
807
|
attr_reader :status, :body, :headers, :error, :path
|
|
619
808
|
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -407,6 +407,16 @@ module Tina4
|
|
|
407
407
|
serve_dashboard
|
|
408
408
|
when ["GET", "/__dev/js/tina4-dev-admin.min.js"]
|
|
409
409
|
serve_dev_js
|
|
410
|
+
when ["GET", "/__dev/toolbar.css"]
|
|
411
|
+
# tina4stack #115: the injected toolbar's stylesheet, served as an
|
|
412
|
+
# external asset so the toolbar carries no inline style= and renders
|
|
413
|
+
# under the default default-src 'self' CSP. Parity with PHP/Python/Node.
|
|
414
|
+
[200, { "content-type" => "text/css; charset=utf-8" }, [Tina4::RackApp.toolbar_css]]
|
|
415
|
+
when ["GET", "/__dev/toolbar.js"]
|
|
416
|
+
# tina4stack #115: the injected toolbar's script (version modal, dash
|
|
417
|
+
# overlay, WebSocket-primary reloader), served as an external asset so
|
|
418
|
+
# the toolbar carries no inline <script>/onclick and stays CSP-clean.
|
|
419
|
+
[200, { "content-type" => "application/javascript; charset=utf-8" }, [Tina4::RackApp.toolbar_js]]
|
|
410
420
|
when ["GET", "/__dev/api/mtime"]
|
|
411
421
|
json_response({ mtime: @reload_mtime || 0, file: @reload_file || "" })
|
|
412
422
|
when ["POST", "/__dev/api/reload"]
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -915,64 +915,174 @@ module Tina4
|
|
|
915
915
|
[-1, {}, []]
|
|
916
916
|
end
|
|
917
917
|
|
|
918
|
+
# Inject the CSP-clean dev toolbar into HTML responses in dev mode.
|
|
919
|
+
#
|
|
920
|
+
# tina4stack #115: the toolbar is CSP-clean - it carries NO inline `style=`,
|
|
921
|
+
# NO `onclick=` (or any inline handler) and NO inline `<script>` block. All
|
|
922
|
+
# styling lives in the external `/__dev/toolbar.css` stylesheet and every
|
|
923
|
+
# interaction is wired via `addEventListener` in the external
|
|
924
|
+
# `/__dev/toolbar.js`, so the toolbar renders under the framework's default
|
|
925
|
+
# `default-src 'self'` CSP with no violations. Parity with PHP/Python/Node,
|
|
926
|
+
# which serve the same external `/__dev/toolbar.{css,js}` assets.
|
|
918
927
|
def inject_dev_overlay(body, request_info, ai_port: false)
|
|
919
928
|
version = Tina4::VERSION
|
|
920
|
-
# DEVADMIN-DEC-04 (feature 127): the toolbar
|
|
921
|
-
#
|
|
922
|
-
#
|
|
923
|
-
#
|
|
924
|
-
#
|
|
925
|
-
# and the Python master html.escape; Ruby was reflecting them raw.)
|
|
929
|
+
# DEVADMIN-DEC-04 (feature 127): the toolbar rides EVERY text/html response
|
|
930
|
+
# (including 404s), so the reflected request method/path/matched-pattern
|
|
931
|
+
# MUST be HTML-escaped or a crafted path reflects <script> that runs in the
|
|
932
|
+
# dev-server origin and can then drive every ungated /__dev mutation route.
|
|
933
|
+
# (Parity with PHP htmlspecialchars and the Python master html.escape.)
|
|
926
934
|
method = CGI.escapeHTML(request_info[:method].to_s)
|
|
927
935
|
path = CGI.escapeHTML(request_info[:path].to_s)
|
|
928
936
|
matched_pattern = CGI.escapeHTML(request_info[:matched_pattern].to_s)
|
|
929
|
-
request_id = Tina4::Log.get_request_id || "-"
|
|
937
|
+
request_id = CGI.escapeHTML((Tina4::Log.get_request_id || "-").to_s)
|
|
930
938
|
route_count = Tina4::Router.routes.length
|
|
939
|
+
ruby_version = RUBY_VERSION
|
|
931
940
|
|
|
932
|
-
|
|
941
|
+
# data-reload gates the live reloader in toolbar.js: "1" on the human dev
|
|
942
|
+
# port, "0" on the AI/stable port (the JS early-returns before starting the
|
|
943
|
+
# reloader when it is not "1"). Matches Python's render_dev_toolbar.
|
|
944
|
+
reload = ai_port ? "0" : "1"
|
|
945
|
+
ai_badge = ai_port ? '<span class="t4-ai-badge">AI PORT</span>' : ""
|
|
933
946
|
|
|
934
947
|
toolbar = <<~HTML.strip
|
|
935
|
-
<
|
|
936
|
-
|
|
937
|
-
<
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
<
|
|
948
|
+
<link rel="stylesheet" href="/__dev/toolbar.css">
|
|
949
|
+
<div id="tina4-dev-toolbar" data-reload="#{reload}">
|
|
950
|
+
#{ai_badge}<span id="tina4-ver-btn" title="Click to check for updates">Tina4 v#{version}</span>
|
|
951
|
+
<div id="tina4-ver-modal">
|
|
952
|
+
<div class="t4-modal-head">
|
|
953
|
+
<strong class="t4-modal-title">Version Info</strong>
|
|
954
|
+
<span id="tina4-ver-close" class="t4-x">×</span>
|
|
941
955
|
</div>
|
|
942
|
-
<div id="tina4-ver-body"
|
|
943
|
-
<div>Current: <strong
|
|
944
|
-
<div id="tina4-ver-latest"
|
|
956
|
+
<div id="tina4-ver-body">
|
|
957
|
+
<div>Current: <strong class="t4-ok">v#{version}</strong></div>
|
|
958
|
+
<div id="tina4-ver-latest" class="t4-dim">Checking for updates...</div>
|
|
945
959
|
</div>
|
|
946
960
|
</div>
|
|
947
|
-
<span
|
|
961
|
+
<span class="t4-green">#{method}</span>
|
|
948
962
|
<span>#{path}</span>
|
|
949
|
-
<span
|
|
950
|
-
<span
|
|
951
|
-
<span
|
|
952
|
-
<span
|
|
953
|
-
<a href="#"
|
|
954
|
-
<span
|
|
963
|
+
<span class="t4-arrow">→ #{matched_pattern}</span>
|
|
964
|
+
<span class="t4-yellow">req:#{request_id}</span>
|
|
965
|
+
<span class="t4-blue">#{route_count} routes</span>
|
|
966
|
+
<span class="t4-dim">Ruby #{ruby_version}</span>
|
|
967
|
+
<a href="#" id="tina4-dash-link" class="t4-dash">Dashboard ↗</a>
|
|
968
|
+
<span id="tina4-bar-close" class="t4-x t4-bar-close">✕</span>
|
|
955
969
|
</div>
|
|
956
|
-
<script>
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
970
|
+
<script src="/__dev/toolbar.js"></script>
|
|
971
|
+
HTML
|
|
972
|
+
|
|
973
|
+
if body.include?("</body>")
|
|
974
|
+
body.sub("</body>", "#{toolbar}\n</body>")
|
|
975
|
+
else
|
|
976
|
+
body + "\n" + toolbar
|
|
977
|
+
end
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# CSS for the injected dev toolbar. Served as an external stylesheet (see
|
|
981
|
+
# DevAdmin.handle_request) so the toolbar carries no inline `style=` and
|
|
982
|
+
# stays CSP-clean under a strict `default-src 'self'`.
|
|
983
|
+
def self.toolbar_css
|
|
984
|
+
<<~CSS
|
|
985
|
+
#tina4-dev-toolbar{position:fixed;bottom:0;left:0;right:0;background:#333;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;z-index:99999;display:flex;align-items:center;gap:16px}
|
|
986
|
+
#tina4-dev-toolbar a{text-decoration:none}
|
|
987
|
+
.t4-ai-badge{background:#7c3aed;color:#fff;font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold}
|
|
988
|
+
#tina4-ver-btn{color:#d32f2f;font-weight:bold;cursor:pointer;text-decoration:underline dotted}
|
|
989
|
+
#tina4-ver-modal{display:none;position:fixed;bottom:3rem;left:1rem;background:#1e1e2e;border:1px solid #d32f2f;border-radius:8px;padding:16px 20px;z-index:100000;min-width:320px;box-shadow:0 8px 32px rgba(0,0,0,.5);font-family:monospace;font-size:13px;color:#cdd6f4}
|
|
990
|
+
.t4-modal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
|
|
991
|
+
.t4-modal-title{color:#89b4fa}
|
|
992
|
+
#tina4-ver-body{line-height:1.8}
|
|
993
|
+
.t4-x{cursor:pointer;color:#888}
|
|
994
|
+
.t4-bar-close{margin-left:8px}
|
|
995
|
+
.t4-green{color:#4caf50}
|
|
996
|
+
.t4-dim{color:#888}
|
|
997
|
+
.t4-arrow{color:#666}
|
|
998
|
+
.t4-yellow{color:#ffeb3b}
|
|
999
|
+
.t4-blue{color:#90caf9}
|
|
1000
|
+
.t4-ok{color:#a6e3a1}
|
|
1001
|
+
.t4-warn{color:#f9e2af}
|
|
1002
|
+
.t4-err{color:#f38ba8}
|
|
1003
|
+
.t4-purple{color:#cba6f7}
|
|
1004
|
+
.t4-link{color:#89b4fa}
|
|
1005
|
+
.t4-code{background:#313244;padding:2px 6px;border-radius:3px}
|
|
1006
|
+
.t4-note{margin-top:6px}
|
|
1007
|
+
.t4-dash{color:#ef9a9a;margin-left:auto;cursor:pointer}
|
|
1008
|
+
#tina4-dev-panel{position:fixed;top:3rem;left:0;right:0;bottom:2rem;z-index:99998;transition:all .2s}
|
|
1009
|
+
#tina4-dev-panel iframe{width:100%;height:100%;border:1px solid #CC342D;border-radius:.5rem;box-shadow:0 8px 32px rgba(0,0,0,.5);background:#0f172a}
|
|
1010
|
+
CSS
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
# JS for the injected dev toolbar - the version-check modal, the dashboard
|
|
1014
|
+
# overlay, and the WebSocket-primary live reloader. Served as an external
|
|
1015
|
+
# script (see DevAdmin.handle_request) so the toolbar carries no inline
|
|
1016
|
+
# handlers or `<script>` and stays CSP-clean. Every handler is wired via
|
|
1017
|
+
# addEventListener; the reloader only starts when the toolbar's `data-reload`
|
|
1018
|
+
# attribute is "1" (reload not suppressed for this request). Mirrors the
|
|
1019
|
+
# Python master's injected client and the PHP external toolbar.js exactly.
|
|
1020
|
+
def self.toolbar_js
|
|
1021
|
+
poll_interval_ms = (ENV["TINA4_DEV_POLL_INTERVAL"] || "3000").to_i
|
|
1022
|
+
poll_interval_ms = 3000 if poll_interval_ms <= 0
|
|
1023
|
+
<<~JS
|
|
1024
|
+
(function () {
|
|
1025
|
+
var bar = document.getElementById('tina4-dev-toolbar');
|
|
1026
|
+
if (!bar) { return; }
|
|
1027
|
+
|
|
1028
|
+
var modal = document.getElementById('tina4-ver-modal');
|
|
1029
|
+
function upToDate(el, latest) {
|
|
1030
|
+
el.className = 't4-ok';
|
|
1031
|
+
el.innerHTML = 'Latest: <strong class="t4-ok">v' + latest + '</strong> — You are up to date!';
|
|
1032
|
+
}
|
|
1033
|
+
function checkVersion() {
|
|
1034
|
+
if (modal.style.display === 'block') { modal.style.display = 'none'; return; }
|
|
1035
|
+
modal.style.display = 'block';
|
|
1036
|
+
var el = document.getElementById('tina4-ver-latest');
|
|
1037
|
+
el.className = 't4-dim';
|
|
1038
|
+
el.textContent = 'Checking for updates...';
|
|
1039
|
+
fetch('/__dev/api/version-check').then(function (r) { return r.json(); }).then(function (d) {
|
|
1040
|
+
var latest = d.latest, current = d.current;
|
|
1041
|
+
if (latest === current) { upToDate(el, latest); return; }
|
|
1042
|
+
var cP = current.split('.').map(Number), lP = latest.split('.').map(Number);
|
|
1043
|
+
var isNewer = false, i, c, l;
|
|
1044
|
+
for (i = 0; i < Math.max(cP.length, lP.length); i++) { c = cP[i] || 0; l = lP[i] || 0; if (l > c) { isNewer = true; break; } if (l < c) { break; } }
|
|
1045
|
+
var isAhead = false;
|
|
1046
|
+
if (!isNewer) { for (i = 0; i < Math.max(cP.length, lP.length); i++) { var c2 = cP[i] || 0, l2 = lP[i] || 0; if (c2 > l2) { isAhead = true; break; } if (c2 < l2) { break; } } }
|
|
1047
|
+
if (isNewer) {
|
|
1048
|
+
var breaking = (lP[0] !== cP[0] || lP[1] !== cP[1]);
|
|
1049
|
+
el.className = '';
|
|
1050
|
+
el.innerHTML = 'Latest: <strong class="t4-warn">v' + latest + '</strong>';
|
|
1051
|
+
if (breaking) {
|
|
1052
|
+
el.innerHTML += '<div class="t4-err t4-note">⚠ Major/minor version change — check the <a href="https://github.com/tina4stack/tina4-ruby/releases" target="_blank" class="t4-link">changelog</a> for breaking changes before upgrading.</div>';
|
|
1053
|
+
} else {
|
|
1054
|
+
el.innerHTML += '<div class="t4-warn t4-note">Patch update available. Run: <code class="t4-code">gem install tina4ruby</code></div>';
|
|
1055
|
+
}
|
|
1056
|
+
} else if (isAhead) {
|
|
1057
|
+
el.className = 't4-purple';
|
|
1058
|
+
el.innerHTML = 'You are running <strong class="t4-purple">v' + current + '</strong> (ahead of RubyGems <strong>v' + latest + '</strong> — not yet published).';
|
|
1059
|
+
} else {
|
|
1060
|
+
upToDate(el, latest);
|
|
1061
|
+
}
|
|
1062
|
+
}).catch(function () {
|
|
1063
|
+
el.className = 't4-err';
|
|
1064
|
+
el.textContent = 'Could not check for updates (offline?)';
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
var verBtn = document.getElementById('tina4-ver-btn');
|
|
1068
|
+
if (verBtn) { verBtn.addEventListener('click', checkVersion); }
|
|
1069
|
+
var verClose = document.getElementById('tina4-ver-close');
|
|
1070
|
+
if (verClose) { verClose.addEventListener('click', function () { modal.style.display = 'none'; }); }
|
|
1071
|
+
var barClose = document.getElementById('tina4-bar-close');
|
|
1072
|
+
if (barClose) { barClose.addEventListener('click', function () { bar.style.display = 'none'; }); }
|
|
1073
|
+
|
|
962
1074
|
var STATE_KEY = 'tina4_dev_overlay_open';
|
|
963
1075
|
function buildOverlay() {
|
|
964
1076
|
var c = document.createElement('div');
|
|
965
1077
|
c.id = 'tina4-dev-panel';
|
|
966
|
-
c.style.cssText = 'position:fixed;top:3rem;left:0;right:0;bottom:2rem;z-index:99998;transition:all 0.2s';
|
|
967
1078
|
var f = document.createElement('iframe');
|
|
968
1079
|
f.src = '/__dev';
|
|
969
|
-
f.style.cssText = 'width:100%;height:100%;border:1px solid #CC342D;border-radius:0.5rem;box-shadow:0 8px 32px rgba(0,0,0,0.5);background:#0f172a';
|
|
970
1080
|
c.appendChild(f);
|
|
971
1081
|
document.body.appendChild(c);
|
|
972
1082
|
return c;
|
|
973
1083
|
}
|
|
974
|
-
|
|
975
|
-
if (e) e.preventDefault();
|
|
1084
|
+
function toggleOverlay(e) {
|
|
1085
|
+
if (e) { e.preventDefault(); }
|
|
976
1086
|
var p = document.getElementById('tina4-dev-panel');
|
|
977
1087
|
if (p) {
|
|
978
1088
|
var hide = p.style.display !== 'none';
|
|
@@ -982,151 +1092,58 @@ module Tina4
|
|
|
982
1092
|
}
|
|
983
1093
|
buildOverlay();
|
|
984
1094
|
try { localStorage.setItem(STATE_KEY, '1'); } catch (_) {}
|
|
985
|
-
};
|
|
986
|
-
function restoreIfOpen() {
|
|
987
|
-
try {
|
|
988
|
-
if (location.pathname.indexOf('/__dev') === 0) return;
|
|
989
|
-
if (localStorage.getItem(STATE_KEY) === '1' && !document.getElementById('tina4-dev-panel')) {
|
|
990
|
-
buildOverlay();
|
|
991
|
-
}
|
|
992
|
-
} catch (_) {}
|
|
993
|
-
}
|
|
994
|
-
if (document.readyState === 'loading') {
|
|
995
|
-
document.addEventListener('DOMContentLoaded', restoreIfOpen);
|
|
996
|
-
} else {
|
|
997
|
-
restoreIfOpen();
|
|
998
1095
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
var el=document.getElementById('tina4-ver-latest');
|
|
1007
|
-
el.innerHTML='Checking for updates...';
|
|
1008
|
-
el.style.color='#888';
|
|
1009
|
-
fetch('/__dev/api/version-check')
|
|
1010
|
-
.then(function(r){return r.json()})
|
|
1011
|
-
.then(function(d){
|
|
1012
|
-
var latest=d.latest;
|
|
1013
|
-
var current=d.current;
|
|
1014
|
-
if(latest===current){
|
|
1015
|
-
el.innerHTML='Latest: <strong style="color:#a6e3a1;">v'+latest+'</strong> — You are up to date!';
|
|
1016
|
-
el.style.color='#a6e3a1';
|
|
1017
|
-
}else{
|
|
1018
|
-
var cParts=current.split('.').map(Number);
|
|
1019
|
-
var lParts=latest.split('.').map(Number);
|
|
1020
|
-
var isNewer=false;
|
|
1021
|
-
for(var i=0;i<Math.max(cParts.length,lParts.length);i++){
|
|
1022
|
-
var c=cParts[i]||0,l=lParts[i]||0;
|
|
1023
|
-
if(l>c){isNewer=true;break;}
|
|
1024
|
-
if(l<c)break;
|
|
1025
|
-
}
|
|
1026
|
-
var isAhead=false;
|
|
1027
|
-
if(!isNewer){for(var i=0;i<Math.max(cParts.length,lParts.length);i++){var c2=cParts[i]||0,l2=lParts[i]||0;if(c2>l2){isAhead=true;break;}if(c2<l2)break;}}
|
|
1028
|
-
if(isNewer){
|
|
1029
|
-
var breaking=(lParts[0]!==cParts[0]||lParts[1]!==cParts[1]);
|
|
1030
|
-
el.innerHTML='Latest: <strong style="color:#f9e2af;">v'+latest+'</strong>';
|
|
1031
|
-
if(breaking){
|
|
1032
|
-
el.innerHTML+='<div style="color:#f38ba8;margin-top:6px;">⚠ Major/minor version change — check the <a href="https://github.com/tina4stack/tina4-ruby/releases" target="_blank" style="color:#89b4fa;">changelog</a> for breaking changes before upgrading.</div>';
|
|
1033
|
-
}else{
|
|
1034
|
-
el.innerHTML+='<div style="color:#f9e2af;margin-top:6px;">Patch update available. Run: <code style="background:#313244;padding:2px 6px;border-radius:3px;">gem install tina4ruby</code></div>';
|
|
1035
|
-
}
|
|
1036
|
-
}else if(isAhead){
|
|
1037
|
-
el.innerHTML='You are running <strong style="color:#cba6f7;">v'+current+'</strong> (ahead of RubyGems <strong>v'+latest+'</strong> — not yet published).';
|
|
1038
|
-
el.style.color='#cba6f7';
|
|
1039
|
-
}else{
|
|
1040
|
-
el.innerHTML='Latest: <strong style="color:#a6e3a1;">v'+latest+'</strong> — You are up to date!';
|
|
1041
|
-
el.style.color='#a6e3a1';
|
|
1042
|
-
}
|
|
1096
|
+
var dash = document.getElementById('tina4-dash-link');
|
|
1097
|
+
if (dash) { dash.addEventListener('click', toggleOverlay); }
|
|
1098
|
+
try {
|
|
1099
|
+
if (location.pathname.indexOf('/__dev') !== 0
|
|
1100
|
+
&& localStorage.getItem(STATE_KEY) === '1'
|
|
1101
|
+
&& !document.getElementById('tina4-dev-panel')) {
|
|
1102
|
+
buildOverlay();
|
|
1043
1103
|
}
|
|
1044
|
-
})
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
body + "\n" + toolbar
|
|
1058
|
-
end
|
|
1059
|
-
end
|
|
1060
|
-
|
|
1061
|
-
# WebSocket-primary dev reloader injected into HTML pages in debug mode.
|
|
1062
|
-
#
|
|
1063
|
-
# The running server re-imports changed src/ route files in-process and
|
|
1064
|
-
# pushes a {type,file,mtime} message over /__dev_reload — no respawn,
|
|
1065
|
-
# instant refresh. The mtime poll is a FALLBACK only: it is started when
|
|
1066
|
-
# the socket is down and stopped the moment it connects. On a CSS change
|
|
1067
|
-
# the client swaps <link rel=stylesheet> hrefs with a cache-bust query;
|
|
1068
|
-
# any other change does a full location.reload(). The poll seeds its
|
|
1069
|
-
# last-seen mtime to a null sentinel (NOT 0) and reloads whenever the
|
|
1070
|
-
# polled mtime DIFFERS (not just when greater) so the first change after
|
|
1071
|
-
# load isn't swallowed and a counter reset on restart still triggers.
|
|
1072
|
-
# Mirrors the Python master's injected client exactly.
|
|
1073
|
-
def dev_reload_client_js
|
|
1074
|
-
poll_interval_ms = (ENV["TINA4_DEV_POLL_INTERVAL"] || "3000").to_i
|
|
1075
|
-
poll_interval_ms = 3000 if poll_interval_ms <= 0
|
|
1076
|
-
<<~JS
|
|
1077
|
-
(function(){
|
|
1078
|
-
var _t4_css_exts=['.css','.scss'],_t4_debounce=null;
|
|
1079
|
-
var _t4_interval=parseInt('#{poll_interval_ms}')||3000;
|
|
1080
|
-
var _t4_ws=null,_t4_poll_timer=null,_t4_mtime=null;
|
|
1081
|
-
function _t4_apply(d){
|
|
1082
|
-
d=d||{};
|
|
1083
|
-
var f=d.file||'',t=d.type||'';
|
|
1084
|
-
var isCss=t==='css'||_t4_css_exts.some(function(e){return f.endsWith(e)});
|
|
1085
|
-
if(isCss){
|
|
1086
|
-
var links=document.querySelectorAll('link[rel="stylesheet"]');
|
|
1087
|
-
links.forEach(function(l){
|
|
1088
|
-
var href=l.getAttribute('href');
|
|
1089
|
-
if(href){l.setAttribute('href',href.split('?')[0]+'?_t4='+(d.mtime||Date.now()))}
|
|
1104
|
+
} catch (_) {}
|
|
1105
|
+
|
|
1106
|
+
if (bar.getAttribute('data-reload') !== '1') { return; }
|
|
1107
|
+
var cssExts = ['.css', '.scss'], debounce = null, interval = #{poll_interval_ms};
|
|
1108
|
+
var ws = null, pollTimer = null, mtime = null;
|
|
1109
|
+
function apply(d) {
|
|
1110
|
+
d = d || {};
|
|
1111
|
+
var f = d.file || '', t = d.type || '';
|
|
1112
|
+
var isCss = t === 'css' || cssExts.some(function (e) { return f.endsWith(e); });
|
|
1113
|
+
if (isCss) {
|
|
1114
|
+
document.querySelectorAll('link[rel="stylesheet"]').forEach(function (l) {
|
|
1115
|
+
var href = l.getAttribute('href');
|
|
1116
|
+
if (href) { l.setAttribute('href', href.split('?')[0] + '?_t4=' + (d.mtime || Date.now())); }
|
|
1090
1117
|
});
|
|
1091
|
-
}else{
|
|
1118
|
+
} else {
|
|
1092
1119
|
location.reload();
|
|
1093
1120
|
}
|
|
1094
1121
|
}
|
|
1095
|
-
function
|
|
1096
|
-
fetch('/__dev/api/mtime').then(function(r){return r.json()}).then(function(d){
|
|
1097
|
-
if(
|
|
1098
|
-
if(d.mtime!==
|
|
1099
|
-
|
|
1100
|
-
if(_t4_debounce)clearTimeout(_t4_debounce);
|
|
1101
|
-
_t4_debounce=setTimeout(function(){_t4_apply(d);},500);
|
|
1102
|
-
}
|
|
1103
|
-
}).catch(function(){});
|
|
1104
|
-
}
|
|
1105
|
-
function _t4_startPoll(){
|
|
1106
|
-
if(_t4_poll_timer)return;
|
|
1107
|
-
_t4_mtime=null;
|
|
1108
|
-
_t4_poll_timer=setInterval(_t4_poll,_t4_interval);
|
|
1109
|
-
}
|
|
1110
|
-
function _t4_stopPoll(){
|
|
1111
|
-
if(_t4_poll_timer){clearInterval(_t4_poll_timer);_t4_poll_timer=null;}
|
|
1122
|
+
function poll() {
|
|
1123
|
+
fetch('/__dev/api/mtime').then(function (r) { return r.json(); }).then(function (d) {
|
|
1124
|
+
if (mtime === null) { mtime = d.mtime; return; }
|
|
1125
|
+
if (d.mtime !== mtime) { mtime = d.mtime; if (debounce) { clearTimeout(debounce); } debounce = setTimeout(function () { apply(d); }, 500); }
|
|
1126
|
+
}).catch(function () {});
|
|
1112
1127
|
}
|
|
1113
|
-
function
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1128
|
+
function startPoll() { if (pollTimer) { return; } mtime = null; pollTimer = setInterval(poll, interval); }
|
|
1129
|
+
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
|
1130
|
+
function connect() {
|
|
1131
|
+
var url = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + '/__dev_reload';
|
|
1132
|
+
try { ws = new WebSocket(url); } catch (_) { startPoll(); return; }
|
|
1133
|
+
ws.addEventListener('open', function () { stopPoll(); });
|
|
1134
|
+
ws.addEventListener('message', function (ev) {
|
|
1135
|
+
var d = null;
|
|
1136
|
+
try { d = typeof ev.data === 'string' ? JSON.parse(ev.data) : null; } catch (_) {}
|
|
1137
|
+
if (!d) { return; }
|
|
1138
|
+
if (d.type === 'reload' || d.type === 'change' || d.type === 'css') {
|
|
1139
|
+
if (debounce) { clearTimeout(debounce); }
|
|
1140
|
+
debounce = setTimeout(function () { apply(d); }, 150);
|
|
1124
1141
|
}
|
|
1125
1142
|
});
|
|
1126
|
-
|
|
1127
|
-
|
|
1143
|
+
ws.addEventListener('close', function () { ws = null; startPoll(); setTimeout(connect, 2000); });
|
|
1144
|
+
ws.addEventListener('error', function () { try { ws && ws.close(); } catch (_) {} });
|
|
1128
1145
|
}
|
|
1129
|
-
|
|
1146
|
+
connect();
|
|
1130
1147
|
})();
|
|
1131
1148
|
JS
|
|
1132
1149
|
end
|
data/lib/tina4/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.13.
|
|
4
|
+
version: 3.13.113
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|