add ai chat
This commit is contained in:
parent
aa38208fff
commit
846bfd6fe0
11
.env.example
11
.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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
253
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}}
|
||||
|
|
|
|||
590
app.js
590
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 = '<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());
|
||||
|
|
|
|||
13
index.html
13
index.html
|
|
@ -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>`;
|
||||
|
|
|
|||
|
|
@ -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
407
server.js
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue