You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
305 lines
15 KiB
JavaScript
305 lines
15 KiB
JavaScript
/* 아리 — 리서치 (지능형 리서치 파트너)
|
|
① 멀티소스 수집·교차 분석 ② 지식 베이스 Q&A ③ 시각화·추세 예측 */
|
|
const { useState, useEffect, useRef } = React;
|
|
const R = window.ResearchData;
|
|
|
|
const P = {
|
|
spark: "M12 3l1.9 5.2L19 10l-5.1 1.8L12 17l-1.9-5.2L5 10l5.1-1.8z|M19 4v3|M5 17v3",
|
|
grid: "M3 3h7v7H3z|M14 3h7v7h-7z|M14 14h7v7h-7z|M3 14h7v7H3z",
|
|
inbox: "M22 12h-6l-2 3h-4l-2-3H2|M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",
|
|
route: "M6 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M18 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M9 16h6a3 3 0 0 0 3-3V11",
|
|
cal: "M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",
|
|
check: "M9 11l3 3L22 4|M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
|
|
tick: "M20 6 9 17l-5-5",
|
|
mail: "M22 7 13.03 12.7a1.94 1.94 0 0 1-2.06 0L2 7|M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",
|
|
brain: "M12 5a3 3 0 1 0-5.9.8A3 3 0 0 0 4 9.5 3 3 0 0 0 6 14a3 3 0 0 0 6 1zM12 5a3 3 0 1 1 5.9.8A3 3 0 0 1 20 9.5 3 3 0 0 1 18 14a3 3 0 0 1-6 1z|M12 5v14",
|
|
heart: "M19 14c1.5-1.5 3-3.4 3-5.5A4.5 4.5 0 0 0 12 5.5 4.5 4.5 0 0 0 2 8.5c0 2.1 1.5 4 3 5.5l7 7z",
|
|
search: "M21 21l-4.34-4.34|M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z",
|
|
bell: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9|M10.3 21a1.94 1.94 0 0 0 3.4 0",
|
|
sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10z|M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4",
|
|
moon: "M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z",
|
|
send: "M22 2 11 13|M22 2 15 22l-4-9-9-4z",
|
|
file: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|M14 2v6h6M9 13h6M9 17h4",
|
|
globe: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|M2 12h20|M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z",
|
|
pen: "M12 20h9|M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z",
|
|
msg: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z",
|
|
chart: "M3 3v16a2 2 0 0 0 2 2h16|M7 14l4-4 4 3 5-6",
|
|
zap: "M13 2 3 14h9l-1 8 10-12h-9z",
|
|
link: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71|M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
|
|
arrow: "M5 12h14|M13 6l6 6-6 6",
|
|
plus: "M12 5v14M5 12h14",
|
|
shield: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z",
|
|
clock: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|M12 7v5l3 2",
|
|
};
|
|
function I({ d, w }) {
|
|
return (
|
|
<svg className="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
strokeWidth={w || 1.9} strokeLinecap="round" strokeLinejoin="round">
|
|
{P[d].split("|").map((s, i) => <path key={i} d={s} />)}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
const MAIN = [
|
|
{ id: "dash", label: "대시보드", icon: "grid", href: "대시보드.html" },
|
|
{ id: "sbox", label: "인박스", icon: "inbox", href: "인박스.html" },
|
|
{ id: "appr", label: "결재함", icon: "spark", badge: "3", href: "결재함.html" },
|
|
{ id: "auto", label: "자동화", icon: "zap", href: "자동화.html" },
|
|
{ id: "journey", label: "여정", icon: "route", href: "여정.html" },
|
|
{ id: "cal", label: "일정", icon: "cal", href: "일정.html" },
|
|
{ id: "task", label: "작업", icon: "check", badge: "4", href: "작업.html" },
|
|
{ id: "mail", label: "메일", icon: "mail", href: "메일.html" },
|
|
{ id: "noti", label: "알림", icon: "bell", badge: "6", href: "알림.html" },
|
|
{ id: "research", label: "리서치", icon: "brain", href: "리서치.html" },
|
|
{ id: "trip", label: "여행", icon: "send", href: "여행.html" },
|
|
{ id: "life", label: "라이프", icon: "heart", href: "라이프.html" },
|
|
{ id: "wrap", label: "하루 마감", icon: "moon", href: "하루 마감.html" },
|
|
];
|
|
const KIND = { pdf: { icon: "file", label: "PDF" }, web: { icon: "globe", label: "웹" }, note: { icon: "pen", label: "메모" } };
|
|
const RS_NAV = [
|
|
{ id: "home", icon: "spark", label: "새 조사", sub: "무엇이든 맡기기" },
|
|
{ id: "report", icon: "globe", label: "종합 리포트", sub: "멀티소스 교차분석", tone: "blue" },
|
|
{ id: "qa", icon: "brain", label: "지식 Q&A", sub: "내 자료에 질문", tone: "green" },
|
|
{ id: "chart", icon: "chart", label: "시각화·예측", sub: "추세 그리기", tone: "coral" },
|
|
];
|
|
const RS_HEAD = {
|
|
home: { h: "지능형 리서치 파트너", p: "찾고, 비교하고, 그려보는 일은 아리가 — 판단은 지우님이 하세요." },
|
|
report: { h: "멀티소스 종합 리포트", p: "논문·뉴스·보고서를 모아 출처군별로 교차 분석해요." },
|
|
qa: { h: "지식 베이스 Q&A", p: "저장한 PDF·웹·메모를 학습해 근거와 함께 답해요." },
|
|
chart: { h: "시각화 & 추세 예측", p: "데이터를 그리고, 한계까지 짚어 예측해요." },
|
|
};
|
|
const load = (k, fb) => { try { const v = localStorage.getItem("ariR." + k); return v === null ? fb : JSON.parse(v); } catch (e) { return fb; } };
|
|
const save = (k, v) => { try { localStorage.setItem("ariR." + k, JSON.stringify(v)); } catch (e) {} };
|
|
|
|
/* ---------- 리서치 명령 컴포저 ---------- */
|
|
function Composer() {
|
|
const [val, setVal] = useState("");
|
|
const [queued, setQueued] = useState(null);
|
|
const go = () => { const v = val.trim(); if (!v) return; setQueued(v); setVal(""); };
|
|
return (
|
|
<div className="rs-composer">
|
|
<div className="rs-cbox">
|
|
<span className="rs-cspark"><I d="spark" /></span>
|
|
<input
|
|
value={val} placeholder="무엇이든 조사를 맡겨보세요 — 전문 분야가 아니어도 괜찮아요"
|
|
onChange={(e) => setVal(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter") go(); }}
|
|
/>
|
|
<button className="rs-go" onClick={go} disabled={!val.trim()}><I d="send" />조사 시작</button>
|
|
</div>
|
|
<div className="rs-chips">
|
|
{R.prompts.map((p, i) => (
|
|
<button key={i} className="rs-chip" onClick={() => setVal(p)}>{p}</button>
|
|
))}
|
|
</div>
|
|
{queued && (
|
|
<div className="rs-queued">
|
|
<span className="rs-qpulse" />
|
|
<p><b>“{queued}”</b> — 출처를 모으고 교차 분석하는 중이에요. 끝나면 알림으로 알려드릴게요.</p>
|
|
<button onClick={() => setQueued(null)} aria-label="닫기">취소</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------- ① 멀티소스 종합 리포트 ---------- */
|
|
function ReportCard() {
|
|
const r = R.report;
|
|
return (
|
|
<section className="rs-card rs-report">
|
|
<div className="rs-ch">
|
|
<span className="rs-ci tone-blue"><I d="globe" /></span>
|
|
<div className="rs-cht">
|
|
<span className="rs-k">멀티소스 종합 리포트</span>
|
|
<h2>{r.title}</h2>
|
|
<span className="rs-sub">{r.asked} · {r.meta}</span>
|
|
</div>
|
|
<div className="rs-counts">
|
|
{r.counts.map((c, i) => (
|
|
<span key={i} className="rs-count"><span className="dot" style={{ background: `var(--${c.tone})` }} />{c.k} {c.n}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<p className="rs-synth" dangerouslySetInnerHTML={{ __html: r.synthesis }} />
|
|
<div className="rs-cross">
|
|
<div className="rs-cross-head"><span>출처군</span><span>핵심 주장</span><span>논조</span></div>
|
|
{r.cross.map((c, i) => (
|
|
<div key={i} className="rs-cross-row">
|
|
<span className="rs-src"><span className="dot" style={{ background: `var(--${c.tone})` }} />{c.src}</span>
|
|
<span className="rs-claim">{c.claim}</span>
|
|
<span className="rs-stance">{c.stance}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="rs-note"><I d="shield" />{r.note}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* ---------- ② 지식 베이스 Q&A ---------- */
|
|
function QACard() {
|
|
const q = R.qa;
|
|
return (
|
|
<section className="rs-card">
|
|
<div className="rs-ch">
|
|
<span className="rs-ci tone-green"><I d="brain" /></span>
|
|
<div className="rs-cht">
|
|
<span className="rs-k">지식 베이스에 물어보기</span>
|
|
<h2>저장한 자료가 곧 나만의 위키</h2>
|
|
<span className="rs-sub">PDF·웹페이지·메모를 아리가 학습해 근거와 함께 답해요</span>
|
|
</div>
|
|
</div>
|
|
<div className="rs-q"><I d="msg" />{q.q}</div>
|
|
<p className="rs-a" dangerouslySetInnerHTML={{ __html: q.a }} />
|
|
<div className="rs-refs">
|
|
{q.refs.map((ref, i) => (
|
|
<button key={i} className="rs-ref">
|
|
<I d="link" /><b>{ref.title}</b><span>{ref.part}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* ---------- ③ 시각화 + 추세 예측 ---------- */
|
|
function ChartCard() {
|
|
const c = R.chart;
|
|
const max = Math.max(...c.bars.map((b) => b.v));
|
|
return (
|
|
<section className="rs-card">
|
|
<div className="rs-ch">
|
|
<span className="rs-ci tone-coral"><I d="chart" /></span>
|
|
<div className="rs-cht">
|
|
<span className="rs-k">시각화 & 추세 예측</span>
|
|
<h2>{c.title}</h2>
|
|
<span className="rs-sub">{c.asked}</span>
|
|
</div>
|
|
</div>
|
|
<div className="rs-chart" role="img" aria-label="연도별 매출 막대 차트, 마지막 막대는 예측치">
|
|
{c.bars.map((b, i) => (
|
|
<div key={i} className={"rs-bar" + (b.forecast ? " forecast" : "")}>
|
|
<span className="rs-bv mono">{b.v}</span>
|
|
<div className="rs-bcol" style={{ height: Math.round((b.v / max) * 100) + "%" }}>
|
|
{b.forecast && <span className="rs-btag">예측</span>}
|
|
</div>
|
|
<span className="rs-by">{b.y}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="rs-insight" dangerouslySetInnerHTML={{ __html: c.insight }} />
|
|
<div className="rs-note"><I d="clock" />{c.caution}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* ---------- 새 조사 홈: 결과로 가는 입구 ---------- */
|
|
function HomeGrid({ go }) {
|
|
const cards = [
|
|
{ id: "report", icon: "globe", tone: "blue", k: "멀티소스 종합 리포트", t: R.report.title, s: R.report.counts.map((c) => c.k + " " + c.n).join(" · ") },
|
|
{ id: "qa", icon: "brain", tone: "green", k: "지식 베이스 Q&A", t: R.qa.q, s: R.sources.length + "개 자료 학습됨" },
|
|
{ id: "chart", icon: "chart", tone: "coral", k: "시각화 & 추세 예측", t: R.chart.title, s: R.chart.asked },
|
|
];
|
|
return (
|
|
<div className="rs-entries">
|
|
{cards.map((c) => (
|
|
<button key={c.id} className="rs-entry" onClick={() => go(c.id)}>
|
|
<span className={"rs-ci tone-" + c.tone}><I d={c.icon} /></span>
|
|
<span className="rs-entry-k">{c.k}</span>
|
|
<span className="rs-entry-t">{c.t}</span>
|
|
<span className="rs-entry-s">{c.s}</span>
|
|
<span className="rs-entry-go"><I d="arrow" />열기</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------- 앱 ---------- */
|
|
function App() {
|
|
const [theme, setTheme] = useState(() => load("theme", "light"));
|
|
const [view, setView] = useState(() => load("view", "home"));
|
|
const shellRef = useRef(null);
|
|
useEffect(() => { document.documentElement.setAttribute("data-theme", theme); save("theme", theme); }, [theme]);
|
|
useEffect(() => save("view", view), [view]);
|
|
useEffect(() => { const el = shellRef.current; const id = requestAnimationFrame(() => el && el.classList.add("entered")); return () => cancelAnimationFrame(id); }, []);
|
|
|
|
return (
|
|
<div className="tpage" ref={shellRef}>
|
|
<header className="topbar">
|
|
<div className="brand">
|
|
<div className="brand-mark"><I d="spark" /></div>
|
|
<div className="brand-tag"><b>아리</b><span>AI LIFE OS</span></div>
|
|
</div>
|
|
<nav className="mainnav">
|
|
{MAIN.map((m) => (
|
|
<a key={m.id} href={m.href} className={m.id === "research" ? "active" : ""}><I d={m.icon} />{m.label}{m.badge && <span className="badge">{m.badge}</span>}</a>
|
|
))}
|
|
</nav>
|
|
<div className="top-actions">
|
|
<button className="t-btn" aria-label="검색"><I d="search" /></button>
|
|
<button className="t-btn dot" aria-label="알림"><I d="bell" /></button>
|
|
<button className="t-btn" aria-label="테마" onClick={() => setTheme(theme === "light" ? "dark" : "light")}><I d={theme === "light" ? "moon" : "sun"} /></button>
|
|
<div className="t-ava">지</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="twork">
|
|
{/* 지식 베이스 사이드바 */}
|
|
<aside className="subnav">
|
|
<div className="sn-title">리서치</div>
|
|
<div className="rs-nav">
|
|
{RS_NAV.map((n) => (
|
|
<button key={n.id} className={"rs-navrow" + (view === n.id ? " on" : "")} onClick={() => setView(n.id)}>
|
|
<span className={"rs-nav-ic" + (n.tone ? " tone-" + n.tone : "")}><I d={n.icon} /></span>
|
|
<span className="rs-nav-tx"><b>{n.label}</b><span>{n.sub}</span></span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="rs-sn-label">컬렉션</div>
|
|
<div className="rs-cols">
|
|
{R.collections.map((c) => (
|
|
<button key={c.id} className="rs-col">
|
|
<span className="dot" style={{ background: `var(--${c.tone})` }} />
|
|
<span className="nm">{c.name}</span>
|
|
<span className="n">{c.n}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="rs-sn-label">최근 저장 자료</div>
|
|
<div className="rs-srcs">
|
|
{R.sources.map((s) => (
|
|
<button key={s.id} className="rs-srcrow">
|
|
<span className="rs-srcic"><I d={KIND[s.kind].icon} /></span>
|
|
<span className="tx"><b>{s.title}</b><span>{s.from}</span></span>
|
|
{s.learned
|
|
? <span className="rs-learned" title="아리가 학습 완료"><I d="tick" w="2.8" /></span>
|
|
: <span className="rs-learning" title="학습 중" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="rs-sn-hint">저장하면 아리가 내용을 학습해요 — 북마크가 아니라 답을 주는 자료가 돼요.</p>
|
|
<div className="sn-foot">
|
|
<div className="av">지</div>
|
|
<div className="txt"><b>지우님</b><span>Pro 플랜</span></div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="rs-main">
|
|
<div className="rs-head">
|
|
<h1>{RS_HEAD[view].h}</h1>
|
|
<p>{RS_HEAD[view].p}</p>
|
|
</div>
|
|
{view === "home" && <React.Fragment><Composer /><HomeGrid go={setView} /></React.Fragment>}
|
|
{view === "report" && <ReportCard />}
|
|
{view === "qa" && <QACard />}
|
|
{view === "chart" && <ChartCard />}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|