Files
gitmost/apps/client/src/features/share/components/share-ai-widget.tsx
T
agent_coder cb445f0966 feat(ai-chat): show tool-call arguments in the action-log card (#392)
For tools without a friendly label (esp. external MCP tools like
Search_web_search) the card showed a generic "Ran tool <name>" with no
sense of what was searched. Add a compact one-line, dimmed summary of the
call's arguments under the label, pulled from part.input.

New pure toolInputSummary(part): picks the first present "primary" field
(query/q/searchQuery/url/urls/title/name/text/prompt), collapses
whitespace, clamps to ~140 chars; arrays render "first (+N)". It returns
undefined during input-streaming (the input grows while state is fixed and
messageSignature doesn't track input, so a live summary would freeze) —
the state flip to input-available re-renders the row with the final value,
so message-signature.ts is left untouched. The value renders ONLY through
Mantine <Text> (React-escaped) — no markdown/HTML, no XSS.

A showInput prop (default true) is threaded MessageList -> MessageItem
(+memo) -> ToolCallCard; the public share widget passes showInput={false}
so an anonymous reader never sees the agent's raw query text (mirrors
showCitations). No JSON fallback when no primary field is present.

closes #392

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:28:24 +03:00

234 lines
7.5 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
import { generateId } from "ai";
import {
ActionIcon,
Affix,
Alert,
Box,
Group,
Paper,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import {
IconAlertTriangle,
IconArrowUp,
IconSparkles,
IconX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import MessageList from "@/features/ai-chat/components/message-list.tsx";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
interface ShareAiWidgetProps {
/** The share id (or key) the assistant is scoped to. */
shareId: string;
/** The page the reader currently has open (context for "this page"). */
pageId: string;
/** Display name of the configured assistant identity; falls back to 'AI agent' when absent. */
assistantName?: string;
}
/**
* Lightweight, EPHEMERAL "Ask AI" widget for a public shared page.
*
* A stripped version of the authenticated chat: text input only, no chat list,
* no history, no persistence, no voice input. The transcript lives only in
* memory (this component's `useChat` store) and is sent with `credentials:
* "omit"` to the anonymous `/api/shares/ai/stream` endpoint. The server stores
* nothing.
*
* Presentation is now shared with the internal chat: the same `MessageList`
* renders the streamed transcript, so the public share gets the SAME
* incremental markdown render, animated typing indicator, and tool-call cards
* as the internal chat. Only the anonymous specifics differ — no auth, no
* history, `credentials: "omit"`, suppressed page citations (an anonymous
* reader cannot open the linked internal pages), neutralized internal markdown
* links (so internal UUIDs/auth-gated routes in the answer don't leak as
* clickable links), and a documentation-focused empty state.
*/
export default function ShareAiWidget({
shareId,
pageId,
assistantName,
}: ShareAiWidgetProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
// Stable per-mount store key (see ai-chat ChatThread for the rationale on why
// useChat needs a stable, non-undefined id to avoid re-creating its store).
const storeIdRef = useRef<string>(`share-ai-${generateId()}`);
const transport = useMemo(
() =>
new DefaultChatTransport<UIMessage>({
api: "/api/shares/ai/stream",
// Anonymous endpoint: never send cookies/credentials.
credentials: "omit",
prepareSendMessagesRequest: ({ messages, body }) => ({
body: {
...body,
shareId,
pageId,
messages,
},
}),
}),
[shareId, pageId],
);
const { messages, sendMessage, status, stop, error } = useChat({
id: storeIdRef.current,
transport,
});
const isStreaming = status === "submitted" || status === "streaming";
// Same classified-error banner as the internal chat: name the cause instead of a
// generic heading.
const errorView = error ? describeChatError(error.message ?? "", t) : null;
const handleSend = () => {
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
void sendMessage({ text });
};
if (!open) {
return (
// Offset 80px from the bottom so the FAB stacks ABOVE the bottom-right
// "Powered by Gitmost" branding button (share-branding.tsx) without
// overlapping it.
<Affix position={{ bottom: 80, right: 20 }}>
<Tooltip label={t("Ask AI")} position="left">
<ActionIcon
size="xl"
radius="xl"
variant="filled"
aria-label={t("Ask AI")}
onClick={() => setOpen(true)}
>
<IconSparkles size={22} />
</ActionIcon>
</Tooltip>
</Affix>
);
}
return (
<Affix position={{ bottom: 80, right: 20 }}>
<Paper
shadow="md"
radius="md"
withBorder
style={{
width: 360,
maxWidth: "calc(100vw - 40px)",
height: 480,
maxHeight: "calc(100vh - 100px)",
display: "flex",
flexDirection: "column",
}}
>
<Group
justify="space-between"
p="xs"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
>
<Group gap="xs">
<IconSparkles size={18} />
<Text fw={600} size="sm">
{t("Ask AI")}
</Text>
</Group>
<ActionIcon
variant="subtle"
aria-label={t("Close")}
onClick={() => setOpen(false)}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Shared transcript: same incremental streaming render, animated typing
indicator, markdown, and tool-call cards as the internal chat. The
share is anonymous, so page citation links are suppressed (an
anonymous reader cannot open the linked internal pages). */}
<Box style={{ flex: 1, minHeight: 0, display: "flex", padding: "var(--mantine-spacing-sm)" }}>
<MessageList
messages={messages}
isStreaming={isStreaming}
assistantName={assistantName}
showCitations={false}
// Anonymous reader: suppress the tool-argument summary line so the
// agent's raw query/argument text isn't shown on the public share.
showInput={false}
// Anonymous reader: neutralize internal/relative links in the
// assistant's markdown so internal UUIDs/auth-gated routes don't
// leak as clickable links (external http(s) links are kept).
neutralizeInternalLinks={true}
emptyState={
<Text size="sm" c="dimmed" ta="center">
{t("Ask a question about this documentation.")}
</Text>
}
/>
</Box>
{errorView && (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
mx="sm"
mb="xs"
title={errorView.title}
>
{/* Surface the real cause (provider/gating category) instead of a
generic line — same helper the internal chat uses. */}
{errorView.detail}
</Alert>
)}
<Group
gap="xs"
p="xs"
align="flex-end"
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
>
<Textarea
style={{ flex: 1 }}
autosize
minRows={1}
maxRows={4}
placeholder={t("Ask a question…")}
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<ActionIcon
size="lg"
radius="xl"
variant="filled"
aria-label={isStreaming ? t("Stop") : t("Send")}
onClick={isStreaming ? () => stop() : handleSend}
disabled={!isStreaming && input.trim().length === 0}
>
{isStreaming ? <IconX size={18} /> : <IconArrowUp size={18} />}
</ActionIcon>
</Group>
</Paper>
</Affix>
);
}