174 lines
6.5 KiB
TypeScript
174 lines
6.5 KiB
TypeScript
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>
|
||
);
|
||
}
|