diff --git a/.env.example b/.env.example index ea764b3..0052719 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,14 @@ -# 把這個檔案複製成 .env,然後填入你自己的 FRED 金鑰 -# 申請(免費、約 1 分鐘):https://fred.stlouisfed.org/docs/api/api_key.html +# 把這個檔案複製成 .env,或直接到網頁「AI 設定」頁填寫。 +# FRED 申請(免費、約 1 分鐘):https://fred.stlouisfed.org/docs/api/api_key.html FRED_API_KEY=your_fred_api_key_here +# AI Provider:設定頁會把這些欄位寫回本機 .env +OPENCODE_GO_API_KEY= +OPENCODE_GO_MODEL= +GROK_API_KEY= +GROK_MODEL= +AI_ACTIVE_PROVIDER=grok + # 伺服器埠號(可不改) PORT=3000 diff --git a/README.md b/README.md index 001bab3..a9f4d1a 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,13 @@ 到 註冊帳號,取得一組 32 碼的金鑰。免費、即時核發。 ### 2. 設定金鑰 -把範例檔複製成 `.env`,填入你的金鑰: +可以直接啟動後到頂部的 **AI 設定** 頁填入 FRED、OpenCode Go、Grok 等 API key;頁面會把金鑰寫入本機專案的 `.env`,金鑰不會放在瀏覽器前端。AI provider 填好 key 後可按「抓取模型」,系統會向 provider 的 `/models` 端點取得你帳號實際可用的 model。 + +若想手動建立,也可以把範例檔複製成 `.env` 再填入: ```bash cp .env.example .env -# 然後編輯 .env,把 FRED_API_KEY 換成你的金鑰 +# 然後編輯 .env,把需要的 API key 換成你的金鑰 ``` ### 3. 安裝並啟動 diff --git a/app.css b/app.css index 745012e..66d5fde 100644 --- a/app.css +++ b/app.css @@ -129,6 +129,22 @@ body:not([data-view="macro"]) #navLinks{display:none} background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:22px; box-shadow:var(--shadow);margin-bottom:16px; } +.learning-method{ + display:grid;grid-template-columns:minmax(0,1fr) auto;gap:18px;align-items:center; + background:#202421;color:#fff;border-radius:16px;padding:22px 24px;margin-bottom:16px; + box-shadow:var(--shadow); +} +.learning-method .eyebrow{color:rgba(255,255,255,.62)} +.learning-method h2{font-size:1.28rem;line-height:1.3;margin-bottom:8px} +.learning-method p{font-size:.86rem;line-height:1.65;color:rgba(255,255,255,.74);max-width:720px} +.method-rail{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end} +.method-rail span{ + font-size:.76rem;font-weight:760;color:#202421;background:#fff;border-radius:999px; + padding:7px 12px;box-shadow:0 10px 24px rgba(0,0,0,.14); +} +.method-rail span:nth-child(2){background:#dff1ff} +.method-rail span:nth-child(3){background:#efe8ff} +.method-rail span:nth-child(4){background:#e4f7ea} .board-copy h2{font-size:1.35rem;line-height:1.25;margin-bottom:10px} .board-copy p{font-size:.88rem;color:var(--text2);line-height:1.75} .learning-cards{display:grid;gap:10px} @@ -175,6 +191,101 @@ body:not([data-view="macro"]) #navLinks{display:none} .learn-article .la-kind{font-size:.72rem;color:var(--blue);font-weight:700;margin-bottom:6px} .learn-article .la-title{font-size:1.45rem;line-height:1.25;margin:0 0 8px} .learn-article .la-lead{font-size:.92rem;color:var(--text2);line-height:1.6;margin:0} +.learn-tabs{ + display:inline-flex;gap:3px;background:rgba(0,0,0,.05);border-radius:12px;padding:4px;margin:4px 0 16px; + max-width:100%;overflow:auto; +} +.learn-tab{ + border:none;background:transparent;color:var(--text2);border-radius:9px;padding:8px 14px; + font-size:.82rem;font-weight:760;cursor:pointer;font-family:inherit;white-space:nowrap; +} +.learn-tab.on{background:var(--surface);color:var(--text);box-shadow:0 1px 4px rgba(0,0,0,.08)} +.learn-tab-panel{display:grid;gap:14px} +.learn-tab-panel[hidden]{display:none} +.learning-panel{ + background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px; + box-shadow:0 1px 2px rgba(32,40,33,.04); +} +.panel-head{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.panel-head span{ + width:28px;height:28px;border-radius:8px;background:#202421;color:#fff; + display:inline-flex;align-items:center;justify-content:center;font-size:.7rem;font-weight:820;flex-shrink:0; +} +.panel-head h2{font-size:.98rem;margin:0} +.insight-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px} +.insight-card{ + background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:13px 14px;min-height:104px; +} +.insight-card b{display:block;font-size:.7rem;color:var(--blue);margin-bottom:7px} +.insight-card p{font-size:.82rem;line-height:1.55;color:var(--text);margin:0} +.recall-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px} +.recall-card{ + border:1px solid rgba(35,103,199,.16);background:rgba(35,103,199,.05); + border-radius:12px;padding:13px 14px;display:flex;flex-direction:column;gap:10px; +} +.recall-q{font-size:.84rem;font-weight:720;line-height:1.5} +.recall-toggle{ + align-self:flex-start;border:1px solid rgba(35,103,199,.24);background:var(--surface);color:var(--blue); + border-radius:999px;padding:6px 11px;font-size:.74rem;font-weight:720;cursor:pointer;font-family:inherit; +} +.recall-a{ + color:var(--text2);font-size:.8rem;line-height:1.55;background:var(--surface); + border:1px solid var(--border);border-radius:10px;padding:10px 12px; +} +.section-ladder{display:grid;gap:8px} +.section-chip{ + background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:10px 12px; +} +.section-chip summary{cursor:pointer;list-style:none;font-size:.84rem;font-weight:760} +.section-chip summary::-webkit-details-marker{display:none} +.section-chip p{font-size:.8rem;color:var(--text2);line-height:1.6;margin:9px 0 0} +.review-track{ + display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;position:relative; +} +.review-step{ + border:1px solid var(--border);background:#f9faf7;color:var(--text2);border-radius:10px; + min-height:42px;font-size:.8rem;font-weight:760;cursor:pointer;font-family:inherit; +} +.review-step.on{background:#202421;color:#fff;border-color:#202421;box-shadow:0 8px 18px rgba(32,40,33,.14)} +.personal-note-input{ + width:100%;min-height:112px;resize:vertical;border:1px solid var(--border);border-radius:12px; + padding:12px 14px;background:#f9faf7;color:var(--text);font:inherit;font-size:.86rem;line-height:1.55;outline:none; +} +.personal-note-input:focus{border-color:var(--blue);box-shadow:0 0 0 4px rgba(35,103,199,.12);background:#fff} +.personal-note-actions{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:10px} +.personal-note-status{font-size:.74rem;color:var(--text2)} +.learn-lab{display:grid;gap:16px} +.lab-hero{ + display:grid;grid-template-columns:minmax(0,1fr) auto;gap:18px;align-items:center; + background:#202421;color:#fff;border-radius:16px;padding:22px 24px;box-shadow:var(--shadow); +} +.lab-hero .eyebrow{color:rgba(255,255,255,.62)} +.lab-hero h2{font-size:1.35rem;line-height:1.25;margin-bottom:8px} +.lab-hero p{font-size:.86rem;color:rgba(255,255,255,.74);line-height:1.65;max-width:760px} +.lab-score{background:#fff;color:#202421;border-radius:14px;padding:14px 18px;min-width:110px;text-align:center} +.lab-score b{display:block;font-size:1.45rem}.lab-score span{font-size:.72rem;color:var(--text2)} +.lab-game,.lab-notes{ + background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:16px;box-shadow:var(--shadow); +} +.match-board{display:grid;grid-template-columns:1fr 1.2fr;gap:14px} +.match-board>div{display:grid;gap:10px} +.match-card{ + min-height:58px;text-align:left;border:1px solid var(--border);background:#f9faf7;border-radius:12px; + padding:12px 14px;color:var(--text);font:inherit;font-size:.86rem;font-weight:760;cursor:pointer;transition:.16s; +} +.match-card small{display:block;font-weight:500;color:var(--text2);margin-top:4px} +.match-card.picked{border-color:var(--blue);box-shadow:0 0 0 4px rgba(35,103,199,.12);background:#fff} +.match-card.matched{border-color:rgba(31,157,102,.28);background:rgba(31,157,102,.08);color:var(--green)} +.match-card.wrong{border-color:rgba(216,79,69,.35);background:rgba(216,79,69,.08);color:var(--red)} +.lab-actions{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;margin-top:14px} +.note-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px} +.note-card{ + text-align:left;border:1px solid var(--border);background:#f9faf7;border-radius:12px;padding:13px 14px; + color:var(--text);font:inherit;cursor:pointer; +} +.note-card:hover{border-color:rgba(35,103,199,.3);box-shadow:var(--soft-shadow)} +.note-card b{display:block;font-size:.88rem;line-height:1.35}.note-card span{font-size:.68rem;color:var(--text2)} +.note-card p{font-size:.76rem;color:var(--text2);line-height:1.45;margin-top:8px} .la-principles{background:#f7f9f5;border:1px solid var(--border);border-radius:14px;padding:14px 16px;margin:16px 0} .la-principles-head h2{font-size:.95rem;margin:0 0 4px} .la-principles-head p{font-size:.78rem;color:var(--text2);margin:0 0 10px;line-height:1.5} @@ -208,6 +319,11 @@ body:not([data-view="macro"]) #navLinks{display:none} .pg-card .mod-name{font-size:.82rem;line-height:1.35} @media(max-width:760px){ .la-tool-grid{grid-template-columns:1fr} + .learning-method{grid-template-columns:1fr} + .method-rail{justify-content:flex-start} + .review-track{grid-template-columns:repeat(2,minmax(0,1fr))} + .lab-hero{grid-template-columns:1fr} + .match-board{grid-template-columns:1fr} } @media(max-width:860px){ .learning-board{grid-template-columns:1fr} @@ -305,11 +421,41 @@ body:not([data-view="macro"]) #navLinks{display:none} display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:14px 16px; border-bottom:1px solid var(--border); } -.graph-canvas{height:min(62vh,520px);background:linear-gradient(180deg,#fafafa 0%,#f5f5f7 100%)} +.graph-canvas{min-height:420px;background:linear-gradient(180deg,#fafafa 0%,#f5f5f7 100%);padding:14px} .graph-foot{font-size:.74rem;color:var(--text2);padding:10px 16px;border-top:1px solid var(--border)} .graph-legend{display:flex;flex-wrap:wrap;gap:12px;font-size:.72rem} .graph-legend span{display:inline-flex;align-items:center;gap:5px} .graph-legend i{width:10px;height:10px;border-radius:50%;display:inline-block} +.knowledge-map{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.36fr);gap:14px;align-items:start} +.kg-map{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px} +.kg-column{ + background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:12px; + box-shadow:0 1px 2px rgba(32,40,33,.04);min-width:0; +} +.kg-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px} +.kg-head b{font-size:.86rem}.kg-head span{font-size:.7rem;color:var(--text2);background:#f0f2ee;border-radius:999px;padding:2px 8px} +.kg-nodes{display:grid;gap:7px;max-height:420px;overflow:auto;padding-right:2px} +.kg-node,.kg-relation,.kg-edge{ + width:100%;text-align:left;border:1px solid var(--border);background:#f9faf7;color:var(--text); + border-radius:10px;padding:9px 10px;font:inherit;cursor:pointer;transition:.15s; +} +.kg-node:hover,.kg-relation:hover,.kg-edge:hover{border-color:rgba(35,103,199,.28);background:#fff} +.kg-node.active{border-color:var(--blue);box-shadow:0 0 0 3px rgba(35,103,199,.12);background:#fff} +.kg-node span{display:block;font-size:.78rem;font-weight:760;line-height:1.3} +.kg-node small{display:block;font-size:.66rem;color:var(--text2);margin-top:3px} +.kg-focus{ + position:sticky;top:96px;background:var(--surface);border:1px solid var(--border);border-radius:14px; + box-shadow:var(--shadow);padding:14px;min-height:260px; +} +.kg-focus-head b{display:block;font-size:.95rem;line-height:1.35}.kg-focus-head span{display:block;font-size:.72rem;color:var(--text2);margin-top:4px} +.kg-focus-actions{margin:12px 0}.kg-relations{display:grid;gap:8px} +.kg-relation span{display:block;font-size:.66rem;color:var(--blue);font-weight:800;margin-bottom:4px} +.kg-relation b{font-size:.78rem;line-height:1.35} +.kg-list{grid-column:1/-1;display:grid;gap:8px} +.kg-edge{display:grid;grid-template-columns:minmax(0,1fr) 24px minmax(0,1fr);align-items:center;gap:8px} +.kg-edge span{font-size:.8rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.kg-edge i{height:2px;background:var(--border);position:relative}.kg-edge i:after{content:'';position:absolute;right:0;top:-3px;border-left:6px solid var(--border);border-top:4px solid transparent;border-bottom:4px solid transparent} +@media(max-width:880px){.knowledge-map{grid-template-columns:1fr}.kg-focus{position:static}.kg-edge{grid-template-columns:1fr}.kg-edge i{display:none}} .quiz-q{ background:var(--card);border:1px solid var(--border);border-radius:var(--radius); @@ -333,6 +479,23 @@ body:not([data-view="macro"]) #navLinks{display:none} .finbox-search button:disabled{opacity:.5;cursor:wait} .finbox-examples{font-size:.78rem;color:var(--text2);margin-bottom:20px} .finbox-examples b{cursor:pointer;color:var(--blue);font-weight:600;margin:0 6px} +.stock-learn-bridge{ + background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:14px 16px; + box-shadow:var(--soft-shadow);margin:0 0 18px; +} +.slb-head{display:flex;justify-content:space-between;gap:12px;align-items:baseline;flex-wrap:wrap;margin-bottom:12px} +.slb-head b{font-size:.9rem}.slb-head span{font-size:.74rem;color:var(--text2)} +.slb-steps{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px} +.slb-steps button{ + text-align:left;border:1px solid var(--border);background:#f9faf7;border-radius:12px;padding:11px 12px; + color:var(--text);font:inherit;cursor:pointer;min-height:86px;transition:.15s; +} +.slb-steps button:hover{border-color:rgba(35,103,199,.32);background:#fff;transform:translateY(-1px);box-shadow:var(--soft-shadow)} +.slb-steps span{ + width:22px;height:22px;border-radius:7px;background:#202421;color:#fff;display:inline-flex; + align-items:center;justify-content:center;font-size:.68rem;font-weight:900;margin-bottom:8px; +} +.slb-steps b{display:block;font-size:.82rem;line-height:1.25}.slb-steps small{display:block;font-size:.68rem;color:var(--text2);line-height:1.35;margin-top:4px} .sub-tabs{ display:flex;gap:4px;background:rgba(0,0,0,.04);border-radius:12px; @@ -649,6 +812,7 @@ body.cal-modal-open{overflow:hidden} .stock-detail-layout{grid-template-columns:1fr} .chain-map{grid-template-columns:1fr} .insider-row{grid-template-columns:1fr} + .slb-steps{grid-template-columns:1fr 1fr} } .chart-wrap{ @@ -719,6 +883,23 @@ body.cal-modal-open{overflow:hidden} padding:16px 20px;font-weight:700;margin-bottom:16px;box-shadow:var(--shadow); } .map-core span{display:block;font-weight:400;color:var(--text2);font-size:.84rem;margin-top:6px;line-height:1.6} +.map-funnel{ + display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px;margin-bottom:16px; +} +.funnel-step{ + border:1px solid var(--border);background:var(--surface);border-radius:12px;padding:12px 10px;min-height:94px; + text-align:left;font:inherit;color:var(--text);cursor:pointer;box-shadow:var(--shadow);transition:.15s; +} +.funnel-step:hover{transform:translateY(-1px);box-shadow:var(--soft-shadow)} +.funnel-step span{ + width:24px;height:24px;border-radius:8px;background:#202421;color:#fff;display:flex;align-items:center; + justify-content:center;font-size:.7rem;font-weight:900;margin-bottom:8px; +} +.funnel-step b{display:block;font-size:.78rem;line-height:1.25}.funnel-step small{display:block;font-size:.68rem;color:var(--text2);margin-top:5px} +.funnel-step.pass{border-color:rgba(31,157,102,.28);background:rgba(31,157,102,.07)} +.funnel-step.watch{border-color:rgba(200,138,29,.28);background:rgba(200,138,29,.07)} +.funnel-step.out{border-color:rgba(216,79,69,.28);background:rgba(216,79,69,.07)} +.funnel-step.pending{opacity:.82} .map-verdict{ background:var(--card);border:1px solid var(--border);border-radius:var(--radius); padding:18px 22px;margin-bottom:18px;border-left:5px solid var(--text2);box-shadow:var(--shadow); @@ -765,6 +946,7 @@ body.cal-modal-open{overflow:hidden} .ans.unsure.on{background:rgba(255,149,0,.1);border-color:var(--orange);color:var(--orange);font-weight:600} .ans.no.on{background:rgba(255,59,48,.1);border-color:var(--red);color:var(--red);font-weight:600} .ml-out{font-size:.74rem;color:var(--text2);margin-top:10px;font-style:italic} +@media(max-width:900px){.slb-steps{grid-template-columns:repeat(2,minmax(0,1fr))}.map-funnel{grid-template-columns:repeat(2,minmax(0,1fr))}} /* 回測 */ .bt-controls{ @@ -866,3 +1048,72 @@ body.cal-modal-open{overflow:hidden} .check-inline{display:flex;align-items:center;gap:10px;font-size:.88rem;cursor:pointer} .check-inline input{width:18px;height:18px;accent-color:var(--blue)} @media(max-width:600px){ .form-grid{grid-template-columns:1fr} } + +/* AI Provider / Page Assistant */ +.ai-settings-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px} +.ai-provider-card{ + background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:18px; + box-shadow:var(--shadow); +} +.ai-provider-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:14px} +.ai-provider-head b{display:block;font-size:1rem}.ai-provider-head span{display:block;font-size:.76rem;color:var(--text2);line-height:1.55;margin-top:5px} +.ai-default{font-size:.78rem;color:var(--text2);display:flex;gap:6px;align-items:center;white-space:nowrap} +.ai-default input{accent-color:var(--blue)} +.ai-model-row{display:flex;gap:8px;align-items:center} +.ai-model-row input{min-width:0;flex:1} +.ai-model-row button{flex-shrink:0} +.ai-provider-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:12px;font-size:.74rem;color:var(--text2)} +.ai-settings-msg{font-size:.82rem;color:var(--text2);margin-top:12px;line-height:1.55} +.ai-dock{position:fixed;right:22px;bottom:22px;z-index:380} +.ai-fab{ + width:54px;height:54px;border-radius:50%;border:none;background:#202421;color:#fff; + font-weight:900;box-shadow:0 16px 44px rgba(32,40,33,.24);cursor:pointer;font-family:inherit; +} +.ai-panel{ + position:absolute;right:0;bottom:66px;width:min(430px,calc(100vw - 32px));height:min(76vh,680px); + background:var(--surface);border:1px solid var(--border);border-radius:18px;box-shadow:0 24px 80px rgba(0,0,0,.18); + padding:0;display:flex;flex-direction:column;overflow:hidden; +} +.ai-panel[hidden]{display:none} +.ai-panel-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;border-bottom:1px solid var(--border);padding:14px 14px 10px;background:rgba(249,250,247,.92)} +.ai-panel-head b{display:block;font-size:.95rem}.ai-panel-head span{display:block;font-size:.72rem;color:var(--text2);line-height:1.45;margin-top:3px} +.ai-panel-head button{border:1px solid var(--border);background:#f9faf7;color:var(--text2);border-radius:8px;width:30px;height:30px;cursor:pointer;font-size:1rem} +.ai-provider-row{display:flex;gap:8px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--border);background:var(--surface)} +.ai-provider-row select{ + flex:1;border:1px solid var(--border);border-radius:10px;background:#f9faf7;color:var(--text); + padding:9px 11px;font:inherit;font-size:.82rem; +} +.ai-chat{ + flex:1;overflow:auto;padding:14px;background: + linear-gradient(180deg,rgba(35,103,199,.04),rgba(32,40,33,.02)); + display:flex;flex-direction:column;gap:10px; +} +.ai-msg{display:flex;flex-direction:column;max-width:86%} +.ai-msg-user{align-self:flex-end;align-items:flex-end} +.ai-msg-bot{align-self:flex-start;align-items:flex-start} +.ai-bubble{ + border:1px solid var(--border);background:#fff;color:var(--text); + border-radius:16px;padding:10px 12px;font-size:.86rem;line-height:1.6;box-shadow:0 1px 2px rgba(32,40,33,.05); + overflow-wrap:anywhere; +} +.ai-msg-user .ai-bubble{background:#2367c7;color:#fff;border-color:#2367c7;border-bottom-right-radius:5px} +.ai-msg-bot .ai-bubble{border-bottom-left-radius:5px} +.ai-msg-meta{font-size:.66rem;color:var(--text2);margin:4px 6px 0} +.ai-bubble .md{font-size:.86rem}.ai-bubble .md p{margin:.4em 0}.ai-bubble .md p:first-child{margin-top:0}.ai-bubble .md p:last-child{margin-bottom:0} +.ai-compose{display:flex;gap:8px;align-items:flex-end;padding:10px 12px;border-top:1px solid var(--border);background:var(--surface)} +#aiQuestion{ + flex:1;width:100%;min-height:40px;max-height:112px;resize:none;border:1px solid var(--border);border-radius:18px; + background:#f9faf7;padding:9px 13px;font:inherit;font-size:.86rem;line-height:1.45;outline:none; +} +#aiQuestion:focus{border-color:var(--blue);box-shadow:0 0 0 4px rgba(35,103,199,.12);background:#fff} +.ai-send{ + width:40px;height:40px;border:none;border-radius:50%;background:#202421;color:#fff; + font-size:1rem;font-weight:900;cursor:pointer;flex-shrink:0;font-family:inherit; +} +.ai-send:disabled{opacity:.45;cursor:wait} +.ai-typing{display:inline-flex;gap:4px;align-items:center;padding:4px 2px} +.ai-typing i{width:6px;height:6px;border-radius:50%;background:var(--text2);opacity:.45;animation:aiTyping 1s infinite ease-in-out} +.ai-typing i:nth-child(2){animation-delay:.15s}.ai-typing i:nth-child(3){animation-delay:.3s} +@keyframes aiTyping{0%,80%,100%{transform:translateY(0);opacity:.35}40%{transform:translateY(-3px);opacity:.8}} +.ai-error{color:var(--red);background:rgba(216,79,69,.08);border:1px solid rgba(216,79,69,.16);border-radius:10px;padding:10px 12px} +@media(max-width:520px){.ai-dock{right:16px;bottom:16px}.ai-panel{height:min(82vh,680px)}.ai-provider-row,.ai-model-row{align-items:stretch;flex-direction:column}} diff --git a/app.js b/app.js index ebb4550..54f2825 100644 --- a/app.js +++ b/app.js @@ -186,17 +186,27 @@ function bindWlinks(container) { // ═══════════════════════════════════════════════════════════ // 主視圖路由 // ═══════════════════════════════════════════════════════════ -const VIEW_IDS = ['macro', 'calendar', 'learn', 'stock', 'journal']; +const VIEW_IDS = ['macro', 'calendar', 'learn', 'stock', 'journal', 'settings']; const inited = {}; function parseHash() { const m = location.hash.match(/^#\/(\w+)/); const v = m ? m[1] : 'macro'; return VIEW_IDS.includes(v) ? v : 'macro'; } +function setAIFocus(focus) { + const view = focus.view || document.body.dataset.view || parseHash(); + window.__AI_FOCUS = { ...(window.__AI_FOCUS || {}), ...focus, view, updatedAt: new Date().toISOString() }; + updateAIContextLabel(); + return window.__AI_FOCUS; +} +window.setAIFocus = setAIFocus; function setView(view) { document.body.dataset.view = view; + if ((window.__AI_FOCUS || {}).view !== view) setAIFocus({ view, type: 'view' }); VIEW_IDS.forEach(v => { const e = $('#view-' + v); if (e) e.hidden = v !== view; }); $$('#viewTabs a').forEach(a => a.classList.toggle('active', a.dataset.view === view)); if (view === 'calendar' && !inited.calendar) { inited.calendar = true; initCalendar(); } if (view === 'learn' && !inited.learn) { inited.learn = true; initLearn(); } if (view === 'stock' && !inited.stock) { inited.stock = true; initStock(); } if (view === 'journal' && !inited.journal) { inited.journal = true; initJournal(); } + if (view === 'settings' && !inited.settings) { inited.settings = true; initSettings(); } + updateAIContextLabel(); if (view !== 'macro') window.scrollTo({ top: 0 }); } $$('#viewTabs a').forEach(a => a.addEventListener('click', () => { @@ -204,6 +214,329 @@ $$('#viewTabs a').forEach(a => a.addEventListener('click', () => { })); window.addEventListener('hashchange', () => setView(parseHash())); +// ═══════════════════════════════════════════════════════════ +// AI Provider 設定與頁面上下文問答 +// ═══════════════════════════════════════════════════════════ +const AI_PROVIDER_META = { + 'opencode-go': { + label: 'OpenCode Go', + hint: 'OpenAI-compatible chat completions。官方 Go 端點使用 /zen/go/v1/chat/completions。', + }, + grok: { + label: 'Grok', + hint: 'xAI/Grok。後端會使用 xAI Responses API,且 store=false。', + }, +}; +function readAISettings() { + const fields = window.__ENV_SETTINGS?.fields || []; + const get = (k) => fields.find(f => f.key === k) || {}; + return { + active: get('AI_ACTIVE_PROVIDER').value || 'grok', + providers: { + 'opencode-go': { + model: get('OPENCODE_GO_MODEL').value || '', + hasKey: !!get('OPENCODE_GO_API_KEY').hasValue, + }, + grok: { + model: get('GROK_MODEL').value || '', + hasKey: !!get('GROK_API_KEY').hasValue, + }, + }, + }; +} +async function loadEnvSettings() { + window.__ENV_SETTINGS = await api('/api/settings/env'); + return window.__ENV_SETTINGS; +} +function envField(settings, key) { + return (settings.fields || []).find(f => f.key === key) || { key, value: '', hasValue: false, masked: '' }; +} +async function saveEnvSettings(view) { + const values = { AI_ACTIVE_PROVIDER: $('input[name="aiActiveProvider"]:checked')?.value || 'grok' }; + $$('[data-env-key]', view).forEach(input => values[input.dataset.envKey] = input.value.trim()); + window.__ENV_SETTINGS = await api('/api/settings/env', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ values }), + }); + updateAIContextLabel(); + return window.__ENV_SETTINGS; +} +async function loadProviderModels(provider) { + return api('/api/ai/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider }), + }); +} +async function getProviderModels(provider) { + window.__AI_MODEL_CACHE = window.__AI_MODEL_CACHE || {}; + if (window.__AI_MODEL_CACHE[provider]) return window.__AI_MODEL_CACHE[provider]; + const d = await loadProviderModels(provider); + window.__AI_MODEL_CACHE[provider] = d.models || []; + return window.__AI_MODEL_CACHE[provider]; +} +async function initSettings() { + const view = $('#view-settings'); + view.innerHTML = '
載入設定中…
'; + const envSettings = await loadEnvSettings(); + const settings = readAISettings(); + view.innerHTML = ` +
+
+
API Key 與 AI Provider 設定
+
所有金鑰會寫入本機專案的 .env:${escapeHtml(envSettings.envPath || '.env')}。金鑰欄位留空代表保留原值;模型與預設 provider 會直接更新。
+
+
+
+
市場資料目前總經與日曆使用 FRED API key。儲存後本次伺服器程序會立即使用新值;若你改了 PORT 或 TTL 類設定,仍建議重啟。
+
+
+
+
+
狀態:${escapeHtml(envField(envSettings, 'FRED_API_KEY').masked || '未設定')}
+
+
+ ${Object.entries(AI_PROVIDER_META).map(([id, meta]) => { + const p = settings.providers?.[id] || {}; + const keyName = id === 'opencode-go' ? 'OPENCODE_GO_API_KEY' : 'GROK_API_KEY'; + const modelName = id === 'opencode-go' ? 'OPENCODE_GO_MODEL' : 'GROK_MODEL'; + const keyField = envField(envSettings, keyName); + const listId = `models-${id}`; + return `
+
+
${escapeHtml(meta.label)}${escapeHtml(meta.hint)}
+ +
+
+
+
+
+
狀態:${escapeHtml(keyField.masked || '未設定')}
+
`; + }).join('')} +
+
+
+
`; + $('#saveAISettings').addEventListener('click', async () => { + try { + await saveEnvSettings(view); + $('#aiSettingsMsg').textContent = '已寫入 .env。金鑰留空的欄位已保留原值。'; + } catch (e) { + $('#aiSettingsMsg').textContent = '儲存失敗:' + ((e.data && e.data.message) || e.message || ''); + } + }); + $$('[data-ai-test]').forEach(btn => btn.addEventListener('click', async () => { + try { + $('#aiSettingsMsg').textContent = '先寫入 .env,接著測試連線…'; + await saveEnvSettings(view); + await askAI({ provider: btn.dataset.aiTest, question: '請用一句話確認你已收到連線測試。', context: { page: 'settings', purpose: 'provider connection test' }, target: '#aiSettingsMsg' }); + } catch (e) { + $('#aiSettingsMsg').textContent = '測試失敗:' + ((e.data && e.data.message) || e.message || ''); + } + })); + $$('[data-ai-models]').forEach(btn => btn.addEventListener('click', async () => { + const provider = btn.dataset.aiModels; + const card = btn.closest('.ai-provider-card'); + const input = card?.querySelector(`[data-ai-model-input="${provider}"]`); + const list = card?.querySelector('datalist'); + try { + $('#aiSettingsMsg').textContent = '先寫入 .env,接著向 provider 抓取可用模型…'; + await saveEnvSettings(view); + const d = await loadProviderModels(provider); + const models = d.models || []; + if (list) list.innerHTML = models.map(m => ``).join(''); + if (input && !input.value && models[0]) input.value = models[0].id; + $('#aiSettingsMsg').textContent = models.length ? `已抓到 ${models.length} 個模型,請從 Model 欄位選擇後儲存。` : 'Provider 沒有回傳可用模型。'; + } catch (e) { + $('#aiSettingsMsg').textContent = '抓取模型失敗:' + ((e.data && e.data.message) || e.message || ''); + } + })); +} +function initAIWidget() { + const dock = $('#aiDock'); + if (!dock) return; + dock.innerHTML = ` + + `; + const refreshProviders = async () => { + try { await loadEnvSettings(); } catch (_) {} + const s = readAISettings(); + $('#aiProviderSelect').innerHTML = Object.entries(AI_PROVIDER_META).map(([id, meta]) => { + const p = s.providers?.[id] || {}; + return ``; + }).join(''); + await refreshWidgetModels($('#aiProviderSelect').value); + }; + refreshProviders(); + $('#aiFab').addEventListener('click', async () => { await refreshProviders(); $('#aiPanel').hidden = !$('#aiPanel').hidden; updateAIContextLabel(); }); + $('#aiClose').addEventListener('click', () => { $('#aiPanel').hidden = true; }); + $('#aiProviderSelect').addEventListener('change', async () => { + await refreshWidgetModels($('#aiProviderSelect').value); + updateAIContextLabel(); + }); + $('#aiOpenSettings').addEventListener('click', () => { location.hash = '#/settings'; $('#aiPanel').hidden = true; }); + $('#aiAskBtn').addEventListener('click', () => askAIFromWidget()); + $('#aiQuestion').addEventListener('input', () => autosizeAIInput()); + $('#aiQuestion').addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + askAIFromWidget(); + } + }); +} +function autosizeAIInput() { + const input = $('#aiQuestion'); + if (!input) return; + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 112) + 'px'; +} +function appendAIMessage(role, html, meta = '') { + const log = $('#aiChatLog'); + if (!log) return null; + const msg = document.createElement('div'); + msg.className = `ai-msg ${role === 'user' ? 'ai-msg-user' : 'ai-msg-bot'}`; + msg.innerHTML = `
${html}
${meta ? `
${escapeHtml(meta)}
` : ''}`; + log.appendChild(msg); + log.scrollTop = log.scrollHeight; + return msg; +} +async function refreshWidgetModels(provider) { + const select = $('#aiModelSelect'); + if (!select) return; + const settings = readAISettings(); + const current = settings.providers?.[provider]?.model || ''; + select.innerHTML = ``; + if (!settings.providers?.[provider]?.hasKey) { + select.innerHTML = ''; + return; + } + try { + const models = await getProviderModels(provider); + const opts = models.map(m => ``).join(''); + select.innerHTML = `${opts}`; + if (current) select.value = current; + } catch (e) { + select.innerHTML = ``; + } +} +function updateAIContextLabel() { + const el = $('#aiContextLabel'); + if (!el) return; + const labels = { macro: '總經', calendar: '日曆', learn: '學習', stock: '個股', journal: '復盤', settings: '設定' }; + const view = document.body.dataset.view || parseHash(); + const dataViews = new Set(['macro', 'calendar', 'learn', 'stock', 'journal']); + const focus = window.__AI_FOCUS || {}; + const focusLabel = focus.label || focus.title || focus.symbol || focus.date || focus.key || ''; + el.textContent = dataViews.has(view) + ? `會附上「${labels[view] || '頁面'}」${focusLabel ? `目前焦點:${focusLabel}` : '目前焦點'}` + : '一般聊天,不附頁面資料'; +} +async function collectAIContext() { + const view = document.body.dataset.view || parseHash(); + const focus = { ...(window.__AI_FOCUS || {}), view }; + const client = { urlHash: location.hash, visibleText: '', currentNote: null, personalNotes: [], symbol: '', subPage: '' }; + if (view === 'learn') { + client.currentNote = LEARN.currentNote ? { + kind: LEARN.currentNote.kind, id: LEARN.currentNote.id, title: LEARN.currentNote.title, + summary: LEARN.currentNote.summary, + } : null; + client.visibleText = $('#learnContent')?.innerText?.slice(0, 6000) || ''; + client.personalNotes = readLearnNotes().slice(0, 8); + } else if (view === 'stock') { + client.symbol = STOCK.symbol || ''; + client.subPage = STOCK.sub; + client.mapAnswers = STOCK.mapAnswers; + if (STOCK.sub === 'map') client.investMap = STOCK.mapCfg; + client.visibleText = $('#view-stock')?.innerText?.slice(0, 5000) || ''; + } else if (view === 'journal') { + client.visibleText = $('#view-journal')?.innerText?.slice(0, 5000) || ''; + } else if (view === 'calendar') { + client.visibleText = $('#view-calendar')?.innerText?.slice(0, 8000) || ''; + } else if (view === 'macro') { + client.visibleText = $('#view-macro')?.innerText?.slice(0, 8000) || ''; + } + return api('/api/ai/context', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ view, focus, client, allowFetch: true }), + }).catch(e => ({ + mode: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view) ? 'page' : 'chat', + hasPageData: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view), + view, + focus, + client, + contextError: (e.data && e.data.message) || e.message, + })); +} +async function askAIFromWidget() { + const question = $('#aiQuestion').value.trim(); + if (!question) return; + const input = $('#aiQuestion'); + const send = $('#aiAskBtn'); + appendAIMessage('user', escapeHtml(question), '你'); + input.value = ''; + autosizeAIInput(); + if (send) send.disabled = true; + const typing = appendAIMessage('bot', '', '正在回覆'); + try { + const context = await collectAIContext(); + const provider = $('#aiProviderSelect').value; + const model = $('#aiModelSelect')?.value || ''; + const d = await askAI({ provider, model, question, context }); + if (typing) { + typing.querySelector('.ai-bubble').innerHTML = renderMarkdown(d?.text || '(AI 沒有回傳文字)'); + const meta = typing.querySelector('.ai-msg-meta'); + if (meta) meta.textContent = `${AI_PROVIDER_META[provider]?.label || provider}${d?.model ? ' · ' + d.model : ''}`; + } + } catch (e) { + if (typing) { + typing.querySelector('.ai-bubble').innerHTML = `
${escapeHtml((e.data && e.data.message) || e.message || 'AI 呼叫失敗')}
`; + const meta = typing.querySelector('.ai-msg-meta'); + if (meta) meta.textContent = '傳送失敗'; + } + } finally { + if (send) send.disabled = false; + $('#aiChatLog').scrollTop = $('#aiChatLog').scrollHeight; + } +} +async function askAI({ provider, model, question, context, target }) { + const out = target ? $(target) : null; + const settings = readAISettings(); + const p = settings.providers?.[provider] || {}; + try { + const d = await api('/api/ai/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider, model: model || p.model || '', question, context }), + }); + if (out) out.innerHTML = renderMarkdown(d.text || '(AI 沒有回傳文字)'); + return d; + } catch (e) { + if (out) out.innerHTML = `
${escapeHtml((e.data && e.data.message) || e.message || 'AI 呼叫失敗')}
`; + if (!out) throw e; + return null; + } +} + // ═══════════════════════════════════════════════════════════ // 知識庫資料 // ═══════════════════════════════════════════════════════════ @@ -246,6 +579,7 @@ function renderNote(note) { const content = $('#learnContent'); LEARN.currentNote = note; const kind = note.kind || LEARN.noteKind; + setAIFocus({ view: 'learn', type: 'learning-note', kind, id: note.id || '', title: note.title || note.id || '' }); const center = (kind && note.id) ? `${kind}:${note.id}` : ''; content.innerHTML = LearnUI.renderArticle(note, { escapeHtml, @@ -540,6 +874,7 @@ function openCalendarDay(date) { const cell = $(`.cal-cell[data-date="${date}"]`); if (cell) cell.classList.add('selected'); const events = CAL.events.filter(ev => ev.date === date); + setAIFocus({ type: 'calendar-day', date, label: `${date} · ${events.length} 項事件`, eventCount: events.length }); const modal = $('#calendarModal'); const panel = $('#calendarModalPanel'); if (!modal || !panel) return; @@ -669,7 +1004,7 @@ async function refreshCalendarData(force) { // ═══════════════════════════════════════════════════════════ // 學習教材視圖 // ═══════════════════════════════════════════════════════════ -const LEARN = { lastSection: 'overview', graphFilter: 'curriculum', currentNote: null, noteKind: null }; +const LEARN = { lastSection: 'overview', graphFilter: 'curriculum', graphView: 'map', currentNote: null, noteKind: null }; const GRAPH_KINDS = [ { id: 'curriculum', label: '課程骨架', kinds: 'overview,principleMap,category,case,principle' }, { id: 'terms', label: '名詞', kinds: 'term', includeIndex: '1' }, @@ -704,6 +1039,7 @@ async function initLearn() {
課程
今日入口 + 學習實驗室 原則地圖 練習題庫
內容
@@ -730,6 +1066,7 @@ function showSection(section) { const content = $('#learnContent'); if (!content) return; if (section === 'overview') return renderLearnHome(); + if (section === 'lab') return renderLearnLab(); if (section === 'principleMap') return renderNote(Object.assign({ kind: 'principleMap' }, KB.principleMap || { body: '# 心法地圖\n(尚無內容)' })); if (section === 'quiz') return renderQuiz(); if (section === 'graph') return showGraph(); @@ -748,6 +1085,108 @@ function renderLearnHome() { }); window.scrollTo({ top: 0 }); } +function learnNoteKey(note) { + if (!note || !note.kind || !note.id) return ''; + return 'learn_note:' + note.kind + ':' + note.id; +} +function readLearnNotes() { + try { + const out = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key || !key.startsWith('learn_note:')) continue; + const raw = localStorage.getItem(key); + if (!raw) continue; + out.push(JSON.parse(raw)); + } + return out.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))); + } catch (_) { return []; } +} +function saveLearnNote(note, text) { + const key = learnNoteKey(note); + if (!key) return; + const payload = { + key, kind: note.kind, id: note.id, title: deEmmyText(note.title || note.id || ''), + text: String(text || '').trim(), updatedAt: new Date().toISOString(), + }; + if (!payload.text) localStorage.removeItem(key); + else localStorage.setItem(key, JSON.stringify(payload)); +} +function renderLearnLab() { + const content = $('#learnContent'); + const pairs = [ + ['財報基本功', '用營收、毛利率、EPS 判斷公司賺錢品質', 'category', '財報基本功'], + ['總經與利率', '用通膨、利率、就業判斷市場順逆風', 'category', '總經與利率'], + ['交易與資金管理', '用倉位、停損、復盤控制犯錯成本', 'category', '交易與資金管理'], + ['護城河與商業模式', '用定價權、平台、生態位判斷長期競爭力', 'category', '護城河與商業模式'], + ]; + const notes = readLearnNotes(); + content.innerHTML = ` +
+
+
+
Practice Lab
+

