Compare commits

...

2 Commits

Author SHA1 Message Date
王性驊 8de18e0e95 feat tools 2026-06-22 09:16:20 +00:00
王性驊 78be2a682f feat add new boot strap 2026-06-21 20:47:48 +00:00
28 changed files with 2714 additions and 39 deletions

View File

@ -40,3 +40,68 @@ docker compose up --build
```
完成後開啟 <http://localhost:8080>
## 內容管線YouTube / HyRead
### 知識庫
知識庫分為兩層:
- `content/raw/emmy/` — 原始 Obsidian 格式筆記Markdown + frontmatter
- `data/knowledge.json` + `data/notes.json``npm run build:knowledge` 快照產出
匯入的 YouTube / HyRead 內容會自動寫入 `content/raw/emmy/`,再透過 `build:knowledge` 輸出到前端。
### 前端頁面
| 路由 | 頁面 | 說明 |
|------|------|------|
| `/library` | Library | 知識總覽(全文檢索 + 類型過濾) |
| `/library/:kind/:id` | LibraryDetail | 單篇知識全文 |
| `/content` | ContentManager | 內容管理(來源列表 / YouTube 擷取 / HyRead 匯入 / 排程) |
Library 和 LibraryDetail 的選單項目在 `Chrome.tsx` 的「修練 → 知識 · 圖書館」。
ContentManager 在「內容 → 內容 · 管理」。
### API 端點
所有端點以 `/api/content` 為前綴:
| 方法 | 路徑 | 說明 |
|------|------|------|
| GET | `/api/content` | 列舉內容來源(支援 `?kind=` `?status=` `?limit=` `?offset=` |
| GET | `/api/content/stats` | 來源統計by kind / by status |
| GET | `/api/content/:id` | 單筆來源詳情 |
| DELETE | `/api/content/:id` | 刪除來源 |
| POST | `/api/content/youtube/fetch` | 擷取 YouTube 影片/播放清單(傳 `{ url }` |
| POST | `/api/content/youtube/process/:id` | 用 AI 處理 transcript`{ transcript, episode? }` |
| GET | `/api/content/youtube/yt-dlp-status` | 檢查 yt-dlp 是否可用 |
| GET | `/api/content/youtube/local-transcripts` | 列出本地 `~/youtube_transcripts/` 整理檔 |
| GET | `/api/content/hyread/scan` | 掃描 `hyread-tools/output/` 目錄 |
| POST | `/api/content/hyread/import` | 匯入指定書籍(傳 `{ dir }` |
| POST | `/api/content/hyread/upload` | 上傳 book.html`{ title, html }` |
| GET | `/api/content/schedules` | 列舉排程 |
| POST | `/api/content/schedules` | 新增排程(傳 `{ kind, name, url, ... }` |
| DELETE | `/api/content/schedules/:id` | 刪除排程 |
| POST | `/api/content/capture` | 擴充功能接收端(傳 `{ kind, url, title, transcript/html, ... }` |
| POST | `/api/content/rebuild-knowledge` | 觸發 `npm run build:knowledge` |
### Chrome 擴充功能
位於 `extension/` 目錄。
**安裝步驟:**
1. 打開 Chrome前往 `chrome://extensions`
2. 開啟右上角「開發人員模式」
3. 點選「載入未封裝項目」
4. 選擇 `path/to/app/extension/` 資料夾
**使用方式:**
- **YouTube 影片頁**:擴充功能會在影片標題下方自動加入「📥 擷取此影片」按鈕,點擊後取得字幕並傳送至後端
- **HyRead 閱讀器**:擴充功能會在頁面右上角加入「📥 匯入此書」按鈕,點擊後擷取 book HTML 並傳送至後端
- **Popup 面板**:點擊工具列圖示可查看伺服器狀態、偵測當前分頁類型,也可手動擷取
### 排程器
伺服器啟動後會自動啟動排程器(預設每 5 分鐘檢查一次排程),在內容管理頁面的「排程」分頁中管理 YouTube 頻道 / 播放清單的定期抓取。

View File

@ -1,11 +1,6 @@
{
"enabled": [
"macroscope",
"stock-scanner",
"openinsider",
"context7",
"yfinance",
"alphavantage"
"macroscope"
],
"note": "啟用的 MCP 會由後端真實呼叫,結果附在每次對話中。"
}

1
content Symbolic link
View File

@ -0,0 +1 @@
/opt/content

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

161
extension/content-hyread.js Normal file
View File

@ -0,0 +1,161 @@
// Investor RPG — HyRead 閱讀器內容擷取
const API_HOST = "http://localhost:3000";
function isReaderPage() {
return /\/reader\//.test(window.location.pathname) ||
/reader\.html/.test(window.location.href) ||
document.querySelector(".hyread-reader") ||
document.querySelector("#readerContainer") ||
document.querySelector("[class*='reader']");
}
function getBookTitle() {
const el = document.querySelector(".book-title") ||
document.querySelector("h1") ||
document.querySelector("meta[property='og:title']") ||
document.querySelector("title");
return el?.textContent?.trim() || el?.getAttribute("content") || "HyRead 書籍";
}
function extractPageContent() {
const contentSelectors = [
"#pageContent", ".page-content", ".reader-content",
"#contentArea", ".content-area", "#textLayer",
"section.page", ".book-page", "[class*='page-text']",
"#iframeContent", "article",
];
for (const sel of contentSelectors) {
const el = document.querySelector(sel);
if (el && el.textContent.trim().length > 50) {
return {
text: el.textContent.trim(),
html: el.innerHTML,
};
}
}
const iframes = document.querySelectorAll("iframe");
for (const iframe of iframes) {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc && doc.body.textContent.trim().length > 50) {
return {
text: doc.body.textContent.trim(),
html: doc.body.innerHTML,
};
}
} catch {}
}
const mainText = document.body.innerText?.trim() || "";
return {
text: mainText,
html: document.body.innerHTML,
};
}
function getAllPageContent() {
const allTexts = [];
const allHtmls = [];
const content = extractPageContent();
if (content.text.length > 50) {
allTexts.push(content.text);
allHtmls.push(content.html);
}
return {
text: allTexts.join("\n\n---\n\n"),
html: allHtmls.join("\n"),
pageCount: allTexts.length,
};
}
async function captureBook() {
const title = getBookTitle();
const content = getAllPageContent();
if (content.text.length < 50) {
throw new Error("無法擷取到足夠的內容,請確認閱讀器已開啟");
}
const payload = {
kind: "hyread",
title,
url: window.location.href,
html: content.html,
metadata: {
title,
url: window.location.href,
pageCount: content.pageCount,
textLength: content.text.length,
},
};
const res = await fetch(`${API_HOST}/api/content/capture`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `伺服器回應 ${res.status}`);
}
return await res.json();
}
function injectCaptureButton() {
if (document.querySelector("#irpg-capture-btn")) return;
if (!isReaderPage()) return;
const btn = document.createElement("button");
btn.id = "irpg-capture-btn";
btn.textContent = "📖 擷取到 RPG";
btn.style.cssText = `
position: fixed; top: 12px; right: 12px; z-index: 99999;
background: #2a6c3b; color: #fff; border: none; border-radius: 8px;
padding: 8px 16px; font-size: 14px; cursor: pointer;
font-weight: 500; box-shadow: 0 2px 8px rgba(0,0,0,.3);
transition: background .15s;
`;
btn.onmouseenter = () => btn.style.background = "#1e7e34";
btn.onmouseleave = () => btn.style.background = "#2a6c3b";
btn.onclick = async () => {
btn.textContent = "擷取中...";
btn.disabled = true;
try {
const result = await captureBook();
btn.textContent = `${result.count || "已送出"}`;
btn.style.background = "#1e7e34";
setTimeout(() => {
btn.remove();
}, 3000);
} catch (err) {
btn.textContent = `${err.message.slice(0, 25)}`;
btn.style.background = "#a13a3a";
setTimeout(() => {
btn.textContent = "📖 擷取到 RPG";
btn.style.background = "#2a6c3b";
btn.disabled = false;
}, 3000);
}
};
document.body.appendChild(btn);
}
setTimeout(injectCaptureButton, 2000);
let lastHref = window.location.href;
setInterval(() => {
if (window.location.href !== lastHref) {
lastHref = window.location.href;
document.querySelector("#irpg-capture-btn")?.remove();
setTimeout(injectCaptureButton, 1500);
}
}, 1500);

View File

@ -0,0 +1,190 @@
// Investor RPG — YouTube 內容擷取
// 執行在 YouTube 頁面上,利用使用者的登入 session 取得字幕
const API_HOST = "http://localhost:3000";
let injectButton = null;
function extractVideoId() {
const m = window.location.pathname.match(/\/watch\/([a-zA-Z0-9_-]{11})/) ||
window.location.search.match(/[?&]v=([a-zA-Z0-9_-]{11})/);
return m ? m[1] : null;
}
function extractPlaylistId() {
const m = window.location.search.match(/[?&]list=([a-zA-Z0-9_-]+)/);
return m ? m[1] : null;
}
function getVideoTitle() {
const el = document.querySelector("h1 yt-formatted-string") ||
document.querySelector("#title h1") ||
document.querySelector("meta[itemprop='name']");
return el?.textContent?.trim() || el?.getAttribute("content") || document.title.replace(" - YouTube", "");
}
function getChannelName() {
const el = document.querySelector("#owner #channel-name a") ||
document.querySelector("ytd-channel-name a") ||
document.querySelector("link[itemprop='name'][href^='http']");
return el?.textContent?.trim() || el?.getAttribute("content") || "";
}
function getUploadDate() {
const el = document.querySelector("meta[itemprop='datePublished']") ||
document.querySelector("#info-strings yt-formatted-string");
return el?.getAttribute("content") || el?.textContent?.trim() || "";
}
function getThumbnail() {
const el = document.querySelector("meta[property='og:image']");
return el?.getAttribute("content") || "";
}
function injectCaptureButton() {
if (injectButton || !extractVideoId()) return;
injectButton = document.createElement("button");
injectButton.textContent = "📥 擷取到 RPG";
injectButton.style.cssText = `
background: #2a6c3b; color: #fff; border: none; border-radius: 18px;
padding: 6px 16px; font-size: 13px; cursor: pointer;
margin-left: 8px; white-space: nowrap; font-weight: 500;
transition: background .15s;
`;
injectButton.onmouseenter = () => injectButton.style.background = "#1e7e34";
injectButton.onmouseleave = () => injectButton.style.background = "#2a6c3b";
injectButton.onclick = async () => {
injectButton.textContent = "擷取中...";
injectButton.disabled = true;
try {
const result = await captureVideo();
injectButton.textContent = `${result.count || "已送出"}`;
setTimeout(() => {
injectButton.textContent = "📥 擷取到 RPG";
injectButton.disabled = false;
}, 3000);
} catch (err) {
injectButton.textContent = `${err.message.slice(0, 20)}`;
injectButton.style.background = "#a13a3a";
setTimeout(() => {
injectButton.textContent = "📥 擷取到 RPG";
injectButton.style.background = "#2a6c3b";
injectButton.disabled = false;
}, 3000);
}
};
const waitForTarget = () => {
const target = document.querySelector("#menu-container") ||
document.querySelector("#top-level-buttons-computed") ||
document.querySelector("#actions-inner");
if (target) {
target.appendChild(injectButton);
} else {
setTimeout(waitForTarget, 500);
}
};
waitForTarget();
}
async function fetchTranscriptFromPage() {
const videoId = extractVideoId();
if (!videoId) return "";
try {
const apiKey = "";
const response = await fetch(`https://youtubetranscript.com/api?vid=${videoId}`, {
signal: AbortSignal.timeout(8000),
});
if (response.ok) {
const data = await response.json();
return (data.segments || []).map(s => s.text).join(" ");
}
} catch {}
try {
const pageResponse = await fetch(`https://www.youtube.com/watch?v=${videoId}`, {
credentials: "include",
signal: AbortSignal.timeout(8000),
});
const html = await pageResponse.text();
const capsMatch = html.match(/"captionTracks":\s*(\[.*?\])/);
if (capsMatch) {
const tracks = JSON.parse(capsMatch[1]);
const zhTrack = tracks.find(t => t.languageCode?.startsWith("zh")) || tracks[0];
if (zhTrack?.baseUrl) {
const subResp = await fetch(zhTrack.baseUrl, { signal: AbortSignal.timeout(8000) });
const subText = await subResp.text();
return subText.replace(/<\s*\/?\s*[^>]+>/g, "").replace(/[\d:,.\s]+-->[\d:,.\s]+/g, "").replace(/WEBVTT.*?\n/, "").trim();
}
}
} catch {}
return "";
}
function extractDescription() {
const el = document.querySelector("#description yt-formatted-string") ||
document.querySelector("#description");
return el?.textContent?.trim()?.slice(0, 1000) || "";
}
async function captureVideo() {
const videoId = extractVideoId();
if (!videoId) throw new Error("找不到影片 ID");
const title = getVideoTitle();
const channel = getChannelName();
const uploadDate = getUploadDate();
const thumbnail = getThumbnail();
const description = extractDescription();
const transcript = await fetchTranscriptFromPage();
const payload = {
kind: "youtube",
title,
url: window.location.href,
videoId,
transcript,
metadata: {
channel,
uploadDate,
thumbnail,
description,
title,
videoId,
},
};
const res = await fetch(`${API_HOST}/api/content/capture`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `伺服器回應 ${res.status}`);
}
return await res.json();
}
let lastUrl = "";
function checkForVideoChange() {
const currentUrl = window.location.href;
if (currentUrl !== lastUrl) {
lastUrl = currentUrl;
if (injectButton) {
injectButton.remove();
injectButton = null;
}
if (extractVideoId()) {
setTimeout(injectCaptureButton, 1000);
}
}
}
setInterval(checkForVideoChange, 1500);
setTimeout(injectCaptureButton, 1500);

39
extension/manifest.json Normal file
View File

