1import { Controller } from "@hotwired/stimulus" 2import { formatAiUsage } from "controllers/ai_usage" 3 4const FIELDS = ["title", "subtitle", "meta_description", "body", "tags", "reading_time"] 5 6export default class extends Controller { 7 static targets = ["promptInput", "submitButton", "loading", "error", "undoButton", "usage"] 8 static values = { articleId: String } 9 10 connect() { 11 this.previousValues = null 12 } 13 14 getFieldValues() { 15 const values = {} 16 FIELDS.forEach(field => { 17 const el = document.getElementById(`article_${field}`) 18 if (el) values[field] = el.value 19 }) 20 return values 21 } 22 23 setFieldValues(data) { 24 FIELDS.forEach(field => { 25 const el = document.getElementById(`article_${field}`) 26 if (el && data[field] != null) el.value = data[field] 27 }) 28 } 29 30 async apply(event) { 31 event.preventDefault() 32 33 const prompt = this.promptInputTarget.value.trim() 34 if (!prompt) { 35 this.showError("Escribe una instrucción primero.") 36 return 37 } 38 39 this.previousValues = this.getFieldValues() 40 this.showLoading() 41 42 try { 43 const response = await fetch(`/admin/articles/${this.articleIdValue}/edit_with_ai`, { 44 method: "POST", 45 credentials: "same-origin", 46 headers: { 47 "Content-Type": "application/json", 48 "Accept": "application/json", 49 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content 50 }, 51 body: JSON.stringify({ 52 ...this.previousValues, 53 prompt: prompt 54 }) 55 }) 56 57 const data = await response.json() 58 59 if (!response.ok) { 60 throw new Error(data.error || "Error al iniciar la edición") 61 } 62 63 const result = await this.pollForResult( 64 `/admin/articles/edit_status?job_id=${data.job_id}` 65 ) 66 this.setFieldValues(result) 67 // Totales acumulados del artÃculo (el servidor ya los ha guardado). 68 if (result.ai && this.hasUsageTarget) this.usageTarget.textContent = formatAiUsage(result.ai) 69 this.promptInputTarget.value = "" 70 this.undoButtonTarget.classList.remove("hidden") 71 } catch (e) { 72 this.showError(e.message) 73 } finally { 74 this.hideLoading() 75 } 76 } 77 78 async pollForResult(url) { 79 const timeout = 600000 // mismo tope que el read_timeout del servicio Ruby (10 min) 80 const interval = 2000 81 const start = Date.now() 82 83 while (Date.now() - start < timeout) { 84 await new Promise(r => setTimeout(r, interval)) 85 86 const response = await fetch(url, { 87 credentials: "same-origin", 88 headers: { "Accept": "application/json" } 89 }) 90 const data = await response.json() 91 92 if (data.status === "done") return data 93 if (data.status === "error") throw new Error(data.error) 94 } 95 96 throw new Error("Timeout: la edición tardó demasiado. Inténtalo de nuevo.") 97 } 98 99 undo() { 100 if (this.previousValues !== null) { 101 this.setFieldValues(this.previousValues) 102 this.previousValues = null 103 this.undoButtonTarget.classList.add("hidden") 104 } 105 } 106 107 showLoading() { 108 this.submitButtonTarget.disabled = true 109 this.loadingTarget.classList.remove("hidden") 110 this.errorTarget.classList.add("hidden") 111 } 112 113 hideLoading() { 114 this.submitButtonTarget.disabled = false 115 this.loadingTarget.classList.add("hidden") 116 } 117 118 showError(message) { 119 this.errorTarget.textContent = message 120 this.errorTarget.classList.remove("hidden") 121 } 122}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.