COFOS-Chat / interface.py
Willlzh's picture
Fix contrast throughout terminal and graph; add persistent System Light Dark appearance
40f5226 verified
Raw History Blame Contribute Delete
14.3 kB
"""Terminal styling, browser routing and a source-grounded SVG evidence graph."""
import html
import math
import re
from retrieval import DATASET, REVISION
CSS = '''
:root {--co-bg:#f4f5ef;--co-panel:#ffffff;--co-input:#fafbf7;--co-text:#20291d;--co-muted:#53634b;--co-border:#c6cebe;--co-accent:#426525;--co-button:#426525;--co-on-button:#ffffff;--co-hover:#e5ebdd;--co-source:#236e82;--co-entity:#8a4e20;color-scheme:light}
:root[data-cofos-theme="dark"] {--co-bg:#111510;--co-panel:#1b2118;--co-input:#131910;--co-text:#edf2e5;--co-muted:#b5c3a7;--co-border:#53614a;--co-accent:#c2e297;--co-button:#c2e297;--co-on-button:#18240f;--co-hover:#2d3825;--co-source:#8bd3e5;--co-entity:#f0bd83;color-scheme:dark}
body,.gradio-container{background:var(--co-bg)!important;color:var(--co-text)!important;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace!important}
.gradio-container{max-width:1500px!important;padding:24px!important;--body-text-color:var(--co-text);--body-text-color-subdued:var(--co-muted);--body-background-fill:var(--co-bg);--block-background-fill:var(--co-panel);--input-background-fill:var(--co-input);--input-text-color:var(--co-text);--block-label-text-color:var(--co-muted)}
.gradio-container *{color:var(--co-text)!important}
.gradio-container svg{color:var(--co-text)!important}
footer{display:none!important}
.block,.form,.panel{background:transparent!important;border-color:var(--co-border)!important}
#masthead{border-bottom:1px solid var(--co-border);padding-bottom:20px;margin-bottom:12px}
.brand{font-size:30px;letter-spacing:-2px;font-weight:700}.brand span{color:var(--co-accent)!important}
.eyebrow{font-size:11px;letter-spacing:2px;color:var(--co-muted)!important;margin-bottom:10px}
.mast-row{display:flex;justify-content:space-between;align-items:center;gap:20px}
.muted{color:var(--co-muted)!important;font-size:12px;line-height:1.7}
.live{color:var(--co-accent)!important;font-size:11px;border:1px solid var(--co-border);padding:7px 10px;border-radius:4px}
.theme-control{display:flex;gap:8px;align-items:center;margin-top:12px;font-size:12px}
.theme-control select{background:var(--co-panel);color:var(--co-text);border:1px solid var(--co-border);padding:7px 28px 7px 10px;border-radius:4px;font:inherit;cursor:pointer}
#terminal,#evidence{background:var(--co-panel)!important;border:1px solid var(--co-border)!important;border-radius:8px!important;padding:18px!important}
#conversation{border:none!important;background:transparent!important}
#conversation .message{font-family:inherit!important;font-size:14px!important;line-height:1.8!important;background:transparent!important}
#conversation .message *,#conversation .prose *{color:var(--co-text)!important}
#conversation pre,#conversation code,#route-log code{background:var(--co-input)!important;color:var(--co-text)!important}
#conversation .icon-button{background:var(--co-hover)!important;color:var(--co-text)!important}
#conversation .icon-button[aria-label="清空对话"],#conversation .icon-button[aria-label="Clear conversation"]{display:none!important}
#prompt textarea{background:var(--co-input)!important;border:1px solid var(--co-border)!important;border-radius:5px!important;color:var(--co-text)!important;font-family:inherit!important;padding:15px!important;box-shadow:none!important}
#prompt textarea::placeholder{color:var(--co-muted)!important;opacity:1!important}
#prompt textarea:focus{border-color:var(--co-accent)!important;outline:2px solid var(--co-accent)!important;outline-offset:2px}
button{font-family:inherit!important;border-radius:4px!important}
#run,#run *{background:var(--co-button)!important;color:var(--co-on-button)!important;border:0!important;font-weight:700}
#stop,#clear{background:var(--co-hover)!important;color:var(--co-text)!important;border:1px solid var(--co-border)!important}
button:disabled{opacity:.55!important}
#route-log{min-height:28px}#route-log p,#route-log code{font-family:inherit!important;font-size:12px!important;color:var(--co-accent)!important}
.graph-toolbar{display:flex;justify-content:space-between;align-items:center;margin:12px 0;font-size:11px;letter-spacing:1px;gap:10px}
.graph-toolbar button{border:1px solid var(--co-border);background:var(--co-hover);padding:5px 9px;cursor:pointer}
.graph-canvas{background:radial-gradient(var(--co-border) 1px,transparent 1px);background-size:18px 18px;border:1px solid var(--co-border);border-radius:5px;overflow:hidden}
.graph-canvas svg{display:block;width:100%;height:auto;min-height:260px}
.graph-canvas text{fill:var(--co-text)!important;stroke:var(--co-panel)!important;stroke-width:3px;paint-order:stroke; font-size:11px}
.graph-canvas line{stroke:var(--co-accent)!important}.graph-canvas line[stroke-dasharray]{stroke:var(--co-border)!important}
.graph-node{cursor:pointer;outline:none}.graph-node circle{fill:var(--co-panel)!important;stroke:var(--co-source)!important}
.graph-node[data-detail="query"] circle{stroke:var(--co-accent)!important}
.graph-node:hover circle,.graph-node:focus circle,.graph-node.selected circle{stroke:var(--co-entity)!important;stroke-width:4px}
.graph-detail{border-top:1px solid var(--co-border);margin-top:14px;padding-top:14px;font-size:12px;line-height:1.8;overflow-wrap:anywhere}
.graph-detail p{font-size:12px!important;line-height:1.8!important}.graph-detail a{color:var(--co-accent)!important;text-decoration:underline}
.graph-detail h4{font-size:13px!important;margin:0 0 8px}
.legend{font-size:11px;line-height:1.8;margin:10px 0;color:var(--co-muted)!important}
.empty-graph{padding:45px 16px;text-align:center;color:var(--co-muted)!important;font-size:12px;line-height:2}
.bottom-line{border-top:1px solid var(--co-border);padding-top:14px;margin-top:15px;color:var(--co-muted)!important;font-size:11px;letter-spacing:.5px}
@media(max-width:800px){.gradio-container{padding:12px!important}.brand{font-size:25px}.mast-row{align-items:flex-start;flex-wrap:wrap}.live{font-size:10px}#terminal{padding:10px!important}}
'''
HEADER = '''<div id="masthead"><div class="eyebrow">COF / ROS RESEARCH ENVIRONMENT</div><div class="mast-row"><div><div class="brand"><span>❯</span> COFOS<span>_</span></div><div class="muted">Your chemistry research terminal. Ask, trace, investigate.</div></div><div class="live">● ZERO GPU / ONLINE</div></div><label class="theme-control">Appearance <select aria-label="Appearance"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label></div>'''
WELCOME = '''<div style="padding:30px 10px;color:#b1bc9f;text-align:left"><pre style="color:#a8c67b;font-size:13px"> β”Œβ”€ COFOS / research session ─────────────┐
β”‚ Model reasoning. Traceable evidence. β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜</pre><p>Ask a chemistry question or describe what you want to find.</p><p style="color:#77866a;font-size:12px">Try: What ROS does TT-T-COF generate?<br>Try: Find sources about H2O2 photocatalysis.</p></div>'''
# This exact classifier runs in the browser before dispatch. Python mirrors it
# for API clients and checks the hint; no model call is spent on routing.
ROUTE_JS = r'''(question, history, route) => {
const q = question.trim().toLowerCase();
const lookup = /^(find|search|look up|list|show|retrieve)\b/.test(q) && /\b(sources?|papers?|evidence|references?|documents?|studies)\b/.test(q);
const chemistry = /cof|ros|h2o2|oxygen|photocatal|superoxide|singlet|radical|redox|mechanism|peroxide|evidence|papers?|sources?|ζ΄»ζ€§ζ°§|ε…‰ε‚¬εŒ–|θΏ‡ζ°§εŒ–ζ°’|ζ‘†ζžΆ/.test(q);
const followup = history.length > 0 && /\b(it|its|they|their|those|this|that|more|why|how)\b/.test(q);
route = lookup ? 'retrieval' : ((chemistry || followup) ? 'rag' : 'model');
return [question, history, route];
}'''
def route_query(question, history):
q = question.strip().lower()
lookup = re.search(r'^(find|search|look up|list|show|retrieve)\b', q) and re.search(r'\b(sources?|papers?|evidence|references?|documents?|studies)\b', q)
chemistry = re.search(r'cof|ros|h2o2|oxygen|photocatal|superoxide|singlet|radical|redox|mechanism|peroxide|evidence|papers?|sources?|ζ΄»ζ€§ζ°§|ε…‰ε‚¬εŒ–|θΏ‡ζ°§εŒ–ζ°’|ζ‘†ζžΆ', q)
followup = bool(history) and re.search(r'\b(it|its|they|their|those|this|that|more|why|how)\b', q)
return 'retrieval' if lookup else ('rag' if chemistry or followup else 'model')
THEME_JS = '''
const picker = element.querySelector('select');
const media = window.matchMedia('(prefers-color-scheme: dark)');
let mode='system';
try {mode=localStorage.getItem('cofos-appearance')||'system';} catch (_) {}
if (!['system','light','dark'].includes(mode)) mode='system';
picker.value=mode;
function apply(){document.documentElement.dataset.cofosTheme=mode==='system'?(media.matches?'dark':'light'):mode;}
picker.addEventListener('change',()=>{mode=picker.value;try{localStorage.setItem('cofos-appearance',mode);}catch(_){}apply();});
media.addEventListener('change',()=>{if(mode==='system')apply();});
apply();
'''
GRAPH_JS = '''
function selectNode(node) {
const root = node.closest('.evidence-graph');
root.querySelectorAll('.graph-node').forEach(n => n.classList.remove('selected'));
node.classList.add('selected');
const key = node.dataset.detail;
root.querySelectorAll('.graph-detail').forEach(d => { d.hidden = d.dataset.key !== key; });
}
element.addEventListener('click', e => {
const node = e.target.closest('.graph-node'); if (node) selectNode(node);
const btn = e.target.closest('[data-zoom]');
if (btn) { const svg=btn.closest('.evidence-graph').querySelector('svg');
const old=Number(svg.dataset.scale||1); const scale=btn.dataset.zoom==='reset'?1:Math.min(2.5,Math.max(.6,old*Number(btn.dataset.zoom)));
svg.dataset.scale=scale; svg.querySelector('.graph-world').setAttribute('transform',`translate(260 195) scale(${scale}) translate(-260 -195)`); }
});
element.addEventListener('keydown',e=>{if((e.key==='Enter'||e.key===' ')&&e.target.matches('.graph-node')){e.preventDefault();selectNode(e.target);}});
'''
def esc(value):
return html.escape(str(value), quote=True)
def graph_html(items, question='', route='idle'):
if not items:
msg = 'Evidence will appear here as your investigation unfolds.' if route == 'idle' else ('Model-only response. No dataset evidence was retrieved.' if route == 'model' else 'No matching evidence. Try a material name or different keywords.')
return f'<div class="evidence-graph"><div class="eyebrow">EVIDENCE EXPLORER</div><div class="empty-graph">β—Œ<br>{msg}</div><div class="legend">Materials β†’ relations β†’ products<br>Click a node to inspect its source.</div></div>'
nodes = [{'label':'QUERY','kind':'query','detail':'query'}]
edges=[]
details = [f'<div class="graph-detail" data-key="query"><h4>Current investigation</h4><p>{esc(question)}</p><p>Dashed edges mean retrieval relevance, not experimental support. Solid labelled edges come from dataset KG triples.</p></div>']
entity_ids={}
for i,item in enumerate(items,1):
key=f'source-{i}'; idx=len(nodes)
nodes.append({'label':f'[{i}] '+item['kind'],'kind':'source','detail':key})
edges.append((0,idx,'retrieved',True))
url=f'https://huggingface.co/datasets/{DATASET}/blob/{REVISION}/{item["file"]}'
details.append(f'<div class="graph-detail" data-key="{key}" hidden><h4>[{i}] {esc(item["kind"])} / row {item["row"]}</h4><p>Level: {esc(item.get("evidence_level","unspecified"))} Β· Support: {esc(item.get("support_status","unspecified"))}</p><p>{esc(item["text"][:2200])}</p><a href="{esc(url)}" target="_blank" rel="noopener noreferrer">Open dataset source β†—</a></div>')
if item['kind']=='KG':
pair=[]
for field in ('head','tail'):
entity=item.get(field,'')
if entity not in entity_ids:
entity_ids[entity]=len(nodes)
label=entity.split(':',1)[-1].replace('_',' ')
nodes.append({'label':label[:25]+('…' if len(label)>25 else ''),'kind':'entity','detail':key})
pair.append(entity_ids[entity])
edges.append((idx,pair[0],'describes',True))
edges.append((pair[0],pair[1],item.get('relation','related').replace('_',' '),False))
n=len(nodes)-1
positions=[(260,195)] + [(260+178*math.cos(-math.pi/2+2*math.pi*j/n),195+133*math.sin(-math.pi/2+2*math.pi*j/n)) for j in range(n)]
svg=['<svg viewBox="0 0 520 390" role="group" aria-label="Interactive evidence knowledge graph"><g class="graph-world">']
for a,b,label,dashed in edges:
x,y=positions[a];xx,yy=positions[b]
dash='stroke-dasharray="4,5"' if dashed else ''
svg.append(f'<line x1="{x}" y1="{y}" x2="{xx}" y2="{yy}" stroke="{"#526045" if dashed else "#b0c885"}" stroke-width="1.4" {dash}/>')
if not dashed:
svg.append(f'<text x="{(x+xx)/2}" y="{(y+yy)/2-8}" fill="#d1dba9" font-size="9" text-anchor="middle" stroke="#151812" stroke-width="3" paint-order="stroke">{esc(label)}</text>')
for i,node in enumerate(nodes):
x,y=positions[i];color={'query':'#c1d894','source':'#83acb0','entity':'#cda77c'}[node['kind']]
svg.append(f'<g class="graph-node" data-detail="{node["detail"]}" role="button" tabindex="0" aria-label="Inspect {esc(node["label"])}"><circle cx="{x}" cy="{y}" r="{19 if i==0 else 12}" fill="#1b2416" stroke="{color}" stroke-width="2"/><text x="{x}" y="{y+32}" text-anchor="middle" fill="{color}" font-size="10">{esc(node["label"])}</text><title>{esc(node["label"])}</title></g>')
svg.append('</g></svg>')
return '<div class="evidence-graph"><div class="eyebrow">EVIDENCE EXPLORER</div><div class="graph-toolbar"><span>LIVE SUBGRAPH / '+str(len(items))+' SOURCES</span><span><button data-zoom="1.2" aria-label="Zoom in">+</button> <button data-zoom="0.833" aria-label="Zoom out">βˆ’</button> <button data-zoom="reset">Reset</button></span></div><div class="graph-canvas">'+''.join(svg)+'</div><div class="legend"><b>●</b> query Β· <span style="color:#83acb0">●</span> evidence Β· <span style="color:#cda77c">●</span> entity<br>Click a node to inspect evidence and support status.</div>'+''.join(details)+'</div>'