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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc71b0c82d6e81e389c825ceaea7a266203979977c2215af5efe677c2aabc57d
4
- data.tar.gz: 9022b81347b8d00a6c95c6f24edeef3e28d528018a6b8a0afd446b3da877d77e
3
+ metadata.gz: 964e92e8030ced5a8f4af3934fb9bb7e49df0056d795c5025d5176aed2fa06d2
4
+ data.tar.gz: 39fa0f71a1b3219bbaa4382f136c1fb69a4f4760436d75339a50d6906ba5a8e0
5
5
  SHA512:
6
- metadata.gz: 1690d686f268e05963e12078a6097bb02e3c920cef1a0a9e063da928bca30b37a58f507a7ab151be65a64bed2e482b9c61c902b2eb84e14e835c04a5e5b966ec
7
- data.tar.gz: 32929f5560047f02f29e0f75a4585704fc6ccdf9b74bcce3abb664c8a5fb5b55ba2c78c930e0449f7e8cba1c700b200c2294e863c70186d76b9cc80520e220a1
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).
@@ -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 (ADR-0053).
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
- def chat(messages, model: nil, temperature: nil, max_tokens: nil, stream: false, timeout: nil, provider: nil)
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 stream_request(config, headers(config), body) if stream
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
- valid = messages.is_a?(Array) && !messages.empty? && messages.all? do |message|
72
- message.is_a?(Hash) && %w[system user assistant].include?((message[:role] || message["role"]).to_s) &&
73
- (message.key?(:content) ? message[:content] : message["content"]).is_a?(String)
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
- normalized = messages.map { |message| { role: (message[:role] || message["role"]).to_s, content: message.key?(:content) ? message[:content] : message["content"] } }
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 config[:provider] == "anthropic"
151
- system = normalized.select { |message| message[:role] == "system" }.map { |message| message[:content] }
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] = system.join("\n\n") unless system.empty?
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
- def stream_delta(provider, data)
240
- return [true, nil] if data == "[DONE]"
367
+ # ── streaming (ADR-0060 typed events, built on Api#stream_sse) ─────────
241
368
 
242
- event = JSON.parse(data)
243
- text = if provider == "anthropic"
244
- event["type"] == "content_block_delta" ? event.dig("delta", "text") : nil
245
- else
246
- event.dig("choices", 0, "delta", "content")
247
- end
248
- raise AiParseError, "AI provider returned malformed stream data" unless text.nil? || text.is_a?(String)
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
- [false, text]
251
- rescue JSON::ParserError
252
- raise AiParseError, "AI provider returned malformed stream data"
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 each_stream_data(response, deadline)
256
- buffer = +""
257
- response.read_body do |chunk|
258
- raise AiTimeoutError, "AI total request timeout expired" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
259
- buffer << chunk
260
- while (index = buffer.index("\n"))
261
- line = buffer.slice!(0..index).strip
262
- yield line.delete_prefix("data:").strip if line.start_with?("data:")
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 stream_request(config, request_headers, body)
268
- Enumerator.new do |yielder|
269
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config[:total_timeout]
270
- yielded = false
271
- (config[:max_retries] + 1).times do |attempt|
272
- begin
273
- http, request = http_request(config, deadline, request_headers.merge("Accept" => "text/event-stream"), body)
274
- retry_response = false
275
- completed = false
276
- http.request(request) do |response|
277
- status = response.code.to_i
278
- unless status.between?(200, 299)
279
- response.read_body { |_chunk| nil }
280
- if (status == 429 || status >= 500) && attempt < config[:max_retries]
281
- retry_delay(response, deadline)
282
- retry_response = true
283
- next
284
- end
285
- raise AiHTTPError.new("AI provider returned HTTP #{status}", status)
286
- end
287
- each_stream_data(response, deadline) do |data|
288
- completed, text = stream_delta(config[:provider], data)
289
- break if completed
290
- next if text.nil?
291
- yielded = true
292
- yielder << text
293
- end
294
- end
295
- next if retry_response
296
- raise AiParseError, "AI provider stream ended before [DONE]" unless completed
297
- break
298
- rescue Net::OpenTimeout
299
- raise AiTimeoutError, "AI connection timeout expired" if yielded || attempt >= config[:max_retries]
300
- rescue Net::ReadTimeout, Timeout::Error
301
- raise AiTimeoutError, "AI total request timeout expired" if yielded || attempt >= config[:max_retries]
302
- rescue SocketError, EOFError, IOError, SystemCallError => e
303
- raise AiHTTPError, "AI transport failed (#{e.class.name})" if yielded || attempt >= config[:max_retries]
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
 
