mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
534 lines
17 KiB
TypeScript
534 lines
17 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
|
import { Loader2, Send, Settings2, Sparkles, Square } from "lucide-react";
|
|
import {
|
|
Button,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Textarea,
|
|
} from "@termix/plugin-sdk/ui";
|
|
import {
|
|
getAiConversation,
|
|
getAiProviders,
|
|
getAiStatus,
|
|
setAiOptIn,
|
|
type AiProposal,
|
|
type AiProvider,
|
|
} from "./ai-api";
|
|
import { AiMessage } from "./AiMessage";
|
|
import { AiProviderSettings } from "./AiProviderSettings";
|
|
import { AiToolCall } from "./AiToolCall";
|
|
import { ProposalCard } from "./ProposalCard";
|
|
import { useAiStream, type ToolActivity } from "./use-ai-stream";
|
|
import {
|
|
activeMentionQuery,
|
|
useMentions,
|
|
type MentionItem,
|
|
} from "./useMentions";
|
|
import { mentionLabel } from "./labels";
|
|
|
|
interface HistoryEntry {
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
}
|
|
|
|
/** One entry in the rendered conversation, in the order it happened. */
|
|
type TimelineItem =
|
|
| {
|
|
kind: "message";
|
|
key: string;
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
}
|
|
| { kind: "tool"; key: string; tool: ToolActivity }
|
|
| { kind: "proposal"; key: string; proposal: AiProposal };
|
|
|
|
export function AiPanel({ activeTab }: { activeTab?: string | null }) {
|
|
const { t } = useTranslation();
|
|
const { state, send, stop } = useAiStream();
|
|
|
|
const [providers, setProviders] = useState<AiProvider[]>([]);
|
|
const [providerId, setProviderId] = useState<number | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [input, setInput] = useState("");
|
|
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
|
const [resolvedProposals, setResolvedProposals] = useState<
|
|
Record<number, { status: "applied" | "rejected"; resultSummary?: string }>
|
|
>({});
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const [mention, setMention] = useState<{
|
|
query: string;
|
|
start: number;
|
|
} | null>(null);
|
|
const [mentionIndex, setMentionIndex] = useState(0);
|
|
const { search: searchMentions } = useMentions(Boolean(providerId));
|
|
|
|
const mentionMatches = mention ? searchMentions(mention.query) : [];
|
|
|
|
const updateMentionQuery = (value: string, caret: number) => {
|
|
const next = activeMentionQuery(value, caret);
|
|
setMention(next);
|
|
setMentionIndex(0);
|
|
};
|
|
|
|
/**
|
|
* Replaces the "@partial" under the caret with the chosen name. The mention
|
|
* is plain text: the assistant still has to call a read tool to look the
|
|
* item up, so nothing is attached to the prompt behind the user's back.
|
|
*/
|
|
const insertMention = (item: MentionItem) => {
|
|
if (!mention) return;
|
|
const caret = textareaRef.current?.selectionStart ?? input.length;
|
|
const before = input.slice(0, mention.start);
|
|
const after = input.slice(caret);
|
|
const inserted = `@${item.label} `;
|
|
|
|
setInput(`${before}${inserted}${after}`);
|
|
setMention(null);
|
|
|
|
requestAnimationFrame(() => {
|
|
const position = before.length + inserted.length;
|
|
textareaRef.current?.focus();
|
|
textareaRef.current?.setSelectionRange(position, position);
|
|
});
|
|
};
|
|
|
|
const loadProviders = useCallback(async (selectId?: number) => {
|
|
try {
|
|
const list = await getAiProviders();
|
|
setProviders(list);
|
|
setProviderId((current) => {
|
|
// A provider that was just added becomes the active one, so there is
|
|
// no second step between creating it and being able to use it.
|
|
if (selectId && list.some((entry) => entry.id === selectId)) {
|
|
return selectId;
|
|
}
|
|
// Fall back to the first if the current selection has gone away.
|
|
if (current && list.some((entry) => entry.id === current)) {
|
|
return current;
|
|
}
|
|
return list[0]?.id ?? null;
|
|
});
|
|
return list;
|
|
} catch {
|
|
setProviders([]);
|
|
return [];
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const status = await getAiStatus();
|
|
if (cancelled) return;
|
|
|
|
// Opening the panel is consent to use it. Without this every write
|
|
// 403s through the gate, which surfaces as a bare permission error
|
|
// rather than anything the user can act on.
|
|
if (status.globallyEnabled && !status.enabled) {
|
|
await setAiOptIn(true);
|
|
}
|
|
if (!status.globallyEnabled) return;
|
|
|
|
const list = await loadProviders();
|
|
// With nothing configured there is nothing to chat with, so go
|
|
// straight to the form instead of showing a card that just vanishes.
|
|
if (!cancelled && list.length === 0) setShowSettings(true);
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [loadProviders]);
|
|
|
|
useEffect(() => {
|
|
scrollRef.current?.scrollTo({
|
|
top: scrollRef.current.scrollHeight,
|
|
behavior: "smooth",
|
|
});
|
|
}, [state.assistantText, state.tools.length, history.length]);
|
|
|
|
const reloadHistory = useCallback(
|
|
async (conversationId: number | null, reply: string) => {
|
|
// Reload from the server so the transcript is the stored one rather
|
|
// than a client-side reconstruction.
|
|
if (conversationId) {
|
|
try {
|
|
const loaded = await getAiConversation(conversationId);
|
|
setHistory(
|
|
loaded.messages
|
|
.filter((entry) => entry.role !== "tool")
|
|
.map((entry) => ({
|
|
role: entry.role as "user" | "assistant",
|
|
content: entry.content,
|
|
})),
|
|
);
|
|
return;
|
|
} catch {
|
|
// Fall through to keeping the streamed copy.
|
|
}
|
|
}
|
|
|
|
// No stored copy to fall back on, so the streamed reply is appended
|
|
// locally. Without this it would vanish when the next turn resets the
|
|
// stream state.
|
|
if (reply) {
|
|
setHistory((prev) => [...prev, { role: "assistant", content: reply }]);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
async function handleSend() {
|
|
const message = input.trim();
|
|
if (!message || !providerId || state.streaming) return;
|
|
|
|
setInput("");
|
|
setHistory((prev) => [...prev, { role: "user", content: message }]);
|
|
|
|
await send({
|
|
message,
|
|
providerId,
|
|
conversationId: state.conversationId,
|
|
activeTab,
|
|
onComplete: reloadHistory,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Records the outcome on the card and tells the assistant what happened.
|
|
*
|
|
* Without the follow-up the run just stops: the assistant proposed a
|
|
* command, the user approved it, and the model never learns it produced
|
|
* output, so it sits there as if the tool were still running.
|
|
*/
|
|
function handleProposalResolved(
|
|
id: number,
|
|
status: "applied" | "rejected",
|
|
resultSummary?: string,
|
|
) {
|
|
setResolvedProposals((prev) => ({
|
|
...prev,
|
|
[id]: { status, resultSummary },
|
|
}));
|
|
|
|
if (!providerId || state.streaming) return;
|
|
|
|
const proposal = state.proposals.find((entry) => entry.id === id);
|
|
const label = proposal?.summary ?? proposal?.kind ?? `proposal ${id}`;
|
|
|
|
const followUp =
|
|
status === "applied"
|
|
? resultSummary
|
|
? `I approved "${label}". Result:\n\n${resultSummary}`
|
|
: `I approved "${label}". It completed.`
|
|
: `I rejected "${label}". Do not retry it unless I ask.`;
|
|
|
|
setHistory((prev) => [...prev, { role: "user", content: followUp }]);
|
|
|
|
void send({
|
|
message: followUp,
|
|
providerId,
|
|
conversationId: state.conversationId,
|
|
activeTab,
|
|
onComplete: reloadHistory,
|
|
});
|
|
}
|
|
|
|
const proposals: AiProposal[] = state.proposals.map((proposal) => {
|
|
const resolved = resolvedProposals[proposal.id];
|
|
if (!resolved) return proposal;
|
|
return {
|
|
...proposal,
|
|
status: resolved.status,
|
|
resultSummary: resolved.resultSummary ?? proposal.resultSummary,
|
|
};
|
|
});
|
|
|
|
/**
|
|
* The reply exists in two places once a turn finishes: the streamed text in
|
|
* `state`, and the stored copy that `onComplete` reloads into `history`.
|
|
* Whichever arrives is shown exactly once, and the stored copy wins because
|
|
* it sits in the right place in the timeline.
|
|
*/
|
|
const last = history[history.length - 1];
|
|
const historyEndsWithThisReply =
|
|
state.assistantText.length > 0 &&
|
|
last?.role === "assistant" &&
|
|
last.content === state.assistantText;
|
|
|
|
const streamingReply = historyEndsWithThisReply ? "" : state.assistantText;
|
|
|
|
/**
|
|
* One ordered list rather than three stacked blocks.
|
|
*
|
|
* Tool calls happen before the reply that describes them, but once a turn
|
|
* ends its reply moves into `history`, which used to render above a separate
|
|
* tools block. The tool call appeared to jump below the answer it produced.
|
|
* Splicing the current turn's activity in ahead of its own reply keeps the
|
|
* order stable across that handover.
|
|
*/
|
|
const timeline: TimelineItem[] = [];
|
|
|
|
const trailingReply = historyEndsWithThisReply ? history.length - 1 : -1;
|
|
|
|
history.forEach((entry, index) => {
|
|
if (index === trailingReply) return;
|
|
timeline.push({
|
|
kind: "message",
|
|
key: `history-${index}`,
|
|
role: entry.role,
|
|
content: entry.content,
|
|
});
|
|
});
|
|
|
|
for (const tool of state.tools) {
|
|
timeline.push({ kind: "tool", key: tool.id, tool });
|
|
}
|
|
|
|
if (trailingReply >= 0) {
|
|
timeline.push({
|
|
kind: "message",
|
|
key: `history-${trailingReply}`,
|
|
role: "assistant",
|
|
content: history[trailingReply].content,
|
|
});
|
|
} else if (streamingReply) {
|
|
timeline.push({
|
|
kind: "message",
|
|
key: "streaming",
|
|
role: "assistant",
|
|
content: streamingReply,
|
|
});
|
|
}
|
|
|
|
for (const proposal of proposals) {
|
|
timeline.push({
|
|
kind: "proposal",
|
|
key: `proposal-${proposal.id}`,
|
|
proposal,
|
|
});
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<Loader2 className="animate-spin text-muted-foreground" size={20} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{/*
|
|
The panel also lives in the narrow sidebar, so the title and the
|
|
provider picker get their own rows rather than competing for one line.
|
|
*/}
|
|
<div className="border-b border-border">
|
|
<div className="flex items-center gap-2 px-3 py-2">
|
|
<Sparkles size={16} className="shrink-0" />
|
|
<span className="truncate text-sm font-medium">{t("ai.title")}</span>
|
|
<a
|
|
href="https://docs.termix.site/features/ai/overview"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="shrink-0"
|
|
onClick={() => setShowSettings((value) => !value)}
|
|
aria-label={t("ai.providerSettings")}
|
|
>
|
|
<Settings2 size={14} />
|
|
</Button>
|
|
</div>
|
|
|
|
{providers.length > 0 && (
|
|
<div className="flex items-center gap-2 px-3 pb-2">
|
|
<Select
|
|
value={providerId ? String(providerId) : undefined}
|
|
onValueChange={(value) => setProviderId(Number(value))}
|
|
>
|
|
<SelectTrigger className="h-7 min-w-0 flex-1 rounded-none text-xs">
|
|
<SelectValue placeholder={t("ai.selectProvider")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{providers.map((provider) => (
|
|
<SelectItem key={provider.id} value={String(provider.id)}>
|
|
{provider.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showSettings && (
|
|
<div className="border-b border-border p-3">
|
|
<AiProviderSettings
|
|
providers={providers}
|
|
onChanged={loadProviders}
|
|
// Adding the first provider is the reason the form opened, so it
|
|
// closes once that is done and the chat becomes usable.
|
|
onAdded={() => setShowSettings(false)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={scrollRef} className="flex-1 space-y-3 overflow-y-auto p-3">
|
|
{timeline.map((item) => {
|
|
if (item.kind === "message") {
|
|
return (
|
|
<AiMessage
|
|
key={item.key}
|
|
role={item.role}
|
|
content={item.content}
|
|
/>
|
|
);
|
|
}
|
|
if (item.kind === "tool") {
|
|
return (
|
|
<AiToolCall
|
|
key={item.key}
|
|
tool={item.tool}
|
|
streaming={state.streaming}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<ProposalCard
|
|
key={item.key}
|
|
proposal={item.proposal}
|
|
onResolved={handleProposalResolved}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{state.error && (
|
|
<div className="rounded-none border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
{state.error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative border-t border-border p-3">
|
|
{mentionMatches.length > 0 && (
|
|
<div className="absolute bottom-full left-3 right-3 z-10 max-h-56 overflow-y-auto border border-border bg-popover shadow-md">
|
|
{mentionMatches.map((item, index) => (
|
|
<button
|
|
key={`${item.kind}-${item.id}`}
|
|
type="button"
|
|
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs ${
|
|
index === mentionIndex ? "bg-muted" : ""
|
|
}`}
|
|
onMouseDown={(event) => {
|
|
// mousedown, so the textarea does not lose the caret first.
|
|
event.preventDefault();
|
|
insertMention(item);
|
|
}}
|
|
>
|
|
<span className="shrink-0 text-[10px] uppercase text-muted-foreground">
|
|
{mentionLabel(item.kind)}
|
|
</span>
|
|
<span className="truncate font-medium">{item.label}</span>
|
|
{item.detail && (
|
|
<span className="ml-auto shrink-0 truncate text-[10px] text-muted-foreground">
|
|
{item.detail}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<Textarea
|
|
ref={textareaRef}
|
|
className="min-h-[64px] resize-none rounded-none text-sm"
|
|
value={input}
|
|
onChange={(event) => {
|
|
setInput(event.target.value);
|
|
updateMentionQuery(
|
|
event.target.value,
|
|
event.target.selectionStart ?? 0,
|
|
);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (mentionMatches.length > 0) {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
setMentionIndex((i) => (i + 1) % mentionMatches.length);
|
|
return;
|
|
}
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
setMentionIndex(
|
|
(i) =>
|
|
(i - 1 + mentionMatches.length) % mentionMatches.length,
|
|
);
|
|
return;
|
|
}
|
|
if (event.key === "Enter" || event.key === "Tab") {
|
|
event.preventDefault();
|
|
insertMention(mentionMatches[mentionIndex]);
|
|
return;
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
setMention(null);
|
|
return;
|
|
}
|
|
}
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
void handleSend();
|
|
}
|
|
}}
|
|
placeholder={t("ai.inputPlaceholder")}
|
|
disabled={!providerId}
|
|
/>
|
|
{/*
|
|
The hint gets its own line and wraps: sharing a row with the send
|
|
button left it truncated to an ellipsis in the sidebar, where the
|
|
panel is at its narrowest.
|
|
*/}
|
|
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">
|
|
{t("ai.attachHint")}
|
|
</p>
|
|
<div className="mt-1.5 flex items-center justify-end gap-2">
|
|
{state.streaming ? (
|
|
<Button size="sm" variant="outline" onClick={stop}>
|
|
<Square size={14} />
|
|
{t("ai.stop")}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
disabled={!input.trim() || !providerId}
|
|
onClick={() => void handleSend()}
|
|
>
|
|
<Send size={14} />
|
|
{t("ai.send")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|