File size: 15,576 Bytes
3e0f245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
#!/usr/bin/env python
"""Web Research Agent (#13) — Flask UI.

Golden-standard research UX (inline domain chips, visible research steps, source passages on click)
plus our wedge: 'dig deeper' on any citation, selection, or source — with YOUR own steering text —
spawns a scoped research pass appended as an expandable, recursive branch.

    pip install -r requirements.txt
    python app.py            # http://127.0.0.1:7860
"""
import os

from flask import Flask, request, jsonify, render_template_string

import agent
import llm

app = Flask(__name__)
AREAS = ["web", "opportunity", "technical", "competitive", "client"]

PAGE = r"""<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>GritAI · Research</title>
<link rel="stylesheet" href="/static/gritai.css">
<style>
  .wrap{max-width:1240px;margin:0 auto;padding:22px 20px 90px}
  .bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px}
  .bar input#q{flex:1;min-width:260px;background:var(--panel);border:1px solid var(--line2);border-radius:3px;
    color:var(--ink);font-family:var(--body);font-size:15.5px;padding:14px 16px;outline:none}
  .bar select{background:var(--panel);border:1px solid var(--line2);border-radius:3px;color:var(--ink);
    font-family:var(--mono);font-size:12px;padding:13px 10px;text-transform:uppercase;letter-spacing:.08em}
  .cols{display:grid;grid-template-columns:1fr 320px;gap:20px;margin-top:22px}
  @media(max-width:860px){.cols{grid-template-columns:1fr}}
  .muted{color:var(--dim2);font-family:var(--mono);font-size:10.5px;letter-spacing:.2em;text-transform:uppercase}
  .steps{border-left:2px solid var(--grit-dim);padding:6px 0 6px 14px;margin:6px 0 20px}
  .steps div{color:var(--dim);font-family:var(--mono);font-size:12px;padding:2px 0}
  .report{font-size:15.5px;line-height:1.75}
  .report h4{font-family:var(--disp);color:var(--grit);font-size:14px;letter-spacing:.05em;text-transform:uppercase;margin:20px 0 8px}
  .report p{margin:11px 0}.report li{margin:5px 0}
  .chip{display:inline-flex;align-items:baseline;gap:4px;background:var(--panel2);border:1px solid var(--line2);
    border-radius:3px;padding:1px 6px;margin:0 2px;font-family:var(--mono);font-size:11px;color:var(--dim);
    cursor:pointer;vertical-align:baseline;transition:.14s;white-space:nowrap}
  .chip:hover{color:var(--grit);border-color:var(--grit-dim)}
  .chip b{color:var(--grit);font-weight:600}
  .card{border:1px solid var(--line2);border-radius:3px;background:var(--panel);padding:13px 14px;margin-bottom:10px}
  .card .dom{font-family:var(--mono);font-size:10.5px;color:var(--grit);letter-spacing:.06em}
  .card .ti{font-size:13.5px;margin:4px 0 6px;color:var(--ink);line-height:1.35}
  .card .ex{font-size:12px;color:var(--dim);line-height:1.5;max-height:56px;overflow:hidden}
  .dig{border:0;background:transparent;color:var(--dim);border:1px solid var(--line2);border-radius:3px;
    font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;padding:5px 9px;
    margin-top:8px;cursor:pointer;transition:.14s}
  .dig:hover{color:var(--grit);border-color:var(--grit-dim)}
  #pop{position:fixed;z-index:60;max-width:340px;background:var(--panel2);border:1px solid var(--line2);
    border-radius:4px;padding:12px 13px;box-shadow:0 10px 40px -8px #000;display:none}
  #pop .dom{font-family:var(--mono);font-size:10.5px;color:var(--grit)}
  #pop .ti{font-size:13px;color:var(--ink);margin:4px 0 6px}#pop .ex{font-size:11.5px;color:var(--dim);line-height:1.5}
  #pop a{color:var(--grit2);font-size:11px;font-family:var(--mono);text-decoration:none}
  #selbtn{position:absolute;z-index:70;display:none}
  .digbox{border:1px solid var(--grit-dim);border-radius:4px;background:var(--panel2);padding:14px;margin:12px 0}
  .digbox .fx{font-size:12.5px;color:var(--dim);border-left:2px solid var(--grit);padding-left:9px;margin-bottom:9px}
  .digbox textarea{width:100%;box-sizing:border-box;background:var(--panel);border:1px solid var(--line2);border-radius:3px;
    color:var(--ink);font-family:var(--body);font-size:14px;padding:10px;min-height:56px;resize:vertical;outline:none}
  .branch{border:1px solid var(--line);border-left:3px solid var(--grit);border-radius:3px;background:var(--bg2);
    padding:14px 16px;margin:14px 0}
  .branch .bh{font-family:var(--mono);font-size:11px;color:var(--grit);letter-spacing:.06em;margin-bottom:8px}
  .branch .srcline{font-size:11px;color:var(--dim);font-family:var(--mono);margin-top:8px}
  .spin{display:inline-block;color:var(--grit);font-family:var(--mono);font-size:12px}
  .run{border:0;background:var(--grit);color:#140a06;font-family:var(--mono);font-weight:600;font-size:12px;
    letter-spacing:.12em;text-transform:uppercase;padding:14px 20px;cursor:pointer;border-radius:3px}
  .run:hover{background:var(--grit2)}
</style></head><body>
<div class="wrap">
  <header style="display:flex;align-items:center;gap:13px">
    <svg width="34" height="34" viewBox="0 0 38 38" fill="none"><rect x="1" y="1" width="36" height="36" rx="2" stroke="#2b303c"/><path d="M27 12.5A9 9 0 1 0 28 22H19" stroke="#ff5a2a" stroke-width="2.4" stroke-linecap="square"/><rect x="10.5" y="10.5" width="3" height="3" fill="#ff5a2a"/></svg>
    <div class="brand"><div class="co">Grit<b>AI</b> · Research</div>
    <div class="sub">Cited web research · dig deeper into anything</div></div>
    <div style="flex:1"></div><div class="tag hot">#13 · 30-in-15</div>
  </header>

  <div class="bar">
    <input id="q" placeholder="Ask a research question…" autofocus>
    <select id="area">__AREAS__</select>
    <select id="mode"><option value="quick">Quick</option><option value="deep" selected>Deep</option></select>
    <button class="run" onclick="ask()">Research ▸</button>
  </div>

  <div class="cols">
    <div>
      <div id="status"></div>
      <div id="steps" class="steps" style="display:none"></div>
      <div id="report" class="report"></div>
      <div id="branches"></div>
    </div>
    <div>
      <div class="muted" id="srchead" style="display:none;margin-bottom:10px">Sources</div>
      <div id="sources"></div>
    </div>
  </div>
  <p class="muted" style="margin-top:34px">Backend: __BACKEND__ · local fleet by default · GritAI Solutions</p>
</div>

<div id="pop"></div>
<button id="selbtn" class="dig" onmousedown="event.preventDefault()">⌕ dig deeper</button>

<script>
const BLOCKS = {};           // blockId -> sources[]
let curSel = null;           // {text, blockId, question}
let bseq = 0;

function esc(s){return (s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}

function mdChips(text, blockId, sources){
  // minimal markdown -> html, then citation tokens -> domain chips
  const lines=(text||"").split("\n"); let out=[], ul=false;
  const flush=()=>{if(ul){out.push("</ul>");ul=false;}};
  for(let raw of lines){
    let h=esc(raw.trim());
    let mh=raw.match(/^\s*#{1,6}\s*(.+?):?\s*$/);
    let mli=raw.match(/^\s*[-*]\s+(.+)$/);
    if(mh){flush();out.push("<h4>"+esc(mh[1])+"</h4>");}
    else if(mli){if(!ul){out.push("<ul>");ul=true;}out.push("<li>"+esc(mli[1])+"</li>");}
    else if(h){flush();out.push("<p>"+h+"</p>");}
  }
  flush();
  let html=out.join("\n").replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>");
  // citations [n] [n,m] [n-m]
  html=html.replace(/\[([\d\s,–-]+)\]/g,(m,grp)=>{
    let nums=[]; grp.split(",").forEach(p=>{p=p.trim().replace("–","-");
      if(p.includes("-")){let[a,b]=p.split("-").map(x=>parseInt(x));for(let i=a;i<=b;i++)nums.push(i);}
      else if(/^\d+$/.test(p))nums.push(parseInt(p));});
    return nums.map(n=>{let s=sources[n-1]; if(!s)return "";
      return `<span class="chip" data-b="${blockId}" data-n="${n}"><span>${esc(s.domain)}</span><b>${n}</b></span>`;
    }).join("");
  });
  return html;
}

function renderSources(sources){
  const box=document.getElementById("sources"); box.innerHTML="";
  document.getElementById("srchead").style.display=sources.length?"block":"none";
  sources.forEach(s=>{
    const d=document.createElement("div"); d.className="card";
    d.innerHTML=`<div class="dom">${esc(s.domain)} · [${s.n}]</div>
      <div class="ti">${esc(s.title)}</div><div class="ex">${esc(s.excerpt)}</div>
      <button class="dig">⌕ dig deeper</button>`;
    d.querySelector(".dig").onclick=()=>openDig(s.title, "root", document.getElementById("q").dataset.q||"");
    box.appendChild(d);
  });
}

function popover(chip){
  const s=BLOCKS[chip.dataset.b][parseInt(chip.dataset.n)-1]; if(!s)return;
  const p=document.getElementById("pop");
  p.innerHTML=`<div class="dom">${esc(s.domain)}</div><div class="ti">${esc(s.title)}</div>
    <div class="ex">${esc(s.excerpt)}…</div><a href="${esc(s.url)}" target="_blank" rel="noopener">open source ↗</a>`;
  const r=chip.getBoundingClientRect();
  p.style.display="block";
  p.style.left=Math.min(r.left, window.innerWidth-360)+"px";
  p.style.top=(r.bottom+6)+"px";
}
document.addEventListener("click",e=>{
  const chip=e.target.closest(".chip");
  if(chip){popover(chip);e.stopPropagation();return;}
  if(!e.target.closest("#pop")) document.getElementById("pop").style.display="none";
});

// selection -> floating dig button
document.addEventListener("mouseup",e=>{
  if(e.target.closest("#selbtn"))return;
  const sel=window.getSelection(); const t=sel.toString().trim();
  const btn=document.getElementById("selbtn");
  const block=e.target.closest("[data-question]");
  if(t.length>8 && block){
    const r=sel.getRangeAt(0).getBoundingClientRect();
    btn.style.display="block";
    btn.style.left=(window.scrollX+r.left)+"px";
    btn.style.top=(window.scrollY+r.bottom+6)+"px";
    curSel={text:t, blockId:block.dataset.block, question:block.dataset.question};
  } else if(!e.target.closest(".digbox")) btn.style.display="none";
});
document.getElementById("selbtn").onclick=()=>{
  document.getElementById("selbtn").style.display="none";
  if(curSel) openDig(curSel.text, curSel.blockId, curSel.question);
};

function openDig(focus, blockId, question){
  const box=document.createElement("div"); box.className="digbox";
  box.innerHTML=`<div class="fx">Dig deeper into: “${esc(focus.slice(0,160))}”</div>
    <textarea placeholder="What do you want explored? (optional — e.g. 'focus on the cost figures' or 'find counter-evidence')"></textarea>
    <div style="margin-top:9px;display:flex;gap:8px">
      <button class="run">Research ▸</button>
      <button class="dig cancel">cancel</button></div>`;
  const anchor=(blockId==="root")?document.getElementById("branches"):
     (document.querySelector(`[data-block="${blockId}"] .branch-slot`)||document.getElementById("branches"));
  anchor.prepend(box);
  box.querySelector(".cancel").onclick=()=>box.remove();
  box.querySelector(".run").onclick=()=>{
    const instr=box.querySelector("textarea").value;
    const target=anchor; box.remove();
    dig(focus, instr, question, target);
  };
  box.scrollIntoView({behavior:"smooth",block:"center"});
}

async function post(url,body){
  const r=await fetch(url,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
  return r.json();
}

async function ask(){
  const q=document.getElementById("q").value.trim(); if(!q)return;
  document.getElementById("q").dataset.q=q;
  const area=document.getElementById("area").value, mode=document.getElementById("mode").value;
  document.getElementById("steps").style.display="none";
  document.getElementById("report").innerHTML=""; document.getElementById("branches").innerHTML="";
  document.getElementById("sources").innerHTML=""; document.getElementById("srchead").style.display="none";
  const st=document.getElementById("status");
  st.innerHTML=`<span class="spin">◐ researching (${mode})… this runs live searches + reads pages</span>`;
  let dots=0; const iv=setInterval(()=>{dots=(dots+1)%4; st.querySelector(".spin").textContent="◐ researching ("+mode+")"+".".repeat(dots)+" — live searches + page reads";},500);
  try{
    const res=await post("/research",{question:q,mode,area});
    clearInterval(iv); st.innerHTML="";
    const blockId="root"; BLOCKS[blockId]=res.sources||[];
    const rep=document.getElementById("report");
    rep.setAttribute("data-question",q); rep.setAttribute("data-block",blockId);
    const steps=document.getElementById("steps");
    steps.style.display="block"; steps.innerHTML="<div class='muted'>Research steps</div>"+
      (res.trace||[]).map(t=>"<div>· "+esc(t)+"</div>").join("");
    rep.innerHTML=mdChips(res.report,blockId,res.sources||[]);
    renderSources(res.sources||[]);
  }catch(e){clearInterval(iv);st.innerHTML="<span style='color:var(--bad)'>Research failed — try again.</span>";}
}

async function dig(focus, instruction, parent, targetEl){
  const area=document.getElementById("area").value, mode=document.getElementById("mode").value;
  const br=document.createElement("div"); br.className="branch";
  const bid="b"+(++bseq);
  br.setAttribute("data-question",parent); br.setAttribute("data-block",bid);
  br.innerHTML=`<div class="bh">⌕ digging: ${esc((instruction||focus).slice(0,120))} <span class="spin">◐…</span></div>`;
  targetEl.prepend(br);
  try{
    const res=await post("/dig",{focus,instruction,parent,area,mode});
    BLOCKS[bid]=res.sources||[];
    br.innerHTML=`<div class="bh">⌕ ${esc((instruction||focus).slice(0,120))}</div>
      <div class="report">${mdChips(res.report,bid,res.sources||[])}</div>
      <div class="srcline">${(res.sources||[]).map(s=>`[${s.n}] ${esc(s.domain)}`).join("  ·  ")}</div>
      <div class="branch-slot"></div>`;
  }catch(e){br.innerHTML="<div class='bh' style='color:var(--bad)'>dig failed — try again</div>";}
}

document.getElementById("q").addEventListener("keydown",e=>{if(e.key==="Enter")ask();});
</script></body></html>"""


