finance-tools/src/components/GuideMascot.tsx

174 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}