@@ -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"]
@@ -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 is injected into EVERY
921
- # text/html response (including 404s), so the reflected request
922
- # method/path/matched-pattern MUST be HTML-escaped or a crafted path
923
- # reflects <script> that runs in the dev-server origin and can then drive
924
- # every ungated /__dev mutation route. (Parity with PHP htmlspecialchars
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
- ai_badge = ai_port ? '<span style="background:#7c3aed;color:#fff;font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold;">AI PORT</span>' : ""
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
- <div id="tina4-dev-toolbar" style="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;">
936
- #{ai_badge}<span id="tina4-ver-btn" style="color:#d32f2f;font-weight:bold;cursor:pointer;text-decoration:underline dotted;" onclick="tina4VersionModal()" title="Click to check for updates">Tina4 v#{version}</span>
937
- <div id="tina4-ver-modal" style="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,0.5);font-family:monospace;font-size:13px;color:#cdd6f4;">
938
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
939
- <strong style="color:#89b4fa;">Version Info</strong>
940
- <span onclick="document.getElementById('tina4-ver-modal').style.display='none'" style="cursor:pointer;color:#888;">&times;</span>
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">&times;</span>
941
955
  </div>
942
- <div id="tina4-ver-body" style="line-height:1.8;">
943
- <div>Current: <strong style="color:#a6e3a1;">v#{version}</strong></div>
944
- <div id="tina4-ver-latest" style="color:#888;">Checking for updates...</div>
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 style="color:#4caf50;">#{method}</span>
961
+ <span class="t4-green">#{method}</span>
948
962
  <span>#{path}</span>
949
- <span style="color:#666;">&rarr; #{matched_pattern}</span>
950
- <span style="color:#ffeb3b;">req:#{request_id}</span>
951
- <span style="color:#90caf9;">#{route_count} routes</span>
952
- <span style="color:#888;">Ruby #{RUBY_VERSION}</span>
953
- <a href="#" onclick="window.__tina4ToggleOverlay(event)" style="color:#ef9a9a;margin-left:auto;text-decoration:none;cursor:pointer;">Dashboard &#8599;</a>
954
- <span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">&#10005;</span>
963
+ <span class="t4-arrow">&rarr; #{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 &#8599;</a>
968
+ <span id="tina4-bar-close" class="t4-x t4-bar-close">&#10005;</span>
955
969
  </div>
956
- <script>
957
- // Overlay open/toggle helper + auto-restore. Persist the dev-admin
958
- // iframe's open/closed state across parent reloads so saving a
959
- // file doesn't lose the user's dev-admin context. Cross-framework
960
- // parity with PHP / Python / Node — same localStorage key.
961
- (function(){
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> &mdash; 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">&#9888; Major/minor version change &mdash; 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> &mdash; 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
- window.__tina4ToggleOverlay = function(e) {
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
- </script>
1001
- <script>
1002
- function tina4VersionModal(){
1003
- var m=document.getElementById('tina4-ver-modal');
1004
- if(m.style.display==='block'){m.style.display='none';return;}
1005
- m.style.display='block';
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> &mdash; 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;">&#9888; Major/minor version change &mdash; 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> &mdash; not yet published).';
1038
- el.style.color='#cba6f7';
1039
- }else{
1040
- el.innerHTML='Latest: <strong style="color:#a6e3a1;">v'+latest+'</strong> &mdash; 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
- .catch(function(){
1046
- el.innerHTML='Could not check for updates (offline?)';
1047
- el.style.color='#f38ba8';
1048
- });
1049
- }
1050
- #{ai_port ? "" : dev_reload_client_js}
1051
- </script>
1052
- HTML
1053
-
1054
- if body.include?("</body>")
1055
- body.sub("</body>", "#{toolbar}\n</body>")
1056
- else
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 _t4_poll(){
1096
- fetch('/__dev/api/mtime').then(function(r){return r.json()}).then(function(d){
1097
- if(_t4_mtime===null){_t4_mtime=d.mtime;return;}
1098
- if(d.mtime!==_t4_mtime){
1099
- _t4_mtime=d.mtime;
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 _t4_connect(){
1114
- var url=(location.protocol==='https:'?'wss':'ws')+'://'+location.host+'/__dev_reload';
1115
- try{_t4_ws=new WebSocket(url);}catch(_){_t4_startPoll();return;}
1116
- _t4_ws.addEventListener('open',function(){_t4_stopPoll();});
1117
- _t4_ws.addEventListener('message',function(ev){
1118
- var d=null;
1119
- try{d=typeof ev.data==='string'?JSON.parse(ev.data):null;}catch(_){}
1120
- if(!d)return;
1121
- if(d.type==='reload'||d.type==='change'||d.type==='css'){
1122
- if(_t4_debounce)clearTimeout(_t4_debounce);
1123
- _t4_debounce=setTimeout(function(){_t4_apply(d);},150);
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
- _t4_ws.addEventListener('close',function(){_t4_ws=null;_t4_startPoll();setTimeout(_t4_connect,2000);});
1127
- _t4_ws.addEventListener('error',function(){try{_t4_ws&&_t4_ws.close();}catch(_){}});
1143
+ ws.addEventListener('close', function () { ws = null; startPoll(); setTimeout(connect, 2000); });
1144
+ ws.addEventListener('error', function () { try { ws && ws.close(); } catch (_) {} });
1128
1145
  }
1129
- _t4_connect();
1146
+ connect();
1130
1147
  })();
1131
1148
  JS
1132
1149
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.111"
4
+ VERSION = "3.13.113"
5
5
  end
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.111
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-21 00:00:00.000000000 Z
11
+ date: 2026-08-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack