mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { setAssistantEnabledForUser } from "./settings/AiUserSettings";
|
||||
|
||||
/**
|
||||
* Asks once, plainly.
|
||||
*
|
||||
* "Leave it off" is preselected and applied on mount, so someone who clicks
|
||||
* straight through ends up without the assistant rather than with it. Both
|
||||
* options are described in the same flat register and neither is marked
|
||||
* recommended: this is a choice, not a pitch.
|
||||
*/
|
||||
export function AiAssistantStep() {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
function apply(next: boolean) {
|
||||
setEnabled(next);
|
||||
setAssistantEnabledForUser(next);
|
||||
}
|
||||
|
||||
// Skipping the step entirely still has to mean off, not unset.
|
||||
useEffect(() => {
|
||||
apply(false);
|
||||
}, []);
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: false,
|
||||
title: t("onboarding.aiSkipTitle"),
|
||||
description: t("onboarding.aiSkipDesc"),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
title: t("onboarding.aiEnableTitle"),
|
||||
description: t("onboarding.aiEnableDesc"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">{t("onboarding.aiIntro")}</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
onClick={() => apply(option.value)}
|
||||
className={`flex items-start gap-2.5 border p-2.5 text-left ${
|
||||
enabled === option.value
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">{option.title}</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
{/*
|
||||
Always rendered and only made visible when selected, so picking an
|
||||
option never reflows the text beside it.
|
||||
*/}
|
||||
<Check
|
||||
size={14}
|
||||
className={`ml-auto mt-0.5 shrink-0 ${
|
||||
enabled === option.value ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
{t("onboarding.aiNote")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface AiMessageProps {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function AiMessage({ role, content }: AiMessageProps) {
|
||||
if (role === "user") {
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted px-3 py-2 text-sm whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-sm leading-relaxed [&_a]:underline [&_code]:font-mono [&_code]:text-xs [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-none [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted [&_pre]:p-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
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 } from "@/components/button";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import { saveUserPreferences } from "@/main-axios";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import {
|
||||
getAiConversation,
|
||||
getAiProviders,
|
||||
getAiStatus,
|
||||
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 saveUserPreferences({ aiAssistantEnabled: 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Label } from "@/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import {
|
||||
createAiProvider,
|
||||
deleteAiProvider,
|
||||
getAiProviderModels,
|
||||
probeAiModels,
|
||||
updateAiProvider,
|
||||
type AiProvider,
|
||||
type AiProviderType,
|
||||
} from "./ai-api";
|
||||
|
||||
const PROVIDER_TYPES: Array<{
|
||||
value: AiProviderType;
|
||||
labelKey: string;
|
||||
needsBaseUrl: boolean;
|
||||
needsApiKey: boolean;
|
||||
defaultBaseUrl?: string;
|
||||
}> = [
|
||||
{
|
||||
value: "ollama",
|
||||
labelKey: "ai.providerOllama",
|
||||
needsBaseUrl: true,
|
||||
needsApiKey: false,
|
||||
defaultBaseUrl: "http://localhost:11434",
|
||||
},
|
||||
{
|
||||
value: "anthropic",
|
||||
labelKey: "ai.providerAnthropic",
|
||||
needsBaseUrl: false,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
value: "openai",
|
||||
labelKey: "ai.providerOpenai",
|
||||
needsBaseUrl: false,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
value: "gemini",
|
||||
labelKey: "ai.providerGemini",
|
||||
needsBaseUrl: false,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
value: "openai_compatible",
|
||||
labelKey: "ai.providerOpenaiCompatible",
|
||||
needsBaseUrl: true,
|
||||
needsApiKey: false,
|
||||
},
|
||||
];
|
||||
|
||||
interface AiProviderSettingsProps {
|
||||
providers: AiProvider[];
|
||||
/** selectId names a provider that should become the active one. */
|
||||
onChanged: (selectId?: number) => void;
|
||||
onAdded?: () => void;
|
||||
}
|
||||
|
||||
function AiProviderEditForm({
|
||||
provider,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
provider: AiProvider;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [label, setLabel] = useState(provider.label);
|
||||
const [defaultModel, setDefaultModel] = useState(provider.defaultModel ?? "");
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [customModel, setCustomModel] = useState(false);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectWarning, setDetectWarning] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const detectModels = useCallback(async () => {
|
||||
setDetecting(true);
|
||||
setDetectWarning(null);
|
||||
try {
|
||||
const detected = await getAiProviderModels(provider.id);
|
||||
setModels(detected);
|
||||
setCustomModel(!!defaultModel && !detected.includes(defaultModel));
|
||||
} catch (error) {
|
||||
setModels([]);
|
||||
setCustomModel(true);
|
||||
setDetectWarning(getErrorMessage(error, t("ai.modelDetectFailed")));
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, [provider.id, defaultModel, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void detectModels();
|
||||
// The initial model value belongs to this provider. Subsequent edits must
|
||||
// not trigger a provider model-list request on every keystroke.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [provider.id]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!label.trim()) {
|
||||
toast.error(t("ai.labelRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAiProvider(provider.id, {
|
||||
label: label.trim(),
|
||||
defaultModel: defaultModel.trim() || null,
|
||||
});
|
||||
toast.success(t("ai.providerUpdated"));
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-none border border-border p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`ai-provider-label-${provider.id}`}>
|
||||
{t("ai.providerLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`ai-provider-label-${provider.id}`}
|
||||
className="rounded-none"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor={`ai-provider-model-${provider.id}`}
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
{t("ai.defaultModel")}
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
onClick={() => void detectModels()}
|
||||
disabled={detecting}
|
||||
>
|
||||
{detecting ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={11} />
|
||||
)}
|
||||
{t("ai.modelRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{models.length > 0 && !customModel ? (
|
||||
<Select
|
||||
value={defaultModel || undefined}
|
||||
onValueChange={(value) => {
|
||||
if (value === "__custom__") {
|
||||
setCustomModel(true);
|
||||
setDefaultModel("");
|
||||
return;
|
||||
}
|
||||
setDefaultModel(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`ai-provider-model-${provider.id}`}
|
||||
className="rounded-none"
|
||||
>
|
||||
<SelectValue placeholder={t("ai.modelPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__custom__">{t("ai.modelCustom")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={`ai-provider-model-${provider.id}`}
|
||||
className="rounded-none"
|
||||
value={defaultModel}
|
||||
onChange={(event) => setDefaultModel(event.target.value)}
|
||||
placeholder={t("ai.defaultModelPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
{detectWarning && (
|
||||
<p className="text-[11px] leading-snug text-destructive">
|
||||
{detectWarning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" disabled={saving} onClick={() => void handleSave()}>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{t("ai.save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("ai.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiProviderSettings({
|
||||
providers,
|
||||
onChanged,
|
||||
onAdded,
|
||||
}: AiProviderSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [providerType, setProviderType] = useState<AiProviderType>("ollama");
|
||||
const [label, setLabel] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("http://localhost:11434");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [defaultModel, setDefaultModel] = useState("");
|
||||
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectWarning, setDetectWarning] = useState<string | null>(null);
|
||||
const [customModel, setCustomModel] = useState(false);
|
||||
|
||||
const spec = PROVIDER_TYPES.find((entry) => entry.value === providerType)!;
|
||||
|
||||
useEffect(() => {
|
||||
setBaseUrl(spec.defaultBaseUrl ?? "");
|
||||
setApiKey("");
|
||||
setModels([]);
|
||||
setDefaultModel("");
|
||||
setCustomModel(false);
|
||||
setDetectWarning(null);
|
||||
}, [providerType, spec.defaultBaseUrl]);
|
||||
|
||||
/**
|
||||
* Fetches the provider's own model list so nobody has to go and look model
|
||||
* names up. Falls back to a curated list when the endpoint is unreachable,
|
||||
* and a free-text field is always available for anything not listed.
|
||||
*/
|
||||
const detectModels = useCallback(async () => {
|
||||
setDetecting(true);
|
||||
setDetectWarning(null);
|
||||
try {
|
||||
const result = await probeAiModels({
|
||||
providerType,
|
||||
baseUrl: baseUrl.trim() || null,
|
||||
apiKey: apiKey.trim() || null,
|
||||
});
|
||||
setModels(result.models);
|
||||
if (result.source !== "live") {
|
||||
setDetectWarning(result.warning || t("ai.modelDetectFailed"));
|
||||
}
|
||||
// Pick the first suggestion so the field is never left empty.
|
||||
setDefaultModel((current) => current || result.models[0] || "");
|
||||
} catch {
|
||||
setDetectWarning(t("ai.modelDetectFailed"));
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, [providerType, baseUrl, apiKey, t]);
|
||||
|
||||
// Detect as soon as the provider has enough detail to be reachable.
|
||||
useEffect(() => {
|
||||
if (!adding) return;
|
||||
const ready = spec.needsApiKey ? apiKey.trim().length > 0 : true;
|
||||
if (!ready) return;
|
||||
const timer = setTimeout(() => void detectModels(), 400);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [adding, providerType, baseUrl, apiKey]);
|
||||
|
||||
async function handleAdd() {
|
||||
if (!label.trim()) {
|
||||
toast.error(t("ai.labelRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await createAiProvider({
|
||||
providerType,
|
||||
label: label.trim(),
|
||||
baseUrl: baseUrl.trim() || null,
|
||||
apiKey: apiKey.trim() || null,
|
||||
defaultModel: defaultModel.trim() || null,
|
||||
});
|
||||
setAdding(false);
|
||||
setLabel("");
|
||||
setApiKey("");
|
||||
setDefaultModel("");
|
||||
onChanged(created.id);
|
||||
onAdded?.();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await deleteAiProvider(id);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.providerDeleteFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{providers.map((provider) =>
|
||||
editingId === provider.id ? (
|
||||
<AiProviderEditForm
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
onSaved={() => {
|
||||
setEditingId(null);
|
||||
onChanged(provider.id);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between gap-2 rounded-none border border-border bg-muted px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{provider.label}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{provider.providerType}
|
||||
{provider.defaultModel ? ` · ${provider.defaultModel}` : ""}
|
||||
{provider.baseUrl ? ` · ${provider.baseUrl}` : ""}
|
||||
{provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}…` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setAdding(false);
|
||||
setEditingId(provider.id);
|
||||
}}
|
||||
aria-label={t("ai.editProvider")}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(provider.id)}
|
||||
aria-label={t("ai.removeProvider")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
{!adding && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setAdding(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("ai.addProvider")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<div className="space-y-3 rounded-none border border-border p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.providerType")}</Label>
|
||||
<Select
|
||||
value={providerType}
|
||||
onValueChange={(value) =>
|
||||
setProviderType(value as AiProviderType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="rounded-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDER_TYPES.map((entry) => (
|
||||
<SelectItem key={entry.value} value={entry.value}>
|
||||
{t(entry.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.providerLabel")}</Label>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder={t("ai.providerLabelPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{spec.needsBaseUrl && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.baseUrl")}</Label>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("ai.privateEndpointHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(spec.needsApiKey || providerType === "openai_compatible") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.apiKey")}</Label>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="min-w-0 flex-1">{t("ai.defaultModel")}</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void detectModels()}
|
||||
disabled={detecting}
|
||||
>
|
||||
{detecting ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={11} />
|
||||
)}
|
||||
{t("ai.modelRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{models.length > 0 && !customModel ? (
|
||||
<Select
|
||||
value={defaultModel || undefined}
|
||||
onValueChange={(value) => {
|
||||
if (value === "__custom__") {
|
||||
setCustomModel(true);
|
||||
setDefaultModel("");
|
||||
return;
|
||||
}
|
||||
setDefaultModel(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="rounded-none">
|
||||
<SelectValue placeholder={t("ai.modelPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
{/* Anything the provider did not list is still reachable. */}
|
||||
<SelectItem value="__custom__">
|
||||
{t("ai.modelCustom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
className="rounded-none"
|
||||
value={defaultModel}
|
||||
onChange={(event) => setDefaultModel(event.target.value)}
|
||||
placeholder={t("ai.defaultModelPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detectWarning && (
|
||||
<p className="text-[11px] leading-snug text-muted-foreground">
|
||||
{detectWarning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={saving}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{t("ai.save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => setAdding(false)}
|
||||
>
|
||||
{t("ai.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { ChevronDown, ChevronRight, Loader2, Wrench } from "lucide-react";
|
||||
import type { ToolActivity } from "./use-ai-stream";
|
||||
import { toolLabel } from "./labels";
|
||||
|
||||
/**
|
||||
* Every tool call is shown, so the user can always see what the assistant
|
||||
* looked at rather than having to trust the summary.
|
||||
*/
|
||||
export function AiToolCall({
|
||||
tool,
|
||||
streaming = false,
|
||||
}: {
|
||||
tool: ToolActivity;
|
||||
/** False once the turn has ended, whatever ended it. */
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
// A tool can only still be running while the turn is. An aborted or failed
|
||||
// stream leaves calls without results, and treating those as pending left a
|
||||
// spinner running forever after the conversation had finished.
|
||||
const pending = !("result" in tool) && streaming;
|
||||
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted/50 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-2 py-1.5 text-left"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
{pending ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Wrench size={12} />
|
||||
)}
|
||||
<span className="truncate">{toolLabel(tool.name)}</span>
|
||||
{pending && (
|
||||
<span className="text-muted-foreground">{t("ai.running")}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-2 border-t border-border px-2 py-2">
|
||||
{/* The header reads as an action, so the actual tool id lives here. */}
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{tool.name}
|
||||
</div>
|
||||
{Object.keys(tool.arguments).length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-muted-foreground">
|
||||
{t("ai.toolArguments")}
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono">
|
||||
{JSON.stringify(tool.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
Gated on the result existing, not on !pending: an interrupted turn
|
||||
leaves a call with no result at all, which would otherwise print
|
||||
"undefined" under a Result heading.
|
||||
*/}
|
||||
{"result" in tool && (
|
||||
<div>
|
||||
<div className="mb-1 text-muted-foreground">
|
||||
{t("ai.toolResult")}
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono">
|
||||
{JSON.stringify(tool.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { applyAiProposal, rejectAiProposal, type AiProposal } from "./ai-api";
|
||||
import { fieldLabel, toolLabel } from "./labels";
|
||||
|
||||
interface ProposalCardProps {
|
||||
proposal: AiProposal;
|
||||
onResolved: (
|
||||
id: number,
|
||||
status: "applied" | "rejected",
|
||||
resultSummary?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function isDestructive(kind: string): boolean {
|
||||
return kind.includes("delete");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields shown in their own block rather than the generic row list, so the
|
||||
* card does not print the same command twice.
|
||||
*/
|
||||
const PROMOTED_FIELDS = new Set(["command", "explanation", "reason"]);
|
||||
|
||||
/** Renders the payload as readable field rows rather than raw JSON. */
|
||||
function fieldRows(payload: Record<string, unknown>): Array<[string, string]> {
|
||||
const rows: Array<[string, string]> = [];
|
||||
|
||||
const push = (key: string, value: unknown) => {
|
||||
if (value === null || value === undefined || value === "") return;
|
||||
if (PROMOTED_FIELDS.has(key)) return;
|
||||
const text =
|
||||
typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
rows.push([key, text]);
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "changes" && value && typeof value === "object") {
|
||||
for (const [childKey, childValue] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
push(childKey, childValue);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
push(key, value);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function asText(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function ProposalCard({ proposal, onResolved }: ProposalCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState<"apply" | "reject" | null>(null);
|
||||
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(proposal.payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
const rows = fieldRows(payload);
|
||||
const destructive = isDestructive(proposal.kind);
|
||||
const resolved = proposal.status !== "pending";
|
||||
|
||||
const command = asText(payload.command);
|
||||
const explanation = asText(payload.explanation) ?? asText(payload.reason);
|
||||
|
||||
// The stored summary repeats the command for run_command proposals, so a
|
||||
// short action label is used instead of printing it twice.
|
||||
const title =
|
||||
command !== null
|
||||
? t("ai.runCommandTitle")
|
||||
: (proposal.summary ?? toolLabel(proposal.kind));
|
||||
|
||||
async function handleApply() {
|
||||
setBusy("apply");
|
||||
try {
|
||||
const result = await applyAiProposal(proposal.id);
|
||||
// The result goes into the card, not a toast: command output can be
|
||||
// hundreds of lines, which covered the screen.
|
||||
onResolved(proposal.id, "applied", result.summary);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalApplyFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setBusy("reject");
|
||||
try {
|
||||
await rejectAiProposal(proposal.id);
|
||||
onResolved(proposal.id, "rejected");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalRejectFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{destructive && (
|
||||
<TriangleAlert size={13} className="shrink-0 text-destructive" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{title}
|
||||
</span>
|
||||
{resolved && (
|
||||
<span className="shrink-0 border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{proposal.status === "applied"
|
||||
? t("ai.statusApplied")
|
||||
: t("ai.statusRejected")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A command is the payload, not a field of it, so it gets its own block
|
||||
rather than a label column. In the sidebar a fixed label gutter left
|
||||
barely any room for the command itself.
|
||||
*/}
|
||||
{command && (
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug">
|
||||
{command}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{explanation && (
|
||||
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">
|
||||
{explanation}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
|
||||
{rows.map(([key, value]) => (
|
||||
// Stacked, not a two-column row: the value is what matters and it
|
||||
// needs the full width to stay readable.
|
||||
<div key={key} className="min-w-0">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{fieldLabel(key)}
|
||||
</span>
|
||||
<div className="break-all font-mono text-[11px] leading-snug">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proposal.resultSummary && (
|
||||
// Command output comes back here, so it keeps the monospace treatment.
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug text-muted-foreground">
|
||||
{proposal.resultSummary}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{!resolved && (
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className={`h-7 flex-1 text-xs ${
|
||||
destructive
|
||||
? ""
|
||||
: "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
}`}
|
||||
variant={destructive ? "destructive" : "outline"}
|
||||
disabled={busy !== null}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{busy === "apply" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Check size={13} />
|
||||
)}
|
||||
{t("ai.approve")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
onClick={handleReject}
|
||||
>
|
||||
{busy === "reject" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<X size={13} />
|
||||
)}
|
||||
{t("ai.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export type AiProviderType =
|
||||
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
|
||||
|
||||
export interface AiProvider {
|
||||
id: number;
|
||||
providerType: AiProviderType;
|
||||
label: string;
|
||||
baseUrl: string | null;
|
||||
/** The first few characters only; the key itself never leaves the server. */
|
||||
apiKeyPrefix: string | null;
|
||||
defaultModel: string | null;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiConversation {
|
||||
id: number;
|
||||
title: string | null;
|
||||
providerId: number | null;
|
||||
model: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AiMessage {
|
||||
id: number;
|
||||
conversationId: number;
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
toolCalls: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiProposal {
|
||||
id: number;
|
||||
conversationId: number;
|
||||
kind: string;
|
||||
summary: string | null;
|
||||
payload: string;
|
||||
status: "pending" | "applied" | "rejected" | "expired";
|
||||
resultSummary: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiStatus {
|
||||
globallyEnabled: boolean;
|
||||
enabled: boolean;
|
||||
allowReadOnlyCommands: boolean;
|
||||
}
|
||||
|
||||
export async function getAiStatus(): Promise<AiStatus> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/status")).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get AI status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiProviders(): Promise<AiProvider[]> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/providers")).data.providers;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list AI providers");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAiProvider(input: {
|
||||
providerType: AiProviderType;
|
||||
label: string;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
defaultModel?: string | null;
|
||||
}): Promise<AiProvider> {
|
||||
try {
|
||||
return (await authApi.post("/plugin-api/ai/providers", input)).data
|
||||
.provider;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAiProvider(
|
||||
id: number,
|
||||
input: Partial<{
|
||||
label: string;
|
||||
baseUrl: string | null;
|
||||
apiKey: string | null;
|
||||
defaultModel: string | null;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
): Promise<AiProvider> {
|
||||
try {
|
||||
return (await authApi.patch(`/plugin-api/ai/providers/${id}`, input)).data
|
||||
.provider;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAiProvider(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/plugin-api/ai/providers/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Model list for a provider that may not be saved yet, so the add form can
|
||||
* fill its picker before anything is persisted.
|
||||
*/
|
||||
export async function probeAiModels(input: {
|
||||
providerType: AiProviderType;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
providerId?: number | null;
|
||||
}): Promise<{
|
||||
models: string[];
|
||||
source: "live" | "fallback" | "none";
|
||||
warning?: string;
|
||||
}> {
|
||||
try {
|
||||
return (await authApi.post("/plugin-api/ai/probe-models", input)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "detect models");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiProviderModels(id: number): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get(`/plugin-api/ai/providers/${id}/models`)).data
|
||||
.models;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list provider models");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiConversations(): Promise<AiConversation[]> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/conversations")).data
|
||||
.conversations;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list AI conversations");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiConversation(id: number): Promise<{
|
||||
conversation: AiConversation;
|
||||
messages: AiMessage[];
|
||||
proposals: AiProposal[];
|
||||
}> {
|
||||
try {
|
||||
return (await authApi.get(`/plugin-api/ai/conversations/${id}`)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "load AI conversation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAiConversation(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/plugin-api/ai/conversations/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete AI conversation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyAiProposal(
|
||||
id: number,
|
||||
): Promise<{ success: boolean; summary: string }> {
|
||||
try {
|
||||
return (await authApi.post(`/plugin-api/ai/proposals/${id}/apply`)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "apply AI proposal");
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectAiProposal(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.post(`/plugin-api/ai/proposals/${id}/reject`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reject AI proposal");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For the terminal-docked assistant: records that a run_command proposal was
|
||||
* typed into the user's open terminal instead of run over a pooled connection.
|
||||
*/
|
||||
export async function markAiProposalRunInTerminal(
|
||||
id: number,
|
||||
hostId: number,
|
||||
summary?: string,
|
||||
): Promise<{ success: boolean; summary: string }> {
|
||||
try {
|
||||
return (
|
||||
await authApi.post(
|
||||
`/plugin-api/ai/proposals/${id}/mark-run-in-terminal`,
|
||||
{
|
||||
hostId,
|
||||
summary,
|
||||
},
|
||||
)
|
||||
).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "mark AI proposal run in terminal");
|
||||
}
|
||||
}
|
||||
|
||||
// --- admin ---
|
||||
|
||||
export async function getAiGloballyEnabled(): Promise<boolean> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/enabled")).data.enabled;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get AI enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
|
||||
try {
|
||||
return (await authApi.patch("/plugin-api/ai/enabled", { enabled })).data
|
||||
.enabled;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update AI enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiPrivateEndpoints(): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/private-endpoints")).data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get AI endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAiPrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.patch("/plugin-api/ai/private-endpoints", { hosts }))
|
||||
.data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update AI endpoint allowlist");
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Frontend half of the ai plugin.
|
||||
*
|
||||
* AiPanel and the rest of src/ui/features/ai/ stay in core rather than
|
||||
* moving here: TerminalAiPanel.tsx and TerminalProposalCard.tsx in
|
||||
* src/ui/features/terminal/ (also core) import AiMessage, AiToolCall,
|
||||
* useAiStream and ProposalCard directly, for the terminal's own docked AI
|
||||
* panel. That is a real external dependency, the same reason session-manager
|
||||
* stays in core for ssh-terminal rather than being copied into that plugin.
|
||||
*
|
||||
* The "ai" surface itself is not registered through registerTabComponent:
|
||||
* it is a rail-view/right-dock panel baked into AppShell.tsx and
|
||||
* tabUtils.tsx (railView === "ai", case "ai"), the same way the terminal's
|
||||
* tab is baked into Terminal.tsx rather than going through the generic tab
|
||||
* registry. What gates it is isTabTypeAvailable("ai") in
|
||||
* src/ui/shell/pluginLoader.ts's BUILT_IN_TABS_BY_PLUGIN map.
|
||||
*
|
||||
* What this file DOES contribute is the terminal toolbar's AI button, through
|
||||
* the action registry: ai.openWithContext, declared in manifest.json and
|
||||
* gated on ai.services.use. The button used to be hardwired into
|
||||
* TerminalToolbar.tsx behind a showAiAssistant prop.
|
||||
*
|
||||
* There is still no frontend plugin loader, so nothing imports this file yet
|
||||
* (see src/ui/tests/sidebar/plugin-extension-seam.test.tsx). Until one exists,
|
||||
* pluginLoader.ts performs the same registration itself when the ai plugin is
|
||||
* enabled. This file is the version that runs once a loader lands, and the two
|
||||
* must be kept in step.
|
||||
*/
|
||||
|
||||
export const id = "ai";
|
||||
export const tabId = "ai";
|
||||
export const actionId = "ai.openWithContext";
|
||||
|
||||
/** Asks the focused terminal to open its AI panel seeded with `context`. */
|
||||
export function openWithContext(context) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:ai:openWithContext", {
|
||||
detail: { context: typeof context === "string" ? context : "" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function register({
|
||||
registerAction,
|
||||
registerSlotContribution,
|
||||
icons,
|
||||
}) {
|
||||
registerAction(actionId, openWithContext, {
|
||||
permission: "ai.services.use",
|
||||
pluginId: id,
|
||||
});
|
||||
|
||||
registerSlotContribution("terminal.toolbar", {
|
||||
actionId,
|
||||
titleKey: "ai.assistant",
|
||||
icon: icons.Bot,
|
||||
kind: "button",
|
||||
pluginId: id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregister({
|
||||
unregisterAction,
|
||||
unregisterSlotContribution,
|
||||
}) {
|
||||
unregisterSlotContribution("terminal.toolbar", actionId);
|
||||
unregisterAction(actionId);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { Bot, Sparkles } from "lucide-react";
|
||||
import type {
|
||||
PanelProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
TerminalDockProps,
|
||||
TerminalSlotApi,
|
||||
} from "@/features/terminal/terminal-slots";
|
||||
import { AiPanel } from "./AiPanel";
|
||||
import { TerminalAiPanel } from "./terminal/TerminalAiPanel";
|
||||
import { AiAssistantStep } from "./AiAssistantStep";
|
||||
import { AiAccessSettings } from "./settings/AiAccessSettings";
|
||||
import { AI_RAIL_ID, AiUserSettings } from "./settings/AiUserSettings";
|
||||
import { getAiStatus } from "./ai-api";
|
||||
import { AI_STATUS_CHANGED_EVENT } from "./use-ai-availability";
|
||||
|
||||
const ASSISTANT_ACTION = "ai.openWithContext";
|
||||
const DOCK_ID = "ai.assistant";
|
||||
|
||||
function AssistantPanel({ activeTabType }: PanelProps) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<AiPanel activeTab={activeTabType ?? null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantTab() {
|
||||
return <AiPanel />;
|
||||
}
|
||||
|
||||
/** The assistant docked beside a terminal. */
|
||||
function TerminalDock({
|
||||
host,
|
||||
hostId,
|
||||
hostLabel,
|
||||
dockProps,
|
||||
onClose,
|
||||
onRunInTerminal,
|
||||
}: TerminalDockProps) {
|
||||
if (!hostId) return null;
|
||||
return (
|
||||
<TerminalAiPanel
|
||||
hostLabel={hostLabel}
|
||||
hostId={hostId}
|
||||
activeTab={`terminal:${host?.name || host?.ip || ""}`}
|
||||
initialContext={
|
||||
typeof dockProps.context === "string" ? dockProps.context : ""
|
||||
}
|
||||
onClose={onClose}
|
||||
onRunInTerminal={onRunInTerminal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
let globallyEnabled = false;
|
||||
let userEnabled = false;
|
||||
const surface: (() => void)[] = [];
|
||||
|
||||
const clearSurface = () => {
|
||||
while (surface.length > 0) surface.pop()!();
|
||||
};
|
||||
|
||||
// What the assistant shows depends on the admin switch and the user's own
|
||||
// choice, so it is re-registered whenever either changes.
|
||||
const applyStatus = () => {
|
||||
clearSurface();
|
||||
|
||||
surface.push(
|
||||
app.registerRailItem({
|
||||
id: AI_RAIL_ID,
|
||||
icon: Sparkles,
|
||||
titleKey: "nav.ai",
|
||||
promotable: true,
|
||||
rightDockable: true,
|
||||
after: "macros",
|
||||
order: 40,
|
||||
// Off for everyone: not even a Navigation toggle.
|
||||
hidden: !globallyEnabled,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!globallyEnabled) {
|
||||
app.tabs.closeTab(AI_RAIL_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
surface.push(
|
||||
app.registerTab(AI_RAIL_ID, AssistantTab as ComponentType<TabProps>, {
|
||||
icon: Sparkles,
|
||||
titleKey: "nav.ai",
|
||||
singleton: true,
|
||||
hostless: true,
|
||||
panelFrame: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const offeredOnHost = (context: Record<string, unknown>) =>
|
||||
userEnabled &&
|
||||
(context.host as { enableAiAssistant?: boolean } | undefined)
|
||||
?.enableAiAssistant === true;
|
||||
|
||||
surface.push(
|
||||
app.registerSlotContribution("terminal.toolbar", {
|
||||
actionId: ASSISTANT_ACTION,
|
||||
titleKey: "ai.assistant",
|
||||
icon: Bot,
|
||||
kind: "button",
|
||||
when: offeredOnHost,
|
||||
}),
|
||||
);
|
||||
surface.push(
|
||||
app.registerSlotContribution("terminal.dock", {
|
||||
actionId: DOCK_ID,
|
||||
titleKey: "ai.assistant",
|
||||
icon: Bot,
|
||||
kind: "component",
|
||||
component: TerminalDock as unknown as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
when: offeredOnHost,
|
||||
}),
|
||||
);
|
||||
surface.push(
|
||||
app.registerSlotContribution("onboarding.steps", {
|
||||
actionId: "ai.onboarding",
|
||||
titleKey: "onboarding.aiTitle",
|
||||
kind: "component",
|
||||
component: AiAssistantStep as unknown as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
getAiStatus()
|
||||
.then((status) => {
|
||||
globallyEnabled = status.globallyEnabled;
|
||||
userEnabled = status.globallyEnabled && status.enabled;
|
||||
})
|
||||
.catch(() => {
|
||||
globallyEnabled = false;
|
||||
userEnabled = false;
|
||||
})
|
||||
.finally(() => {
|
||||
if (!disposed) applyStatus();
|
||||
});
|
||||
};
|
||||
|
||||
let disposed = false;
|
||||
app.onDispose(() => {
|
||||
disposed = true;
|
||||
clearSurface();
|
||||
});
|
||||
|
||||
app.registerPanel(AI_RAIL_ID, AssistantPanel);
|
||||
|
||||
// Opens the docked assistant on the terminal it was clicked from, seeded
|
||||
// with what is on screen.
|
||||
app.registerAction(
|
||||
ASSISTANT_ACTION,
|
||||
(terminal: TerminalSlotApi) =>
|
||||
terminal?.openDock(DOCK_ID, { context: terminal.getBufferText() }),
|
||||
{ permission: "services.use" },
|
||||
);
|
||||
|
||||
app.registerSettingsComponent("access", AiAccessSettings);
|
||||
app.registerSettingsComponent("assistant", AiUserSettings);
|
||||
|
||||
applyStatus();
|
||||
refresh();
|
||||
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
|
||||
window.addEventListener("hiddenRailTabsChanged", refresh);
|
||||
app.onDispose(() => {
|
||||
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
|
||||
window.removeEventListener("hiddenRailTabsChanged", refresh);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Turns the internal identifiers the assistant works in into text a user can
|
||||
* read. Tool names, proposal kinds and payload keys all surface in the panel,
|
||||
* and raw ids like "propose_create_automation" or "thresholddurationseconds"
|
||||
* are not something to show anyone.
|
||||
*/
|
||||
|
||||
/** What each tool is doing, phrased as an action rather than a function name. */
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
list_hosts: "Reading hosts",
|
||||
get_host: "Reading host",
|
||||
list_fleets: "Reading fleets",
|
||||
list_snippets: "Reading snippets",
|
||||
list_automations: "Reading automations",
|
||||
get_automation: "Reading automation",
|
||||
list_workspaces: "Reading workspaces",
|
||||
list_notification_channels: "Reading notification channels",
|
||||
list_homepage_items: "Reading homepage items",
|
||||
get_command_history: "Reading command history",
|
||||
get_network_topology: "Reading network topology",
|
||||
propose_create_host: "Add host",
|
||||
propose_update_host: "Update host",
|
||||
propose_delete_host: "Delete host",
|
||||
propose_create_snippet: "Create snippet",
|
||||
propose_update_snippet: "Update snippet",
|
||||
propose_delete_snippet: "Delete snippet",
|
||||
propose_create_automation: "Create automation",
|
||||
propose_create_fleet: "Create fleet",
|
||||
propose_run_command: "Run command",
|
||||
};
|
||||
|
||||
/** Readable names for the payload keys the propose tools emit. */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
hostId: "Host",
|
||||
hostIds: "Hosts",
|
||||
snippetId: "Snippet",
|
||||
automationId: "Automation",
|
||||
name: "Name",
|
||||
ip: "Address",
|
||||
port: "Port",
|
||||
username: "Username",
|
||||
folder: "Folder",
|
||||
tags: "Tags",
|
||||
content: "Command",
|
||||
description: "Description",
|
||||
definition: "Definition",
|
||||
enabled: "Enabled",
|
||||
triggerType: "Trigger",
|
||||
thresholdValue: "Threshold",
|
||||
thresholdDurationSeconds: "Duration (seconds)",
|
||||
cooldownMinutes: "Cooldown (minutes)",
|
||||
};
|
||||
|
||||
const MENTION_LABELS: Record<string, string> = {
|
||||
host: "Host",
|
||||
snippet: "Snippet",
|
||||
automation: "Automation",
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits snake_case and camelCase into words and capitalises the first.
|
||||
* Used whenever an identifier has no explicit label, so something added later
|
||||
* still reads as words rather than one run-on token.
|
||||
*/
|
||||
export function humanize(value: string): string {
|
||||
const spaced = value
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[_-]+/g, " ")
|
||||
.trim();
|
||||
if (!spaced) return value;
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
export function toolLabel(name: string): string {
|
||||
return TOOL_LABELS[name] ?? humanize(name);
|
||||
}
|
||||
|
||||
export function fieldLabel(key: string): string {
|
||||
return FIELD_LABELS[key] ?? humanize(key);
|
||||
}
|
||||
|
||||
export function mentionLabel(kind: string): string {
|
||||
return MENTION_LABELS[kind] ?? humanize(kind);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useToast, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { FakeSwitch, Input, SettingRow } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
getAiGloballyEnabled,
|
||||
getAiPrivateEndpoints,
|
||||
setAiGloballyEnabled,
|
||||
setAiPrivateEndpoints,
|
||||
} from "../ai-api";
|
||||
import { notifyAiStatusChanged } from "../use-ai-availability";
|
||||
|
||||
/**
|
||||
* The instance-wide switch and the private address allowlist. Stored in the
|
||||
* assistant's own backend rather than plugin settings, so this saves on
|
||||
* change instead of through the page's Save.
|
||||
*/
|
||||
export function AiAccessSettings() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [endpoints, setEndpoints] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.allSettled([
|
||||
getAiGloballyEnabled(),
|
||||
getAiPrivateEndpoints(),
|
||||
]).then(([enabledResult, endpointsResult]) => {
|
||||
if (cancelled) return;
|
||||
if (enabledResult.status === "fulfilled") setEnabled(enabledResult.value);
|
||||
setEndpoints(
|
||||
endpointsResult.status === "fulfilled" ? endpointsResult.value : [],
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggle = async (next: boolean) => {
|
||||
setEnabled(next);
|
||||
try {
|
||||
await setAiGloballyEnabled(next);
|
||||
notifyAiStatusChanged();
|
||||
} catch {
|
||||
setEnabled(!next);
|
||||
toast.error(t("admin.updateAiEnabledFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const saveEndpoints = async (hosts: string[]) => {
|
||||
const previous = endpoints ?? [];
|
||||
setEndpoints(hosts);
|
||||
try {
|
||||
setEndpoints(await setAiPrivateEndpoints(hosts));
|
||||
} catch {
|
||||
setEndpoints(previous);
|
||||
toast.error(t("admin.updateAiEndpointsFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<SettingRow
|
||||
label={t("admin.aiGloballyEnabled")}
|
||||
description={t("admin.aiGloballyEnabledDesc")}
|
||||
>
|
||||
<FakeSwitch checked={enabled} onChange={(next) => void toggle(next)} />
|
||||
</SettingRow>
|
||||
{enabled && endpoints !== null && (
|
||||
<div className="flex flex-col gap-1.5 py-2">
|
||||
<span className="text-xs font-medium">
|
||||
{t("admin.aiPrivateEndpoints")}
|
||||
</span>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("admin.aiPrivateEndpointsDesc")}
|
||||
</span>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
defaultValue={endpoints.join(", ")}
|
||||
placeholder="localhost, 127.0.0.1"
|
||||
onBlur={(event) =>
|
||||
void saveEndpoints(
|
||||
event.target.value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { FakeSwitch, SettingRow } from "@termix/plugin-sdk/ui";
|
||||
import { saveUserPreferences } from "@/main-axios";
|
||||
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
|
||||
import { getAiStatus } from "../ai-api";
|
||||
import { notifyAiStatusChanged } from "../use-ai-availability";
|
||||
|
||||
/** The rail id the assistant registers under. */
|
||||
export const AI_RAIL_ID = "ai";
|
||||
|
||||
/**
|
||||
* Turns the assistant on or off for one person. Off also hides its rail
|
||||
* entry, so declining removes the feature from view rather than leaving an
|
||||
* inert button behind. Stored in core user preferences, where it has always
|
||||
* lived, so it roams with the rest of a user's settings.
|
||||
*/
|
||||
export function setAssistantEnabledForUser(enabled: boolean): void {
|
||||
const hidden = readHiddenRailTabs();
|
||||
if (enabled) hidden.delete(AI_RAIL_ID);
|
||||
else hidden.add(AI_RAIL_ID);
|
||||
|
||||
const serialized = JSON.stringify([...hidden]);
|
||||
localStorage.setItem("hiddenRailTabs", serialized);
|
||||
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
|
||||
|
||||
saveUserPreferences({
|
||||
aiAssistantEnabled: enabled,
|
||||
hiddenRailTabs: serialized,
|
||||
})
|
||||
.catch(() => {
|
||||
// The local choice still applies if the write fails.
|
||||
})
|
||||
.finally(() => notifyAiStatusChanged());
|
||||
}
|
||||
|
||||
export function AiUserSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<{
|
||||
globallyEnabled: boolean;
|
||||
enabled: boolean;
|
||||
readOnly: boolean;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAiStatus()
|
||||
.then((next) => {
|
||||
if (!cancelled) {
|
||||
setStatus({
|
||||
globallyEnabled: next.globallyEnabled,
|
||||
enabled: next.enabled,
|
||||
readOnly: next.allowReadOnlyCommands,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setStatus({
|
||||
globallyEnabled: false,
|
||||
enabled: false,
|
||||
readOnly: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// A user who cannot have the feature is not told about it.
|
||||
if (!status?.globallyEnabled) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.assistantUnavailable")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<SettingRow
|
||||
label={t("ai.enableTitle")}
|
||||
description={t("ai.enableDescription")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={status.enabled}
|
||||
onChange={(enabled) => {
|
||||
setStatus({ ...status, enabled });
|
||||
setAssistantEnabledForUser(enabled);
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
{status.enabled && (
|
||||
<SettingRow
|
||||
label={t("ai.readOnlyCommands")}
|
||||
description={t("ai.readOnlyCommandsDescription")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={status.readOnly}
|
||||
onChange={(readOnly) => {
|
||||
setStatus({ ...status, readOnly });
|
||||
saveUserPreferences({ aiReadOnlyCommands: readOnly }).catch(
|
||||
() => {},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Bot, GripVertical, Send, Square, X } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { getAiProviders, type AiProposal, type AiProvider } from "../ai-api";
|
||||
import { AiMessage } from "../AiMessage";
|
||||
import { AiToolCall } from "../AiToolCall";
|
||||
import { useAiStream } from "../use-ai-stream";
|
||||
import { TerminalProposalCard } from "./TerminalProposalCard";
|
||||
import {
|
||||
clampToolbarPosition,
|
||||
persistAiPanelPosition,
|
||||
readStoredAiPanelPosition,
|
||||
type ToolbarPosition,
|
||||
} from "@/features/terminal/toolbar-geometry";
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
type TimelineItem =
|
||||
| {
|
||||
kind: "message";
|
||||
key: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
kind: "tool";
|
||||
key: string;
|
||||
tool: import("../use-ai-stream").ToolActivity;
|
||||
}
|
||||
| { kind: "proposal"; key: string; proposal: AiProposal };
|
||||
|
||||
interface TerminalAiPanelProps {
|
||||
hostLabel: string;
|
||||
hostId: number;
|
||||
activeTab?: string | null;
|
||||
/** Terminal output to open with, from a contributed action. */
|
||||
initialContext?: string;
|
||||
onClose: () => void;
|
||||
onRunInTerminal: (command: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A slimmed-down version of AiPanel docked to a terminal tab. Same backend
|
||||
* (/ai/chat/stream, propose_run_command, ProposalCard) as the main assistant,
|
||||
* so it respects the same admin/user gates and never runs a command without
|
||||
* an explicit approve.
|
||||
*/
|
||||
export function TerminalAiPanel({
|
||||
hostLabel,
|
||||
hostId,
|
||||
activeTab,
|
||||
initialContext,
|
||||
onClose,
|
||||
onRunInTerminal,
|
||||
}: TerminalAiPanelProps) {
|
||||
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 [input, setInput] = useState("");
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [resolvedProposals, setResolvedProposals] = useState<
|
||||
Record<number, { status: "applied" | "rejected"; resultSummary?: string }>
|
||||
>({});
|
||||
const conversationIdRef = useRef<number | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Prefilled rather than sent: the user sees exactly what the terminal is
|
||||
// about to hand the model, and can trim it or add a question first.
|
||||
useEffect(() => {
|
||||
if (!initialContext) return;
|
||||
setInput((current) =>
|
||||
current
|
||||
? current
|
||||
: t("ai.terminalContextPrefill", { output: initialContext }),
|
||||
);
|
||||
}, [initialContext, t]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const [position, setPosition] = useState<ToolbarPosition>(
|
||||
readStoredAiPanelPosition,
|
||||
);
|
||||
const positionRef = useRef(position);
|
||||
positionRef.current = position;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
basePosition: ToolbarPosition;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
const host = panel?.offsetParent as HTMLElement | null;
|
||||
if (!panel || !host) return;
|
||||
const next = clampToolbarPosition(
|
||||
positionRef.current,
|
||||
panel.getBoundingClientRect(),
|
||||
host.getBoundingClientRect(),
|
||||
positionRef.current,
|
||||
);
|
||||
if (next.x !== positionRef.current.x || next.y !== positionRef.current.y) {
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
}
|
||||
// Only re-clamp when the panel first mounts; drag handles the rest.
|
||||
}, []);
|
||||
|
||||
const handleDragPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || event.pointerType === "touch") return;
|
||||
if ((event.target as HTMLElement).closest("button")) return;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
basePosition: positionRef.current,
|
||||
};
|
||||
};
|
||||
const handleDragPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const panel = panelRef.current;
|
||||
const host = panel?.offsetParent as HTMLElement | null;
|
||||
if (!panel || !host) return;
|
||||
const deltaX = event.clientX - drag.startX;
|
||||
const deltaY = event.clientY - drag.startY;
|
||||
const next = clampToolbarPosition(
|
||||
{ x: drag.basePosition.x + deltaX, y: drag.basePosition.y + deltaY },
|
||||
panel.getBoundingClientRect(),
|
||||
host.getBoundingClientRect(),
|
||||
positionRef.current,
|
||||
);
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
};
|
||||
const handleDragPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
persistAiPanelPosition(positionRef.current);
|
||||
dragRef.current = null;
|
||||
if (event.type !== "lostpointercapture") {
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
} catch {
|
||||
// The browser may have already released capture for this pointer.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAiProviders()
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setProviders(list);
|
||||
setProviderId((current) => current ?? list[0]?.id ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setProviders([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
||||
}, [state.assistantText, state.tools.length, history.length]);
|
||||
|
||||
const handleResolved = useCallback(
|
||||
(id: number, status: "applied" | "rejected", resultSummary?: string) => {
|
||||
setResolvedProposals((current) => ({
|
||||
...current,
|
||||
[id]: { status, resultSummary },
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const message = input.trim();
|
||||
if (!message || !providerId || state.streaming) return;
|
||||
|
||||
setHistory((current) => [...current, { role: "user", content: message }]);
|
||||
setInput("");
|
||||
|
||||
void send({
|
||||
message,
|
||||
providerId,
|
||||
conversationId: conversationIdRef.current,
|
||||
activeTab: activeTab ?? `terminal:${hostLabel}`,
|
||||
onComplete: (conversationId, reply) => {
|
||||
conversationIdRef.current = conversationId;
|
||||
if (reply) {
|
||||
setHistory((current) => [
|
||||
...current,
|
||||
{ role: "assistant", content: reply },
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [activeTab, hostLabel, input, providerId, send, state.streaming]);
|
||||
|
||||
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 copy `onComplete` appended to `history`. Show it once,
|
||||
// and keep tool calls spliced in ahead of the reply they produced instead
|
||||
// of in a separate trailing block, so the timeline reads in the order
|
||||
// things actually happened rather than text-then-tools-then-proposals.
|
||||
const last = history[history.length - 1];
|
||||
const historyEndsWithThisReply =
|
||||
state.assistantText.length > 0 &&
|
||||
last?.role === "assistant" &&
|
||||
last.content === state.assistantText;
|
||||
const streamingReply = historyEndsWithThisReply ? "" : state.assistantText;
|
||||
const trailingReply = historyEndsWithThisReply ? history.length - 1 : -1;
|
||||
|
||||
const timeline: TimelineItem[] = [];
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute right-2 top-12 z-[120] flex max-h-[calc(100%-4rem)] w-[min(420px,calc(100vw-1rem))] flex-col border border-border bg-background shadow-xl"
|
||||
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-grab items-center justify-between gap-2 border-b border-border px-3 py-2 active:cursor-grabbing"
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerEnd}
|
||||
onPointerCancel={handleDragPointerEnd}
|
||||
onLostPointerCapture={handleDragPointerEnd}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<GripVertical className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<Bot className="size-4 shrink-0 text-accent-brand" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-bold text-foreground">
|
||||
{t("ai.assistant")}
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{hostLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={stop}
|
||||
disabled={!state.streaming}
|
||||
title={t("ai.stop")}
|
||||
>
|
||||
<Square className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={onClose}
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3"
|
||||
>
|
||||
{timeline.length === 0 && !loading && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("ai.terminalPanelEmpty")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && providers.length === 0 && (
|
||||
<div className="border border-border bg-muted/30 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t("ai.noProvidersConfigured")}
|
||||
</div>
|
||||
)}
|
||||
{state.error && (
|
||||
<div className="border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
{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 (
|
||||
<TerminalProposalCard
|
||||
key={item.key}
|
||||
proposal={item.proposal}
|
||||
hostId={hostId}
|
||||
onRunInTerminal={onRunInTerminal}
|
||||
onResolved={handleResolved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border p-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={t("ai.terminalPanelPlaceholder")}
|
||||
rows={3}
|
||||
className="min-h-20 resize-none border border-input bg-background px-2 py-1.5 text-xs text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={state.streaming || providers.length === 0}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
disabled={state.streaming || !input.trim() || !providerId}
|
||||
onClick={handleSend}
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
{t("ai.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
markAiProposalRunInTerminal,
|
||||
rejectAiProposal,
|
||||
type AiProposal,
|
||||
} from "../ai-api";
|
||||
import { ProposalCard } from "../ProposalCard";
|
||||
|
||||
interface TerminalProposalCardProps {
|
||||
proposal: AiProposal;
|
||||
hostId: number;
|
||||
onRunInTerminal: (command: string) => void;
|
||||
onResolved: (
|
||||
id: number,
|
||||
status: "applied" | "rejected",
|
||||
resultSummary?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A run_command proposal in the terminal-docked assistant runs in the user's
|
||||
* already-open session instead of a hidden pooled connection, so approving it
|
||||
* here is a different action from the main AiPanel's apply button. Every
|
||||
* other proposal kind still goes through the normal apply flow.
|
||||
*/
|
||||
export function TerminalProposalCard({
|
||||
proposal,
|
||||
hostId,
|
||||
onRunInTerminal,
|
||||
onResolved,
|
||||
}: TerminalProposalCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState<"run" | "reject" | null>(null);
|
||||
|
||||
if (
|
||||
proposal.kind !== "propose_run_command" ||
|
||||
proposal.status !== "pending"
|
||||
) {
|
||||
return <ProposalCard proposal={proposal} onResolved={onResolved} />;
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(proposal.payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
const command = typeof payload.command === "string" ? payload.command : "";
|
||||
const explanation =
|
||||
typeof payload.explanation === "string" ? payload.explanation : "";
|
||||
|
||||
async function handleRun() {
|
||||
if (!command) return;
|
||||
setBusy("run");
|
||||
try {
|
||||
onRunInTerminal(command);
|
||||
const result = await markAiProposalRunInTerminal(
|
||||
proposal.id,
|
||||
hostId,
|
||||
`$ ${command}`,
|
||||
);
|
||||
onResolved(proposal.id, "applied", result.summary);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalApplyFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setBusy("reject");
|
||||
try {
|
||||
await rejectAiProposal(proposal.id);
|
||||
onResolved(proposal.id, "rejected");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalRejectFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{t("ai.runCommandTitle")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug">
|
||||
{command}
|
||||
</pre>
|
||||
|
||||
{explanation && (
|
||||
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">
|
||||
{explanation}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<TriangleAlert size={11} className="shrink-0" />
|
||||
{t("ai.runsInOpenTerminal")}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
variant="outline"
|
||||
disabled={busy !== null || !command}
|
||||
onClick={handleRun}
|
||||
>
|
||||
{busy === "run" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Check size={13} />
|
||||
)}
|
||||
{t("ai.runInTerminal")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
onClick={handleReject}
|
||||
>
|
||||
{busy === "reject" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<X size={13} />
|
||||
)}
|
||||
{t("ai.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface AiAvailability {
|
||||
/** Admin kill switch. When false the feature does not exist for anyone. */
|
||||
globallyEnabled: boolean;
|
||||
/** Global switch and the user's own opt-in, which is what the rail needs. */
|
||||
userEnabled: boolean;
|
||||
/** False until the status call answers, so nothing flashes in or out. */
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
/** Fired when the AI status may have changed, so every surface re-reads it. */
|
||||
export const AI_STATUS_CHANGED_EVENT = "aiStatusChanged";
|
||||
|
||||
export function notifyAiStatusChanged(): void {
|
||||
window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* One source of truth for whether any AI surface should render.
|
||||
*
|
||||
* The admin global is a hard kill switch: when it is off the assistant is
|
||||
* hidden everywhere for everyone, including the tab bar, command palette,
|
||||
* mobile bar, and the navigation visibility toggles, so nobody can reach or
|
||||
* even see an entry the server would refuse anyway.
|
||||
*/
|
||||
export function useAiAvailability(): AiAvailability {
|
||||
const [state, setState] = useState<AiAvailability>({
|
||||
globallyEnabled: false,
|
||||
userEnabled: false,
|
||||
loaded: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = () => {
|
||||
import("./ai-api")
|
||||
.then(({ getAiStatus }) => getAiStatus())
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
globallyEnabled: status.globallyEnabled,
|
||||
userEnabled: status.globallyEnabled && status.enabled,
|
||||
loaded: true,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled)
|
||||
setState({
|
||||
globallyEnabled: false,
|
||||
userEnabled: false,
|
||||
loaded: true,
|
||||
});
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
|
||||
// The profile toggle and onboarding still fire this older event.
|
||||
window.addEventListener("hiddenRailTabsChanged", refresh);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
|
||||
window.removeEventListener("hiddenRailTabsChanged", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { authApi } from "@/main-axios";
|
||||
import type { AiProposal } from "./ai-api";
|
||||
|
||||
/**
|
||||
* Reuses whatever base authApi resolved to, so the stream follows the same
|
||||
* dev proxy, Electron localhost and reverse-proxy base path rules as every
|
||||
* other call instead of hardcoding an origin.
|
||||
*/
|
||||
function streamUrl(): string {
|
||||
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
|
||||
return `${base}/ai/chat/stream`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the chat stream.
|
||||
*
|
||||
* EventSource cannot POST, and the request carries a message body, so this
|
||||
* reads the SSE frames off a fetch response by hand.
|
||||
*/
|
||||
|
||||
export interface ToolActivity {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
export interface StreamState {
|
||||
streaming: boolean;
|
||||
assistantText: string;
|
||||
tools: ToolActivity[];
|
||||
proposals: AiProposal[];
|
||||
error: string | null;
|
||||
conversationId: number | null;
|
||||
}
|
||||
|
||||
const INITIAL: StreamState = {
|
||||
streaming: false,
|
||||
assistantText: "",
|
||||
tools: [],
|
||||
proposals: [],
|
||||
error: null,
|
||||
conversationId: null,
|
||||
};
|
||||
|
||||
export function useAiStream() {
|
||||
const [state, setState] = useState<StreamState>(INITIAL);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState(INITIAL);
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setState((prev) => ({ ...prev, streaming: false }));
|
||||
}, []);
|
||||
|
||||
const send = useCallback(
|
||||
async (input: {
|
||||
message: string;
|
||||
providerId: number;
|
||||
model?: string;
|
||||
conversationId?: number | null;
|
||||
activeTab?: string | null;
|
||||
onComplete?: (conversationId: number | null, reply: string) => void;
|
||||
}) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setState({
|
||||
streaming: true,
|
||||
assistantText: "",
|
||||
tools: [],
|
||||
proposals: [],
|
||||
error: null,
|
||||
conversationId: input.conversationId ?? null,
|
||||
});
|
||||
|
||||
let conversationId = input.conversationId ?? null;
|
||||
let replyText = "";
|
||||
let toolSequence = 0;
|
||||
|
||||
try {
|
||||
const response = await fetch(streamUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
message: input.message,
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
conversationId: input.conversationId ?? undefined,
|
||||
activeTab: input.activeTab ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = "The assistant could not be reached";
|
||||
try {
|
||||
message = (await response.json()).error ?? message;
|
||||
} catch {
|
||||
// Keep the generic message.
|
||||
}
|
||||
setState((prev) => ({ ...prev, streaming: false, error: message }));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
streaming: false,
|
||||
error: "The assistant returned an empty response",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
|
||||
if (!line.startsWith("data:")) continue;
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(line.slice(5).trim());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "conversation") {
|
||||
conversationId = event.conversationId;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
conversationId: event.conversationId,
|
||||
}));
|
||||
} else if (event.type === "token") {
|
||||
replyText += event.text;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
assistantText: prev.assistantText + event.text,
|
||||
}));
|
||||
} else if (event.type === "tool_call") {
|
||||
// The id is assigned here rather than inside the updater: React
|
||||
// may run an updater more than once, and deriving the id from
|
||||
// prev.tools.length appended a duplicate entry that no result
|
||||
// could ever match, leaving it stuck on "running".
|
||||
const activityId = `tool-${toolSequence++}`;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tools: [
|
||||
...prev.tools,
|
||||
{
|
||||
id: activityId,
|
||||
name: event.name,
|
||||
arguments: event.arguments ?? {},
|
||||
},
|
||||
],
|
||||
}));
|
||||
} else if (event.type === "tool_result") {
|
||||
setState((prev) => {
|
||||
const tools = [...prev.tools];
|
||||
// Attach to the most recent call of this tool awaiting a result.
|
||||
for (let i = tools.length - 1; i >= 0; i -= 1) {
|
||||
if (tools[i].name === event.name && !("result" in tools[i])) {
|
||||
tools[i] = { ...tools[i], result: event.result };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { ...prev, tools };
|
||||
});
|
||||
} else if (event.type === "proposal") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
proposals: [...prev.proposals, event.proposal],
|
||||
}));
|
||||
} else if (event.type === "error") {
|
||||
setState((prev) => ({ ...prev, error: event.message }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, streaming: false }));
|
||||
input.onComplete?.(conversationId, replyText);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
setState((prev) => ({ ...prev, streaming: false }));
|
||||
return;
|
||||
}
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
streaming: false,
|
||||
error: getErrorMessage(error, "The assistant stopped unexpectedly"),
|
||||
}));
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { state, send, stop, reset, setState };
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { getSnippets } from "@/api/snippets-api";
|
||||
import { invokeAction } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/**
|
||||
* Backs the @-mention picker in the composer.
|
||||
*
|
||||
* A mention is only ever a name the assistant reads in the message text; it
|
||||
* does not grant access to anything. The assistant still has to call a read
|
||||
* tool to see the referenced item, so every access stays visible in the
|
||||
* transcript rather than being silently attached to the prompt.
|
||||
*/
|
||||
|
||||
export type MentionKind = "host" | "snippet" | "automation";
|
||||
|
||||
export interface MentionItem {
|
||||
kind: MentionKind;
|
||||
id: number;
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Extracts the "@partial" the caret currently sits in, if any. */
|
||||
export function activeMentionQuery(
|
||||
value: string,
|
||||
caret: number,
|
||||
): { query: string; start: number } | null {
|
||||
const before = value.slice(0, caret);
|
||||
const at = before.lastIndexOf("@");
|
||||
if (at === -1) return null;
|
||||
|
||||
// Only trigger at a word boundary, so an email address does not open it.
|
||||
if (at > 0 && !/\s/.test(before[at - 1])) return null;
|
||||
|
||||
const query = before.slice(at + 1);
|
||||
if (/\s/.test(query)) return null;
|
||||
|
||||
return { query, start: at };
|
||||
}
|
||||
|
||||
/** The automations plugin's list, or nothing when it is not running. */
|
||||
async function listAutomationsForMentions(): Promise<
|
||||
{ id: number; name: string }[]
|
||||
> {
|
||||
const result = await invokeAction("automations.list");
|
||||
return Array.isArray(result)
|
||||
? (result as { id: number; name: string }[])
|
||||
: [];
|
||||
}
|
||||
|
||||
export function useMentions(enabled: boolean) {
|
||||
const [items, setItems] = useState<MentionItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const [hosts, snippets, automations] = await Promise.allSettled([
|
||||
getSSHHosts(),
|
||||
getSnippets(),
|
||||
// Through the automations plugin's action, so the assistant works
|
||||
// without it and never imports its code.
|
||||
listAutomationsForMentions(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
const collected: MentionItem[] = [];
|
||||
|
||||
if (hosts.status === "fulfilled") {
|
||||
for (const host of (hosts.value ?? []) as any[]) {
|
||||
collected.push({
|
||||
kind: "host",
|
||||
id: host.id,
|
||||
label: host.name || host.ip,
|
||||
detail: host.ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (snippets.status === "fulfilled") {
|
||||
for (const snippet of (snippets.value ?? []) as any[]) {
|
||||
collected.push({
|
||||
kind: "snippet",
|
||||
id: snippet.id,
|
||||
label: snippet.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (automations.status === "fulfilled") {
|
||||
for (const automation of (automations.value ?? []) as any[]) {
|
||||
collected.push({
|
||||
kind: "automation",
|
||||
id: automation.id,
|
||||
label: automation.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setItems(collected);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string): MentionItem[] => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const matches = needle
|
||||
? items.filter((item) => item.label.toLowerCase().includes(needle))
|
||||
: items;
|
||||
return matches.slice(0, 8);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ items, search }), [items, search]);
|
||||
}
|
||||
Reference in new issue
Block a user