add ai chat

This commit is contained in:
王性驊 2026-06-04 09:35:37 +08:00
parent aa38208fff
commit 846bfd6fe0
7 changed files with 1448 additions and 55 deletions

View File

@ -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

View File

@ -14,11 +14,13 @@
<https://fred.stlouisfed.org/docs/api/api_key.html> 註冊帳號,取得一組 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. 安裝並啟動

253
app.css
View File

@ -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}}

590
app.js
View File

@ -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 = '<div class="page"><div class="empty-state">載入設定中…</div></div>';
const envSettings = await loadEnvSettings();
const settings = readAISettings();
view.innerHTML = `
<div class="page">
<div class="page-head">
<div class="page-title">API Key AI Provider 設定</div>
<div class="page-sub">所有金鑰會寫入本機專案的 <code>.env</code>${escapeHtml(envSettings.envPath || '.env')} provider </div>
</div>
<section class="ai-provider-card env-provider-card">
<div class="ai-provider-head">
<div><b>市場資料</b><span>使 FRED API key使 PORT TTL </span></div>
</div>
<div class="form-grid">
<div class="field full"><label>FRED API Key</label><input type="password" data-env-key="FRED_API_KEY" placeholder="${envField(envSettings, 'FRED_API_KEY').hasValue ? '' : ' FRED API key'}"></div>
</div>
<div class="ai-provider-foot"><span>狀態${escapeHtml(envField(envSettings, 'FRED_API_KEY').masked || '未設定')}</span></div>
</section>
<div class="ai-settings-grid">
${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 `<section class="ai-provider-card" data-provider="${id}">
<div class="ai-provider-head">
<div><b>${escapeHtml(meta.label)}</b><span>${escapeHtml(meta.hint)}</span></div>
<label class="ai-default"><input type="radio" name="aiActiveProvider" value="${id}" ${settings.active === id ? 'checked' : ''}> 預設</label>
</div>
<div class="form-grid">
<div class="field full"><label>API Key</label><input type="password" data-env-key="${keyName}" placeholder="${keyField.hasValue ? '' : ` ${meta.label} API key`}"></div>
<div class="field full"><label>Model</label><div class="ai-model-row"><input type="text" data-env-key="${modelName}" data-ai-model-input="${id}" list="${listId}" value="${escapeHtml(p.model || '')}" placeholder=" provider "><button type="button" class="btn ghost sm" data-ai-models="${id}"></button></div><datalist id="${listId}"></datalist></div>
</div>
<div class="ai-provider-foot"><span>狀態${escapeHtml(keyField.masked || '未設定')}</span><button class="btn ghost sm" data-ai-test="${id}"></button></div>
</section>`;
}).join('')}
</div>
<div class="form-actions"><button class="btn" id="saveAISettings">儲存設定</button></div>
<div id="aiSettingsMsg" class="ai-settings-msg"></div>
</div>`;
$('#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 => `<option value="${escapeHtml(m.id)}"></option>`).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 = `
<button class="ai-fab" id="aiFab">AI</button>
<div class="ai-panel" id="aiPanel" hidden>
<div class="ai-panel-head"><div><b>MacroScope AI</b><span id="aiContextLabel"></span></div><button id="aiClose">×</button></div>
<div class="ai-provider-row">
<select id="aiProviderSelect"></select>
<select id="aiModelSelect"></select>
<button class="btn ghost sm" id="aiOpenSettings">設定</button>
</div>
<div class="ai-chat" id="aiChatLog">
<div class="ai-msg ai-msg-bot">
<div class="ai-bubble">我會看你目前 focus 的頁面資料沒有資料時也可以直接聊天</div>
<div class="ai-msg-meta">MacroScope AI</div>
</div>
</div>
<div class="ai-compose">
<textarea id="aiQuestion" rows="1" placeholder="輸入訊息..."></textarea>
<button class="ai-send" id="aiAskBtn" aria-label="送出"></button>
</div>
</div>`;
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 `<option value="${id}" ${s.active === id ? 'selected' : ''}>${escapeHtml(meta.label)}${p.model ? ` · ${escapeHtml(p.model)}` : ' · 自動抓取模型'}</option>`;
}).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 = `<div class="ai-bubble">${html}</div>${meta ? `<div class="ai-msg-meta">${escapeHtml(meta)}</div>` : ''}`;
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 = `<option value="">${current ? `使用設定:${escapeHtml(current)}` : '自動抓取模型'}</option>`;
if (!settings.providers?.[provider]?.hasKey) {
select.innerHTML = '<option value="">先設定 API key</option>';
return;
}
try {
const models = await getProviderModels(provider);
const opts = models.map(m => `<option value="${escapeHtml(m.id)}" ${m.id === current ? 'selected' : ''}>${escapeHtml(m.id)}</option>`).join('');
select.innerHTML = `<option value="">${current ? `使用設定:${escapeHtml(current)}` : '自動抓取模型'}</option>${opts}`;
if (current) select.value = current;
} catch (e) {
select.innerHTML = `<option value="">${current ? `使用設定:${escapeHtml(current)}` : '抓取模型失敗'}</option>`;
}
}
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', '<span class="ai-typing"><i></i><i></i><i></i></span>', '正在回覆');
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 = `<div class="ai-error">${escapeHtml((e.data && e.data.message) || e.message || 'AI 呼叫失敗')}</div>`;
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 = `<div class="ai-error">${escapeHtml((e.data && e.data.message) || e.message || 'AI 呼叫失敗')}</div>`;
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() {
<div class="learn-side" id="learnSide">
<div class="side-group">課程</div>
<a data-section="overview">今日入口</a>
<a data-section="lab">學習實驗室</a>
<a data-section="principleMap">原則地圖</a>
<a data-section="quiz">練習題庫</a>
<div class="side-group">內容</div>
@ -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 = `
<div class="learn-lab">
<section class="lab-hero">
<div>
<div class="eyebrow">Practice Lab</div>
<h2>把學到的東西連起來</h2>
<p>先做配對再到個股工具套用最後把自己的判斷寫成筆記這裡不是要背答案是訓練你看到資料時知道該問哪一組問題</p>
</div>
<div class="lab-score"><b id="labScore">0/${pairs.length}</b><span></span></div>
</section>
<section class="lab-game">
<div class="metric-section-head"><h3>概念配對</h3><span></span></div>
<div class="match-board">
<div>${pairs.map((p, i) => `<button class="match-card concept" data-pair="${i}" data-kind="${p[2]}" data-id="${escapeHtml(p[3])}">${escapeHtml(p[0])}</button>`).join('')}</div>
<div>${pairs.map((p, i) => `<button class="match-card answer" data-pair="${i}">${escapeHtml(p[1])}</button>`).join('')}</div>
</div>
<div class="lab-actions"><button class="btn ghost sm" id="labReset">重玩</button><button class="btn sm" id="labApply"></button></div>
</section>
<section class="lab-notes">
<div class="metric-section-head"><h3>我的學習筆記</h3><span>便</span></div>
<div class="note-list">${notes.length ? notes.map(n => `
<button class="note-card" data-kind="${escapeHtml(n.kind)}" data-id="${escapeHtml(n.id)}">
<b>${escapeHtml(n.title)}</b><span>${new Date(n.updatedAt).toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
<p>${escapeHtml(n.text.slice(0, 120))}</p>
</button>`).join('') : '<div class="empty-state"></div>'}</div>
</section>
</div>`;
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 = `
<div class="page-title" style="font-size:1.2rem;margin-bottom:8px">知識</div>
<div class="page-sub" style="margin-bottom:16px">節點是筆記與概念連線來自文內 [[連結]]點一下節點可開啟該篇拖曳平移雙指或滾輪縮放</div>
<div class="page-title" style="font-size:1.2rem;margin-bottom:8px">知識</div>
<div class="page-sub" style="margin-bottom:16px">不再把所有連線擠成一團先按類型分群再點節點看它真正連到哪些案例原則名詞與公司</div>
<div class="graph-panel">
<div class="graph-toolbar"><div id="graphFilterChips" class="chip-row"></div></div>
<div id="graphCanvas" class="graph-canvas"><div class="empty-state">載入</div></div>
<div class="graph-toolbar"><div id="graphFilterChips" class="chip-row"></div><div id="graphViewChips" class="chip-row"></div></div>
<div id="graphCanvas" class="graph-canvas knowledge-map"><div class="empty-state">載入知識地圖中</div></div>
<div class="graph-foot"><div class="graph-legend" id="graphLegend"></div><span id="graphStat"></span></div>
</div>`;
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]) =>
`<span><i style="background:${col}"></i>${lab}</span>`).join('');
const cfg = GRAPH_KINDS.find(g => g.id === filter) || GRAPH_KINDS[0];
@ -846,43 +1290,83 @@ async function showGraph(opts = {}) {
el.innerHTML = '<div class="empty-state">此範圍沒有足夠的連結可繪製。</div>';
return;
}
if (!window.vis) { el.innerHTML = '<div class="empty-state">圖譜元件載入失敗,請重新整理。</div>'; 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 = `<div class="empty-state">圖譜載入失敗:${escapeHtml(e.message || '')}</div>`;
}
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 = `<div class="kg-list">${edges.slice(0, 160).map(e => {
const a = byId.get(e.from), b = byId.get(e.to);
if (!a || !b) return '';
return `<button class="kg-edge" data-id="${escapeHtml(a.id)}">
<span>${escapeHtml(a.label || a.title || a.id)}</span><i></i><span>${escapeHtml(b.label || b.title || b.id)}</span>
</button>`;
}).join('')}</div>`;
} 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 = `<div class="kg-map">
${keys.map(k => `<section class="kg-column">
<div class="kg-head"><b>${escapeHtml(graphKindLabel(k))}</b><span>${groups[k].length}</span></div>
<div class="kg-nodes">${groups[k].slice(0, 42).map(n => `<button class="kg-node ${opts.center === n.id ? 'active' : ''}" data-id="${escapeHtml(n.id)}">
<span>${escapeHtml(n.label || n.title || n.id)}</span><small>${n.degree} </small>
</button>`).join('')}</div>
</section>`).join('')}
</div><aside class="kg-focus" id="kgFocus"><div class="empty-state"></div></aside>`;
}
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 = `
<div class="kg-focus-head"><b>${escapeHtml(n.label || n.title || n.id)}</b><span>${escapeHtml(graphKindLabel(n.kind || graphKind(n.id)))}</span></div>
<div class="kg-focus-actions"><button class="btn sm" data-open-node="${escapeHtml(n.id)}">打開筆記</button></div>
<div class="kg-relations">${near.length ? near.map(x => `<button class="kg-relation" data-id="${escapeHtml(x.id)}">
<span>${escapeHtml(graphKindLabel(x.kind || graphKind(x.id)))}</span><b>${escapeHtml(x.label || x.title || x.id)}</b>
</button>`).join('') : '<div class="empty-state"></div>'}</div>`;
$$('.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() {
<button id="stkGo">查詢</button>
</div>
<div class="finbox-examples">範例<b data-sym="NVDA">NVDA</b><b data-sym="AMD">AMD</b><b data-sym="MSFT">MSFT</b><b data-sym="AVGO">AVGO</b><b data-sym="AAPL">AAPL</b></div>
<div id="stockLearnBridge"></div>
<div class="sub-tabs" id="stkSub">
<a data-sub="metrics" class="active">指標面板</a>
<a data-sub="price">價格走勢</a>
@ -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 = `
<div class="stock-learn-bridge">
<div class="slb-head"><b>個股研究任務</b><span></span></div>
<div class="slb-steps">
<button data-learn-kind="category" data-learn-id="財報基本功"><span>1</span><b></b><small>EPS</small></button>
<button data-sub-target="finbox"><span>2</span><b></b><small></small></button>
<button data-learn-kind="category" data-learn-id="護城河與商業模式"><span>3</span><b></b><small></small></button>
<button data-sub-target="map"><span>4</span><b></b><small></small></button>
<button data-sub-target="backtest"><span>5</span><b></b><small></small></button>
</div>
</div>`;
$$('[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 = `<div class="map-funnel">${cfg.layers.map((L, i) => {
const st = statuses[i];
return `<button class="funnel-step ${st}" data-jump-layer="${i}">
<span>${i + 1}</span><b>${escapeHtml(L.title)}</b><small>${ST_META[st].lab}</small>
</button>`;
}).join('')}</div>`;
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 = `
<div class="map-core">🧭 下單前的核心提問<br><span>${escapeHtml(cfg.coreQuestion)}</span></div>
${funnelHTML}
<div class="map-verdict ${vcls}"><div class="mv-lab">${target} 的判斷</div><div class="mv-text">${verdict}</div>
<div class="mv-actions"><button class="btn ghost sm" id="mapReset">重設</button>${STOCK.symbol ? '<button class="btn sm" id="mapSave"></button>' : ''}</div>
</div>
@ -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());

