finance-tools/src/components/GuideMascot.tsx

174 lines
6.5 KiB
TypeScript
Raw Normal View History

2026-06-21 20:28:06 +00:00
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { useAI } from "../context/AIContext";
import { fetchSkillsForRoute, type AISkill } from "../lib/ai-skills";
import { AI_PROVIDER_META, type AIProviderId } from "../lib/ai";
import { api } from "../lib/api";
import { extractAllStrategyBlocks } from "../lib/strategyImport";
import MarkdownMessage from "./MarkdownMessage";
import PixelMascot from "./PixelMascot";
export default function GuideMascot() {
const loc = useLocation();
const ai = useAI();
const qc = useQueryClient();
const [skills, setSkills] = useState<AISkill[]>([]);
const [importing, setImporting] = useState<string | null>(null);
useEffect(() => {
void ai.refreshStatus();
}, []);
useEffect(() => {
if (ai.open) void ai.refreshStatus();
}, [ai.open]);
useEffect(() => {
void fetchSkillsForRoute(loc.pathname, ai.focus || undefined).then(setSkills);
}, [loc.pathname, ai.focus?.cardTitle, ai.focus?.label, ai.open]);
return (
<div className={"guide-dock" + (ai.open ? " open" : "")}>
{ai.open && (
<div className="guide-panel" role="dialog" aria-label="AI 導覽員">
<div className="guide-panel-head">
<PixelMascot size={44} variant="chat" />
<div>
<div className="guide-name"></div>
<div className="guide-ctx small muted">{ai.contextLabel}</div>
</div>
<button type="button" className="guide-close" onClick={ai.closeAI} aria-label="關閉">
×
</button>
</div>
{!ai.status?.ready && (
<div className="guide-setup">
<p> AI <b>Grok</b> <b>OpenCode Go</b> API Key</p>
<Link to="/settings" className="guide-setup-link" onClick={ai.closeAI}>
AI
</Link>
</div>
)}
<div className="guide-toolbar">
<label className="guide-field">
<span>Provider</span>
<select
value={ai.provider}
onChange={(e) => ai.setProvider(e.target.value as AIProviderId)}
>
{(ai.status?.providers || []).map((p) => (
<option key={p.id} value={p.id} disabled={!p.hasKey}>
{AI_PROVIDER_META[p.id as AIProviderId]?.label || p.id}
{!p.hasKey ? "(未設定)" : ""}
</option>
))}
{!ai.status?.providers?.length &&
Object.entries(AI_PROVIDER_META).map(([id, m]) => (
<option key={id} value={id}>
{m.label}
</option>
))}
</select>
</label>
<button type="button" className="guide-clear" onClick={ai.clearChat}>
</button>
</div>
<div className="guide-chat">
{ai.messages.map((msg) => {
const blocks = msg.role === "assistant" ? extractAllStrategyBlocks(msg.text) : [];
return (
<div key={msg.id} className={"guide-msg " + msg.role}>
{msg.role === "assistant" && <PixelMascot size={32} variant="chat" />}
<div className="guide-bubble-wrap">
<div className="guide-bubble">
<MarkdownMessage text={msg.text} />
</div>
{blocks.map((b, i) => (
<button
key={`${msg.id}-bt-${i}`}
type="button"
className="guide-strategy-import"
disabled={importing != null}
onClick={() => {
setImporting(`${msg.id}-${i}`);
void api
.createStrategy({
name: b.name,
engine: b.engine,
params: b.params || {},
description: b.description,
formula: b.formula,
source: b.source || "owl",
tags: b.tags || ["貓頭鷹"],
})
.then(() => qc.invalidateQueries({ queryKey: ["strategies"] }))
.finally(() => setImporting(null));
}}
>
{importing === `${msg.id}-${i}` ? "加入中…" : ` 加入策略庫:${b.name}`}
</button>
))}
{msg.meta && <div className="guide-meta small muted">{msg.meta}</div>}
</div>
</div>
);
})}
</div>
{skills.length > 0 && (
<div className="guide-skills">
<div className="guide-skills-k"></div>
<div className="guide-skill-row">
{skills.map((s) => (
<button
key={s.id}
type="button"
className="guide-skill"
disabled={ai.busy}
onClick={() => void ai.sendMessage(s.prompt, ai.focus, s.id)}
>
{s.label}
</button>
))}
</div>
</div>
)}
<div className="guide-compose">
<textarea
rows={1}
value={ai.draft}
placeholder="問導覽員…Enter 送出)"
onChange={(e) => ai.setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
void ai.sendMessage();
}
}}
/>
<button type="button" className="guide-send" disabled={ai.busy} onClick={() => void ai.sendMessage()}>
</button>
</div>
</div>
)}
<button
type="button"
className="guide-fab"
aria-label="開啟 AI 導覽員"
onClick={() => ai.openAI()}
>
<PixelMascot size={58} blink={!ai.open} variant="fab" />
<span className="guide-fab-label"></span>
</button>
</div>
);
}