@ -0,0 +1,39 @@
{
"manifest_version": 3,
"name": "Investor RPG 內容擷取器",
"version": "1.0.0",
"description": "從 YouTube含會員影片和 HyRead 閱讀器擷取內容,傳送到 Investor RPG",
"permissions": ["storage", "activeTab"],
"host_permissions": [
"https://www.youtube.com/*",
"https://youtube.com/*",
"https://*.ebook.hyread.com.tw/*",
"https://*.hyread.com.tw/*"
],
"content_scripts": [
{
"matches": ["https://www.youtube.com/*", "https://youtube.com/*"],
"js": ["content-youtube.js"],
"run_at": "document_idle"
},
{
"matches": ["https://*.ebook.hyread.com.tw/*", "https://*.hyread.com.tw/*"],
"js": ["content-hyread.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup.html",
"default_title": "Investor RPG 擷取器",
"default_icon": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
},
"icons": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
}

53
extension/popup.html Normal file
View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { width: 300px; padding: 12px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 13px; background: #0d1224; color: #e0e8f0; }
h1 { font-size: 15px; margin: 0 0 8px; color: #e7c66b; }
.section { margin-bottom: 12px; padding: 8px; background: #1b2240; border-radius: 6px; }
.section h2 { font-size: 12px; margin: 0 0 4px; color: #8fb8ff; text-transform: uppercase; letter-spacing: .5px; }
.status { display: flex; justify-content: space-between; align-items: center; }
.badge { padding: 2px 6px; border-radius: 4px; font-size: 11px; }
.badge-ok { background: #1a5a2a; color: #6fcf97; }
.badge-warn { background: #5a4a1a; color: #e7c66b; }
.btn { background: #2a6c3b; color: #fff; border: none; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 12px; margin-top: 4px; }
.btn:hover { background: #1e7e34; }
.text-dim { color: #6b7a99; font-size: 11px; }
a { color: #8fb8ff; text-decoration: none; }
.row { padding: 4px 0; border-bottom: 1px solid #242b50; }
.server-status { margin-top: 8px; }
</style>
</head>
<body>
<h1>📥 Investor RPG 擷取器</h1>
<div class="section" id="current-page">
<h2>目前頁面</h2>
<div id="page-info" class="text-dim">請在 YouTube 或 HyRead 頁面上使用</div>
</div>
<div class="section">
<h2>快速操作</h2>
<button class="btn" id="btn-capture" style="display:none">擷取此頁面</button>
<div id="capture-result" class="text-dim" style="margin-top:4px"></div>
</div>
<div class="section">
<h2>伺服器連線</h2>
<div class="status">
<span id="server-status" class="text-dim">檢查中...</span>
<span id="server-badge" class="badge badge-warn">??</span>
</div>
<div style="margin-top:4px">
<input type="text" id="api-host" value="http://localhost:3000" style="width:100%;padding:4px;background:#0d1224;border:1px solid #242b50;color:#e0e8f0;border-radius:4px;font-size:11px;box-sizing:border-box" />
</div>
</div>
<div class="text-dim" style="font-size:10px;text-align:center;margin-top:8px">
Investor RPG v1.0.0
</div>
<script src="popup.js"></script>
</body>
</html>

78
extension/popup.js Normal file
View File

@ -0,0 +1,78 @@
let apiHost = "http://localhost:3000";
function $(id) { return document.getElementById(id); }
async function checkServer() {
const host = $("api-host").value.trim() || apiHost;
apiHost = host;
try {
const res = await fetch(`${host}/api/health`, { signal: AbortSignal.timeout(3000) });
if (res.ok) {
const data = await res.json();
$("server-status").textContent = `✓ 已連線 (knowledge: ${data.knowledge ? "✓" : "✕"})`;
$("server-badge").textContent = "OK";
$("server-badge").className = "badge badge-ok";
} else {
$("server-status").textContent = `✕ 伺服器回應 ${res.status}`;
$("server-badge").className = "badge badge-warn";
}
} catch (err) {
$("server-status").textContent = `✕ 無法連線: ${err.message.slice(0, 30)}`;
$("server-badge").className = "badge badge-warn";
}
}
async function getCurrentTab() {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
return tabs[0];
}
async function updatePageInfo() {
const tab = await getCurrentTab();
if (!tab) return;
const url = tab.url || "";
const title = tab.title || "";
if (url.includes("youtube.com/watch") || url.includes("youtu.be")) {
$("page-info").innerHTML = `🎬 YouTube 影片<br/><span class="text-dim">${title.slice(0, 60)}</span>`;
$("btn-capture").style.display = "block";
$("btn-capture").textContent = "📥 擷取此影片";
$("btn-capture").onclick = async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.tabs.sendMessage(tab.id, { action: "capture" });
$("capture-result").textContent = "✓ 已觸發擷取,請查看頁面按鈕狀態";
} catch (err) {
$("capture-result").textContent = `${err.message}`;
}
};
} else if (url.includes("hyread")) {
$("page-info").innerHTML = `📖 HyRead 閱讀器<br/><span class="text-dim">${title.slice(0, 60)}</span>`;
$("btn-capture").style.display = "block";
$("btn-capture").textContent = "📖 擷取此書籍";
$("btn-capture").onclick = async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.tabs.sendMessage(tab.id, { action: "capture" });
$("capture-result").textContent = "✓ 已觸發擷取,請查看頁面按鈕狀態";
} catch (err) {
$("capture-result").textContent = `${err.message}`;
}
};
} else {
$("page-info").textContent = `目前頁面: ${title.slice(0, 40)} (不支援此頁面)`;
$("btn-capture").style.display = "none";
}
}
$("api-host").addEventListener("change", checkServer);
document.addEventListener("DOMContentLoaded", async () => {
const stored = await chrome.storage.local.get("apiHost");
if (stored.apiHost) {
$("api-host").value = stored.apiHost;
}
checkServer();
updatePageInfo();
});

View File

@ -9,7 +9,7 @@
"dev:server": "node --disable-warning=ExperimentalWarning --watch server.js",
"dev:all": "node scripts/dev-all.mjs",
"start": "node --disable-warning=ExperimentalWarning server.js",
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit && vite build && chmod -R 755 dist/icons dist/mascot",
"preview": "vite preview",
"build:knowledge": "node scripts/build-knowledge.mjs",
"build:skill-drills": "node scripts/build-skill-drills.mjs",

View File

@ -2,7 +2,7 @@
set -euo pipefail
REMOTE="${REMOTE:-daniel@10.0.0.5}"
APP_DIR="${APP_DIR:-/opt/investor-rpg}"
APP_DIR="${APP_DIR:-/home/daniel/investor-rpg}"
SERVICE_NAME="${SERVICE_NAME:-investor-rpg}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"

View File

@ -2,7 +2,7 @@
set -euo pipefail
APP_USER="${APP_USER:-daniel}"
APP_DIR="${APP_DIR:-/opt/investor-rpg}"
APP_DIR="${APP_DIR:-/home/daniel/investor-rpg}"
SERVICE_NAME="${SERVICE_NAME:-investor-rpg}"
SERVER_NAME="${SERVER_NAME:-_}"

427
server.js
View File

@ -59,6 +59,7 @@ import {
getTraderBrief,
buildTraderOperationalStatus,
buildTraderParameterSummary,
buildTraderUniversePreview,
refreshDailyTakeaway,
} from './lib/ai-trader.js';
import { listTraderPersonalities } from './lib/trader-personalities.js';
@ -77,6 +78,7 @@ import {
} from './lib/ai-debug.js';
import { buildReport } from './lib/fincheck.js';
import { buildFundAnalysis } from './lib/fund-analysis.js';
import { computeCompositeAnalysis, computeDCF, computeFinancialMetrics } from './lib/quant-analysis.js';
import { RANGES, INTERVALS } from './lib/marketdata.js';
import { runBacktest, STRATEGIES } from './lib/backtest.js';
import {
@ -1476,6 +1478,18 @@ app.get('/api/trades/personality-templates', (_req, res) => {
res.json({ templates: listTraderPersonalities() });
} catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.get('/api/trades/accounts/:id/agent-models', async (req, res) => {
try {
const account = getTradeAccount(Number(req.params.id));
if (!account) return res.status(404).json({ error: 'not_found' });
const { listAgentModelConfig } = await import('./lib/agent-orchestrator.js');
const config = listAgentModelConfig(account);
res.json({
agents: config,
multiAgentEnabled: account?.traderConfig?.multiAgentEnabled !== false,
});
} catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.get('/api/trades/accounts/:id/trader-brief', async (req, res) => {
try {
const id = Number(req.params.id);
@ -1492,6 +1506,7 @@ app.get('/api/trades/accounts/:id/trader-brief', async (req, res) => {
canTrade: phase === 'intraday' && isMarketOpenForAccount(account.simulationMarket),
brief: getTraderBrief(id),
followSignals,
universePreview: account.kind === 'ai' ? buildTraderUniversePreview(id) : null,
operationalStatus: buildTraderOperationalStatus(id),
effectiveParams: account.kind === 'ai' ? buildTraderParameterSummary(id) : null,
});
@ -1533,6 +1548,7 @@ app.post('/api/trades/accounts/:id/simulate', async (req, res) => {
else if (phase === 'industry_report') result = await runDailyIndustryResearch(id, { force });
else if (phase === 'post_market') result = await runPostMarketReview(id, { force });
else if (phase === 'intraday_review') result = await runIntradayReview(id, { force });
else if (phase === 'intraday_test') result = await runAiSimulation(id, { force: true, phase: 'intraday_test', deps });
else if (phase === 'intraday') result = await runAiSimulation(id, { force, phase: 'intraday' });
else result = await runAiSimulation(id, { force, phase: 'auto', deps });
res.json(result);
@ -1552,6 +1568,18 @@ app.get('/api/trades/accounts/:id/simulation-runs', (req, res) => {
});
} catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.get('/api/quant-analysis/:symbol', async (req, res) => {
try {
const symbol = String(req.params.symbol).trim().toUpperCase();
if (!symbol) return res.status(400).json({ error: 'missing_symbol' });
const fundamentals = await getFundamentals(symbol);
const dcf = computeDCF(fundamentals, fundamentals.price);
const financialMetrics = computeFinancialMetrics(fundamentals);
res.json({ symbol, name: fundamentals.name, price: fundamentals.price, dcf, financialMetrics });
} catch (e) {
res.status(500).json({ error: String(e?.message || e), symbol: req.params.symbol });
}
});
app.get('/api/trades', (req, res) => res.json({ trades: listTrades(parseAccountId(req)) }));
app.get('/api/trades/stats', (req, res) => res.json(tradeStats(parseAccountId(req))));
app.post('/api/trades', (req, res) => {
@ -2386,6 +2414,395 @@ app.post('/api/settings/env', (req, res) => {
}
});
// ═══════════════════════════════════════════════════════════
// 內容管線 APIYouTube 擷取 / HyRead 匯入 / 排程)
// ═══════════════════════════════════════════════════════════
import {
listContentSources, getContentSource, insertContentSource, updateContentSource, deleteContentSource,
enqueueJob, listPendingJobs, claimJob, completeJob,
listSchedules, getSchedule, upsertSchedule, deleteSchedule,
getContentStats,
} from './lib/content-pipeline/db.js';
import { fetchTranscript, fetchPlaylist, extractVideoId, extractPlaylistId, checkYtDlp, listLocalTranscripts } from './lib/content-pipeline/youtube.js';
import { processTranscriptWithAI, processTranscriptLocally, saveAsKnowledgeNote, appendPrinciple } from './lib/content-pipeline/transcript-to-knowledge.js';
import { scanHyreadOutput, parseBookContent, extractPrinciplesFromBook, extractTermsFromBook } from './lib/content-pipeline/hyread.js';
import { callAI, extractJSONObject } from './lib/ai-client.js';
// ─── 內容來源列表 ───
app.get('/api/content', (req, res) => {
try {
const kind = req.query.kind || null;
const status = req.query.status || null;
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
const offset = Math.max(0, Number(req.query.offset) || 0);
const sources = listContentSources(kind, status, limit, offset);
const stats = getContentStats();
res.json({ sources, stats });
} catch (err) {
res.status(500).json({ error: 'content_list_failed', message: String(err?.message || err) });
}
});
app.get('/api/content/stats', (req, res) => {
try {
res.json(getContentStats());
} catch (err) {
res.status(500).json({ error: 'content_stats_failed', message: String(err?.message || err) });
}
});
// ─── YouTube ───
app.post('/api/content/youtube/fetch', async (req, res) => {
const { url } = req.body || {};
if (!url) return res.status(400).json({ error: 'missing_url', message: '請提供 YouTube URL' });
try {
const playlistId = extractPlaylistId(url);
if (playlistId) {
const entries = await fetchPlaylist(url);
const results = [];
for (const entry of entries) {
const sourceId = insertContentSource({
kind: 'youtube', title: entry.title, url: entry.url, sourceId: entry.id,
channel: entry.channel, publishedAt: null, metadata: { playlistId, entry },
});
enqueueJob(sourceId, 'youtube');
results.push({ id: sourceId, title: entry.title, videoId: entry.id });
}
return res.json({ ok: true, kind: 'playlist', count: results.length, items: results });
}
const result = await fetchTranscript(url);
const videoId = result.videoId;
const meta = result.metadata || {};
const sourceId = insertContentSource({
kind: 'youtube', title: meta.title || videoId, url: meta.webpageUrl || url,
sourceId: videoId, channel: meta.channel, author: meta.channel,
publishedAt: meta.uploadDate ? `${meta.uploadDate.slice(0,4)}-${meta.uploadDate.slice(4,6)}-${meta.uploadDate.slice(6,8)}` : null,
thumbnailUrl: meta.thumbnail, durationSec: meta.duration,
metadata: { title: meta.title, description: meta.description, videoId },
});
const jobId = enqueueJob(sourceId, 'youtube', 'process', { transcript: result.transcript, metadata: meta });
res.json({
ok: true, kind: 'single', sourceId, videoId,
title: meta.title || videoId, transcriptLength: result.transcript.length,
});
} catch (err) {
res.status(502).json({ error: 'youtube_fetch_failed', message: String(err?.message || err) });
}
});
app.post('/api/content/youtube/process/:id', async (req, res) => {
try {
const source = getContentSource(req.params.id);
if (!source) return res.status(404).json({ error: 'not_found', message: '找不到該內容' });
if (!req.body?.transcript) {
return res.status(400).json({ error: 'missing_transcript', message: '需要提供 transcript' });
}
updateContentSource(source.id, { status: 'processing' });
const transcript = req.body.transcript;
const metadata = source.metadata || {};
let items;
const aiCfg = await import('./lib/ai-client.js').then(m => m.getActiveAIConfig());
if (aiCfg) {
try {
const aiClient = {
chat: async (messages, opts) => {
const sysMsg = messages.find(m => m.role === 'system');
const userMsg = messages.find(m => m.role === 'user');
const result = await callAI({
system: sysMsg?.content || '',
user: userMsg?.content || '',
temperature: opts?.temperature ?? 0.3,
timeoutMs: 120000,
debugMeta: { source: 'content_pipeline_youtube' },
});
if (!result.ok) throw new Error(result.error || 'AI call failed');
return result.text;
},
};
items = await processTranscriptWithAI(transcript, metadata, aiClient);
} catch (aiErr) {
console.warn('[content] AI processing failed, using local fallback:', aiErr.message);
items = processTranscriptLocally(transcript, metadata);
}
} else {
items = processTranscriptLocally(transcript, metadata);
}
const saved = { principles: [], terms: [], cases: [], companies: [], episodes: [] };
for (const item of items) {
switch (item.type) {
case 'principle':
saved.principles.push(appendPrinciple({
title: item.title, body: item.body,
source: 'youtube', videoId: metadata.id || source.source_id,
episode: req.body.episode || null, date: metadata.uploadDate || null,
}));
break;
case 'term':
saved.terms.push(saveAsKnowledgeNote({
kind: 'term', id: item.id || item.title, title: item.title, body: item.body,
source: 'youtube', videoId: metadata.id || source.source_id,
category: item.category || null,
}));
break;
case 'case':
saved.cases.push(saveAsKnowledgeNote({
kind: 'case', id: item.id || item.title, title: item.title, body: item.body,
source: 'youtube', videoId: metadata.id || source.source_id,
}));
break;
case 'company':
saved.companies.push(saveAsKnowledgeNote({
kind: 'company', id: item.ticker?.[0] || item.id, title: item.title, body: item.body,
source: 'youtube', videoId: metadata.id || source.source_id,
ticker: item.ticker,
}));
break;
case 'episode':
saved.episodes.push(saveAsKnowledgeNote({
kind: 'episode', id: item.id, title: item.title, body: item.body,
source: 'youtube', videoId: metadata.id || source.source_id,
episode: item.id,
}));
break;
}
}
const count = saved.principles.length + saved.terms.length + saved.cases.length + saved.companies.length + saved.episodes.length;
updateContentSource(source.id, { status: 'ready', processed_count: count, metadata_json: { ...source.metadata, processingResult: { saved, count } } });
res.json({ ok: true, sourceId: source.id, count, saved });
} catch (err) {
res.status(500).json({ error: 'content_process_failed', message: String(err?.message || err) });
}
});
app.get('/api/content/youtube/yt-dlp-status', (req, res) => {
res.json({ available: checkYtDlp() });
});
app.get('/api/content/youtube/local-transcripts', (req, res) => {
try {
res.json({ transcripts: listLocalTranscripts() });
} catch (err) {
res.status(500).json({ error: 'local_transcripts_failed', message: String(err?.message || err) });
}
});
// ─── HyRead ───
app.get('/api/content/hyread/scan', (req, res) => {
try {
const books = scanHyreadOutput();
res.json({ books });
} catch (err) {
res.status(500).json({ error: 'hyread_scan_failed', message: String(err?.message || err) });
}
});
app.post('/api/content/hyread/import', (req, res) => {
const { dir } = req.body || {};
if (!dir) return res.status(400).json({ error: 'missing_dir', message: '請提供目錄名稱' });
try {
const books = scanHyreadOutput();
const book = books.find(b => b.dir === dir);
if (!book) return res.status(404).json({ error: 'not_found', message: '找不到該書籍' });
const content = parseBookContent(book.bookHtmlPath);
if (!content) return res.status(500).json({ error: 'parse_failed', message: '無法解析書籍內容' });
const sourceId = insertContentSource({
kind: 'hyread', title: content.title, url: null, sourceId: book.dir,
author: book.metadata.author || null, publishedAt: null,
metadata: { dir: book.dir, pageCount: content.pages.length, ...book.metadata },
});
const principles = extractPrinciplesFromBook(content);
const terms = extractTermsFromBook(content);
for (const p of principles) {
appendPrinciple({ title: p.title, body: p.body, source: 'hyread', videoId: book.dir });
}
for (const t of terms) {
saveAsKnowledgeNote({ kind: 'term', id: t.title, title: t.title, body: t.body, source: 'hyread', videoId: book.dir });
}
saveAsKnowledgeNote({
kind: 'category', id: book.dir, title: content.title,
body: `# ${content.title}\n\n本書由 HyRead 匯入,共 ${content.pages.length} 頁。\n\n來源hyread-tools/output/${book.dir}/`,
source: 'hyread',
});
updateContentSource(sourceId, { status: 'ready', processed_count: principles.length + terms.length });
res.json({ ok: true, sourceId, title: content.title, principleCount: principles.length, termCount: terms.length });
} catch (err) {
res.status(500).json({ error: 'hyread_import_failed', message: String(err?.message || err) });
}
});
app.post('/api/content/hyread/upload', async (req, res) => {
const { title, html } = req.body || {};
if (!html) return res.status(400).json({ error: 'missing_html', message: '請提供 book.html 內容' });
try {
const bookTitle = title || '匯入書籍';
const content = { title: bookTitle, html, pages: [] };
const pageRegex = /<section[^>]*class="page"[^>]*>(.*?)<\/section>/gs;
let m;
let idx = 0;
while ((m = pageRegex.exec(html)) !== null) {
idx++;
const text = m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
if (text) content.pages.push({ index: idx, text });
}
if (!content.pages.length) {
const text = html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
content.pages = [{ index: 1, text }];
}
const sourceId = insertContentSource({
kind: 'hyread', title: bookTitle, url: null, sourceId: `upload_${Date.now()}`,
metadata: { pageCount: content.pages.length, upload: true },
});
const principles = extractPrinciplesFromBook(content);
const terms = extractTermsFromBook(content);
for (const p of principles) {
appendPrinciple({ title: p.title, body: p.body, source: 'hyread', videoId: sourceId });
}
for (const t of terms) {
saveAsKnowledgeNote({ kind: 'term', id: t.title, title: t.title, body: t.body, source: 'hyread', videoId: sourceId });
}
updateContentSource(sourceId, { status: 'ready', processed_count: principles.length + terms.length });
res.json({ ok: true, sourceId, title: bookTitle, principleCount: principles.length, termCount: terms.length });
} catch (err) {
res.status(500).json({ error: 'hyread_upload_failed', message: String(err?.message || err) });
}
});
// ─── 排程 ───
app.get('/api/content/schedules', (req, res) => {
try {
res.json({ schedules: listSchedules() });
} catch (err) {
res.status(500).json({ error: 'schedules_list_failed', message: String(err?.message || err) });
}
});
app.post('/api/content/schedules', (req, res) => {
try {
const { kind, name, url, channelId, playlistId, intervalMin, enabled } = req.body || {};
if (!kind || !name || !url) {
return res.status(400).json({ error: 'missing_fields', message: '需要 kind, name, url' });
}
const id = upsertSchedule({ kind, name, url, channelId, playlistId, intervalMin, enabled });
res.json({ ok: true, id });
} catch (err) {
res.status(500).json({ error: 'schedule_create_failed', message: String(err?.message || err) });
}
});
app.delete('/api/content/schedules/:id', (req, res) => {
try {
deleteSchedule(Number(req.params.id));
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'schedule_delete_failed', message: String(err?.message || err) });
}
});
// ─── 擴充功能接收 ───
app.post('/api/content/capture', async (req, res) => {
const { kind, url, title, videoId, transcript, html, sourceId: extSourceId, metadata } = req.body || {};
if (!kind) return res.status(400).json({ error: 'missing_kind', message: '需要 kind (youtube/hyread)' });
try {
const sourceId = insertContentSource({
kind, title: title || '擴充功能擷取', url, sourceId: extSourceId || videoId || `ext_${Date.now()}`,
channel: metadata?.channel, author: metadata?.channel,
publishedAt: metadata?.uploadDate, thumbnailUrl: metadata?.thumbnail,
metadata: { ...metadata, extCapture: true },
});
if (kind === 'youtube' && transcript) {
updateContentSource(sourceId, { status: 'processing' });
enqueueJob(sourceId, 'youtube', 'process', { transcript, metadata: metadata || {} });
return res.json({ ok: true, sourceId, note: '已排程 AI 處理' });
}
if (kind === 'hyread' && html) {
const content = { title: title || '擴充功能擷取', html, pages: [] };
const pageRegex = /<section[^>]*class="page"[^>]*>(.*?)<\/section>/gs;
let m;
let idx = 0;
while ((m = pageRegex.exec(html)) !== null) {
idx++;
const text = m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
if (text) content.pages.push({ index: idx, text });
}
if (!content.pages.length) {
const text = html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
content.pages = [{ index: 1, text }];
}
const principles = extractPrinciplesFromBook(content);
const terms = extractTermsFromBook(content);
for (const p of principles) appendPrinciple({ title: p.title, body: p.body, source: 'hyread', videoId: sourceId });
for (const t of terms) saveAsKnowledgeNote({ kind: 'term', id: t.title, title: t.title, body: t.body, source: 'hyread', videoId: sourceId });
updateContentSource(sourceId, { status: 'ready', processed_count: principles.length + terms.length });
return res.json({ ok: true, sourceId, count: principles.length + terms.length });
}
updateContentSource(sourceId, { status: 'ready' });
res.json({ ok: true, sourceId, note: '已接收,待處理' });
} catch (err) {
res.status(500).json({ error: 'content_capture_failed', message: String(err?.message || err) });
}
});
app.post('/api/content/rebuild-knowledge', async (req, res) => {
try {
const { execSync } = await import('node:child_process');
const appDir = path.resolve(__dirname);
const result = execSync('npm run build:knowledge 2>&1', { cwd: appDir, timeout: 30000 });
const output = result.toString();
res.json({ ok: true, output: output.slice(0, 2000) });
} catch (err) {
res.status(500).json({ error: 'rebuild_failed', message: String(err?.message || err), output: String(err?.stdout || '') });
}
});
// ─── 內容來源單篇查詢 / 刪除(放在最後避免攔截特定路徑)───
app.get('/api/content/:id', (req, res) => {
try {
const source = getContentSource(req.params.id);
if (!source) return res.status(404).json({ error: 'not_found', message: '找不到該內容來源' });
res.json(source);
} catch (err) {
res.status(500).json({ error: 'content_get_failed', message: String(err?.message || err) });
}
});
app.delete('/api/content/:id', (req, res) => {
try {
deleteContentSource(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'content_delete_failed', message: String(err?.message || err) });
}
});
app.get('/api/health', (req, res) => res.json({ ok: true, knowledge: knowledgeReady() }));
// 《短線交易日線圖大全》教材卷軸MD → HTML含 content/raw/patterns 後備)
@ -2398,7 +2815,7 @@ app.get('*', (req, res, next) => {
res.sendFile(path.join(DIST_DIR, 'index.html'));
});
app.listen(PORT, HOST, () => {
app.listen(PORT, HOST, async () => {
ensureAIConfigDefaults();
console.log(`\nMacroScope 已啟動 → http://${HOST}:${PORT}\n`);
if (!hasKey) {
@ -2435,4 +2852,12 @@ app.listen(PORT, HOST, () => {
setInterval(() => tickAiSimulations(simDeps), 60_000);
setTimeout(() => tickAiSimulations(simDeps), 15_000);
console.log('AI 交易員排程已啟動:交易日跑盤前/盤中/盤後;每日產業研究包含週末與休市日。');
// 內容管線排程(每 5 分鐘檢查一次排程)
try {
const { startScheduler } = await import('./lib/content-pipeline/scheduler.js');
startScheduler(5 * 60 * 1000);
console.log('內容排程器已啟動。');
} catch (err) {
console.warn('[content-scheduler] 啟動失敗:', err.message);
}
});

View File

@ -9,6 +9,9 @@ import Research from "./pages/Research";
import Skills from "./pages/Skills";
import Patterns from "./pages/Patterns";
import Journal from "./pages/Journal";
import Library from "./pages/Library";
import LibraryDetail from "./pages/LibraryDetail";
import ContentManager from "./pages/ContentManager";
export default function App() {
return (
@ -23,6 +26,9 @@ export default function App() {
<Route path="/patterns" element={<Patterns />} />
<Route path="/journal" element={<Journal />} />
<Route path="/profile" element={<Profile />} />
<Route path="/library" element={<Library />} />
<Route path="/library/:kind/:id" element={<LibraryDetail />} />
<Route path="/content" element={<ContentManager />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>

View File

@ -30,6 +30,13 @@ const NAV: { section: string; items: { to: string; icon: IconName; label: string
items: [
{ to: "/skills", icon: "scroll", label: "心法 · 技能樹" },
{ to: "/patterns", icon: "cards", label: "線型 · 圖鑑" },
{ to: "/library", icon: "book", label: "知識 · 圖書館" },
],
},
{
section: "內容",
items: [
{ to: "/content", icon: "folder", label: "內容 · 管理" },
],
},
{
@ -47,6 +54,7 @@ const BOTTOM_NAV: { to: string; icon: IconName; label: string }[] = [
{ to: "/market", icon: "world", label: "市場" },
{ to: "/research", icon: "folder", label: "背包" },
{ to: "/skills", icon: "scroll", label: "心法" },
{ to: "/library", icon: "book", label: "圖書館" },
];
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {

View File

@ -257,6 +257,22 @@ export function IconCoin({ size = 24, className }: IconProps) {
});
}
export function IconBook({ size = 24, className }: IconProps) {
return base({
size,
className,
children: (
<>
<path d="M6 7 C6 5 8 4 10 4 H26 V22 H10 C8 22 6 21 6 19 Z" fill="#1b2240" stroke="#8fb8ff" strokeWidth="2" />
<line x1="10" y1="4" x2="10" y2="22" stroke="rgba(143,184,255,.4)" strokeWidth="1.5" />
<rect x="14" y="8" width="8" height="1.5" fill="#6fe0d0" />
<rect x="14" y="12" width="6" height="1.5" fill="#8fb8ff" />
<rect x="14" y="16" width="7" height="1.5" fill="#e7c66b" />
</>
),
});
}
export function IconMap({ size = 24, className }: IconProps) {
return base({
size,
@ -524,7 +540,8 @@ export type IconName =
| "warning"
| "key"
| "hammer"
| "hourglass";
| "hourglass"
| "book";
const ICON_MAP: Record<IconName, ComponentType<IconProps>> = {
compass: IconCompass,
@ -538,6 +555,7 @@ const ICON_MAP: Record<IconName, ComponentType<IconProps>> = {
scroll: IconScroll,
cards: IconCards,
folder: IconFolder,
book: IconBook,
wizard: IconWizard,
gear: IconGear,
menu: IconMenu,
@ -584,6 +602,8 @@ export const ROUTE_ICON: Record<string, IconName> = {
"/research": "sword",
"/skills": "scroll",
"/patterns": "cards",
"/library": "book",
"/content": "folder",
"/journal": "folder",
"/profile": "wizard",
"/settings": "gear",

View File

@ -18,6 +18,7 @@ export type AccountForm = {
simulationMarket: string;
universeMode: UniverseMode;
universeCustom: string;
universeGroupIds: string[];
simulationOnlyMarketHours: boolean;
aiProvider: string;
aiModel: string;
@ -27,12 +28,22 @@ export type AccountForm = {
note: string;
};
export const AGENT_ROLES = [
{ type: "value", label: "價值分析師", badge: "V" },
{ type: "growth", label: "成長分析師", badge: "G" },
{ type: "macro", label: "總經分析師", badge: "M" },
{ type: "technical", label: "技術分析師", badge: "T" },
{ type: "portfolioManager", label: "投資組合經理", badge: "PM" },
] as const;
export const DEFAULT_TRADER_CONFIG: TraderCustomConfig = {
riskScore: 5,
maxPositionPct: 30,
maxActionsPerRound: 2,
maxOpenPositions: 8,
minCashReservePct: 10,
multiAgentEnabled: true,
agentModels: {},
observation: {
preMarket: ["總經、利率、美元與 VIX", "板塊輪動與候選標的新聞"],
intraday: ["持倉盈虧與停損停利", "大盤方向與量價關鍵位"],
@ -47,15 +58,23 @@ export const DEFAULT_TRADER_CONFIG: TraderCustomConfig = {
paramOverrides: {},
};
function parseUniverse(raw?: string | null): { mode: UniverseMode; custom: string } {
const u = String(raw || "watchlist").trim().toLowerCase();
if (u === "watchlist") return { mode: "watchlist", custom: "" };
if (u === "ai_pick" || u === "ai" || u === "auto") return { mode: "ai_pick", custom: "" };
return { mode: "custom", custom: String(raw || "").trim() };
function parseUniverse(raw?: string | null): { mode: UniverseMode; custom: string; groupIds: string[] } {
const original = String(raw || "watchlist").trim();
const u = original.toLowerCase();
if (u === "watchlist") return { mode: "watchlist", custom: "", groupIds: [] };
if (u.startsWith("watchlist:")) {
return {
mode: "watchlist",
custom: "",
groupIds: original.slice("watchlist:".length).split(",").map((s) => s.trim()).filter(Boolean),
};
}
if (u === "ai_pick" || u === "ai" || u === "auto") return { mode: "ai_pick", custom: "", groupIds: [] };
return { mode: "custom", custom: String(raw || "").trim(), groupIds: [] };
}
export function accountFormFromRecord(a: TradeAccount): AccountForm {
const { mode, custom } = parseUniverse(a.simulationUniverse);
const { mode, custom, groupIds } = parseUniverse(a.simulationUniverse);
const savedConfig = a.traderConfig || {};
return {
name: a.name,
@ -68,6 +87,7 @@ export function accountFormFromRecord(a: TradeAccount): AccountForm {
simulationMarket: a.simulationMarket || "ANY",
universeMode: mode,
universeCustom: custom,
universeGroupIds: groupIds,
simulationOnlyMarketHours: a.simulationOnlyMarketHours !== false,
aiProvider: a.aiProvider || "",
aiModel: a.aiModel || "",
@ -109,6 +129,7 @@ export const EMPTY_ACCOUNT_FORM: AccountForm = {
simulationMarket: "US",
universeMode: "watchlist",
universeCustom: "",
universeGroupIds: [],
simulationOnlyMarketHours: true,
aiProvider: "",
aiModel: "",
@ -122,6 +143,45 @@ function Hint({ children }: { children: React.ReactNode }) {
return <p className="journal-form-hint">{children}</p>;
}
function ModelSelect({
providerId,
allProviders,
value,
onChange,
}: {
providerId: string;
allProviders: { id: string; label: string; model?: string }[];
value: string;
onChange: (model: string) => void;
}) {
const modelsQ = useQuery({
queryKey: ["ai-models", providerId],
queryFn: () => aiApi.models(providerId as any),
enabled: !!providerId,
staleTime: 120_000,
});
const modelOptions = modelsQ.data?.models || [];
const defaultModel = allProviders.find((p) => p.id === providerId)?.model || modelOptions[0]?.id || "";
return (
<select
value={value}
disabled={!providerId || modelsQ.isLoading || modelOptions.length === 0}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{providerId ? "選擇模型" : "跟隨帳號預設"}</option>
{modelOptions.length === 0 ? (
<option value="">{modelsQ.isLoading ? "載入中…" : (defaultModel || "無可用模型")}</option>
) : (
modelOptions.map((m: any) => (
<option key={m.id} value={m.id}>
{m.id}
</option>
))
)}
</select>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="journal-form-section">
@ -176,6 +236,12 @@ export function AccountSettingsModal({
enabled: open && form.kind === "ai",
});
const providers = (providersQ.data?.providers || []).filter((p) => p.hasKey);
const watchlistQ = useQuery({
queryKey: ["stock-watchlist"],
queryFn: api.watchlist,
enabled: open && form.kind === "ai" && form.simulationEnabled,
});
const watchlistGroups = watchlistQ.data?.groups || [];
const activeProviderId = (form.aiProvider || providers[0]?.id || "grok") as AIProviderId;
const modelsQ = useQuery({
@ -187,7 +253,8 @@ export function AccountSettingsModal({
const modelOptions = modelsQ.data?.models || [];
const defaultModel = providers.find((p) => p.id === activeProviderId)?.model || modelOptions[0]?.id || "";
const effectiveModel = form.aiModel || defaultModel;
const accountProviderSelected = !!form.aiProvider;
const effectiveModel = accountProviderSelected ? (form.aiModel || defaultModel) : "";
const templatesQ = useQuery({
queryKey: ["trader-personality-templates"],
@ -217,13 +284,15 @@ export function AccountSettingsModal({
const universeBehavior = useMemo(() => {
if (form.universeMode === "watchlist") {
return "只在你 App 追蹤清單watchlist內找標的盤中買賣不會超出清單。";
const selected = watchlistGroups.filter((g) => form.universeGroupIds.includes(g.id));
if (selected.length) return `只在追蹤清單分群「${selected.map((g) => g.name).join("、")}」內找標的。`;
return "只在你 App 追蹤清單全部分群內找標的;可下方改成只選指定分群。";
}
if (form.universeMode === "ai_pick") {
return "盤前研究時 AI 依總經、輪動、新聞自選 58 檔寫入 watch_focus盤中只在這個池子裡交易可含清單外標的。";
}
return "只交易你指定的代號(逗號分隔),例如 NVDA,AAPL,2330.TW。";
}, [form.universeMode]);
}, [form.universeGroupIds, form.universeMode, watchlistGroups]);
if (!open) return null;
@ -307,7 +376,7 @@ export function AccountSettingsModal({
<div className="settings-field">
<label>AI Provider</label>
<select
value={form.aiProvider || providers[0]?.id || ""}
value={form.aiProvider || ""}
onChange={(e) => onChange({ aiProvider: e.target.value, aiModel: "" })}
>
<option value="">使</option>
@ -323,11 +392,11 @@ export function AccountSettingsModal({
<label></label>
<select
value={effectiveModel}
disabled={modelsQ.isLoading || modelOptions.length === 0}
disabled={!accountProviderSelected || modelsQ.isLoading || modelOptions.length === 0}
onChange={(e) => onChange({ aiModel: e.target.value })}
>
{modelOptions.length === 0 ? (
<option value="">{modelsQ.isLoading ? "載入模型中…" : defaultModel || "無可用模型"}</option>
<option value="">{!accountProviderSelected ? "跟隨全域預設" : (modelsQ.isLoading ? "載入模型中…" : defaultModel || "無可用模型")}</option>
) : (
modelOptions.map((m) => (
<option key={m.id} value={m.id}>
@ -336,11 +405,91 @@ export function AccountSettingsModal({
))
)}
</select>
<Hint></Hint>
<Hint> Provider Provider </Hint>
</div>
</div>
</Section>
<Section title="多分析師模型配置">
<label className="journal-mistake-check">
<input
type="checkbox"
checked={form.traderConfig.multiAgentEnabled !== false}
onChange={(e) =>
onChange({
traderConfig: {
...form.traderConfig,
multiAgentEnabled: e.target.checked,
},
})
}
/>
4 +
</label>
<Hint>
4 AI ///使
</Hint>
{form.traderConfig.multiAgentEnabled !== false ? (
<div className="multi-agent-table">
{AGENT_ROLES.map((role) => {
const agentCfg = form.traderConfig.agentModels?.[role.type];
return (
<div key={role.type} className="multi-agent-row">
<span className="multi-agent-label"><span className="agent-badge">{role.badge}</span>{role.label}</span>
<div className="grid g3">
<div className="settings-field">
<label>Provider</label>
<select
value={agentCfg?.providerId || ""}
onChange={(e) => {
const models = { ...(form.traderConfig.agentModels || {}) };
if (e.target.value) {
models[role.type] = { providerId: e.target.value, model: "" };
} else {
delete models[role.type];
}
onChange({
traderConfig: { ...form.traderConfig, agentModels: models },
});
}}
>
<option value=""></option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</div>
<div className="settings-field">
<label></label>
<ModelSelect
providerId={agentCfg?.providerId || ""}
allProviders={providers}
value={agentCfg?.model || ""}
onChange={(model) => {
const models = { ...(form.traderConfig.agentModels || {}) };
if (model && agentCfg?.providerId) {
models[role.type] = { ...agentCfg, model };
} else if (!model && agentCfg?.providerId) {
models[role.type] = { ...agentCfg, model: "" };
} else {
delete models[role.type];
}
onChange({
traderConfig: { ...form.traderConfig, agentModels: models },
});
}}
/>
</div>
</div>
</div>
);
})}
</div>
) : null}
</Section>
{form.simulationEnabled ? (
<Section title="自動投資行為">
<div className="grid g2">
@ -384,6 +533,36 @@ export function AccountSettingsModal({
<Hint>{universeBehavior}</Hint>
</div>
{form.universeMode === "watchlist" ? (
<div className="settings-field">
<label></label>
<div className="watchlist-group-picker">
{watchlistGroups.length === 0 ? (
<p className="journal-form-hint"></p>
) : watchlistGroups.map((group) => {
const checked = form.universeGroupIds.includes(group.id);
return (
<label key={group.id} className="watchlist-group-option">
<input
type="checkbox"
checked={checked}
onChange={(event) => {
const next = event.target.checked
? [...form.universeGroupIds, group.id]
: form.universeGroupIds.filter((id) => id !== group.id);
onChange({ universeGroupIds: next });
}}
/>
<span>{group.name}</span>
<small>{group.symbols.length ? group.symbols.join(", ") : "空分群"}</small>
</label>
);
})}
</div>
<Hint> = 使使</Hint>
</div>
) : null}
{form.universeMode === "custom" ? (
<div className="settings-field">
<label></label>
@ -505,6 +684,8 @@ export function accountPayloadFromForm(form: AccountForm) {
const simulationUniverse =
form.universeMode === "custom"
? form.universeCustom.trim() || "watchlist"
: form.universeMode === "watchlist" && form.universeGroupIds.length > 0
? `watchlist:${form.universeGroupIds.join(",")}`
: form.universeMode;
return {
@ -519,12 +700,16 @@ export function accountPayloadFromForm(form: AccountForm) {
simulationUniverse,
simulationOnlyMarketHours: form.simulationOnlyMarketHours,
aiProvider: form.aiProvider.trim() || null,
aiModel: form.aiModel.trim() || null,
aiModel: form.aiProvider.trim() ? (form.aiModel.trim() || null) : null,
traderPersonality: form.kind === "ai" && form.simulationEnabled ? form.traderPersonality || "custom" : null,
traderConfig: form.kind === "ai" && form.simulationEnabled
? form.traderPersonality === "custom"
? form.traderConfig
: { paramOverrides: form.traderConfig.paramOverrides || {} }
: {
paramOverrides: form.traderConfig.paramOverrides || {},
multiAgentEnabled: form.traderConfig.multiAgentEnabled,
agentModels: form.traderConfig.agentModels,
}
: null,
simulationStrategy:
form.kind === "ai" && form.simulationEnabled ? form.simulationStrategy.trim() || null : null,

View File

@ -1,5 +1,7 @@
import { useMemo } from "react";
import type { TradeAccount, TradeDashboardPayload } from "../../lib/api";
import { useQuery } from "@tanstack/react-query";
import type { TradeAccount, TradeDashboardPayload, TraderPersonalityTemplate, TraderCustomConfig } from "../../lib/api";
import { api } from "../../lib/api";
import { Card } from "../ui";
import { AppIcon } from "../PixelIcons";
import EquityChart from "../EquityChart";
@ -153,6 +155,10 @@ export function JournalDashboard({
</div>
</div>
{account.kind === "ai" && account.traderPersonality ? (
<PersonalityCard account={account} />
) : null}
{account.kind === "ai" ? (
<SimulationPanel
account={account}
@ -229,3 +235,107 @@ export function JournalDashboard({
</>
);
}
function PersonalityCard({ account }: { account: TradeAccount }) {
const templatesQ = useQuery({
queryKey: ["trader-personality-templates"],
queryFn: api.traderPersonalityTemplates,
});
const agentMQ = useQuery({
queryKey: ["agent-models", account.id],
queryFn: () => api.tradeAgentModels(account.id),
enabled: account.kind === "ai",
});
const templates = templatesQ.data?.templates || [];
const personality = templates.find((t) => t.id === account.traderPersonality);
const agentConfig = agentMQ.data;
const config = (account.traderConfig || {}) as TraderCustomConfig;
const multiAgent = config.multiAgentEnabled !== false;
if (!personality) return null;
return (
<Card title={`交易員性格 · ${personality.name}`} ico={<AppIcon name="compass" size={22} framed variant="nav" />} className="mt">
<div className="personality-dashboard-grid">
<div className="personality-dash-section">
<div className="personality-dash-head">
<strong>{personality.name}</strong>
<span className="small muted">{personality.nameEn}</span>
<span className={`trader-risk-pill risk-${personality.riskLevel}`}>{personality.riskLabel}</span>
</div>
<p className="small" data-pct={personality.riskScore * 10}>
<span>LV.{personality.riskScore} </span>
<span className="personality-hp-bar">
<span className="personality-hp-fill" style={{ width: `${personality.riskScore * 10}%` }} />
</span>
</p>
<p className="small muted">{personality.strategyPreview}</p>
{personality.assetClasses && personality.assetClasses.length > 0 ? (
<div className="personality-assets">
<span className="small muted"></span>
{personality.assetClasses!.map((a) => (
<span key={a.id} className={`trader-personality-asset-chip w-${a.weight}`} title={a.note}>
{a.label}
</span>
))}
</div>
) : null}
</div>
{personality.tradingRules ? (
<div className="personality-dash-section">
<span className="small muted"></span>
{(["entry", "exit", "sizing", "forbidden"] as const).map((cat) => {
const items = personality.tradingRules?.[cat];
if (!items?.length) return null;
const labels = { entry: "進場", exit: "出場", sizing: "倉位", forbidden: "禁止" };
return (
<div key={cat} className="personality-rule-group" data-cat={cat}>
<span className="personality-rule-label">{labels[cat]}</span>
<ul>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
})}
</div>
) : null}
{multiAgent && agentConfig?.agents?.length ? (
<div className="personality-dash-section">
<span className="small muted"></span>
<div className="personality-agent-models">
{agentConfig.agents.map((a: any) => {
const agentBadge: Record<string, string> = {
value: "V",
growth: "G",
macro: "M",
technical: "T",
portfolioManager: "PM",
};
return (
<div key={a.type} className="personality-agent-row">
<span><span className="agent-badge">{agentBadge[a.type] || "?"}</span> {a.label}</span>
<span className="small">
{a.incomplete ? (
<span className="muted"> {a.providerId || "預設"}/{a.model || "預設"}</span>
) : a.isCustom ? (
<strong>{a.providerId}/{a.model}</strong>
) : (
<span className="muted">{a.providerId || "預設"}/{a.model} ()</span>
)}
</span>
</div>
);
})}
</div>
</div>
) : null}
</div>
</Card>
);
}

View File

@ -37,6 +37,7 @@ const UNIVERSE_LABEL: Record<string, string> = {
function universeLabel(raw?: string | null) {
const u = String(raw || "watchlist").trim().toLowerCase();
if (UNIVERSE_LABEL[u]) return UNIVERSE_LABEL[u];
if (u.startsWith("watchlist:")) return "追蹤清單分群";
if (u === "ai" || u === "auto") return "AI 自選";
return `自訂:${raw}`;
}
@ -44,6 +45,7 @@ function universeLabel(raw?: string | null) {
const PHASE_LABEL: Record<string, string> = {
pre_market: "市場分析",
intraday: "盤中",
intraday_test: "盤中測試",
intraday_review: "盤中復盤",
post_market: "盤後",
industry_report: "每日產業研究",
@ -60,10 +62,30 @@ function fmtMoney(v: number | null | undefined) {
return `$${v.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
}
function RunRow({ run }: { run: SimulationRun }) {
function SymbolChips({ title, symbols, empty = "無" }: { title: string; symbols?: string[]; empty?: string }) {
const rows = (symbols || []).filter(Boolean);
return (
<div className="trader-universe-group">
<span>{title}</span>
<div className="trader-universe-chips">
{rows.length ? rows.map((symbol) => <strong key={`${title}-${symbol}`}>{symbol}</strong>) : <em>{empty}</em>}
</div>
</div>
);
}
function researchModelFallback(account: TradeAccount) {
const cfg = account.traderConfig?.agentModels?.portfolioManager;
if (cfg?.providerId && cfg?.model) return cfg;
if (account.aiProvider && account.aiModel) return { providerId: account.aiProvider, model: account.aiModel };
return null;
}
function RunRow({ run, account }: { run: SimulationRun; account: TradeAccount }) {
const buyRows = run.executed.filter((e) => e.type === "buy" && e.ok);
const sellRows = run.executed.filter((e) => e.type === "sell" && e.ok);
const phase = run.phase ? PHASE_LABEL[run.phase] || run.phase : "";
const fallbackModel = run.phase === "industry_report" && !run.model ? researchModelFallback(account) : null;
return (
<div className="journal-sim-run">
<div className="journal-sim-run-head">
@ -73,8 +95,14 @@ function RunRow({ run }: { run: SimulationRun }) {
</span>
<span className={`journal-sim-status ${statusTone(run.status)}`}>{run.status}</span>
</div>
{run.provider || run.model ? (
<div className="journal-sim-model small muted">
{run.provider || fallbackModel?.providerId || "預設"}
{run.model ? ` / ${run.model}` : fallbackModel ? ` / ${fallbackModel.model}(舊紀錄補示)` : ""}
</div>
) : null}
{run.summary ? <p className="small">{run.summary}</p> : null}
{run.phase === "intraday" ? (
{run.phase === "intraday" || run.phase === "intraday_test" ? (
<div className="small muted">
{buyRows.length > 0 ? (
<div className="trader-entry-sizing">
@ -90,6 +118,9 @@ function RunRow({ run }: { run: SimulationRun }) {
))}
</div>
) : null}
{run.executed.filter((e) => !e.ok && e.error === "test_mode").length > 0 ? (
<p style={{ color: "var(--gold)", marginTop: 4 }}></p>
) : null}
{run.executed.filter((e) => !e.ok && e.error === "market_closed").length > 0 ? (
<p style={{ color: "var(--gold)", marginTop: 4 }}></p>
) : null}
@ -159,6 +190,7 @@ export function SimulationPanel({
const phaseLabel = brief?.phaseLabel || "—";
const operational = brief?.operationalStatus;
const effectiveParams = brief?.effectiveParams;
const universePreview = brief?.universePreview;
const todaySignals = brief?.followSignals?.signals?.length || 0;
const intradayRuns = operational?.today.intradayRuns || 0;
const tradingDay = operational?.tradingDay !== false;
@ -245,6 +277,24 @@ export function SimulationPanel({
</section>
) : null}
{universePreview ? (
<section className="trader-universe-preview">
<div className="trader-universe-head">
<div>
<span></span>
<strong>{universePreview.modeLabel} · </strong>
</div>
<span className="trader-effective-badge">{universePreview.candidateSymbols.length} </span>
</div>
<div className="trader-universe-groups">
<SymbolChips title="今日會觀察" symbols={universePreview.candidateSymbols} />
<SymbolChips title="性格種子" symbols={universePreview.personalitySeeds} />
<SymbolChips title="目前持倉" symbols={universePreview.openSymbols} empty="目前無持倉" />
<SymbolChips title="過去交易過" symbols={universePreview.recentClosedSymbols} empty="尚無平倉紀錄" />
</div>
</section>
) : null}
<div className="ai-command-actions">
<div>
<strong></strong>
@ -263,6 +313,15 @@ export function SimulationPanel({
>
</button>
<button
type="button"
className="btn-ghost sm"
disabled={running}
title="測試盤中每 N 分鐘會跑的多分析師/PM 決策;使用目前可取得的最新或收盤資料,不下單、不更新排程時間"
onClick={() => onRunPhase("intraday_test")}
>
T
</button>
<button type="button" className="btn-ghost sm" disabled={running || !tradingDay} onClick={() => onRunPhase("post_market")}>
</button>
@ -403,7 +462,7 @@ export function SimulationPanel({
<summary>{runs.length}</summary>
{runs.length > 0 ? (
<div className="journal-sim-runs">
{runs.slice(0, 10).map((r) => <RunRow key={r.id} run={r} />)}
{runs.slice(0, 10).map((r) => <RunRow key={r.id} run={r} account={account} />)}
</div>
) : <p className="muted small"></p>}
</details>

View File

@ -47,6 +47,12 @@ const BEHAVIOR_HINT: Record<string, string> = {
druckenmiller: "高信念時敢重倉;宏觀+個股共振才出手。",
soros: "抓反身性趨勢;快進快出,假設錯立刻撤。",
livermore: "投機趨勢、關鍵點突破;賺了才加碼,絕不攤平。",
ackman: "深度研究集中押注;維權改善營運,搭配避險。",
wood: "押注破壞式創新;高成長高估值,不懼波動。",
burry: "深度價值+催化劑;放空泡沫,不跟隨市場共識。",
taleb: "槓鈴策略:極安全資產+尾部風險押注,反脆弱。",
graham: "淨淨值、清算價值、安全邊際;永不虧錢為上。",
munger: "護城河+管理層+耐心;不出價太低不交易。",
custom: "完全由你定義風險、倉位與進出規則。",
};

View File

@ -0,0 +1,312 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../lib/api";
import { RouteIcon } from "../components/PixelIcons";
const TYPE_LABELS: Record<string, string> = { youtube: "YouTube", hyread: "HyRead 書籍", manual: "手動" };
const STATUS_LABELS: Record<string, string> = { captured: "已擷取", processing: "處理中", ready: "已完成", error: "錯誤" };
function SourceRow({ source, onRefresh }: { source: any; onRefresh: () => void }) {
const [processing, setProcessing] = useState(false);
const qc = useQueryClient();
const processMutation = useMutation({
mutationFn: async () => {
const transcript = source.metadata?.transcript || "";
return api.youtubeProcess(source.id, transcript);
},
onSuccess: () => { qc.invalidateQueries({ queryKey: ["content"] }); onRefresh(); },
});
return (
<div className={`cs-row cs-${source.kind}`}>
<div className="cs-info">
<div className="cs-title">{source.title || source.id}</div>
<div className="cs-meta">
<span className={`tag tag-${source.kind}`}>{TYPE_LABELS[source.kind] || source.kind}</span>
<span className={`tag tag-${source.status}`}>{STATUS_LABELS[source.status] || source.status}</span>
{source.channel && <span>{source.channel}</span>}
{source.published_at && <span>{source.published_at}</span>}
{source.processed_count > 0 && <span>{source.processed_count} </span>}
</div>
</div>
<div className="cs-actions">
{source.status === "captured" && source.kind === "youtube" && (
<button className="btn btn-sm" onClick={() => processMutation.mutate()} disabled={processing}>
{processing ? "處理中..." : "AI 處理"}
</button>
)}
<button
className="btn btn-sm btn-ghost"
onClick={() => api.rebuildKnowledge().then(onRefresh)}
>
</button>
</div>
</div>
);
}
export default function ContentManager() {
const [tab, setTab] = useState<"sources" | "youtube" | "hyread" | "schedule">("sources");
const [url, setUrl] = useState("");
const [scheduleUrl, setScheduleUrl] = useState("");
const [scheduleName, setScheduleName] = useState("");
const [scheduleInterval, setScheduleInterval] = useState(10080);
const [fetchStatus, setFetchStatus] = useState<string | null>(null);
const [importStatus, setImportStatus] = useState<string | null>(null);
const qc = useQueryClient();
const sourcesQ = useQuery({
queryKey: ["content"],
queryFn: () => api.contentSources(),
});
const hyreadQ = useQuery({
queryKey: ["hyread-scan"],
queryFn: () => api.hyreadScan(),
enabled: tab === "hyread",
});
const schedulesQ = useQuery({
queryKey: ["content-schedules"],
queryFn: () => api.contentSchedules(),
enabled: tab === "schedule",
});
const ytDlpQ = useQuery({
queryKey: ["yt-dlp"],
queryFn: () => api.ytDlpStatus(),
});
const fetchMutation = useMutation({
mutationFn: (u: string) => api.youtubeFetch(u),
onSuccess: (data) => {
setFetchStatus(`成功: 已加入 ${data.items?.length || 1} 個影片`);
setUrl("");
qc.invalidateQueries({ queryKey: ["content"] });
},
onError: (err: any) => setFetchStatus(`錯誤: ${err.message}`),
});
const scheduleMutation = useMutation({
mutationFn: () => api.createContentSchedule({
kind: "youtube_channel",
name: scheduleName,
url: scheduleUrl,
intervalMin: scheduleInterval,
}),
onSuccess: () => {
setScheduleUrl("");
setScheduleName("");
qc.invalidateQueries({ queryKey: ["content-schedules"] });
},
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["content"] });
qc.invalidateQueries({ queryKey: ["knowledge"] });
};
return (
<div className="page content-manager-page">
<div className="page-head">
<h1></h1>
<p className="page-subtitle"> YouTube HyRead </p>
</div>
<div className="cm-tabs">
{(["sources", "youtube", "hyread", "schedule"] as const).map(t => (
<button key={t} className={`btn ${tab === t ? "btn-active" : ""}`} onClick={() => setTab(t)}>
{t === "sources" ? "所有來源" : t === "youtube" ? "YouTube" : t === "hyread" ? "HyRead" : "排程"}
</button>
))}
</div>
{tab === "sources" && (
<section>
<div className="cm-toolbar">
<span className="text-dim">{sourcesQ.data?.stats?.total || 0} </span>
<button className="btn btn-sm" onClick={() => api.rebuildKnowledge().then(refresh)}></button>
</div>
<div className="cs-list">
{(sourcesQ.data?.sources || []).map((s: any) => (
<SourceRow key={s.id} source={s} onRefresh={refresh} />
))}
{(!sourcesQ.data?.sources || sourcesQ.data.sources.length === 0) && (
<div className="lc-empty"><p> YouTube HyRead </p></div>
)}
</div>
{sourcesQ.data?.stats?.byKind && (
<div className="cm-stats">
{Object.entries(sourcesQ.data.stats.byKind).map(([k, counts]: [string, any]) => (
<div key={k} className="cm-stat-chip">
{TYPE_LABELS[k] || k}: {Object.values(counts).reduce((a: number, b: any) => a + (b as number), 0)}
</div>
))}
</div>
)}
</section>
)}
{tab === "youtube" && (
<section>
<div className="cm-card">
<h3> YouTube </h3>
<div className="cm-input-row">
<input
type="text"
className="cm-input"
placeholder="YouTube URL (單一影片或播放清單)"
value={url}
onChange={e => setUrl(e.target.value)}
/>
<button
className="btn"
onClick={() => url.trim() && fetchMutation.mutate(url.trim())}
disabled={fetchMutation.isPending || !url.trim()}
>
{fetchMutation.isPending ? "抓取中..." : "抓取"}
</button>
</div>
{fetchStatus && <div className={`cm-status ${fetchStatus.startsWith("錯誤") ? "error" : "ok"}`}>{fetchStatus}</div>}
<div className="cm-hint">
{ytDlpQ.data?.available ? "✓ yt-dlp 已安裝" : "⚠ yt-dlp 未安裝(僅能用公開 API"}
Chrome
</div>
</div>
<div className="cm-card">
<h3>Chrome </h3>
<p className="text-dim"> YouTube HyRead </p>
<a href="/extension.crx" className="btn btn-sm" download></a>
</div>
</section>
)}
{tab === "hyread" && (
<section>
<div className="cm-card">
<h3> hyread-tools/output/</h3>
<button className="btn" onClick={() => qc.invalidateQueries({ queryKey: ["hyread-scan"] })}>
</button>
</div>
<div className="cm-card">
<h3> book.html</h3>
<p className="text-dim"> hyread-tools/output/ book.html </p>
<input
type="file"
accept=".html"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const text = await file.text();
const title = file.name.replace(/\.html$/, "");
setImportStatus("上傳中...");
try {
const res = await api.hyreadUpload(title, text);
setImportStatus(`成功: ${res.title} (${res.principleCount} 原則, ${res.termCount} 名詞)`);
qc.invalidateQueries({ queryKey: ["content"] });
} catch (err: any) {
setImportStatus(`錯誤: ${err.message}`);
}
}}
/>
{importStatus && <div className={`cm-status ${importStatus.startsWith("錯誤") ? "error" : "ok"}`}>{importStatus}</div>}
</div>
<div className="cm-card">
<h3></h3>
{(hyreadQ.data?.books || []).length === 0 && <div className="text-dim"></div>}
{(hyreadQ.data?.books || []).map((book: any) => (
<div key={book.dir} className="cs-row">
<div className="cs-info">
<div className="cs-title">{book.title}</div>
<div className="cs-meta">
<span>{book.pageCount} </span>
<span>{book.metadata?.author || ""}</span>
</div>
</div>
<div className="cs-actions">
<button
className="btn btn-sm"
onClick={async () => {
try {
await api.hyreadImport(book.dir);
qc.invalidateQueries({ queryKey: ["content"] });
qc.invalidateQueries({ queryKey: ["hyread-scan"] });
} catch (err: any) {
alert(err.message);
}
}}
>
</button>
</div>
</div>
))}
</div>
</section>
)}
{tab === "schedule" && (
<section>
<div className="cm-card">
<h3></h3>
<div className="cm-input-row">
<input
type="text"
className="cm-input"
placeholder="顯示名稱"
value={scheduleName}
onChange={e => setScheduleName(e.target.value)}
/>
<input
type="text"
className="cm-input"
placeholder="YouTube 頻道/播放清單 URL"
value={scheduleUrl}
onChange={e => setScheduleUrl(e.target.value)}
/>
<select value={scheduleInterval} onChange={e => setScheduleInterval(Number(e.target.value))}>
<option value={1440}></option>
<option value={10080}></option>
<option value={43200}></option>
</select>
<button
className="btn btn-sm"
onClick={() => scheduleMutation.mutate()}
disabled={!scheduleName.trim() || !scheduleUrl.trim()}
>
</button>
</div>
</div>
<div className="cm-card">
<h3></h3>
{(schedulesQ.data?.schedules || []).length === 0 && <div className="text-dim"></div>}
{(schedulesQ.data?.schedules || []).map((s: any) => (
<div key={s.id} className="cs-row">
<div className="cs-info">
<div className="cs-title">{s.name}</div>
<div className="cs-meta">
<span>{s.interval_min >= 43200 ? "每月" : s.interval_min >= 10080 ? "每週" : "每天"}</span>
<span>{s.enabled ? "啟用" : "停用"}</span>
{s.last_checked_at && <span>: {new Date(s.last_checked_at).toLocaleDateString()}</span>}
</div>
</div>
<div className="cs-actions">
<button className="btn btn-sm btn-ghost" onClick={() => api.deleteContentSchedule(s.id).then(() => qc.invalidateQueries({ queryKey: ["content-schedules"] }))}></button>
</div>
</div>
))}
</div>
</section>
)}
</div>
);
}

View File

@ -148,6 +148,8 @@ export default function Home() {
</Card>
</div>
<ContentWidget />
<h3 className="pixel mt page-section-title" style={{ marginBottom: 12 }}>
<AppIcon name="map" size={26} framed variant="hero" />
@ -306,6 +308,42 @@ function IndicatorBars({ q }: { q: { data?: WeathervanePayload; isLoading: boole
);
}
function ContentWidget() {
const q = useQuery({
queryKey: ["content-sources"],
queryFn: () => api.contentSources(),
staleTime: 30000,
});
const sources = q.data?.sources;
if (q.isLoading || q.error || !sources?.length) return null;
const items = sources.slice(0, 4);
return (
<Card
title="最新知識庫內容"
ico={<AppIcon name="book" size={24} framed variant="nav" />}
className="mt"
>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{items.map((item: any) => (
<Link
key={item.id}
to={item.kind === "youtube" ? `/library/note/${item.id}` : `/library/note/${item.id}`}
className="lc-item"
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span className={`tag tag-${item.kind}`}>{item.kind}</span>
<span style={{ flex: 1 }}>{item.title || item.url}</span>
<span className="small muted">{new Date(item.created_at).toLocaleDateString()}</span>
</Link>
))}
<Link to="/content" className="small" style={{ textAlign: "right", marginTop: 4 }}>
</Link>
</div>
</Card>
);
}
function CalendarBlock({ q }: { q: { data?: CalendarPayload; isLoading: boolean; error: unknown } }) {
if (q.isLoading) return <Loading />;
if (q.error || !q.data) return <ErrorState detail={(q.error as Error)?.message} />;

279
src/pages/Library.tsx Normal file
View File

@ -0,0 +1,279 @@
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { api } from "../lib/api";
import type { KnowledgePayload, KnowledgeNote, KnowledgePrinciple } from "../lib/api";
import { RouteIcon } from "../components/PixelIcons";
const TYPE_LABELS: Record<string, string> = {
overview: "總覽",
principleMap: "心法地圖",
quiz: "練習題庫",
category: "學習分類",
case: "案例講解",
term: "名詞",
company: "公司",
episode: "單集",
principle: "心法原則",
};
const TYPE_ICONS: Record<string, string> = {
category: "book",
case: "sword",
term: "flow",
company: "castle",
episode: "world",
principle: "scroll",
};
function highlightText(text: string, query: string) {
if (!query.trim()) return text;
const parts = text.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'));
return parts.map((p, i) =>
p.toLowerCase() === query.toLowerCase() ? <mark key={i} className="hl">{p}</mark> : p
);
}
function PrincipleCard({ p, query }: { p: KnowledgePrinciple; query: string }) {
return (
<Link to={`/library/principle/${encodeURIComponent(p.id)}`} className="lc-card">
<div className="lc-card-icon">
<RouteIcon to="/skills" size={20} />
</div>
<div className="lc-card-body">
<div className="lc-card-title">{highlightText(p.title, query)}</div>
{p.summary && <div className="lc-card-summary">{highlightText(p.summary.slice(0, 120), query)}</div>}
<div className="lc-card-meta">
<span className="tag tag-principle"></span>
{p.num && <span className="tag">#{p.num}</span>}
</div>
</div>
</Link>
);
}
function NoteCard({ note, kind, query }: { note: KnowledgeNote; kind: string; query: string }) {
const icon = TYPE_ICONS[kind] || "book";
return (
<Link to={`/library/${kind}/${encodeURIComponent(note.id)}`} className="lc-card">
<div className="lc-card-icon">
<RouteIcon to={kind === "case" ? "/skills" : kind === "company" ? "/research" : "/"} size={20} />
</div>
<div className="lc-card-body">
<div className="lc-card-title">{highlightText(note.title, query)}</div>
{note.summary && <div className="lc-card-summary">{highlightText(note.summary.slice(0, 120), query)}</div>}
<div className="lc-card-meta">
<span className={`tag tag-${kind}`}>{TYPE_LABELS[kind] || kind}</span>
</div>
</div>
</Link>
);
}
function IndexItemRow({ item, query }: { item: NonNullable<KnowledgePayload["index"]>[number]; query: string }) {
const kind = item.kind?.split(":")[0] || "term";
const link = kind === "company" ? `/library/company/${item.id}`
: kind === "episode" ? `/library/episode/${item.id}`
: `/library/term/${item.id}`;
return (
<Link to={link} className="lc-index-row">
<span className={`tag tag-${kind}`}>{TYPE_LABELS[kind] || kind}</span>
<span>{highlightText(item.title, query)}</span>
{item.aliases && item.aliases.length > 0 && (
<span className="lc-aliases">{item.aliases.join(", ")}</span>
)}
</Link>
);
}
export default function Library() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get("q") || "";
const typeFilter = searchParams.get("type") || "";
const [showFilters, setShowFilters] = useState(false);
const kbQ = useQuery({ queryKey: ["knowledge"], queryFn: api.knowledge, staleTime: 60_000 });
const data = kbQ.data;
const filtered = useMemo(() => {
if (!data) return null;
const q = query.toLowerCase().trim();
const matchText = (text: string) => !q || text.toLowerCase().includes(q);
const matchType = (t: string) => !typeFilter || t === typeFilter;
const out: {
principles: KnowledgePrinciple[];
categories: KnowledgeNote[];
cases: KnowledgeNote[];
terms: { id: string; title: string; body?: string }[];
companies: { id: string; title: string; body?: string }[];
episodes: { id: string; title: string; body?: string }[];
index: NonNullable<KnowledgePayload["index"]>;
} = { principles: [], categories: [], cases: [], terms: [], companies: [], episodes: [], index: [] };
if (matchType("principle") || !typeFilter) {
out.principles = (data.principles || []).filter(p => matchText(p.title) || matchText(p.summary || "") || matchText(p.body));
}
if (matchType("category") || !typeFilter) {
out.categories = (data.categories || []).filter(c => matchText(c.title) || matchText(c.summary || ""));
}
if (matchType("case") || !typeFilter) {
out.cases = (data.cases || []).filter(c => matchText(c.title) || matchText(c.summary || ""));
}
if (data.index) {
out.index = data.index.filter(item => {
const kind = item.kind?.split(":")[0] || "term";
return matchType(kind) && (matchText(item.title) || (item.aliases || []).some(a => a.toLowerCase().includes(q)));
});
}
return out;
}, [data, query, typeFilter]);
if (kbQ.isLoading) return <div className="page"><div className="loading">...</div></div>;
if (kbQ.error) return <div className="page"><div className="error"></div></div>;
const hasData = data && (data.principles?.length || data.categories?.length || data.cases?.length || data.index?.length);
return (
<div className="page library-page">
<div className="page-head">
<h1></h1>
<p className="page-subtitle"></p>
</div>
<div className="lc-toolbar">
<input
type="text"
className="lc-search"
placeholder="搜尋知識庫..."
value={query}
onChange={e => {
const p = new URLSearchParams(searchParams);
if (e.target.value) p.set("q", e.target.value); else p.delete("q");
setSearchParams(p);
}}
/>
<button className="btn btn-sm" onClick={() => setShowFilters(!showFilters)}>
{typeFilter ? `${TYPE_LABELS[typeFilter] || typeFilter}` : "過濾器 ▾"}
</button>
</div>
{showFilters && (
<div className="lc-filters">
{["", "principle", "category", "case", "term", "company", "episode"].map(t => (
<button
key={t}
className={`btn btn-sm ${typeFilter === t ? "btn-active" : ""}`}
onClick={() => {
const p = new URLSearchParams(searchParams);
if (t) p.set("type", t); else p.delete("type");
setSearchParams(p);
}}
>
{TYPE_LABELS[t] || "全部"}
</button>
))}
</div>
)}
{data?.overview && !query && !typeFilter && (
<section className="lc-section">
<h2 className="lc-section-title">
<RouteIcon to="/" size={18} />
</h2>
<div className="lc-overview-preview">
<div className="markdown-body">{data.overview.summary || data.overview.body?.slice(0, 300)}...</div>
<Link to={`/library/overview`} className="btn btn-sm"> </Link>
</div>
</section>
)}
{!hasData && (
<div className="lc-empty">
<div className="lc-empty-icon">📚</div>
<p></p>
<p className="text-dim"> <code>npm run build:knowledge</code> YouTube/HyRead </p>
</div>
)}
{filtered && (
<>
{filtered.principles.length > 0 && (
<section className="lc-section">
<h2 className="lc-section-title">
<RouteIcon to="/skills" size={18} /> ({filtered.principles.length})
</h2>
<div className="lc-grid">
{filtered.principles.map(p => <PrincipleCard key={p.id} p={p} query={query} />)}
</div>
</section>
)}
{filtered.categories.length > 0 && (
<section className="lc-section">
<h2 className="lc-section-title">
<RouteIcon to="/" size={18} /> ({filtered.categories.length})
</h2>
<div className="lc-grid">
{filtered.categories.map(c => <NoteCard key={c.id} note={c} kind="category" query={query} />)}
</div>
</section>
)}
{filtered.cases.length > 0 && (
<section className="lc-section">
<h2 className="lc-section-title">
<RouteIcon to="/skills" size={18} /> ({filtered.cases.length})
</h2>
<div className="lc-grid">
{filtered.cases.map(c => <NoteCard key={c.id} note={c} kind="case" query={query} />)}
</div>
</section>
)}
{filtered.index.length > 0 && (
<section className="lc-section">
<h2 className="lc-section-title">
<RouteIcon to="/" size={18} /> · · ({filtered.index.length})
</h2>
<div className="lc-index-list">
{filtered.index.slice(0, 100).map((item, i) => (
<IndexItemRow key={`${item.kind}:${item.id}:${i}`} item={item} query={query} />
))}
{filtered.index.length > 100 && (
<div className="text-dim">... {filtered.index.length - 100} </div>
)}
</div>
</section>
)}
{!filtered.principles.length && !filtered.categories.length && !filtered.cases.length && !filtered.index.length && query && (
<div className="lc-empty">
<p>{query}</p>
</div>
)}
</>
)}
{data?.counts && (
<section className="lc-section lc-stats">
<h2 className="lc-section-title"></h2>
<div className="lc-stat-grid">
{Object.entries(data.counts).map(([k, v]) => (
<div key={k} className="lc-stat-card">
<div className="lc-stat-num">{v}</div>
<div className="lc-stat-label">{TYPE_LABELS[k] || k}</div>
</div>
))}
</div>
</section>
)}
</div>
);
}

114
src/pages/LibraryDetail.tsx Normal file
View File

@ -0,0 +1,114 @@
import { useQuery } from "@tanstack/react-query";
import { useParams, Link } from "react-router-dom";
import { api } from "../lib/api";
const TYPE_LABELS: Record<string, string> = {
overview: "總覽",
principleMap: "心法地圖",
quiz: "練習題庫",
category: "學習分類",
case: "案例講解",
term: "名詞",
company: "公司",
episode: "單集",
principle: "心法原則",
};
export default function LibraryDetail() {
const { kind, id } = useParams<{ kind: string; id: string }>();
const kbQ = useQuery({ queryKey: ["knowledge"], queryFn: api.knowledge, staleTime: 60_000 });
const noteQ = useQuery({
queryKey: ["note", kind, id],
queryFn: () => api.knowledgeNote(kind || "", id || ""),
enabled: !!kind && !!id && kind !== "principle",
});
if (kbQ.isLoading) return <div className="page"><div className="loading">...</div></div>;
const data = kbQ.data;
let title = id || "";
let body = "";
let extraMeta: Record<string, any> | null = null;
if (kind === "principle") {
const p = data?.principles?.find(p => p.id === id || encodeURIComponent(p.id) === id);
if (p) {
title = p.title;
body = p.body;
extraMeta = { num: p.num, summary: p.summary };
}
} else if (kind === "overview" && data?.overview) {
title = data.overview.title;
body = data.overview.body;
} else if (kind === "principleMap" && data?.principleMap) {
title = data.principleMap.title;
body = data.principleMap.body;
} else if (kind === "quiz" && data?.quiz) {
title = data.quiz.title;
body = data.quiz.body;
} else if (kind === "category") {
const c = data?.categories?.find(c => c.id === id || encodeURIComponent(c.id) === id);
if (c) { title = c.title; body = c.body; extraMeta = c.frontmatter || null; }
} else if (kind === "case") {
const c = data?.cases?.find(c => c.id === id || encodeURIComponent(c.id) === id);
if (c) { title = c.title; body = c.body; extraMeta = c.frontmatter || null; }
} else if (kind === "term" || kind === "company" || kind === "episode") {
if (noteQ.data) {
title = noteQ.data.title;
body = noteQ.data.body;
extraMeta = noteQ.data.frontmatter || null;
} else if (data?.index) {
const item = data.index.find(i => i.id === id && i.kind?.startsWith(kind || ""));
if (item) title = item.title;
if (!body) body = `# ${title}\n\n尚未擷取全文請執行 npm run build:knowledge 更新)`;
}
}
return (
<div className="page library-detail-page">
<div className="page-head">
<Link to="/library" className="btn btn-sm"> </Link>
<h1>{title}</h1>
<p className="page-subtitle">{TYPE_LABELS[kind || ""] || kind}</p>
{extraMeta && (
<div className="ld-meta">
{Object.entries(extraMeta).map(([k, v]) =>
v != null && <span key={k} className="tag">{k}: {String(v).slice(0, 60)}</span>
)}
</div>
)}
</div>
<div className="ld-body markdown-body">
{body ? (
<RenderMarkdown text={body} />
) : (
<div className="text-dim"></div>
)}
</div>
</div>
);
}
function RenderMarkdown({ text }: { text: string }) {
const lines = text.split("\n");
const html = lines
.map(line => {
if (line.startsWith("### ")) return `<h3>${escapeHtml(line.slice(4))}</h3>`;
if (line.startsWith("## ")) return `<h2>${escapeHtml(line.slice(3))}</h2>`;
if (line.startsWith("# ")) return `<h1>${escapeHtml(line.slice(2))}</h1>`;
if (line.startsWith("> ")) return `<blockquote>${escapeHtml(line.slice(2))}</blockquote>`;
if (line.startsWith("- ") || line.startsWith("* ")) return `<li>${escapeHtml(line.slice(2))}</li>`;
if (line.trim() === "") return "<br/>";
if (line.match(/^\d+\.\s/)) return `<li>${escapeHtml(line.replace(/^\d+\.\s/, ""))}</li>`;
return `<p>${escapeHtml(line)}</p>`;
})
.join("\n");
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
function escapeHtml(s: string) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AttributeRadar } from "../components/AttributeRadar";
import { AppIcon } from "../components/PixelIcons";
import { Card, ErrorState, Loading, Tag } from "../components/ui";
@ -502,6 +502,39 @@ export default function Profile() {
{resetBusy ? "重置中…" : "清空角色養成"}
</button>
</Card>
<Card title="內容統計" ico={<AppIcon name="folder" size={22} framed variant="nav" />} className="mt">
<ContentReadingStats />
</Card>
<Card title="重置角色" ico={<AppIcon name="hourglass" size={22} framed variant="nav" />} className="mt">
<p className="small muted" style={{ marginTop: 0 }}>
Lv.1
</p>
{resetMsg ? <p className="small" style={{ color: "var(--teal)" }}>{resetMsg}</p> : null}
<button type="button" className="btn-ghost sm" disabled={resetBusy} onClick={() => void resetCharacter()}>
{resetBusy ? "重置中…" : "清空角色養成"}
</button>
</Card>
</>
);
}
function ContentReadingStats() {
const q = useQuery({
queryKey: ["content-stats"],
queryFn: () => api.contentStats(),
staleTime: 30000,
});
if (!q.data) return null;
const stats = q.data;
const total = stats.total || 0;
if (!total) return <p className="muted small"> YouTube HyRead </p>;
return (
<div className="dl">
<div className="row"><span className="k">YouTube </span><span className="v">{stats.byKind?.youtube?.ready || 0} </span></div>
<div className="row"><span className="k">HyRead </span><span className="v">{stats.byKind?.hyread?.ready || 0} </span></div>
<div className="row"><span className="k"></span><span className="v">{total} </span></div>
</div>
);
}

View File

@ -26,6 +26,21 @@ import {
type SkillTreeNode,
} from "../lib/skillTree";
const SOURCE_TYPES = [
{ value: "", label: "全部來源" },
{ value: "youtube", label: "YouTube" },
{ value: "hyread", label: "HyRead" },
{ value: "system", label: "系統" },
];
const MASTERY_FILTERS = [
{ value: "", label: "全部狀態" },
{ value: "mastered", label: "已精通" },
{ value: "drilled", label: "已試煉" },
{ value: "read", label: "已閱讀" },
{ value: "unread", label: "未閱讀" },
];
export default function Skills() {
const qc = useQueryClient();
const kbQ = useQuery({ queryKey: ["knowledge"], queryFn: api.knowledge, staleTime: 3600_000 });
@ -53,6 +68,8 @@ export default function Skills() {
const [openTab, setOpenTab] = useState<"detail" | "drill" | "records" | "coach" | "notes">("detail");
const [notesRevision, setNotesRevision] = useState(0);
const [query, setQuery] = useState("");
const [sourceFilter, setSourceFilter] = useState("");
const [masteryFilter, setMasteryFilter] = useState("");
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const principles = kbQ.data?.principles || [];
@ -60,7 +77,41 @@ export default function Skills() {
() => buildSkillTree(principles, kbQ.data?.principleMap?.body),
[principles, kbQ.data?.principleMap?.body],
);
const filtered = useMemo(() => filterSkillTree(groups, query), [groups, query]);
const filtered = useMemo(() => {
let result = filterSkillTree(groups, query);
if (sourceFilter) {
result = result.map(g => ({
...g,
nodes: g.nodes.filter(n => {
const body = n.principle.body || "";
const sourceMatch = body.match(/> 來源:(\w+)/);
return sourceMatch?.[1] === sourceFilter;
}),
})).filter(g => g.nodes.length > 0);
}
if (masteryFilter) {
result = result.map(g => ({
...g,
nodes: g.nodes.filter(n => {
const p = progress[n.principle.id];
const mastered = p?.mastered;
const hasDrill = (p?.drillAttempts || 0) > 0;
const hasRead = (p?.reads || 0) > 0;
switch (masteryFilter) {
case "mastered": return mastered;
case "drilled": return !mastered && hasDrill;
case "read": return !mastered && !hasDrill && hasRead;
case "unread": return !hasRead;
default: return true;
}
}),
})).filter(g => g.nodes.length > 0);
}
return result;
}, [groups, query, sourceFilter, masteryFilter, progress]);
const stats = useMemo(() => countProgress(principles, progress), [principles, progress]);
const achievement = nextAchievement(stats.read, stats.mastered, stats.total);
const nodeById = useMemo(() => {
@ -187,8 +238,14 @@ export default function Skills() {
onChange={(e) => setQuery(e.target.value)}
spellCheck={false}
/>
{query ? (
<button type="button" className="btn-ghost sm" onClick={() => setQuery("")}>
<select className="skill-filter-select" value={sourceFilter} onChange={e => setSourceFilter(e.target.value)}>
{SOURCE_TYPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
<select className="skill-filter-select" value={masteryFilter} onChange={e => setMasteryFilter(e.target.value)}>
{MASTERY_FILTERS.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
{(query || sourceFilter || masteryFilter) ? (
<button type="button" className="btn-ghost sm" onClick={() => { setQuery(""); setSourceFilter(""); setMasteryFilter(""); }}>
</button>
) : null}

View File

@ -3392,6 +3392,343 @@ table.tbl tr:hover td { background: rgba(231,198,107,.05); }
.trader-follow-scale { margin-top: 6px; padding: 6px 8px; border-radius: 6px; background: color-mix(in srgb, var(--gold) 10%, var(--panel)); border: 1px dashed color-mix(in srgb, var(--gold) 35%, var(--line)); }
/* AI 交易員控制台:先摘要與操作,再展開完整資料 */
/* ─── HD-2D 風格:多分析師裝備介面 ─── */
.multi-agent-table { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
.multi-agent-row {
position: relative;
padding: 12px 12px 12px 34px;
background:
radial-gradient(circle at 12px 14px, color-mix(in srgb, var(--teal) 18%, transparent), transparent 18px),
color-mix(in srgb, var(--panel) 94%, var(--sky) 4%);
border: 1px solid color-mix(in srgb, var(--line) 72%, var(--teal) 18%);
border-radius: 10px;
box-shadow:
inset 0 1px 0 rgba(255,255,255,.035),
0 0 12px rgba(111,224,208,.035);
}
.multi-agent-row::before {
content: "";
position: absolute; left: 13px; top: 17px;
width: 7px; height: 7px;
border-radius: 50%;
background: color-mix(in srgb, var(--teal) 80%, var(--gold));
box-shadow: 0 0 10px rgba(111,224,208,.35);
}
.multi-agent-row .multi-agent-label {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--pixel);
font-size: 12px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--gold);
margin-bottom: 8px;
text-shadow: 0 0 8px rgba(231,198,107,.2);
}
.multi-agent-row .grid.g3 { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.multi-agent-row .settings-field { margin: 0; }
.multi-agent-row .settings-field label {
font-family: var(--pixel);
font-size: 9px; letter-spacing: 1px;
color: var(--ink-soft);
}
.multi-agent-row select {
background: color-mix(in srgb, var(--panel) 88%, #0d1224);
border-color: color-mix(in srgb, var(--line) 78%, var(--teal) 16%);
color: var(--ink);
}
.watchlist-group-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 8px;
padding: 8px;
border: 1px solid color-mix(in srgb, var(--line) 70%, var(--teal) 20%);
border-radius: 10px;
background:
radial-gradient(circle at 18px 16px, color-mix(in srgb, var(--teal) 12%, transparent), transparent 24px),
color-mix(in srgb, var(--panel) 92%, var(--sky) 4%);
}
.watchlist-group-option {
position: relative;
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 8px;
align-items: start;
min-width: 0;
padding: 9px 10px;
border: 1px solid color-mix(in srgb, var(--line-soft) 85%, var(--teal) 14%);
border-radius: 8px;
background: rgba(0,0,0,.09);
transition: border-color .12s, background .12s, box-shadow .12s;
}
.watchlist-group-option:has(input:checked) {
border-color: color-mix(in srgb, var(--teal) 40%, var(--line));
background: color-mix(in srgb, var(--teal) 8%, rgba(0,0,0,.08));
box-shadow: inset 0 0 0 1px rgba(111,224,208,.045), 0 0 12px rgba(111,224,208,.055);
}
.watchlist-group-option input { margin-top: 2px; }
.watchlist-group-option span { color: var(--ink); font-weight: 700; min-width: 0; overflow-wrap: anywhere; }
.watchlist-group-option small { grid-column: 2; color: var(--ink-soft); line-height: 1.35; overflow-wrap: anywhere; }
.agent-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 20px;
flex: 0 0 auto;
font-family: var(--pixel);
font-size: 9px;
line-height: 1;
color: var(--gold-soft);
background: color-mix(in srgb, var(--gold) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--gold) 30%, transparent);
border-radius: 3px;
box-shadow: inset 0 1px 0 rgba(255,255,255,.04), 0 0 8px rgba(231,198,107,.12);
text-shadow: 0 0 5px rgba(231,198,107,.2);
}
@media (max-width: 640px) { .multi-agent-row .grid.g3 { grid-template-columns: 1fr; } }
/* ─── HD-2D 風格:性格角色面板 ─── */
.personality-dashboard-grid {
display: flex; flex-direction: column; gap: 10px;
}
/* 角色名牌區 */
.personality-dash-section {
position: relative;
padding: 14px;
background: linear-gradient(160deg, color-mix(in srgb, var(--gold) 5%, var(--paper)), var(--panel) 80%);
border: 1px solid color-mix(in srgb, var(--gold) 20%, var(--line));
border-radius: var(--r);
box-shadow:
0 0 0 1px rgba(231,198,107,.06),
0 2px 12px rgba(0,0,0,.3),
inset 0 1px 0 rgba(255,255,255,.03);
}
.personality-dash-section::before {
content: "";
position: absolute;
top: -7px; left: 12px;
width: 8px; height: 8px;
background: color-mix(in srgb, var(--gold) 35%, transparent);
box-shadow: 0 0 6px rgba(231,198,107,.2);
}
.personality-dash-section::after {
content: "";
position: absolute;
top: -7px; right: 12px;
width: 8px; height: 8px;
background: color-mix(in srgb, var(--gold) 35%, transparent);
box-shadow: 0 0 6px rgba(231,198,107,.2);
}
/* 角色名 */
.personality-dash-head {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 10px;
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px solid color-mix(in srgb, var(--gold) 15%, var(--line));
}
.personality-dash-head strong {
font-family: var(--pixel);
font-size: 16px;
color: var(--gold-soft);
text-shadow: 0 0 12px rgba(231,198,107,.25), 0 1px 0 rgba(0,0,0,.5);
letter-spacing: 1px;
}
.personality-dash-head .small.muted {
font-family: var(--pixel);
font-size: 10px;
letter-spacing: 1px;
}
.personality-dash-section > .small.muted:first-child {
display: block;
margin-bottom: 8px;
font-family: var(--pixel);
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--ink-soft);
text-shadow: 0 0 6px rgba(255,255,255,.05);
}
/* 行為描述-像 RPG 角色背景 */
.personality-dash-section > p.small.muted:nth-child(2) {
font-style: italic;
color: color-mix(in srgb, var(--ink-soft) 80%, var(--gold-soft));
line-height: 1.5;
padding: 4px 0;
}
/* 職業標籤列 */
.personality-dash-head .trader-risk-pill {
font-family: var(--pixel);
font-size: 9px;
letter-spacing: 1px;
padding: 2px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--gold) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--gold) 25%, transparent);
color: var(--gold-soft);
}
/* HP 式風險條 */
.personality-hp-bar {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 1;
height: 10px;
}
.personality-hp-fill {
display: block;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--emerald), var(--gold) 50%, var(--crimson));
box-shadow: 0 0 6px rgba(231,198,107,.2);
transition: width .3s ease;
}
.personality-dash-section > p.small[data-pct] {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--pixel);
font-size: 10px;
color: var(--muted);
margin-bottom: 8px;
padding: 4px 0;
}
.personality-dash-section > p.small[data-pct]::before {
content: "LV";
font-family: var(--pixel);
font-size: 9px;
color: var(--gold-soft);
text-shadow: 0 0 6px rgba(231,198,107,.3);
opacity: .8;
}
/* 資產親和 ─ 元素石 */
.personality-assets {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
margin-top: 6px; padding-top: 8px;
border-top: 1px solid color-mix(in srgb, var(--gold) 10%, var(--line));
}
.personality-assets > .small.muted {
font-family: var(--pixel);
font-size: 9px;
letter-spacing: 1px;
color: var(--ink-soft);
margin-right: 4px;
}
.personality-assets .trader-personality-asset-chip {
font-family: var(--pixel);
font-size: 10px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--gold) 20%, transparent);
background: color-mix(in srgb, var(--gold) 8%, transparent);
letter-spacing: .5px;
color: var(--gold-soft);
text-shadow: 0 0 6px rgba(231,198,107,.15);
}
.personality-assets .trader-personality-asset-chip.w-primary {
border-color: color-mix(in srgb, var(--gold) 40%, transparent);
background: color-mix(in srgb, var(--gold) 14%, transparent);
}
.personality-assets .trader-personality-asset-chip em {
font-style: normal;
margin-right: 3px;
opacity: .7;
}
/* ─── 技能(交易規則)面板 ─── */
.personality-rule-group {
margin-top: 8px;
padding: 6px 8px 6px 10px;
border-left: 2px solid color-mix(in srgb, var(--gold) 20%, transparent);
background: color-mix(in srgb, var(--ink) 5%, transparent);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.personality-rule-group .personality-rule-label {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--pixel);
font-size: 10px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--gold-soft);
margin-bottom: 2px;
}
.personality-rule-group .personality-rule-label::before {
content: "";
width: 7px;
height: 7px;
border-radius: 1px;
background: var(--gold);
opacity: .55;
box-shadow: 0 0 5px rgba(231,198,107,.2);
}
.personality-rule-group[data-cat="exit"] .personality-rule-label::before { background: var(--sky); }
.personality-rule-group[data-cat="sizing"] .personality-rule-label::before { background: var(--emerald); }
.personality-rule-group[data-cat="forbidden"] .personality-rule-label::before { background: var(--crimson); }
.personality-rule-group ul {
margin: 2px 0 2px 14px;
padding: 0;
list-style: none;
font-size: 12px;
line-height: 1.5;
}
.personality-rule-group ul li {
position: relative;
padding-left: 14px;
margin-bottom: 1px;
color: color-mix(in srgb, var(--ink-soft) 85%, var(--gold-soft));
}
.personality-rule-group ul li::before {
content: "·";
position: absolute; left: 2px;
color: var(--gold);
opacity: .5;
}
/* ─── 分析師小隊party 裝備) ─── */
.personality-agent-models {
display: flex; flex-direction: column; gap: 3px;
margin-top: 4px;
}
.personality-agent-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
font-family: var(--pixel);
font-size: 11px;
padding: 5px 8px;
border-radius: 4px;
background: color-mix(in srgb, var(--ink) 8%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--gold) 8%, transparent);
letter-spacing: .5px;
}
.personality-agent-row:last-child { border-bottom: none; }
.personality-agent-row strong {
color: var(--gold-soft);
text-shadow: 0 0 6px rgba(231,198,107,.15);
}
.personality-agent-row .muted {
color: var(--ink-soft);
font-size: 10px;
}
.personality-agent-row span:first-child {
display: flex;
align-items: center;
gap: 6px;
}
.ai-command-card { border-color: color-mix(in srgb, var(--teal) 42%, var(--line)); }
.ai-command-grid {
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px;
@ -3459,15 +3796,35 @@ table.tbl tr:hover td { background: rgba(231,198,107,.05); }
.trader-effective-clauses { margin-top: 8px; padding: 8px 9px; border-left: 2px solid var(--rar-epic); background: rgba(201,139,255,.035); }
.trader-effective-clauses > span { font-family: var(--pixel); font-size: 8px; color: var(--rar-epic); }
.trader-effective-clauses ul { margin: 6px 0 0; padding-left: 17px; font-size: 10px; line-height: 1.5; color: var(--ink-soft); }
.trader-universe-preview {
margin-top: 12px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--line) 72%, var(--teal) 18%);
background:
radial-gradient(circle at 18px 18px, color-mix(in srgb, var(--teal) 13%, transparent), transparent 30px),
color-mix(in srgb, var(--panel) 94%, var(--sky) 4%);
box-shadow: inset 0 1px 0 rgba(255,255,255,.025), 0 0 16px rgba(111,224,208,.035);
}
.trader-universe-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; margin-bottom: 8px; }
.trader-universe-head span:first-child { display: block; font-family: var(--pixel); font-size: 9px; letter-spacing: 1.5px; color: var(--ink-soft); text-transform: uppercase; }
.trader-universe-head strong { display: block; margin-top: 2px; color: var(--gold-soft); }
.trader-universe-groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.trader-universe-group { min-width: 0; padding: 8px 9px; border: 1px solid var(--line-soft); border-radius: 7px; background: rgba(0,0,0,.10); }
.trader-universe-group > span { display: block; margin-bottom: 5px; font-family: var(--pixel); font-size: 9px; letter-spacing: 1px; color: var(--ink-soft); }
.trader-universe-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.trader-universe-chips strong { font-size: 11px; padding: 2px 7px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--teal) 22%, var(--line-soft)); background: color-mix(in srgb, var(--teal) 7%, transparent); color: var(--ink); }
.trader-universe-chips em { font-size: 11px; color: var(--muted); font-style: normal; }
@media (max-width: 760px) { .trader-universe-groups { grid-template-columns: 1fr; } }
.ai-command-actions {
display: flex; align-items: center; justify-content: space-between; gap: 14px;
display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap;
margin-top: 12px; padding: 10px 12px; border: 1px solid var(--line);
border-radius: var(--r-sm); background: rgba(231,198,107,.05);
}
.ai-command-actions > div:first-child { min-width: 170px; }
.ai-command-actions > div:first-child { min-width: min(220px, 100%); flex: 1 1 220px; }
.ai-command-actions strong { display: block; font-family: var(--pixel); font-size: 11px; color: var(--gold-soft); }
.ai-command-actions span { display: block; margin-top: 2px; font-size: 11px; color: var(--ink-soft); }
.ai-command-actions .journal-toolbar { justify-content: flex-end; }
.ai-command-actions .journal-toolbar { justify-content: flex-end; flex: 2 1 360px; margin-bottom: 0; }
.ai-command-strategy {
margin-top: 12px; padding: 12px 14px; border-left: 3px solid var(--teal);
background: rgba(111,224,208,.06);
@ -4002,3 +4359,87 @@ table.tbl tr:hover td { background: rgba(231,198,107,.05); }
.flow-summary, .holder-breakdown, .tech-grid { grid-template-columns: 1fr; }
.candle-chart { height: 260px; }
}
/* ─── 知識圖書館 ─── */
.library-page .page-head { margin-bottom: 12px; }
.lc-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.lc-search { flex: 1; padding: 10px 14px; border-radius: var(--r-sm); border: 2px solid var(--line); background: var(--surface); color: var(--ink); font: inherit; font-size: 14px; }
.lc-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.lc-section { margin-bottom: 20px; }
.lc-section-title { display: flex; align-items: center; gap: 6px; font-family: var(--pixel); font-size: 13px; color: var(--gold-soft); margin: 0 0 10px; text-transform: uppercase; letter-spacing: .5px; }
.lc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
.lc-card { display: flex; gap: 10px; padding: 12px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); text-decoration: none; color: var(--ink); transition: border-color .15s, background .15s; }
.lc-card:hover { border-color: var(--teal); background: rgba(111, 224, 208, .06); }
.lc-card-icon { flex-shrink: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.2); border-radius: 6px; }
.lc-card-body { flex: 1; min-width: 0; }
.lc-card-title { font-size: 14px; font-weight: 600; color: #dce4fa; margin-bottom: 4px; }
.lc-card-summary { font-size: 12px; color: var(--ink-soft); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.lc-card-meta { display: flex; gap: 6px; margin-top: 4px; }
.lc-card-meta .tag { font-size: 10px; }
.lc-overview-preview { padding: 12px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); margin-bottom: 8px; }
.lc-index-list { display: flex; flex-direction: column; gap: 4px; }
.lc-index-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--surface); border: 1px solid var(--line); border-radius: 4px; text-decoration: none; color: var(--ink); font-size: 13px; }
.lc-index-row:hover { border-color: var(--teal); }
.lc-index-row .tag { flex-shrink: 0; font-size: 10px; }
.lc-aliases { color: var(--ink-soft); font-size: 11px; }
.lc-empty { text-align: center; padding: 40px 20px; color: var(--ink-soft); }
.lc-empty-icon { font-size: 40px; margin-bottom: 8px; }
.lc-stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; }
.lc-stat-card { padding: 10px; text-align: center; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); }
.lc-stat-num { font-size: 24px; font-weight: 700; color: var(--gold-soft); font-family: var(--pixel); }
.lc-stat-label { font-size: 11px; color: var(--ink-soft); margin-top: 2px; }
.lc-stats { margin-top: 24px; }
.library-detail-page .ld-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.library-detail-page .ld-body { padding: 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); line-height: 1.7; }
.markdown-body h1 { font-size: 20px; color: var(--gold-soft); margin: 0 0 12px; }
.markdown-body h2 { font-size: 16px; color: var(--gold-soft); margin: 16px 0 8px; }
.markdown-body h3 { font-size: 14px; color: var(--teal); margin: 12px 0 6px; }
.markdown-body p { margin: 0 0 8px; }
.markdown-body blockquote { border-left: 3px solid var(--teal); padding-left: 12px; margin: 8px 0; color: var(--ink-soft); }
.markdown-body li { margin: 2px 0 2px 16px; }
/* ─── 內容管理 ─── */
.content-manager-page { }
.cm-tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
.cm-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.cm-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 16px; margin-bottom: 12px; }
.cm-card h3 { font-family: var(--pixel); font-size: 12px; color: var(--gold-soft); margin: 0 0 10px; text-transform: uppercase; letter-spacing: .5px; }
.cm-input-row { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.cm-input { flex: 1; min-width: 160px; padding: 8px 12px; border-radius: var(--r-sm); border: 2px solid var(--line); background: var(--bg); color: var(--ink); font: inherit; font-size: 13px; }
.cm-input-row select { padding: 8px; border-radius: var(--r-sm); border: 2px solid var(--line); background: var(--bg); color: var(--ink); font: inherit; font-size: 13px; }
.cm-status { margin: 4px 0; font-size: 12px; padding: 4px 8px; border-radius: 4px; }
.cm-status.ok { background: rgba(111, 207, 151, .12); color: #6fcf97; }
.cm-status.error { background: rgba(232, 106, 82, .12); color: #e86a52; }
.cm-hint { font-size: 11px; color: var(--ink-soft); margin-top: 4px; }
.cm-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.cm-stat-chip { padding: 4px 10px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; font-size: 11px; color: var(--ink-soft); }
.cs-list { display: flex; flex-direction: column; gap: 6px; }
.cs-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); gap: 8px; }
.cs-youtube { border-left: 3px solid #e86a52; }
.cs-hyread { border-left: 3px solid #8fb8ff; }
.cs-info { flex: 1; min-width: 0; }
.cs-title { font-size: 14px; font-weight: 600; color: #dce4fa; }
.cs-meta { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; font-size: 11px; color: var(--ink-soft); }
.cs-meta .tag { font-size: 10px; }
.cs-actions { display: flex; gap: 6px; flex-shrink: 0; }
.tag-youtube { background: rgba(232, 106, 82, .2); color: #e86a52; }
.tag-hyread { background: rgba(143, 184, 255, .2); color: #8fb8ff; }
.tag-captured { background: rgba(111, 224, 208, .15); color: #6fe0d0; }
.tag-processing { background: rgba(231, 198, 107, .2); color: #e7c66b; }
.tag-ready { background: rgba(111, 207, 151, .15); color: #6fcf97; }
.tag-error { background: rgba(232, 106, 82, .2); color: #e86a52; }
.tag-principle { background: rgba(231, 198, 107, .15); color: #e7c66b; }
.tag-category { background: rgba(111, 224, 208, .15); color: #6fe0d0; }
.tag-case { background: rgba(143, 184, 255, .15); color: #8fb8ff; }
.tag-term { background: rgba(206, 167, 255, .15); color: #cea7ff; }
.tag-company { background: rgba(111, 207, 151, .15); color: #6fcf97; }
.tag-episode { background: rgba(255, 195, 97, .15); color: #ffc361; }
.hl { background: rgba(231, 198, 107, .25); color: #e7c66b; padding: 0 2px; border-radius: 2px; }
.btn-ghost { background: transparent; border: 1px solid var(--line); color: var(--ink-soft); }
.btn-ghost:hover { border-color: var(--teal); color: var(--teal); }