View File

@ -297,6 +297,7 @@ a{color:var(--blue);text-decoration:none}
<a data-view="learn">學習教材</a>
<a data-view="stock">個股工具</a>
<a data-view="journal">交易復盤</a>
<a data-view="settings">AI 設定</a>
</nav>
<div class="header-right">
<nav class="nav-links" id="navLinks"></nav>
@ -316,8 +317,11 @@ a{color:var(--blue);text-decoration:none}
<section class="view" id="view-learn" hidden></section>
<section class="view" id="view-stock" hidden></section>
<section class="view" id="view-journal" hidden></section>
<section class="view" id="view-settings" hidden></section>
</main>
<div id="aiDock" class="ai-dock"></div>
<!-- 浮動說明框 -->
<div id="tooltip" role="tooltip"></div>
@ -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}<span class="en">${meta.labelEn||''}</span>`;
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='總經健康分數走勢<span class="en">Macro Health Score</span>';
document.getElementById('modalNow').textContent='';
document.getElementById('rangeBtns').innerHTML='';
@ -937,12 +943,11 @@ function renderError(data){
if(data&&data.error==='missing_api_key'){
main.innerHTML=`<div class="state"><div class="err-box">
<h2>還差一步:設定免費的 FRED 金鑰</h2>
<p>本儀表板的真實資料來自美國聖路易聯儲的 FRED。請依下列步驟設定(約 1 分鐘):</p>
<p>本儀表板的真實資料來自美國聖路易聯儲的 FRED。你可以到頂部「AI 設定」頁貼上金鑰,系統會寫入本機 <code>.env</code></p>
<ol style="margin:12px 0 0 18px">
<li><a href="${data.hint}" target="_blank" rel="noopener">FRED 申請頁面</a> 註冊並取得免費金鑰</li>
<li>把專案內的 <code>.env.example</code> 複製成 <code>.env</code></li>
<li><code>.env</code> 填入 <code>FRED_API_KEY=你的金鑰</code></li>
<li>重新啟動伺服器:<code>npm start</code></li>
<li>切到「AI 設定」頁,把金鑰貼到 <code>FRED_API_KEY</code></li>
<li>按「儲存設定」後回來重新載入總經頁</li>
</ol>
<button class="retry" onclick="load(true)">我設定好了,重新載入</button>
</div></div>`;

View File

@ -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(/<h([23])>([\s\S]*?)<\/h\1>/g, (m, level, inner) => {
const t = toc[idx];
idx += 1;
if (!t || String(t.level) !== String(level)) return m;
return `<h${level} id="${t.id}">${inner}</h${level}>`;
});
}
function renderPathSteps(pathId, path) {
return path.steps.map((step, i) => {
const links = [
@ -147,6 +232,16 @@
{ id: 'trade', icon: '📝', ...LEARN_PATHS.trade },
];
return `
<div class="learning-method">
<div>
<div class="eyebrow">Learning UX</div>
<h2>先回想再展開最後拿去判斷</h2>
<p>這裡把長筆記拆成短任務主動回想章節重點案例原則工具應用你不用把全部內容一次吞完而是每次完成一個判斷</p>
</div>
<div class="method-rail">
<span>回想</span><span></span><span></span><span></span>
</div>
</div>
<div class="learning-board">
<div class="board-copy">
<div class="eyebrow">從問題開始</div>
@ -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 += `<span class="fm-tag">代號 ${esc([].concat(fm.ticker).join(' / '))}</span>`;
if (fm.sector) tags += `<span class="fm-tag">${esc(fm.sector)}</span>`;
@ -254,15 +357,61 @@
const tocHtml = toc.length > 2 ? `
<nav class="la-toc" aria-label="本篇目錄">
<div class="la-toc-title">本篇目錄</div>
<div class="la-toc-title">章節導航</div>
<div class="la-toc-links">${toc.map(t => `<a href="#${esc(t.id)}" class="lv${t.level}">${esc(t.text)}</a>`).join('')}</div>
</nav>` : '';
let bodyHtml = opts.renderMarkdown(deEmmy(note.body || ''));
toc.forEach((t, i) => {
const re = new RegExp(`(<h${t.level}>)([^<]*${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 ? `
<section class="learning-panel insight-panel">
<div class="panel-head"><span>01</span><h2></h2></div>
<div class="insight-grid">
${insights.map((x, i) => `<div class="insight-card"><b>${String(i + 1).padStart(2, '0')}</b><p>${esc(x)}</p></div>`).join('')}
</div>
</section>` : '';
const recallPanel = recallCards.length ? `
<section class="learning-panel recall-panel">
<div class="panel-head"><span>02</span><h2></h2></div>
<div class="recall-grid">
${recallCards.map((c, i) => `<div class="recall-card">
<div class="recall-q">${esc(c.q)}</div>
<button type="button" class="recall-toggle" aria-expanded="false">看參考答案</button>
<div class="recall-a" hidden>${esc(c.a)}</div>
</div>`).join('')}
</div>
</section>` : '';
const sectionPanel = sections.length ? `
<section class="learning-panel section-panel">
<div class="panel-head"><span>03</span><h2></h2></div>
<div class="section-ladder">
${sections.slice(0, 6).map(s => `<details class="section-chip">
<summary>${esc(s.title)}</summary>
<p>${esc(s.text.slice(0, 180))}</p>
</details>`).join('')}
</div>
</section>` : '';
const reviewPanel = `
<section class="learning-panel review-panel">
<div class="panel-head"><span>04</span><h2></h2></div>
<div class="review-track">
<button type="button" class="review-step on">今天</button>
<button type="button" class="review-step">3 天後</button>
<button type="button" class="review-step">7 天後</button>
<button type="button" class="review-step">14 天後</button>
</div>
</section>`;
const notePanel = noteKey ? `
<section class="learning-panel personal-note-panel">
<div class="panel-head"><span></span><h2></h2></div>
<textarea class="personal-note-input" data-note-key="${esc(noteKey)}" placeholder="用自己的話寫:這篇對我下次判斷股票、倉位或復盤有什麼用?">${esc(savedNote)}</textarea>
<div class="personal-note-actions"><span class="personal-note-status">尚未儲存</span><button type="button" class="btn sm save-personal-note"></button></div>
</section>` : '';
const toolActions = [
{ view: 'macro', label: '總經儀表板', sub: '對照利率、通膨' },
@ -282,9 +431,28 @@
<h1 class="la-title">${esc(title)}</h1>
${lead ? `<p class="la-lead">${esc(lead)}</p>` : ''}
</header>
<div class="learn-tabs" role="tablist" aria-label="學習模式">
<button type="button" class="learn-tab on" data-tab="study">學習模式</button>
<button type="button" class="learn-tab" data-tab="note">完整筆記</button>
<button type="button" class="learn-tab" data-tab="apply">應用</button>
</div>
<div class="learn-tab-panel" data-panel="study">
${insightPanel}
${recallPanel}
${sectionPanel}
${reviewPanel}
${notePanel}
${principlePanel}
</div>
<div class="learn-tab-panel" data-panel="note" hidden>
${tocHtml}
<div class="learn-body md">${bodyHtml}</div>
</div>
<div class="learn-tab-panel" data-panel="apply" hidden>
${notePanel}
${principlePanel}
${reviewPanel}
</div>
<footer class="la-footer">
<div class="la-footer-title">把這篇用出去</div>
<div class="la-tool-grid">
@ -305,6 +473,45 @@
container.querySelectorAll('.la-tool-card[data-view]').forEach(btn => {
btn.addEventListener('click', () => handlers.goView(btn.dataset.view));
});
container.querySelectorAll('.learn-tab[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
container.querySelectorAll('.learn-tab').forEach(x => x.classList.toggle('on', x === btn));
container.querySelectorAll('.learn-tab-panel').forEach(p => { p.hidden = p.dataset.panel !== tab; });
});
});
container.querySelectorAll('.recall-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const ans = btn.parentElement.querySelector('.recall-a');
const open = ans.hidden;
ans.hidden = !open;
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
btn.textContent = open ? '收起答案' : '看參考答案';
});
});
container.querySelectorAll('.review-step').forEach(btn => {
btn.addEventListener('click', () => {
btn.parentElement.querySelectorAll('.review-step').forEach(x => x.classList.toggle('on', x === btn));
});
});
container.querySelectorAll('.save-personal-note').forEach(btn => {
btn.addEventListener('click', () => {
const panel = btn.closest('.personal-note-panel');
const input = panel?.querySelector('.personal-note-input');
const key = input?.dataset.noteKey;
if (!key) return;
const title = container.querySelector('.la-title')?.textContent?.trim() || '';
const parts = key.split(':');
const payload = {
key, title, kind: parts[1] || '', id: parts.slice(2).join(':'),
text: input.value.trim(), updatedAt: new Date().toISOString(),
};
if (payload.text) localStorage.setItem(key, JSON.stringify(payload));
else localStorage.removeItem(key);
const status = panel.querySelector('.personal-note-status');
if (status) status.textContent = payload.text ? '已儲存' : '已清空';
});
});
container.querySelectorAll('.la-toc a').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();

407
server.js
View File

@ -11,6 +11,7 @@ import 'dotenv/config';
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import { GROUPS, INDICATOR_MAP } from './lib/indicators.js';
import { getIndicatorCards, getYieldCurve, MissingKeyError } from './lib/fred.js';
@ -35,8 +36,9 @@ import {
import { getCompanyIntel } from './lib/companyintel.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ENV_PATH = path.join(__dirname, '.env');
const app = express();
app.use(express.json());
app.use(express.json({ limit: '1mb' }));
const PORT = process.env.PORT || 3000;
const CACHE_TTL_MS = (Number(process.env.CACHE_TTL_SECONDS) || 3600) * 1000;
// 財報變動頻率低(季報),因此長期存資料庫、盡量沿用:
@ -53,6 +55,57 @@ const INTEL_TTL_MS = (Number(process.env.INTEL_TTL_HOURS) || 6) * 3600 * 1000;
const SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/;
const hasKey = process.env.FRED_API_KEY && process.env.FRED_API_KEY !== 'your_fred_api_key_here';
const SETTINGS_FIELDS = [
{ key: 'FRED_API_KEY', label: 'FRED API Key', type: 'secret', group: 'market', hint: '總經資料與部分日曆資料使用。' },
{ key: 'OPENCODE_GO_API_KEY', label: 'OpenCode Go API Key', type: 'secret', group: 'ai', hint: 'OpenCode Go provider。' },
{ key: 'OPENCODE_GO_MODEL', label: 'OpenCode Go Model', type: 'text', group: 'ai', hint: '從 OpenCode Go 的 /models 端點抓取後選擇。' },
{ key: 'GROK_API_KEY', label: 'Grok API Key', type: 'secret', group: 'ai', hint: 'xAI / Grok provider。' },
{ key: 'GROK_MODEL', label: 'Grok Model', type: 'text', group: 'ai', hint: '從 xAI /models 端點抓取後選擇。' },
{ key: 'AI_ACTIVE_PROVIDER', label: '預設 AI Provider', type: 'text', group: 'ai', hint: 'opencode-go 或 grok。' },
];
function parseEnvText(text) {
const out = {};
for (const line of String(text || '').split(/\r?\n/)) {
const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
if (!m) continue;
let v = m[2] || '';
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[m[1]] = v;
}
return out;
}
function readEnvFile() {
try { return parseEnvText(fs.readFileSync(ENV_PATH, 'utf8')); }
catch (_) { return {}; }
}
function quoteEnvValue(v) {
v = String(v == null ? '' : v);
if (!v || /[\s#"'\\]/.test(v)) return JSON.stringify(v);
return v;
}
function writeEnvUpdates(updates) {
const src = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
const lines = src.split(/\r?\n/);
const used = new Set();
const next = lines.map(line => {
const m = line.match(/^(\s*)([\w.-]+)(\s*=\s*)(.*)$/);
if (!m || !(m[2] in updates)) return line;
used.add(m[2]);
return `${m[1]}${m[2]}${m[3]}${quoteEnvValue(updates[m[2]])}`;
});
for (const [k, v] of Object.entries(updates)) {
if (!used.has(k)) next.push(`${k}=${quoteEnvValue(v)}`);
}
fs.writeFileSync(ENV_PATH, next.join('\n').replace(/\n{3,}/g, '\n\n'));
Object.assign(process.env, updates);
}
function maskSecret(v) {
if (!v) return '';
v = String(v);
return v.length <= 8 ? '已設定' : `${v.slice(0, 4)}${v.slice(-4)}`;
}
// 記憶體快取(開機時會用 DB 內容預先填入)
let cache = { at: 0, payload: null };
@ -100,7 +153,7 @@ app.get('/api/macro', async (req, res) => {
if (err instanceof MissingKeyError) {
return res.status(503).json({
error: 'missing_api_key',
message: '尚未設定 FRED 金鑰。請複製 .env.example 為 .env 並填入免費的 FRED_API_KEY再重新啟動伺服器。',
message: '尚未設定 FRED 金鑰。請到 AI 設定頁填入免費的 FRED_API_KEY儲存後重新載入總經頁。',
hint: 'https://fred.stlouisfed.org/docs/api/api_key.html',
});
}
@ -402,6 +455,356 @@ app.delete('/api/trades/:id', (req, res) => {
res.json({ ok: true });
});
// ─── AI Provider 代理OpenCode Go / Grok ───
const AI_PROVIDERS = {
'opencode-go': {
label: 'OpenCode Go',
endpoint: 'https://opencode.ai/zen/go/v1/chat/completions',
modelsEndpoint: 'https://opencode.ai/zen/go/v1/models',
keyEnv: 'OPENCODE_GO_API_KEY',
modelEnv: 'OPENCODE_GO_MODEL',
mode: 'chat',
},
grok: {
label: 'Grok',
endpoint: 'https://api.x.ai/v1/responses',
modelsEndpoint: 'https://api.x.ai/v1/models',
keyEnv: 'GROK_API_KEY',
modelEnv: 'GROK_MODEL',
mode: 'responses',
},
};
function normalizeAIText(data, mode) {
if (mode === 'responses') {
if (data?.output_text) return data.output_text;
const chunks = [];
for (const item of data?.output || []) {
for (const c of item?.content || []) {
if (typeof c?.text === 'string') chunks.push(c.text);
else if (typeof c?.content === 'string') chunks.push(c.content);
}
}
return chunks.join('\n').trim();
}
return data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '';
}
function compactForPrompt(v, max = 16000) {
const s = typeof v === 'string' ? v : JSON.stringify(v, null, 2);
return s.length > max ? s.slice(0, max) + '\n...(上下文已截斷)' : s;
}
function cachedValue(entry) {
if (!entry) return null;
return { ...entry.value, cached: true, cachedAt: new Date(entry.updatedAt).toISOString() };
}
function summarizeMacro(payload, focus) {
if (!payload) return null;
const cards = (payload.groups || []).flatMap(g => (g.cards || []).map(c => ({ ...c, groupTitle: g.title })));
const focusedCard = focus?.key ? cards.find(c => c.key === focus.key) : null;
const series = focusedCard ? getSeries(focusedCard.key, null).slice(-160) : [];
return {
updatedAt: payload.updatedAt,
cached: true,
score: payload.score,
regime: payload.regime,
signals: payload.signals,
focusedCard: focusedCard ? {
key: focusedCard.key,
group: focusedCard.groupTitle,
label: focusedCard.label,
labelEn: focusedCard.labelEn,
value: focusedCard.value,
change: focusedCard.change,
status: focusedCard.status,
human: focusedCard.human,
context: focusedCard.context,
tip: focusedCard.tip,
series,
} : null,
};
}
async function stockAIContext(symbol, focus, allowFetch) {
if (!SYMBOL_RE.test(symbol || '')) return { symbol, error: 'bad_symbol' };
const out = { symbol, subPage: focus?.subPage || null, sources: [] };
let fundEntry = getCachedEntry(`fund:${symbol}`);
if (!fundEntry && allowFetch) {
const fundamentals = await getFundamentals(symbol);
const report = buildReport(fundamentals);
const payload = {
_metricsVersion: 2,
_fetchedAt: Date.now(),
symbol: fundamentals.symbol, name: fundamentals.name, source: fundamentals.source,
currency: fundamentals.currency, asOf: fundamentals.asOf, price: fundamentals.price, report,
peTrailing: fundamentals.peTrailing, marketCap: fundamentals.marketCap,
sharesOutstanding: fundamentals.sharesOutstanding,
targetPrice: fundamentals.targetPrice, dividendYield: fundamentals.dividendYield,
quarters: fundamentals.quarters, annual: fundamentals.annual, balance: fundamentals.balance,
_latestFiling: await getLatestFilingInfo(symbol).catch(() => null),
};
putCachedJSON(`fund:${symbol}`, payload);
fundEntry = getCachedEntry(`fund:${symbol}`);
out.sources.push('fundamentals:fetched');
}
let quoteEntry = getCachedEntry(`quote:${symbol}`);
if (!quoteEntry && allowFetch) {
const quote = await getQuote(symbol);
putCachedJSON(`quote:${symbol}`, { symbol, ...quote, _fetchedAt: Date.now() });
quoteEntry = getCachedEntry(`quote:${symbol}`);
out.sources.push('quote:fetched');
}
let histEntry = getCachedEntry(`hist:${symbol}:max:1d`);
if (!histEntry && allowFetch) {
await getHistoryCached(symbol, 'max', '1d', false).catch(() => null);
histEntry = getCachedEntry(`hist:${symbol}:max:1d`);
if (histEntry) out.sources.push('history:fetched');
}
const fundamentals = cachedValue(fundEntry);
const quote = cachedValue(quoteEntry);
const history = cachedValue(histEntry);
out.fundamentals = fundamentals ? {
symbol: fundamentals.symbol,
name: fundamentals.name,
cachedAt: fundamentals.cachedAt,
asOf: fundamentals.asOf,
price: fundamentals.price,
report: fundamentals.report,
peTrailing: fundamentals.peTrailing,
marketCap: fundamentals.marketCap,
dividendYield: fundamentals.dividendYield,
quarters: (fundamentals.quarters || []).slice(0, 8),
annual: (fundamentals.annual || []).slice(0, 5),
balance: fundamentals.balance,
} : null;
out.quote = quote;
out.history = history ? { ...history, points: (history.points || []).slice(-260) } : null;
out.cacheStatus = {
fundamentals: !!fundEntry,
quote: !!quoteEntry,
history: !!histEntry,
};
return out;
}
async function buildAIPageContext({ view, focus = {}, client = {}, allowFetch = true }) {
const base = {
mode: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view) ? 'page' : 'chat',
hasPageData: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view),
view,
focus,
client,
dataPolicy: 'cache-first: data.db first; fetch only when DB cache is missing; never force fresh for AI context.',
collectedAt: new Date().toISOString(),
};
if (view === 'macro') {
let saved = loadPayload();
if (!saved && allowFetch) {
const payload = await refreshAndCache();
saved = { payload, updatedAt: Date.now() };
}
base.macro = summarizeMacro(saved?.payload || cache.payload, focus);
} else if (view === 'stock') {
const symbol = String(focus.symbol || client.symbol || '').trim().toUpperCase();
if (symbol) base.stock = await stockAIContext(symbol, focus, allowFetch);
} else if (view === 'calendar') {
const symbols = getCalendarWatchlist();
const today = new Date().toISOString().slice(0, 10);
const end = new Date(Date.now() + 60 * 86400000).toISOString().slice(0, 10);
const baseEntry = getCachedEntry(`calendar:base:v5:${today}`);
const earnEntry = symbols.length ? getCachedEntry(`calendar:earn:v5:${today}:${[...symbols].sort().join(',')}`) : null;
if (baseEntry?.value) {
const baseEvents = (baseEntry.value.events || []).filter(e => e.category !== 'earnings');
const earnEvents = symbols.length ? (earnEntry?.value?.events || []).filter(e => e.category === 'earnings') : [];
const events = [...baseEvents, ...earnEvents]
.filter(e => e.date >= today && e.date <= end)
.sort((a, b) => (a.date + (a.time || '')).localeCompare(b.date + (b.time || '')))
.slice(0, 80);
base.calendar = {
cached: true,
cachedAt: new Date(baseEntry.updatedAt).toISOString(),
watchlist: symbols,
events,
sources: baseEntry.value.sources || [],
};
} else {
base.calendar = allowFetch
? await getCalendarPayload({ start: today, end, symbols, forceFresh: false })
.then(d => ({ ...d, events: (d.events || []).slice(0, 80) }))
.catch(e => ({ error: String(e?.message || e), watchlist: symbols }))
: { cached: false, watchlist: symbols, events: [] };
}
} else if (view === 'journal') {
base.journal = {
stats: tradeStats(),
trades: listTrades().slice(0, 80),
};
} else if (view === 'learn') {
const note = focus.kind && focus.id ? getNote(focus.kind, focus.id) : null;
base.learning = {
focusedNote: note ? {
kind: focus.kind,
id: focus.id,
title: note.title,
summary: note.summary,
body: String(note.body || '').slice(0, 10000),
} : client.currentNote || null,
visibleText: client.visibleText || '',
personalNotes: client.personalNotes || [],
};
}
return base;
}
function normalizeModelList(data) {
const items = Array.isArray(data?.data) ? data.data : Array.isArray(data?.models) ? data.models : Array.isArray(data) ? data : [];
return items
.map(m => (typeof m === 'string' ? { id: m } : { id: m?.id || m?.name || m?.model, created: m?.created, ownedBy: m?.owned_by || m?.ownedBy }))
.filter(m => m.id);
}
async function listProviderModels(provider, apiKey) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 30000);
try {
const r = await fetch(provider.modelsEndpoint, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: ctrl.signal,
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = data?.error?.message || data?.message || `${provider.label} 回傳 ${r.status}`;
const err = new Error(msg);
err.status = r.status;
throw err;
}
return normalizeModelList(data);
} finally {
clearTimeout(timer);
}
}
app.post('/api/ai/models', async (req, res) => {
const providerId = String(req.body?.provider || '').trim();
const provider = AI_PROVIDERS[providerId];
if (!provider) return res.status(400).json({ error: 'bad_provider', message: '不支援的 AI provider。' });
const apiKey = String(req.body?.apiKey || process.env[provider.keyEnv] || '').trim();
if (!apiKey) return res.status(400).json({ error: 'missing_key', message: '請先在 AI 設定填入 API key。' });
try {
const models = await listProviderModels(provider, apiKey);
res.json({ provider: providerId, models });
} catch (err) {
res.status(502).json({ error: 'models_failed', message: String(err?.message || err) });
}
});
app.post('/api/ai/context', async (req, res) => {
try {
const view = String(req.body?.view || '').trim();
const focus = req.body?.focus || {};
const client = req.body?.client || {};
const allowFetch = req.body?.allowFetch !== false;
const context = await buildAIPageContext({ view, focus, client, allowFetch });
res.json(context);
} catch (err) {
res.status(502).json({ error: 'context_failed', message: String(err?.message || err) });
}
});
app.post('/api/ai/chat', async (req, res) => {
const providerId = String(req.body?.provider || '').trim();
const provider = AI_PROVIDERS[providerId];
if (!provider) return res.status(400).json({ error: 'bad_provider', message: '不支援的 AI provider。' });
const apiKey = String(req.body?.apiKey || process.env[provider.keyEnv] || '').trim();
let model = String(req.body?.model || process.env[provider.modelEnv] || '').trim();
const question = String(req.body?.question || '').trim();
const context = req.body?.context || {};
if (!apiKey) return res.status(400).json({ error: 'missing_key', message: '請先在 AI 設定填入 API key。' });
if (!model) {
const models = await listProviderModels(provider, apiKey).catch(() => []);
model = models[0]?.id || '';
}
if (!model) return res.status(400).json({ error: 'missing_model', message: '請先設定模型。' });
if (!question) return res.status(400).json({ error: 'missing_question', message: '請輸入問題。' });
const hasPageData = context?.mode === 'page' || context?.hasPageData === true;
const system = hasPageData ? [
'你是 MacroScope 的投資學習助理。',
'使用者正在帶著頁面上下文提問,請根據提供的財報、總經、學習資料或交易復盤做分析與對照。',
'請用繁體中文回答,先給結論,再列出依據、矛盾點、下一步可以在頁面上檢查什麼。',
'不要聲稱已即時查網路;若資料不足,要明確說資料不足。',
'內容僅供學習,不構成投資建議。',
].join('\n') : [
'你是 MacroScope 的 AI 助手。',
'目前沒有可用頁面資料,請把這次對話當一般聊天或一般投資學習問答處理。',
'請用繁體中文自然回答;如果使用者問投資或財務判斷,要提醒內容僅供學習,不構成投資建議。',
'不要聲稱已即時查網路;需要即時資料時,請說明你需要使用者提供資料或切到相關頁面。',
].join('\n');
const user = [
`使用者問題:${question}`,
'',
hasPageData ? '目前頁面上下文:' : '對話狀態:',
hasPageData ? compactForPrompt(context) : compactForPrompt({ mode: 'chat', view: context?.view || '', collectedAt: context?.collectedAt || '' }),
].join('\n');
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 120000);
const body = provider.mode === 'responses'
? { model, store: false, input: [{ role: 'system', content: system }, { role: 'user', content: user }] }
: { model, messages: [{ role: 'system', content: system }, { role: 'user', content: user }], temperature: 0.2 };
const r = await fetch(provider.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body),
signal: ctrl.signal,
});
clearTimeout(timer);
const data = await r.json().catch(() => ({}));
if (!r.ok) {
return res.status(502).json({
error: 'provider_failed',
message: data?.error?.message || data?.message || `${provider.label} 回傳 ${r.status}`,
detail: data,
});
}
res.json({ provider: providerId, model, text: normalizeAIText(data, provider.mode), raw: { id: data.id, usage: data.usage } });
} catch (err) {
res.status(502).json({ error: 'ai_failed', message: String(err?.message || err) });
}
});
app.get('/api/settings/env', (req, res) => {
const env = { ...readEnvFile(), ...process.env };
res.json({
envPath: ENV_PATH,
fields: SETTINGS_FIELDS.map(f => ({
...f,
value: f.type === 'secret' ? '' : (env[f.key] || ''),
hasValue: !!env[f.key],
masked: f.type === 'secret' ? maskSecret(env[f.key]) : '',
})),
});
});
app.post('/api/settings/env', (req, res) => {
const allowed = new Set(SETTINGS_FIELDS.map(f => f.key));
const secret = new Set(SETTINGS_FIELDS.filter(f => f.type === 'secret').map(f => f.key));
const body = req.body?.values || {};
const updates = {};
for (const [k, raw] of Object.entries(body)) {
if (!allowed.has(k)) continue;
const v = String(raw == null ? '' : raw).trim();
if (secret.has(k) && !v) continue; // 留空代表保留既有 secret
updates[k] = v;
}
try {
if (Object.keys(updates).length) writeEnvUpdates(updates);
const env = { ...readEnvFile(), ...process.env };
res.json({
ok: true,
envPath: ENV_PATH,
updated: Object.keys(updates),
fields: SETTINGS_FIELDS.map(f => ({
...f,
value: f.type === 'secret' ? '' : (env[f.key] || ''),
hasValue: !!env[f.key],
masked: f.type === 'secret' ? maskSecret(env[f.key]) : '',
})),
});
} catch (err) {
res.status(500).json({ error: 'env_write_failed', message: String(err?.message || err) });
}
});
app.get('/api/health', (req, res) => res.json({ ok: true, knowledge: knowledgeReady() }));
app.use(express.static(__dirname));