把學到的東西連起來

+

先做配對,再到個股工具套用,最後把自己的判斷寫成筆記。這裡不是要背答案,是訓練你看到資料時知道該問哪一組問題。

+
+
0/${pairs.length}已連對
+
+
+

概念配對

左邊選概念,右邊選它真正要解決的判斷問題
+
+
${pairs.map((p, i) => ``).join('')}
+
${pairs.map((p, i) => ``).join('')}
+
+
+
+
+

我的學習筆記

文章頁的筆記會收在這裡,方便回來複習
+
${notes.length ? notes.map(n => ` + `).join('') : '
還沒有筆記。打開任一篇學習文章,在「我的筆記」裡寫下你的判斷。
'}
+
+
`; + bindLearnLab(content); + window.scrollTo({ top: 0 }); +} +function bindLearnLab(content) { + const picked = { concept: null, answer: null }; + const matched = new Set(); + const total = $$('.match-card.concept', content).length || 1; + const update = () => { const s = $('#labScore'); if (s) s.textContent = `${matched.size}/${total}`; }; + const check = () => { + if (!picked.concept || !picked.answer) return; + const ok = picked.concept.dataset.pair === picked.answer.dataset.pair; + if (ok) { + matched.add(picked.concept.dataset.pair); + picked.concept.classList.add('matched'); + picked.answer.classList.add('matched'); + } else { + picked.concept.classList.add('wrong'); + picked.answer.classList.add('wrong'); + setTimeout(() => { picked.concept?.classList.remove('wrong'); picked.answer?.classList.remove('wrong'); }, 520); + } + picked.concept?.classList.remove('picked'); + picked.answer?.classList.remove('picked'); + picked.concept = null; picked.answer = null; update(); + }; + $$('.match-card.concept', content).forEach(btn => btn.addEventListener('click', () => { + if (btn.classList.contains('matched')) return; + $$('.match-card.concept', content).forEach(x => x.classList.remove('picked')); + picked.concept = btn; btn.classList.add('picked'); check(); + })); + $$('.match-card.answer', content).forEach(btn => btn.addEventListener('click', () => { + if (btn.classList.contains('matched')) return; + $$('.match-card.answer', content).forEach(x => x.classList.remove('picked')); + picked.answer = btn; btn.classList.add('picked'); check(); + })); + $('#labReset')?.addEventListener('click', renderLearnLab); + $('#labApply')?.addEventListener('click', () => { location.hash = '#/stock'; }); + $$('.note-card', content).forEach(btn => btn.addEventListener('click', () => openNote(btn.dataset.kind, btn.dataset.id))); + $$('.match-card.concept', content).forEach(btn => btn.addEventListener('dblclick', () => openNote(btn.dataset.kind, btn.dataset.id))); +} function renderCardList(title, items, kind) { const content = $('#learnContent'); let cards; @@ -823,15 +1262,20 @@ async function showGraph(opts = {}) { const center = opts.center || ''; const depth = opts.depth || 2; LEARN.graphFilter = filter; + LEARN.graphView = opts.view || LEARN.graphView || 'map'; content.innerHTML = ` -
知識圖譜
-
節點是筆記與概念,連線來自文內 [[連結]]。點一下節點可開啟該篇;拖曳平移、雙指或滾輪縮放。
+
知識地圖
+
不再把所有連線擠成一團。先按類型分群,再點節點看它真正連到哪些案例、原則、名詞與公司。
-
-
載入圖譜中…
+
+
載入知識地圖中…
`; mountChips($('#graphFilterChips'), GRAPH_KINDS.map(g => ({ id: g.id, label: g.label })), filter, v => showGraph({ filter: v })); + mountChips($('#graphViewChips'), [ + { id: 'map', label: '分群地圖' }, + { id: 'list', label: '關係清單' }, + ], LEARN.graphView, v => showGraph({ filter, center, depth, view: v }), { sm: true }); $('#graphLegend').innerHTML = GRAPH_LEGEND.map(([, lab, col]) => `${lab}`).join(''); const cfg = GRAPH_KINDS.find(g => g.id === filter) || GRAPH_KINDS[0]; @@ -846,43 +1290,83 @@ async function showGraph(opts = {}) { el.innerHTML = '
此範圍沒有足夠的連結可繪製。
'; return; } - if (!window.vis) { el.innerHTML = '
圖譜元件載入失敗,請重新整理。
'; return; } - const nodes = new vis.DataSet(data.nodes.map(n => ({ - id: n.id, label: n.label, title: n.title, - color: { background: n.color, border: n.color, highlight: { background: n.color, border: '#1d1d1f' } }, - shape: n.shape === 'box' ? 'box' : 'dot', - font: { face: '-apple-system, BlinkMacSystemFont, sans-serif', size: 13, color: '#1d1d1f' }, - margin: 10, - }))); - const edges = new vis.DataSet(data.edges.map(e => ({ - from: e.from, to: e.to, arrows: { to: { scaleFactor: 0.45 } }, - color: { color: 'rgba(0,0,0,.12)', highlight: 'rgba(0,113,227,.45)' }, - smooth: { type: 'continuous', roundness: 0.2 }, - }))); if (graphNetwork) { graphNetwork.destroy(); graphNetwork = null; } - graphNetwork = new vis.Network(el, { nodes, edges }, { - physics: { stabilization: { iterations: 100 }, barnesHut: { gravitationalConstant: -12000, springLength: 120 } }, - interaction: { hover: true, tooltipDelay: 80, navigationButtons: false }, - nodes: { borderWidth: 0, shadow: { enabled: true, size: 6, x: 0, y: 2, color: 'rgba(0,0,0,.08)' } }, - }); - graphNetwork.on('click', p => { - if (!p.nodes.length) return; - const nid = p.nodes[0]; - const node = data.nodes.find(n => n.id === nid); - if (!node) return; - const colon = nid.indexOf(':'); - if (colon < 0) return; - openNote(nid.slice(0, colon), nid.slice(colon + 1)); - }); - if (center && data.nodes.some(n => n.id === center)) { - graphNetwork.focus(center, { scale: 1.2, animation: { duration: 500, easingFunction: 'easeInOutQuad' } }); - } + renderKnowledgeMap(el, data, { center, view: LEARN.graphView }); $('#graphStat').textContent = `${data.nodes.length} 個節點 · ${data.edges.length} 條連線${center ? '(聚焦模式)' : ''}`; } catch (e) { $('#graphCanvas').innerHTML = `
圖譜載入失敗:${escapeHtml(e.message || '')}
`; } window.scrollTo({ top: 0 }); } +function graphKind(id) { + return String(id || '').split(':')[0] || 'note'; +} +function graphKindLabel(kind) { + const extra = { overview: '課綱', principleMap: '原則地圖', quiz: '練習' }; + return extra[kind] || (GRAPH_LEGEND.find(g => g[0] === kind) || [kind, kind])[1]; +} +function renderKnowledgeMap(el, data, opts = {}) { + const nodes = data.nodes || []; + const edges = data.edges || []; + const byId = new Map(nodes.map(n => [n.id, n])); + const degree = new Map(); + edges.forEach(e => { degree.set(e.from, (degree.get(e.from) || 0) + 1); degree.set(e.to, (degree.get(e.to) || 0) + 1); }); + const groups = {}; + nodes.forEach(n => { + const k = n.kind || graphKind(n.id); + if (!groups[k]) groups[k] = []; + groups[k].push({ ...n, degree: degree.get(n.id) || 0 }); + }); + Object.values(groups).forEach(arr => arr.sort((a, b) => b.degree - a.degree || String(a.label).localeCompare(String(b.label)))); + if (opts.view === 'list') { + el.innerHTML = `
${edges.slice(0, 160).map(e => { + const a = byId.get(e.from), b = byId.get(e.to); + if (!a || !b) return ''; + return ``; + }).join('')}
`; + } else { + const order = ['overview', 'category', 'case', 'principle', 'term', 'company', 'episode', 'principleMap']; + const keys = Object.keys(groups).sort((a, b) => (order.indexOf(a) < 0 ? 99 : order.indexOf(a)) - (order.indexOf(b) < 0 ? 99 : order.indexOf(b))); + el.innerHTML = `
+ ${keys.map(k => `
+
${escapeHtml(graphKindLabel(k))}${groups[k].length}
+
${groups[k].slice(0, 42).map(n => ``).join('')}
+
`).join('')} +
`; + } + const focus = (nid) => { + const n = byId.get(nid); + if (!n) return; + const near = edges.filter(e => e.from === nid || e.to === nid).slice(0, 24).map(e => { + const otherId = e.from === nid ? e.to : e.from; + return byId.get(otherId); + }).filter(Boolean); + const box = $('#kgFocus'); + if (box) box.innerHTML = ` +
${escapeHtml(n.label || n.title || n.id)}${escapeHtml(graphKindLabel(n.kind || graphKind(n.id)))}
+
+
${near.length ? near.map(x => ``).join('') : '
這個節點暫時沒有鄰近連結。
'}
`; + $$('.kg-node', el).forEach(x => x.classList.toggle('active', x.dataset.id === nid)); + }; + el.addEventListener('click', e => { + const openBtn = e.target.closest('[data-open-node]'); + if (openBtn) { + const colon = openBtn.dataset.openNode.indexOf(':'); + if (colon >= 0) openNote(openBtn.dataset.openNode.slice(0, colon), openBtn.dataset.openNode.slice(colon + 1)); + return; + } + const nodeBtn = e.target.closest('[data-id]'); + if (!nodeBtn) return; + focus(nodeBtn.dataset.id); + }); + if (opts.center && byId.has(opts.center)) focus(opts.center); +} // ═══════════════════════════════════════════════════════════ // 共用 SVG 折線圖(價格走勢 / 回測權益曲線共用,支援多條線 + hover) @@ -957,6 +1441,7 @@ function initStock() {
範例:NVDAAMDMSFTAVGOAAPL
+
指標面板 價格走勢 @@ -978,12 +1463,31 @@ function initStock() { $('#stkSym').addEventListener('keydown', e => { if (e.key === 'Enter') go(); }); $$('.finbox-examples b', view).forEach(b => b.addEventListener('click', () => { $('#stkSym').value = b.dataset.sym; go(); })); $$('#stkSub a').forEach(a => a.addEventListener('click', () => setSub(a.dataset.sub))); + renderStockLearningBridge(); setSub('metrics'); } +function renderStockLearningBridge() { + const box = $('#stockLearnBridge'); + if (!box) return; + box.innerHTML = ` +
+
個股研究任務每看一檔股票,都照這條路把學習連回工具
+
+ + + + + +
+
`; + $$('[data-learn-kind]', box).forEach(btn => btn.addEventListener('click', () => openNote(btn.dataset.learnKind, btn.dataset.learnId))); + $$('[data-sub-target]', box).forEach(btn => btn.addEventListener('click', () => setSub(btn.dataset.subTarget))); +} function setStockSymbol(sym) { sym = (sym || '').trim().toUpperCase(); if (!sym) return; STOCK.symbol = sym; + setAIFocus({ type: 'stock', symbol: sym, subPage: STOCK.sub, label: `${sym} · ${STOCK.sub}` }); STOCK.rendered = {}; // 換股票 → 各分頁重抓 STOCK.fundamentals = {}; $('#stkSym').value = sym; @@ -993,6 +1497,7 @@ function setStockSymbol(sym) { function setSub(sub) { if (!SUBS.includes(sub)) sub = 'price'; STOCK.sub = sub; + if (STOCK.symbol) setAIFocus({ type: 'stock', symbol: STOCK.symbol, subPage: sub, label: `${STOCK.symbol} · ${sub}` }); $$('#stkSub a').forEach(a => a.classList.toggle('active', a.dataset.sub === sub)); SUBS.forEach(s => { const p = $('#pane-' + s); if (p) p.hidden = s !== sub; }); renderSub(sub); @@ -1695,6 +2200,12 @@ function drawMap() { // 彙整結論 const statuses = cfg.layers.map(layerStatus); + const funnelHTML = `
${cfg.layers.map((L, i) => { + const st = statuses[i]; + return ``; + }).join('')}
`; const anyAnswered = Object.keys(STOCK.mapAnswers).length > 0; let verdict, vcls; if (firstOut >= 0) { verdict = `不建議進場:第 ${firstOut + 1} 層「${cfg.layers[firstOut].title}」出局,依漏斗原則應停手。`; vcls = 'bad'; } @@ -1704,6 +2215,7 @@ function drawMap() { pane.innerHTML = `
🧭 下單前的核心提問
${escapeHtml(cfg.coreQuestion)}
+ ${funnelHTML}
${target} 的判斷
${verdict}
${STOCK.symbol ? '' : ''}
@@ -1714,6 +2226,7 @@ function drawMap() { $$('.mq-ans', pane).forEach(box => { $$('input[type=radio]', box).forEach(r => r.addEventListener('change', () => { STOCK.mapAnswers[box.dataset.layer + ':' + box.dataset.qi] = r.value; + setAIFocus({ type: 'stock-map', symbol: STOCK.symbol, subPage: 'map', label: `${STOCK.symbol || '標的'} · 投資地圖`, mapAnswers: STOCK.mapAnswers }); drawMap(); })); }); @@ -1721,6 +2234,10 @@ function drawMap() { bindWlinks(pane); const rs = $('#mapReset'); if (rs) rs.addEventListener('click', () => { STOCK.mapAnswers = {}; drawMap(); }); const sv = $('#mapSave'); if (sv) sv.addEventListener('click', saveMapToJournal); + $$('.funnel-step', pane).forEach(btn => btn.addEventListener('click', () => { + const layer = $$('.map-layer', pane)[Number(btn.dataset.jumpLayer)]; + layer?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + })); } function saveMapToJournal() { const cfg = STOCK.mapCfg; @@ -2033,4 +2550,5 @@ async function submitTradeForm(e) { // 啟動:依目前 hash 顯示視圖(macro 由 index.html 內聯負責載入) initMermaid(); +initAIWidget(); setView(parseHash()); diff --git a/index.html b/index.html index 8edd055..01fc931 100644 --- a/index.html +++ b/index.html @@ -297,6 +297,7 @@ a{color:var(--blue);text-decoration:none} 學習教材 個股工具 交易復盤 + AI 設定
@@ -316,8 +317,11 @@ a{color:var(--blue);text-decoration:none} + +
+ @@ -740,6 +744,7 @@ function openModal(key,range){ // 預設開啟即顯示「全部」長期走勢,直接看到十年以上歷史與事件標記 MODAL={key,range:range||'max',isScore:false}; const meta=CARD_META[key]||{label:key}; + window.setAIFocus?.({type:'macro-indicator',key,label:meta.label||key,range:MODAL.range}); document.getElementById('modalTitle').innerHTML=`${meta.label}${meta.labelEn||''}`; const now=document.getElementById('modalNow'); now.textContent=meta.value?('目前:'+meta.value):''; @@ -761,6 +766,7 @@ function openModal(key,range){ function openScoreModal(){ MODAL={key:'__score',range:'max',isScore:true}; + window.setAIFocus?.({type:'macro-score',key:'__score',label:'總經健康分數',range:'max'}); document.getElementById('modalTitle').innerHTML='總經健康分數走勢Macro Health Score'; document.getElementById('modalNow').textContent=''; document.getElementById('rangeBtns').innerHTML=''; @@ -937,12 +943,11 @@ function renderError(data){ if(data&&data.error==='missing_api_key'){ main.innerHTML=`

還差一步:設定免費的 FRED 金鑰

-

本儀表板的真實資料來自美國聖路易聯儲的 FRED。請依下列步驟設定(約 1 分鐘):

+

本儀表板的真實資料來自美國聖路易聯儲的 FRED。你可以到頂部「AI 設定」頁貼上金鑰,系統會寫入本機 .env

  1. FRED 申請頁面 註冊並取得免費金鑰
  2. -
  3. 把專案內的 .env.example 複製成 .env
  4. -
  5. .env 填入 FRED_API_KEY=你的金鑰
  6. -
  7. 重新啟動伺服器:npm start
  8. +
  9. 切到「AI 設定」頁,把金鑰貼到 FRED_API_KEY
  10. +
  11. 按「儲存設定」後回來重新載入總經頁
`; diff --git a/lib/learn-html.js b/lib/learn-html.js index 982f8da..0c9fad8 100644 --- a/lib/learn-html.js +++ b/lib/learn-html.js @@ -122,6 +122,91 @@ return items.slice(0, 14); } + function stripMd(text) { + return deEmmy(String(text || '')) + .replace(/```[\s\S]*?```/g, ' ') + .replace(/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g, (_, a, _b, c) => c || a) + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/[#>*_`|]/g, '') + .replace(/\s+/g, ' ') + .trim(); + } + + function splitSections(md) { + const sections = []; + let cur = null; + for (const line of String(md || '').split('\n')) { + const h = line.match(/^(#{2,3})\s+(.+)$/); + if (h) { + if (cur && cur.lines.length) sections.push(cur); + cur = { level: h[1].length, title: stripMd(h[2]), lines: [] }; + } else if (cur) { + cur.lines.push(line); + } + } + if (cur && cur.lines.length) sections.push(cur); + return sections + .map(s => ({ ...s, text: stripMd(s.lines.join('\n')) })) + .filter(s => s.title && s.text.length > 20) + .slice(0, 8); + } + + function extractInsights(note) { + const body = deEmmy(note.body || ''); + const lines = body.split('\n'); + const out = []; + const seen = new Set(); + const push = (raw) => { + let text = stripMd(raw) + .replace(/^[-\d.)、\s]+/, '') + .replace(/^一句話精華[::]\s*/, ''); + if (text.length < 14 || seen.has(text)) return; + seen.add(text); + out.push(text.slice(0, 118)); + }; + for (const line of lines) { + const t = line.trim(); + if (/^>\s*\S/.test(t) || /^[-*]\s+\S/.test(t) || /一句話精華/.test(t) || /^\*\*.+\*\*/.test(t)) push(t); + if (out.length >= 5) break; + } + if (out.length < 3) { + for (const s of splitSections(body)) { + push(s.text); + if (out.length >= 5) break; + } + } + return out; + } + + function buildRecallCards(note, sections, principles) { + const title = cleanPrincipleTitle(note.title || note.id || '這篇筆記'); + const cards = [ + { + q: `先不看內容,你會怎麼用一句話說明「${title}」?`, + a: leadFromNote(note) || '抓出核心判斷,再用自己的話重說一次。', + }, + ]; + sections.slice(0, 3).forEach(s => cards.push({ + q: `「${s.title}」真正要判斷的是什麼?`, + a: s.text.slice(0, 150), + })); + principles.slice(0, 2).forEach(p => cards.push({ + q: `這個案例可以連到哪一條可重複使用的原則?`, + a: cleanPrincipleTitle(p.title), + })); + return cards.slice(0, 5); + } + + function addHeadingAnchors(bodyHtml, toc) { + let idx = 0; + return bodyHtml.replace(/([\s\S]*?)<\/h\1>/g, (m, level, inner) => { + const t = toc[idx]; + idx += 1; + if (!t || String(t.level) !== String(level)) return m; + return `${inner}`; + }); + } + function renderPathSteps(pathId, path) { return path.steps.map((step, i) => { const links = [ @@ -147,6 +232,16 @@ { id: 'trade', icon: '📝', ...LEARN_PATHS.trade }, ]; return ` +
+
+
Learning UX
+

先回想,再展開,最後拿去判斷

+

這裡把長筆記拆成短任務:主動回想、章節重點、案例原則、工具應用。你不用把全部內容一次吞完,而是每次完成一個判斷。

+
+
+ 回想重點連結應用 +
+
從問題開始
@@ -234,8 +329,16 @@ const title = deEmmy(note.title || note.id || ''); const lead = leadFromNote(note); const toc = buildTocFromMarkdown(note.body); + const sections = splitSections(note.body); + const insights = extractInsights(note); const principles = (kind === 'case') ? extractPrinciples(note, opts.linkMap, opts.principles) : []; + const recallCards = buildRecallCards(note, sections, principles); const fm = note.frontmatter || {}; + const noteKey = (kind && note.id) ? `learn_note:${kind}:${note.id}` : ''; + let savedNote = ''; + if (noteKey) { + try { savedNote = JSON.parse(localStorage.getItem(noteKey) || '{}').text || ''; } catch (_) {} + } let tags = ''; if (fm.ticker) tags += `代號 ${esc([].concat(fm.ticker).join(' / '))}`; if (fm.sector) tags += `${esc(fm.sector)}`; @@ -254,15 +357,61 @@ const tocHtml = toc.length > 2 ? ` ` : ''; let bodyHtml = opts.renderMarkdown(deEmmy(note.body || '')); - toc.forEach((t, i) => { - const re = new RegExp(`()([^<]*${t.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').slice(0, 12)}[^<]*)`, 'i'); - if (i === 0) bodyHtml = bodyHtml.replace(re, `$1 id="${t.id}" $2`); - }); + bodyHtml = addHeadingAnchors(bodyHtml, toc); + + const insightPanel = insights.length ? ` +
+
01

先抓重點

+
+ ${insights.map((x, i) => `
${String(i + 1).padStart(2, '0')}

${esc(x)}

`).join('')} +
+
` : ''; + + const recallPanel = recallCards.length ? ` +
+
02

主動回想

+
+ ${recallCards.map((c, i) => `
+
${esc(c.q)}
+ + +
`).join('')} +
+
` : ''; + + const sectionPanel = sections.length ? ` +
+
03

章節速讀

+
+ ${sections.slice(0, 6).map(s => `
+ ${esc(s.title)} +

${esc(s.text.slice(0, 180))}

+
`).join('')} +
+
` : ''; + + const reviewPanel = ` +
+
04

間隔複習

+
+ + + + +
+
`; + + const notePanel = noteKey ? ` +
+

我的筆記

+ +
尚未儲存
+
` : ''; const toolActions = [ { view: 'macro', label: '總經儀表板', sub: '對照利率、通膨' }, @@ -282,9 +431,28 @@

${esc(title)}

${lead ? `

${esc(lead)}

` : ''} - ${principlePanel} - ${tocHtml} -
${bodyHtml}
+
+ + + +
+
+ ${insightPanel} + ${recallPanel} + ${sectionPanel} + ${reviewPanel} + ${notePanel} + ${principlePanel} +
+ +