tina4ruby 3.12.10 → 3.12.13
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/lib/tina4/cli.rb +3 -3
- data/lib/tina4/dev_admin.rb +152 -20
- data/lib/tina4/error_overlay.rb +32 -2
- data/lib/tina4/feedback.rb +307 -0
- data/lib/tina4/mcp.rb +237 -10
- data/lib/tina4/plan.rb +41 -13
- data/lib/tina4/public/__feedback/widget.js +96 -0
- data/lib/tina4/rack_app.rb +85 -4
- data/lib/tina4/request.rb +8 -4
- data/lib/tina4/router.rb +137 -3
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +4 -2
data/lib/tina4/mcp.rb
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
require "json"
|
|
22
22
|
require "socket"
|
|
23
23
|
require "fileutils"
|
|
24
|
+
require "open3"
|
|
24
25
|
|
|
25
26
|
module Tina4
|
|
26
27
|
# ── JSON-RPC 2.0 codec ────────────────────────────────────────────
|
|
@@ -457,7 +458,111 @@ module Tina4
|
|
|
457
458
|
project_root = File.expand_path(Dir.pwd)
|
|
458
459
|
|
|
459
460
|
# ── Helpers ────────────────────────────────────────
|
|
461
|
+
# Append a structured line to `.tina4/agent.log` AND echo to STDERR.
|
|
462
|
+
# Mirrors the Python `_agent_log` helper so a single log file
|
|
463
|
+
# captures every agent action regardless of which side of the
|
|
464
|
+
# stack performed it. Cheap — never blocks the caller on I/O
|
|
465
|
+
# failure.
|
|
466
|
+
agent_log = lambda do |category, message|
|
|
467
|
+
begin
|
|
468
|
+
log_dir = File.join(project_root, ".tina4")
|
|
469
|
+
FileUtils.mkdir_p(log_dir)
|
|
470
|
+
log_path = File.join(log_dir, "agent.log")
|
|
471
|
+
ts = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
472
|
+
File.open(log_path, "a") { |f| f.write("#{ts} [#{category}] #{message}\n") }
|
|
473
|
+
rescue
|
|
474
|
+
# logging must never fail the actual call
|
|
475
|
+
end
|
|
476
|
+
warn " [agent #{category}] #{message}"
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Paths that look like prose rather than filesystem paths. AI
|
|
480
|
+
# agents occasionally pass natural language ("The plan requires
|
|
481
|
+
# implementing...") as `path` to file_write and produce folders
|
|
482
|
+
# with prose for names. Returns an error string on rejection,
|
|
483
|
+
# nil on acceptance.
|
|
484
|
+
sane_path_segment = /\A[A-Za-z0-9._\-]+\z/
|
|
485
|
+
looks_like_prose = lambda do |rel_path|
|
|
486
|
+
if rel_path.nil? || rel_path.strip.empty?
|
|
487
|
+
break "path is empty"
|
|
488
|
+
end
|
|
489
|
+
if rel_path.length > 300
|
|
490
|
+
break "path too long (#{rel_path.length} chars); use a real filename"
|
|
491
|
+
end
|
|
492
|
+
bad_sequences = ["`", "\n", "\t", " ", " — ", " (", " [", "?", "*", "<", ">", "|"]
|
|
493
|
+
bad_hit = bad_sequences.find { |bad| rel_path.include?(bad) }
|
|
494
|
+
if bad_hit
|
|
495
|
+
break "path contains illegal character sequence #{bad_hit.inspect} — looks like prose, not a filename"
|
|
496
|
+
end
|
|
497
|
+
segment_error = nil
|
|
498
|
+
rel_path.split("/").each do |seg|
|
|
499
|
+
next if seg.empty? || seg == "." || seg == ".."
|
|
500
|
+
if seg.length > 80
|
|
501
|
+
segment_error = "path segment too long: #{seg[0, 60].inspect}… — use a short filename"
|
|
502
|
+
break
|
|
503
|
+
end
|
|
504
|
+
unless sane_path_segment.match?(seg)
|
|
505
|
+
segment_error = "path segment #{seg.inspect} contains disallowed characters — stick to [A-Za-z0-9._-]"
|
|
506
|
+
break
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
segment_error
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Rewrite bare top-level Tina4-conventional directories into
|
|
513
|
+
# their `src/<dir>/` canonical form. The framework's
|
|
514
|
+
# auto-discovery only scans `src/`, so a file at
|
|
515
|
+
# `templates/foo.twig` is dead weight — the framework never
|
|
516
|
+
# loads it. Mirrors normalize_coder_path() in the Python agent.
|
|
517
|
+
normalize_coder_path = lambda do |rel_path|
|
|
518
|
+
passthrough_prefixes = ["src/", "migrations/", "plan/", "tests/",
|
|
519
|
+
"test/", ".tina4/"]
|
|
520
|
+
passthrough_files = %w[app.py app.ts app.rb index.php composer.json
|
|
521
|
+
package.json Gemfile pyproject.toml
|
|
522
|
+
requirements.txt .env .env.example]
|
|
523
|
+
if passthrough_prefixes.any? { |p| rel_path.start_with?(p) }
|
|
524
|
+
next rel_path
|
|
525
|
+
end
|
|
526
|
+
if passthrough_files.include?(rel_path)
|
|
527
|
+
next rel_path
|
|
528
|
+
end
|
|
529
|
+
%w[routes orm templates seeds controllers models middleware].each do |d|
|
|
530
|
+
if rel_path.start_with?("#{d}/")
|
|
531
|
+
rewritten = "src/#{rel_path}"
|
|
532
|
+
agent_log.call("write.path_normalized", "#{rel_path} → #{rewritten}")
|
|
533
|
+
return rewritten
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
rel_path
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Copy `target` into `.tina4/backups/` with a timestamped name.
|
|
540
|
+
# Returns the relative backup path on success, nil on failure.
|
|
541
|
+
agent_backup = lambda do |target|
|
|
542
|
+
begin
|
|
543
|
+
next nil unless File.file?(target)
|
|
544
|
+
backup_dir = File.join(project_root, ".tina4", "backups")
|
|
545
|
+
FileUtils.mkdir_p(backup_dir)
|
|
546
|
+
rel = if target.start_with?("#{project_root}/")
|
|
547
|
+
target.sub("#{project_root}/", "")
|
|
548
|
+
else
|
|
549
|
+
File.basename(target)
|
|
550
|
+
end
|
|
551
|
+
safe = rel.gsub("/", "__").gsub("\\", "__")
|
|
552
|
+
ts = Time.now.utc.strftime("%Y-%m-%dT%H-%M-%SZ")
|
|
553
|
+
backup_name = "#{safe}.#{ts}.bak"
|
|
554
|
+
backup_path = File.join(backup_dir, backup_name)
|
|
555
|
+
File.binwrite(backup_path, File.binread(target))
|
|
556
|
+
".tina4/backups/#{backup_name}"
|
|
557
|
+
rescue => e
|
|
558
|
+
agent_log.call("write.backup_failed", "#{target}: #{e.message}")
|
|
559
|
+
nil
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
460
563
|
safe_path = lambda do |rel_path|
|
|
564
|
+
err = looks_like_prose.call(rel_path)
|
|
565
|
+
raise ArgumentError, "Invalid path #{rel_path.inspect}: #{err}" if err
|
|
461
566
|
resolved = File.expand_path(rel_path, project_root)
|
|
462
567
|
unless resolved.start_with?(project_root)
|
|
463
568
|
raise ArgumentError, "Path escapes project directory: #{rel_path}"
|
|
@@ -465,6 +570,52 @@ module Tina4
|
|
|
465
570
|
resolved
|
|
466
571
|
end
|
|
467
572
|
|
|
573
|
+
# Try `ruby -c` on a freshly-written Ruby file to catch syntax
|
|
574
|
+
# errors BEFORE the next request hits the broken handler.
|
|
575
|
+
# Mirrors _verify_python_import() in tina4_python/mcp/tools.py.
|
|
576
|
+
#
|
|
577
|
+
# Returns nil on success (or when verification is skipped), or
|
|
578
|
+
# the captured error string on failure. Only checks files under
|
|
579
|
+
# src/ that end in .rb — skips spec_helper.rb, *_spec.rb, and
|
|
580
|
+
# test_*.rb because those have their own loading patterns
|
|
581
|
+
# (mirrors Python's skip of __init__.py / conftest.py / test_*.py).
|
|
582
|
+
#
|
|
583
|
+
# Why this matters: the AI coder repeatedly produces Ruby with
|
|
584
|
+
# unclosed blocks, missing `end`, dangling parens. Running
|
|
585
|
+
# `ruby -c` right after write catches it inline and surfaces
|
|
586
|
+
# the real Ruby error in the file_write response — the LLM
|
|
587
|
+
# sees the error on its next turn and can retry with context.
|
|
588
|
+
verify_ruby_syntax = lambda do |rel_path|
|
|
589
|
+
next nil unless rel_path.end_with?(".rb")
|
|
590
|
+
next nil unless rel_path.start_with?("src/")
|
|
591
|
+
base = File.basename(rel_path)
|
|
592
|
+
next nil if base == "spec_helper.rb"
|
|
593
|
+
next nil if base.end_with?("_spec.rb")
|
|
594
|
+
next nil if base.start_with?("test_")
|
|
595
|
+
|
|
596
|
+
abs_path = File.expand_path(rel_path, project_root)
|
|
597
|
+
begin
|
|
598
|
+
stdout, stderr, status = Open3.capture3("ruby", "-c", abs_path)
|
|
599
|
+
rescue StandardError => e
|
|
600
|
+
next "verification subprocess failed: #{e.message}"
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
next nil if status.success?
|
|
604
|
+
|
|
605
|
+
# Pull the first meaningful stderr line — ruby -c emits
|
|
606
|
+
# "<file>:<line>: syntax error, ...". Strip the absolute
|
|
607
|
+
# path prefix for readability.
|
|
608
|
+
err_lines = (stderr || "").strip.split("\n")
|
|
609
|
+
if err_lines.empty?
|
|
610
|
+
next "syntax check failed (exit #{status.exitstatus}, no stderr)"
|
|
611
|
+
end
|
|
612
|
+
line = err_lines.first.strip
|
|
613
|
+
# Strip the absolute project_root prefix so the error reads
|
|
614
|
+
# as "src/routes/foo.rb:3: syntax error, ..." instead of the
|
|
615
|
+
# full /Users/... path.
|
|
616
|
+
line.sub("#{project_root}/", "")
|
|
617
|
+
end
|
|
618
|
+
|
|
468
619
|
redact_env = lambda do |key, value|
|
|
469
620
|
sensitive = %w[secret password token key credential api_key]
|
|
470
621
|
if sensitive.any? { |s| key.downcase.include?(s) }
|
|
@@ -549,12 +700,55 @@ module Tina4
|
|
|
549
700
|
}, "Read a project file")
|
|
550
701
|
|
|
551
702
|
server.register_tool("file_write", lambda { |path:, content:|
|
|
703
|
+
# 1. Coder-path normalization — rewrite bare top-level Tina4
|
|
704
|
+
# directories (templates/, routes/, orm/, ...) into their
|
|
705
|
+
# src/ canonical form before resolving.
|
|
706
|
+
path = normalize_coder_path.call(path)
|
|
707
|
+
# 2. safe_path runs looks_like_prose then sandbox check.
|
|
552
708
|
p = safe_path.call(path)
|
|
709
|
+
|
|
710
|
+
old_bytes = File.file?(p) ? File.binread(p) : ""
|
|
711
|
+
old_size = old_bytes.bytesize
|
|
712
|
+
old_lines = old_bytes.count("\n")
|
|
713
|
+
new_bytes = content.to_s
|
|
714
|
+
new_size = new_bytes.bytesize
|
|
715
|
+
new_lines = new_bytes.count("\n")
|
|
716
|
+
rel = p.start_with?("#{project_root}/") ? p.sub("#{project_root}/", "") : path
|
|
717
|
+
|
|
718
|
+
# 3. Truncation guard — refuse suspicious shrinkage on
|
|
719
|
+
# non-trivial files (>200B → <30% of size).
|
|
720
|
+
if old_size > 200 && (new_size * 100) < (old_size * 30)
|
|
721
|
+
msg = "REFUSED #{rel} (would shrink #{old_size} → #{new_size} bytes / " \
|
|
722
|
+
"#{old_lines} → #{new_lines} lines, looks truncated)"
|
|
723
|
+
agent_log.call("write.refused", msg)
|
|
724
|
+
next { "error" => msg, "refused" => true, "old_bytes" => old_size, "new_bytes" => new_size }
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
# 4. Backup before overwrite.
|
|
728
|
+
backup_rel = old_size > 0 ? agent_backup.call(p) : nil
|
|
729
|
+
|
|
553
730
|
FileUtils.mkdir_p(File.dirname(p))
|
|
554
|
-
File.write(p,
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
731
|
+
File.write(p, new_bytes, encoding: "utf-8")
|
|
732
|
+
|
|
733
|
+
# 5. Audit log.
|
|
734
|
+
agent_log.call("write.ok",
|
|
735
|
+
"#{rel} (#{old_size}B/#{old_lines}L → #{new_size}B/#{new_lines}L, " \
|
|
736
|
+
"backup: #{backup_rel || '(no prior file)'})")
|
|
737
|
+
|
|
738
|
+
result = { "written" => rel, "bytes" => new_size }
|
|
739
|
+
result["backup"] = backup_rel if backup_rel
|
|
740
|
+
|
|
741
|
+
# 6. Post-write syntax check — catch hallucinated Ruby
|
|
742
|
+
# (missing `end`, unclosed parens, etc.) before the next
|
|
743
|
+
# request hits the broken handler.
|
|
744
|
+
err = verify_ruby_syntax.call(rel)
|
|
745
|
+
if err
|
|
746
|
+
result["import_error"] = err
|
|
747
|
+
agent_log.call("write.import_failed", "#{rel}: #{err}")
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
result
|
|
751
|
+
}, "Write or update a project file (with backup, truncation guard, audit log)")
|
|
558
752
|
|
|
559
753
|
server.register_tool("file_list", lambda { |path: "."|
|
|
560
754
|
p = safe_path.call(path)
|
|
@@ -706,25 +900,58 @@ module Tina4
|
|
|
706
900
|
|
|
707
901
|
# ── File patch ────────────────────────────────────
|
|
708
902
|
server.register_tool("file_patch", lambda { |path:, old_string:, new_string:, count: 1|
|
|
903
|
+
# 1. Normalize, 2. safe_path (which runs the prose check).
|
|
904
|
+
path = normalize_coder_path.call(path)
|
|
709
905
|
p = safe_path.call(path)
|
|
710
|
-
|
|
906
|
+
|
|
907
|
+
# 3. Existence check.
|
|
908
|
+
next { "error" => "File not found: #{path}" } unless File.file?(p)
|
|
909
|
+
|
|
711
910
|
original = File.read(p, encoding: "utf-8")
|
|
712
911
|
occurrences = original.scan(old_string).size
|
|
713
|
-
|
|
912
|
+
|
|
913
|
+
# 4. Match-count guard (already-existing behaviour).
|
|
914
|
+
next { "error" => "old_string not found in #{path}" } if occurrences.zero?
|
|
714
915
|
if occurrences != count.to_i
|
|
715
|
-
|
|
916
|
+
next({ "error" => "old_string appears #{occurrences} times, expected #{count}. Expand old_string to make it unique, or set count explicitly." })
|
|
716
917
|
end
|
|
918
|
+
|
|
717
919
|
updated = original.sub(old_string, new_string)
|
|
718
920
|
# Ruby String#sub replaces first; if count > 1, do N replacements
|
|
719
921
|
if count.to_i > 1
|
|
720
922
|
updated = original.dup
|
|
721
923
|
count.to_i.times { updated.sub!(old_string, new_string) }
|
|
722
924
|
end
|
|
925
|
+
|
|
926
|
+
rel = p.start_with?("#{project_root}/") ? p.sub("#{project_root}/", "") : path
|
|
927
|
+
|
|
928
|
+
# 5. Backup before overwrite — same path layout as file_write
|
|
929
|
+
# so recovery is uniform regardless of which tool touched
|
|
930
|
+
# the file.
|
|
931
|
+
backup_rel = agent_backup.call(p)
|
|
932
|
+
|
|
723
933
|
File.write(p, updated, encoding: "utf-8")
|
|
724
|
-
|
|
934
|
+
|
|
725
935
|
Tina4::Plan.record_action("patched", rel) if defined?(Tina4::Plan)
|
|
726
|
-
|
|
727
|
-
|
|
936
|
+
|
|
937
|
+
old_size = original.bytesize
|
|
938
|
+
new_size = updated.bytesize
|
|
939
|
+
agent_log.call("patch.ok",
|
|
940
|
+
"#{rel} (replaced #{count.to_i}× old_string, " \
|
|
941
|
+
"#{old_size}B → #{new_size}B, backup: #{backup_rel || '(none)'})")
|
|
942
|
+
|
|
943
|
+
result = { "patched" => rel, "replacements" => count.to_i, "bytes" => new_size }
|
|
944
|
+
result["backup"] = backup_rel if backup_rel
|
|
945
|
+
|
|
946
|
+
# Post-patch syntax check — same rationale as file_write.
|
|
947
|
+
err = verify_ruby_syntax.call(rel)
|
|
948
|
+
if err
|
|
949
|
+
result["import_error"] = err
|
|
950
|
+
agent_log.call("patch.import_failed", "#{rel}: #{err}")
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
result
|
|
954
|
+
}, "Targeted edit: replace old_string with new_string in a file (with backup + audit log)")
|
|
728
955
|
|
|
729
956
|
# ── Docs tools ────────────────────────────────────
|
|
730
957
|
framework_doc_paths = lambda do
|
data/lib/tina4/plan.rb
CHANGED
|
@@ -125,23 +125,51 @@ module Tina4
|
|
|
125
125
|
|
|
126
126
|
# ── Public API ─────────────────────────────────────────────
|
|
127
127
|
|
|
128
|
+
# All plan files — merged from `plan/` (user-curated) and
|
|
129
|
+
# `.tina4/plans/` (where the Rust supervisor's planner writes).
|
|
130
|
+
#
|
|
131
|
+
# Two directories exist because of a historic split: the framework
|
|
132
|
+
# treats `plan/` as the canonical project location, but the Rust
|
|
133
|
+
# agent's planner writes to `.tina4/plans/` (alongside other
|
|
134
|
+
# AI-state artefacts like chat history). Until those are unified we
|
|
135
|
+
# read both so plans created either way are discoverable. Dedup by
|
|
136
|
+
# filename — plan/ wins on collision. Sorted newest-first by
|
|
137
|
+
# filename (filenames start with unix timestamps).
|
|
128
138
|
def list_plans
|
|
129
|
-
d = plan_dir
|
|
130
139
|
cur = current_name || ""
|
|
140
|
+
# Order matters for dedup: user-curated plan/ first, then .tina4/plans/.
|
|
141
|
+
dirs = []
|
|
142
|
+
primary = File.join(project_root, PLAN_DIR)
|
|
143
|
+
dirs << primary if Dir.exist?(primary)
|
|
144
|
+
rust_plans = File.join(project_root, ".tina4", "plans")
|
|
145
|
+
dirs << rust_plans if Dir.exist?(rust_plans)
|
|
146
|
+
|
|
147
|
+
seen = {}
|
|
131
148
|
out = []
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
"
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
dirs.each do |dir|
|
|
150
|
+
Dir.glob(File.join(dir, "*.md")).sort.each do |path|
|
|
151
|
+
name = File.basename(path)
|
|
152
|
+
next if seen.key?(name) # plan/ wins over .tina4/plans/ on name clash
|
|
153
|
+
seen[name] = true
|
|
154
|
+
parsed = parse(File.read(path, encoding: "utf-8"))
|
|
155
|
+
total = parsed["steps"].size
|
|
156
|
+
done = parsed["steps"].count { |s| s["done"] }
|
|
157
|
+
out << {
|
|
158
|
+
"name" => name,
|
|
159
|
+
"title" => parsed["title"].to_s.empty? ? File.basename(name, ".md") : parsed["title"],
|
|
160
|
+
"steps_total" => total,
|
|
161
|
+
"steps_done" => done,
|
|
162
|
+
"is_current" => name == cur,
|
|
163
|
+
# Relative path from project root — lets the SPA open the
|
|
164
|
+
# right file in the editor regardless of which dir the
|
|
165
|
+
# plan came from.
|
|
166
|
+
"path" => path.sub("#{project_root}/", "")
|
|
167
|
+
}
|
|
168
|
+
end
|
|
144
169
|
end
|
|
170
|
+
# Newest first by name (filenames start with unix timestamps).
|
|
171
|
+
out.sort_by! { |x| x["name"] }
|
|
172
|
+
out.reverse!
|
|
145
173
|
out
|
|
146
174
|
end
|
|
147
175
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
(function(){"use strict";(()=>{try{const t=window.location.pathname||"";return t.startsWith("/__dev")||t.startsWith("/__feedback")}catch{return!1}})()?console.info("tina4-feedback-widget: skipping on developer path"):window.__tina4FeedbackLoaded?console.warn("tina4-feedback-widget already loaded; skipping"):(window.__tina4FeedbackLoaded=!0,b());function b(){const c=(getComputedStyle(document.documentElement).getPropertyValue("--primary")||"").trim()||"#3b82f6";h(c);const l=m();document.body.appendChild(l);let e=null,u;const r=[];l.addEventListener("click",()=>{if(e){e.remove(),e=null,l.style.display="";return}e=g(),document.body.appendChild(e),l.style.display="none",setTimeout(()=>e?.querySelector("textarea")?.focus(),0)});function g(){const o=document.createElement("div");o.className="tina4-fb-modal",o.innerHTML=`
|
|
2
|
+
<div class="tina4-fb-head">
|
|
3
|
+
<span class="tina4-fb-title">Tell us what's not working</span>
|
|
4
|
+
<button type="button" class="tina4-fb-close" aria-label="Close">×</button>
|
|
5
|
+
</div>
|
|
6
|
+
<div class="tina4-fb-context">
|
|
7
|
+
<span>📍 ${p(location.pathname+location.search)}</span>
|
|
8
|
+
<span>📐 ${window.innerWidth}×${window.innerHeight}</span>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="tina4-fb-chat" role="log"></div>
|
|
11
|
+
<form class="tina4-fb-form">
|
|
12
|
+
<textarea
|
|
13
|
+
rows="3"
|
|
14
|
+
placeholder="What's hard to use here? Be specific — which field, which button, what you expected."
|
|
15
|
+
aria-label="Feedback message"
|
|
16
|
+
></textarea>
|
|
17
|
+
<button type="submit" class="tina4-fb-send">Send</button>
|
|
18
|
+
</form>
|
|
19
|
+
`,o.querySelector(".tina4-fb-close")?.addEventListener("click",()=>{o.remove(),e=null,l.style.display=""});const a=o.querySelector("form");return a.addEventListener("submit",n=>{n.preventDefault();const i=a.querySelector("textarea"),f=i.value.trim();f&&(i.value="",x(f))}),s(o),o}function s(o){const a=o.querySelector(".tina4-fb-chat");if(a){if(!r.length){a.innerHTML=`<div class="tina4-fb-hint">Your feedback lands directly with the team — no email loop. We'll ask a quick follow-up if we need to.</div>`;return}a.innerHTML=r.map(n=>`<div class="tina4-fb-msg ${n.role==="user"?"tina4-fb-user":"tina4-fb-ai"}">${p(n.text)}</div>`).join(""),a.scrollTop=a.scrollHeight}}async function x(o){if(!e)return;r.push({role:"user",text:o}),s(e),d(e,!0);const a={message:o,context:{url:location.pathname+location.search,viewport:`${window.innerWidth}x${window.innerHeight}`,ua:navigator.userAgent},conversation_id:u};let n;try{const i=await fetch("/__feedback/api/turn",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)});if(n=await i.json(),!i.ok){const f=n?.error||`HTTP ${i.status}`;r.push({role:"ai",text:`Couldn't send: ${f}`}),s(e),d(e,!1);return}}catch(i){r.push({role:"ai",text:`Network issue: ${i?.message||i}`}),s(e),d(e,!1);return}if("ask"in n)u=n.conversation_id,r.push({role:"ai",text:n.ask}),s(e),d(e,!1),e?.querySelector("textarea")?.focus();else if("final"in n)r.push({role:"ai",text:`Thanks — filed as: "${n.final.title}". The team will take it from here.`}),s(e),d(e,!1),u=void 0,r.length=0,setTimeout(()=>{e?.remove(),e=null,l.style.display=""},4500);else{const i=n?.error||"unexpected response";r.push({role:"ai",text:`Issue: ${i}`}),s(e),d(e,!1)}}function d(o,a){const n=o.querySelector(".tina4-fb-send"),i=o.querySelector("textarea");n&&(n.disabled=a,n.textContent=a?"Sending…":"Send"),i&&(i.disabled=a)}}function m(){const t=document.createElement("button");return t.type="button",t.className="tina4-fb-btn",t.setAttribute("aria-label","Send feedback"),t.innerHTML="💬",t.title="Tell us what's not working",t}function h(t){const c=document.createElement("style");c.id="tina4-fb-styles",c.textContent=`
|
|
20
|
+
.tina4-fb-btn {
|
|
21
|
+
position: fixed; bottom: 1.25rem; right: 1.25rem;
|
|
22
|
+
width: 48px; height: 48px; border-radius: 50%; border: none;
|
|
23
|
+
background: ${t}; color: white; font-size: 1.4rem;
|
|
24
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.18); cursor: pointer;
|
|
25
|
+
z-index: 2147483646; transition: transform 0.15s, box-shadow 0.15s;
|
|
26
|
+
display: flex; align-items: center; justify-content: center;
|
|
27
|
+
line-height: 1; padding: 0;
|
|
28
|
+
}
|
|
29
|
+
.tina4-fb-btn:hover { transform: scale(1.06); box-shadow: 0 6px 16px rgba(0,0,0,0.22); }
|
|
30
|
+
.tina4-fb-btn:active { transform: scale(0.96); }
|
|
31
|
+
.tina4-fb-modal {
|
|
32
|
+
position: fixed; bottom: 5rem; right: 1.25rem;
|
|
33
|
+
width: 340px; max-height: 480px; display: flex; flex-direction: column;
|
|
34
|
+
background: #1e1e2e; color: #cdd6f4;
|
|
35
|
+
border: 1px solid #313244; border-radius: 8px;
|
|
36
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.35);
|
|
37
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
38
|
+
font-size: 0.85rem; z-index: 2147483647;
|
|
39
|
+
animation: tina4-fb-in 0.18s ease-out;
|
|
40
|
+
}
|
|
41
|
+
@keyframes tina4-fb-in {
|
|
42
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
43
|
+
to { opacity: 1; transform: translateY(0); }
|
|
44
|
+
}
|
|
45
|
+
.tina4-fb-head {
|
|
46
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
47
|
+
padding: 0.6rem 0.8rem; border-bottom: 1px solid #313244;
|
|
48
|
+
}
|
|
49
|
+
.tina4-fb-title { font-weight: 600; font-size: 0.9rem; }
|
|
50
|
+
.tina4-fb-close {
|
|
51
|
+
background: transparent; border: none; color: #9399b2;
|
|
52
|
+
font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.2rem;
|
|
53
|
+
}
|
|
54
|
+
.tina4-fb-close:hover { color: #cdd6f4; }
|
|
55
|
+
.tina4-fb-context {
|
|
56
|
+
display: flex; gap: 0.6rem; padding: 0.4rem 0.8rem;
|
|
57
|
+
font-size: 0.7rem; color: #9399b2;
|
|
58
|
+
border-bottom: 1px solid #313244;
|
|
59
|
+
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
|
60
|
+
}
|
|
61
|
+
.tina4-fb-chat {
|
|
62
|
+
flex: 1; overflow-y: auto; padding: 0.5rem 0.8rem;
|
|
63
|
+
display: flex; flex-direction: column; gap: 0.4rem;
|
|
64
|
+
min-height: 80px; max-height: 280px;
|
|
65
|
+
}
|
|
66
|
+
.tina4-fb-hint {
|
|
67
|
+
font-size: 0.75rem; color: #9399b2; line-height: 1.4; padding: 0.3rem 0;
|
|
68
|
+
}
|
|
69
|
+
.tina4-fb-msg {
|
|
70
|
+
padding: 0.4rem 0.6rem; border-radius: 6px;
|
|
71
|
+
max-width: 85%; word-wrap: break-word; line-height: 1.35;
|
|
72
|
+
}
|
|
73
|
+
.tina4-fb-user { align-self: flex-end; background: ${t}; color: white; }
|
|
74
|
+
.tina4-fb-ai { align-self: flex-start; background: #313244; }
|
|
75
|
+
.tina4-fb-form {
|
|
76
|
+
display: flex; flex-direction: column; gap: 0.4rem;
|
|
77
|
+
padding: 0.5rem 0.8rem 0.8rem; border-top: 1px solid #313244;
|
|
78
|
+
}
|
|
79
|
+
.tina4-fb-form textarea {
|
|
80
|
+
width: 100%; box-sizing: border-box; resize: vertical;
|
|
81
|
+
min-height: 60px; font-family: inherit; font-size: 0.82rem;
|
|
82
|
+
padding: 0.4rem 0.5rem; border: 1px solid #313244;
|
|
83
|
+
background: #11111b; color: #cdd6f4; border-radius: 4px;
|
|
84
|
+
line-height: 1.3;
|
|
85
|
+
}
|
|
86
|
+
.tina4-fb-form textarea:focus {
|
|
87
|
+
outline: none; border-color: ${t};
|
|
88
|
+
}
|
|
89
|
+
.tina4-fb-send {
|
|
90
|
+
align-self: flex-end; padding: 0.35rem 0.9rem;
|
|
91
|
+
background: ${t}; color: white; border: none; border-radius: 4px;
|
|
92
|
+
font-size: 0.8rem; font-weight: 500; cursor: pointer;
|
|
93
|
+
}
|
|
94
|
+
.tina4-fb-send:disabled { opacity: 0.55; cursor: wait; }
|
|
95
|
+
.tina4-fb-send:hover:not(:disabled) { filter: brightness(1.1); }
|
|
96
|
+
`,document.head.appendChild(c)}function p(t){return t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}})();
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -43,8 +43,18 @@ module Tina4
|
|
|
43
43
|
path = env["PATH_INFO"] || "/"
|
|
44
44
|
request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
45
45
|
|
|
46
|
-
# Fast-path:
|
|
47
|
-
|
|
46
|
+
# Fast-path: CORS preflight. Real CORS preflight requests carry an
|
|
47
|
+
# Origin header AND an Access-Control-Request-Method header — the
|
|
48
|
+
# browser is asking "may I send this method?" before the actual
|
|
49
|
+
# request. If neither is present, the OPTIONS is a plain protocol-
|
|
50
|
+
# introspection request (link checker, monitoring probe, RFC 9110
|
|
51
|
+
# §9.3.7 OPTIONS) and must fall through to the router's generic
|
|
52
|
+
# Allow-header response. Otherwise we'd shadow the framework's own
|
|
53
|
+
# OPTIONS support and force every operator to hand-register CORS
|
|
54
|
+
# exceptions for every introspection client.
|
|
55
|
+
if method == "OPTIONS" && (env["HTTP_ORIGIN"] || env["HTTP_ACCESS_CONTROL_REQUEST_METHOD"])
|
|
56
|
+
return Tina4::CorsMiddleware.preflight_response(env)
|
|
57
|
+
end
|
|
48
58
|
|
|
49
59
|
# WebSocket upgrade — match against registered ws_routes
|
|
50
60
|
if websocket_upgrade?(env)
|
|
@@ -65,6 +75,15 @@ module Tina4
|
|
|
65
75
|
return dev_response if dev_response
|
|
66
76
|
end
|
|
67
77
|
|
|
78
|
+
# Customer feedback widget routes (parity with Python's /__feedback/*
|
|
79
|
+
# surface — see tina4/feedback.rb). Always available — the master
|
|
80
|
+
# switch (TINA4_ENABLE_FEEDBACK) is enforced INSIDE handle_request
|
|
81
|
+
# so route shape stays stable across environments.
|
|
82
|
+
if path.start_with?("/__feedback")
|
|
83
|
+
fb_response = Tina4::Feedback.handle_request(env)
|
|
84
|
+
return fb_response if fb_response
|
|
85
|
+
end
|
|
86
|
+
|
|
68
87
|
# Fast-path: API routes skip static file + swagger checks entirely
|
|
69
88
|
unless path.start_with?("/api/")
|
|
70
89
|
# Swagger
|
|
@@ -87,8 +106,43 @@ module Tina4
|
|
|
87
106
|
rack_response = handle_route(env, route, path_params)
|
|
88
107
|
matched_pattern = route.path
|
|
89
108
|
else
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
# RFC 9110 conformance — before falling through to 404, check whether
|
|
110
|
+
# the PATH is known to the router under any OTHER method.
|
|
111
|
+
# - OPTIONS request → 204 with Allow header (§9.3.7)
|
|
112
|
+
# - Any other method (PUT on GET-only, TRACE, CONNECT, etc.)
|
|
113
|
+
# → 405 with Allow header (§15.5.6 + §10.2.1)
|
|
114
|
+
allowed = Tina4::Router.methods_allowed_for_path(path)
|
|
115
|
+
if !allowed.empty?
|
|
116
|
+
allow_header = allowed.join(", ")
|
|
117
|
+
if method.to_s.upcase == "OPTIONS"
|
|
118
|
+
rack_response = [204, { "allow" => allow_header, "content-length" => "0" }, [""]]
|
|
119
|
+
else
|
|
120
|
+
body = %({"error":"Method Not Allowed","path":"#{path}","method":"#{method}","allow":[#{allowed.map { |m| %("#{m}") }.join(",")}],"status":405})
|
|
121
|
+
rack_response = [405, {
|
|
122
|
+
"allow" => allow_header,
|
|
123
|
+
"content-type" => "application/json",
|
|
124
|
+
"content-length" => body.bytesize.to_s
|
|
125
|
+
}, [body]]
|
|
126
|
+
end
|
|
127
|
+
matched_pattern = nil
|
|
128
|
+
else
|
|
129
|
+
rack_response = handle_404(path)
|
|
130
|
+
matched_pattern = nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# RFC 9110 §9.3.2: a HEAD response MUST NOT include content. Strip
|
|
135
|
+
# the body unconditionally and record what Content-Length the GET
|
|
136
|
+
# would have sent. Cache validators / link checkers / monitoring
|
|
137
|
+
# probes use that header to estimate sizes.
|
|
138
|
+
if method.to_s.upcase == "HEAD"
|
|
139
|
+
status, headers, body_parts = rack_response
|
|
140
|
+
joined = body_parts.respond_to?(:join) ? body_parts.join : body_parts.to_s
|
|
141
|
+
unless joined.empty?
|
|
142
|
+
new_headers = headers.dup
|
|
143
|
+
new_headers["content-length"] = joined.bytesize.to_s
|
|
144
|
+
rack_response = [status, new_headers, [""]]
|
|
145
|
+
end
|
|
92
146
|
end
|
|
93
147
|
|
|
94
148
|
# Capture request for dev inspector
|
|
@@ -118,6 +172,33 @@ module Tina4
|
|
|
118
172
|
end
|
|
119
173
|
end
|
|
120
174
|
|
|
175
|
+
# Customer feedback widget injection — runs LAST so its <script>
|
|
176
|
+
# tag survives any earlier post-processing. No-op if disabled
|
|
177
|
+
# (TINA4_ENABLE_FEEDBACK off), the user isn't whitelisted, the
|
|
178
|
+
# path is /__dev or /__feedback, or the body isn't text/html with
|
|
179
|
+
# a closing </body> tag. Mirrors Python's server.py call site —
|
|
180
|
+
# see tina4_python/core/server.py around line 1543.
|
|
181
|
+
begin
|
|
182
|
+
status, headers, body_parts = rack_response
|
|
183
|
+
content_type = headers["content-type"] || ""
|
|
184
|
+
if content_type.include?("text/html") && body_parts.respond_to?(:join)
|
|
185
|
+
joined = body_parts.join
|
|
186
|
+
if joined.include?("</body>")
|
|
187
|
+
injected = Tina4::Feedback.inject_feedback_widget(
|
|
188
|
+
Struct.new(:path, :env).new(path, env),
|
|
189
|
+
joined
|
|
190
|
+
)
|
|
191
|
+
if injected != joined
|
|
192
|
+
new_headers = headers.dup
|
|
193
|
+
new_headers["content-length"] = injected.bytesize.to_s if new_headers["content-length"]
|
|
194
|
+
rack_response = [status, new_headers, [injected]]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
rescue StandardError
|
|
199
|
+
# Injection is best-effort — never break the response.
|
|
200
|
+
end
|
|
201
|
+
|
|
121
202
|
# Save session and set cookie if session was used
|
|
122
203
|
if result && defined?(rack_response)
|
|
123
204
|
status, headers, body_parts = rack_response
|
data/lib/tina4/request.rb
CHANGED
|
@@ -75,13 +75,17 @@ module Tina4
|
|
|
75
75
|
@body_parsed = nil
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
# Full URL
|
|
78
|
+
# Full absolute URL — scheme://host[:port]/path[?query].
|
|
79
|
+
# Honours X-Forwarded-Proto / X-Forwarded-Host so apps behind a proxy
|
|
80
|
+
# still see the URL the client used. Matches Python/PHP/Node parity.
|
|
79
81
|
def url
|
|
80
|
-
scheme = env["rack.url_scheme"] || "http"
|
|
81
|
-
host = env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
|
|
82
|
+
scheme = env["HTTP_X_FORWARDED_PROTO"] || env["rack.url_scheme"] || "http"
|
|
83
|
+
host = env["HTTP_X_FORWARDED_HOST"] || env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
|
|
82
84
|
port = env["SERVER_PORT"]
|
|
83
85
|
url_str = "#{scheme}://#{host}"
|
|
84
|
-
|
|
86
|
+
# Only append :port when the host doesn't already include one
|
|
87
|
+
# (HTTP_HOST often does) and it's not the default for the scheme.
|
|
88
|
+
url_str += ":#{port}" if port && !host.include?(":") && port.to_s != "80" && port.to_s != "443"
|
|
85
89
|
url_str += @path
|
|
86
90
|
url_str += "?#{@query_string}" unless @query_string.empty?
|
|
87
91
|
url_str
|