@app.route("/")
def index():
    opts = "".join(f'<option value="{a}">{a.title()}</option>' for a in AREAS)
    html = PAGE.replace("__AREAS__", opts).replace("__BACKEND__", llm.backend_label())
    return render_template_string(html)


@app.route("/research", methods=["POST"])
def do_research():
    d = request.get_json(force=True)
    q = (d.get("question") or "").strip()[:500]
    if not q:
        return jsonify({"report": "Ask a question.", "sources": [], "trace": []})
    mode = d.get("mode", "deep") if d.get("mode") in ("quick", "deep", "swarm") else "deep"
    area = d.get("area", "web")
    try:
        res = agent.research(q, mode=mode, area=area)
        return jsonify(res)
    except Exception as e:
        print(f"research error: {type(e).__name__}")
        return jsonify({"report": "Research failed — try again.", "sources": [], "trace": []}), 200


@app.route("/dig", methods=["POST"])
def do_dig():
    d = request.get_json(force=True)
    focus = (d.get("focus") or "").strip()[:600]
    instruction = (d.get("instruction") or "").strip()[:400]
    parent = (d.get("parent") or "").strip()[:500]
    area = d.get("area", "web")
    mode = d.get("mode", "deep") if d.get("mode") in ("quick", "deep", "swarm") else "deep"
    try:
        res = agent.dig(focus, parent, instruction=instruction, area=area, mode=mode)
        return jsonify(res)
    except Exception as e:
        print(f"dig error: {type(e).__name__}")
        return jsonify({"report": "Dig failed — try again.", "sources": [], "trace": []}), 200


if __name__ == "__main__":
    port = int(os.environ.get("PORT", "7860"))
    app.run(host="0.0.0.0", port=port, debug=False)