Skip to content

Commit cf10696

Browse files
authored
Merge pull request Stack-Cairn#9 from Stack-Cairn/features
Features
2 parents 492c7ab + 248fecd commit cf10696

34 files changed

Lines changed: 1585 additions & 156 deletions

crates/agent-gateway/web/src/App.tsx

Lines changed: 6 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { McpHubPage } from "@/pages/mcp-hub/McpHubPage";
3434
import type { SectionId } from "@/pages/settings/types";
3535
import { useChatSkills } from "@/pages/chat/useChatSkills";
3636
import { mergeAlwaysEnabledSkillNames } from "@/lib/skills";
37+
import { buildModelOptions } from "@/lib/chat/chatPageHelpers";
3738
import { SettingsPage } from "@/pages/SettingsPage";
3839
import {
3940
findProviderModelConfig,
@@ -53,7 +54,7 @@ import {
5354
redactSettingsForWebStorage,
5455
type GatewaySettingsSyncPayload,
5556
} from "@/lib/settings/sync";
56-
import type { ModelOption } from "@/lib/providers/llm";
57+
import { toModelValue } from "@/lib/providers/llm";
5758

5859
import {
5960
getGatewayWebSocketClient,
@@ -248,7 +249,6 @@ type SendChatOptions = {
248249

249250
type SendChatFn = (message: string, options?: SendChatOptions) => Promise<void>;
250251

251-
const MODEL_VALUE_SEPARATOR = "::";
252252
const PROTECTED_DRAFT_CONVERSATION = "__protected_draft__";
253253
const HISTORY_DETAIL_INITIAL_MAX_MESSAGES = 360;
254254
const HISTORY_SWITCH_OVERLAY_MIN_MS = 260;
@@ -343,44 +343,6 @@ function isTerminalChatEvent(event: ChatEvent) {
343343
return event.type === "done" || event.type === "error";
344344
}
345345

346-
function toModelValue(model: SelectedModel): string {
347-
return `${model.customProviderId}${MODEL_VALUE_SEPARATOR}${model.model}`;
348-
}
349-
350-
function buildModelOptions(
351-
providers: ModelProviderSource[],
352-
selectedModel?: SelectedModel,
353-
): ModelOption[] {
354-
const options: ModelOption[] = [];
355-
356-
for (const provider of providers) {
357-
for (const model of provider.activeModels) {
358-
options.push({
359-
providerType: provider.type,
360-
providerName: provider.name,
361-
model,
362-
value: `${provider.id}${MODEL_VALUE_SEPARATOR}${model}`,
363-
label: model,
364-
});
365-
}
366-
}
367-
368-
if (!selectedModel) {
369-
return options;
370-
}
371-
372-
const selectedValue = toModelValue(selectedModel);
373-
const selectedIndex = options.findIndex((item) => item.value === selectedValue);
374-
if (selectedIndex <= 0) {
375-
return options;
376-
}
377-
378-
const next = [...options];
379-
const [selected] = next.splice(selectedIndex, 1);
380-
next.unshift(selected);
381-
return next;
382-
}
383-
384346
function buildGatewaySelectedModel(
385347
selectedModel: SelectedModel | undefined,
386348
providers: ModelProviderSource[],
@@ -3957,11 +3919,10 @@ export default function App() {
39573919
const isAgentMode = settings.system.executionMode !== "text";
39583920
const isAgentDevExecutionMode = isAgentDevMode(settings.system.executionMode);
39593921

3960-
const modelOptions = useMemo(
3961-
() => buildModelOptions(activeProviders, settings.selectedModel),
3962-
[activeProviders, settings.selectedModel],
3963-
);
3964-
const selectedValue = settings.selectedModel ? toModelValue(settings.selectedModel) : undefined;
3922+
const modelOptions = useMemo(() => buildModelOptions(settings), [settings]);
3923+
const selectedValue = settings.selectedModel
3924+
? toModelValue(settings.selectedModel.customProviderId, settings.selectedModel.model)
3925+
: undefined;
39653926

39663927
const skillsEnabled = settings.skills.enabled && isAgentMode;
39673928
const selectedSkillNames = useMemo(

crates/agent-gateway/web/src/components/Markdown.tsx

Lines changed: 123 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, type ComponentProps } from "react";
1+
import { memo, useLayoutEffect, useRef, type ComponentProps } from "react";
22
import { createPortal } from "react-dom";
33
import { cjk } from "@streamdown/cjk";
44
import { code } from "@streamdown/code";
@@ -82,6 +82,87 @@ export const markdownReadOnlyComponents: Components = {
8282
...markdownComponents,
8383
a: MarkdownReadOnlyLink,
8484
};
85+
86+
const codeBlockSelector = '[data-streamdown="code-block"]';
87+
const codeCopyButtonSelector =
88+
'[data-streamdown="code-block"] [data-streamdown="code-block-copy-button"]';
89+
const codeBlockBodySelector = '[data-streamdown="code-block-body"] pre';
90+
91+
function enableCodeCopyButtons(root: HTMLElement) {
92+
root.querySelectorAll<HTMLButtonElement>(codeCopyButtonSelector).forEach((button) => {
93+
if (!button.disabled && !button.hasAttribute("disabled")) return;
94+
button.disabled = false;
95+
button.removeAttribute("disabled");
96+
});
97+
}
98+
99+
function getCodeBlockText(button: HTMLButtonElement) {
100+
const codeBlock = button.closest(codeBlockSelector);
101+
const codeBody = codeBlock?.querySelector<HTMLElement>(codeBlockBodySelector);
102+
return codeBody?.textContent ?? null;
103+
}
104+
105+
async function copyCodeBlockText(text: string) {
106+
try {
107+
await navigator.clipboard.writeText(text);
108+
} catch (error) {
109+
console.error("Failed to copy code block", error);
110+
}
111+
}
112+
113+
function useEnabledCodeCopyButtons(enabled: boolean) {
114+
const rootRef = useRef<HTMLDivElement | null>(null);
115+
116+
useLayoutEffect(() => {
117+
if (!enabled) return;
118+
119+
const root = rootRef.current;
120+
if (!root) return;
121+
122+
// Streamdown disables copy controls while animating, but the copy handler
123+
// can safely copy the current partial code during streaming.
124+
enableCodeCopyButtons(root);
125+
126+
const handleCopyClick = (event: MouseEvent) => {
127+
const target = event.target;
128+
if (!(target instanceof Element)) return;
129+
130+
const button = target.closest(codeCopyButtonSelector);
131+
if (!(button instanceof HTMLButtonElement) || !root.contains(button)) return;
132+
133+
const codeText = getCodeBlockText(button);
134+
if (codeText === null) return;
135+
136+
event.preventDefault();
137+
event.stopPropagation();
138+
event.stopImmediatePropagation();
139+
void copyCodeBlockText(codeText);
140+
};
141+
142+
root.addEventListener("click", handleCopyClick, true);
143+
144+
let observer: MutationObserver | undefined;
145+
if (typeof MutationObserver !== "undefined") {
146+
observer = new MutationObserver(() => {
147+
enableCodeCopyButtons(root);
148+
});
149+
observer.observe(root, {
150+
attributes: true,
151+
attributeFilter: ["disabled"],
152+
childList: true,
153+
subtree: true,
154+
});
155+
}
156+
157+
return () => {
158+
root.removeEventListener("click", handleCopyClick, true);
159+
observer?.disconnect();
160+
};
161+
}, [enabled]);
162+
163+
return rootRef;
164+
}
165+
85166
const streamdownTranslations = {
86167
close: "关闭",
87168
copied: "已复制",
@@ -215,46 +296,51 @@ export const Markdown = memo(function Markdown(props: MarkdownProps) {
215296
showCaret = isAnimating,
216297
readOnly = false,
217298
} = props;
299+
const useStreamingMode = isAnimating;
300+
const isActivelyStreaming = showCaret;
301+
const codeCopyRootRef = useEnabledCodeCopyButtons(!readOnly && isActivelyStreaming);
218302
// Keep Streamdown's caret pseudo-element mounted while in streaming mode;
219303
// `showCaret` only toggles visibility so the final token does not reflow.
220-
const keepCaretSlot = isAnimating;
304+
const keepCaretSlot = useStreamingMode;
221305

222306
return (
223-
<Streamdown
224-
className={cn(
225-
"chat-markdown max-w-none break-words",
226-
isAnimating ? "chat-markdown--streaming" : "chat-markdown--static",
227-
// Streamdown's memo equality does not include `caret` in its check,
228-
// so toggling the caret prop alone does not invalidate the render.
229-
// Mirror the visibility into a className modifier to force a re-render
230-
// that recomputes the inline `--streamdown-caret` style.
231-
showCaret ? "chat-markdown--caret-on" : "chat-markdown--caret-off",
232-
className,
233-
)}
234-
plugins={streamdownPlugins}
235-
remarkPlugins={remarkPlugins}
236-
components={readOnly ? markdownReadOnlyComponents : markdownComponents}
237-
mode={isAnimating ? "streaming" : "static"}
238-
dir="auto"
239-
parseIncompleteMarkdown
240-
normalizeHtmlIndentation
241-
isAnimating={isAnimating}
242-
caret={keepCaretSlot ? "block" : undefined}
243-
animated={false}
244-
linkSafety={{
245-
enabled: !readOnly,
246-
renderModal: (modalProps) => <ExternalLinkModal {...modalProps} />,
247-
}}
248-
{...(isAnimating ? {} : { shikiTheme: ["github-light", "github-dark"] as const })}
249-
controls={{
250-
code: { copy: !readOnly, download: false },
251-
mermaid: { copy: !readOnly, download: false, fullscreen: !readOnly, panZoom: !readOnly },
252-
table: { copy: !readOnly, download: false, fullscreen: !readOnly },
253-
}}
254-
translations={streamdownTranslations}
255-
>
256-
{content}
257-
</Streamdown>
307+
<div ref={codeCopyRootRef} style={{ display: "contents" }}>
308+
<Streamdown
309+
className={cn(
310+
"chat-markdown max-w-none break-words",
311+
useStreamingMode ? "chat-markdown--streaming" : "chat-markdown--static",
312+
// Streamdown's memo equality does not include `caret` in its check,
313+
// so toggling the caret prop alone does not invalidate the render.
314+
// Mirror the visibility into a className modifier to force a re-render
315+
// that recomputes the inline `--streamdown-caret` style.
316+
showCaret ? "chat-markdown--caret-on" : "chat-markdown--caret-off",
317+
className,
318+
)}
319+
plugins={streamdownPlugins}
320+
remarkPlugins={remarkPlugins}
321+
components={readOnly ? markdownReadOnlyComponents : markdownComponents}
322+
mode={useStreamingMode ? "streaming" : "static"}
323+
dir="auto"
324+
parseIncompleteMarkdown
325+
normalizeHtmlIndentation
326+
isAnimating={isActivelyStreaming}
327+
caret={keepCaretSlot ? "block" : undefined}
328+
animated={false}
329+
linkSafety={{
330+
enabled: !readOnly,
331+
renderModal: (modalProps) => <ExternalLinkModal {...modalProps} />,
332+
}}
333+
{...(useStreamingMode ? {} : { shikiTheme: ["github-light", "github-dark"] as const })}
334+
controls={{
335+
code: { copy: !readOnly, download: false },
336+
mermaid: { copy: !readOnly, download: false, fullscreen: !readOnly, panZoom: !readOnly },
337+
table: { copy: !readOnly, download: false, fullscreen: !readOnly },
338+
}}
339+
translations={streamdownTranslations}
340+
>
341+
{content}
342+
</Streamdown>
343+
</div>
258344
);
259345
});
260346

crates/agent-gateway/web/src/i18n/config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,14 @@ export const translations: Record<Locale, Record<string, string>> = {
413413
"settings.deleteConfirm": "确认删除",
414414
"settings.deleteConfirmDesc": "此操作不可撤销。",
415415
"settings.deleteConfirmYes": "确定删除",
416+
"settings.customSettings": "自定义设置",
417+
"settings.openCustomSettings": "打开自定义设置",
418+
"settings.closeCustomSettings": "关闭自定义设置",
419+
"settings.conversationTitleGeneration": "对话标题生成",
420+
"settings.conversationTitleModel": "标题生成模型",
421+
"settings.conversationTitleModelFollowCurrent": "使用当前对话模型",
422+
"settings.conversationTitleModelHint": "未选择时,标题生成会使用当前对话使用的模型。",
423+
"settings.customSettingsModelEmpty": "当前 Provider 未配置模型。",
416424

417425
/* ── Settings Prompt ── */
418426
"settings.agentsTitle": "全局提示词",
@@ -1196,6 +1204,14 @@ export const translations: Record<Locale, Record<string, string>> = {
11961204
"settings.deleteConfirm": "Confirm Delete",
11971205
"settings.deleteConfirmDesc": "This action cannot be undone.",
11981206
"settings.deleteConfirmYes": "Delete",
1207+
"settings.customSettings": "Custom Settings",
1208+
"settings.openCustomSettings": "Open custom settings",
1209+
"settings.closeCustomSettings": "Close custom settings",
1210+
"settings.conversationTitleGeneration": "Conversation title generation",
1211+
"settings.conversationTitleModel": "Title generation model",
1212+
"settings.conversationTitleModelFollowCurrent": "Use current chat model",
1213+
"settings.conversationTitleModelHint": "When unselected, title generation uses the model from the current chat.",
1214+
"settings.customSettingsModelEmpty": "No active models are configured for the current providers.",
11991215

12001216
/* ── Settings Prompt ── */
12011217
"settings.agentsTitle": "Prompt",

crates/agent-gateway/web/src/index.css

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,66 @@
755755
.composer-dropdown[data-state="open"] [role="menuitemcheckbox"]:nth-child(n+6),
756756
.composer-dropdown[data-state="open"] [role="menuitem"]:nth-child(n+6) { animation-delay: 0.12s; }
757757

758+
/* Composer reasoning select animations */
759+
@keyframes composerReasoningDropdownIn {
760+
from {
761+
opacity: 0;
762+
transform: translateY(6px) scale(0.97);
763+
}
764+
to {
765+
opacity: 1;
766+
transform: translateY(0) scale(1);
767+
}
768+
}
769+
770+
@keyframes composerReasoningDropdownOut {
771+
from {
772+
opacity: 1;
773+
transform: translateY(0) scale(1);
774+
}
775+
to {
776+
opacity: 0;
777+
transform: translateY(5px) scale(0.975);
778+
}
779+
}
780+
781+
@keyframes composerReasoningItemIn {
782+
from {
783+
opacity: 0;
784+
transform: translateY(4px);
785+
}
786+
to {
787+
opacity: 1;
788+
transform: translateY(0);
789+
}
790+
}
791+
792+
.composer-reasoning-trigger[data-state="open"] {
793+
box-shadow:
794+
0 8px 18px -14px rgba(88, 28, 135, 0.55),
795+
inset 0 0 0 1px rgba(167, 139, 250, 0.12);
796+
}
797+
798+
.composer-reasoning-dropdown[data-state="open"] {
799+
animation: composerReasoningDropdownIn 0.18s cubic-bezier(0.16, 1, 0.3, 1);
800+
transform-origin: var(--radix-select-content-transform-origin);
801+
--tw-enter-opacity: initial;
802+
--tw-enter-scale: initial;
803+
--tw-enter-translate-y: initial;
804+
}
805+
806+
.composer-reasoning-dropdown[data-state="closed"] {
807+
animation: composerReasoningDropdownOut 0.12s cubic-bezier(0.4, 0, 1, 1);
808+
transform-origin: var(--radix-select-content-transform-origin);
809+
--tw-exit-opacity: initial;
810+
--tw-exit-scale: initial;
811+
--tw-exit-translate-y: initial;
812+
}
813+
814+
.composer-reasoning-dropdown[data-state="open"] .composer-reasoning-item {
815+
animation: composerReasoningItemIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
816+
}
817+
758818
/* Chat header model selector animations */
759819
@keyframes modelSelectorDropdownIn {
760820
from {
@@ -809,6 +869,25 @@
809869
animation: modelSelectorItemIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
810870
}
811871

872+
@media (prefers-reduced-motion: reduce) {
873+
.composer-dropdown,
874+
.composer-dropdown [role="menuitemcheckbox"],
875+
.composer-dropdown [role="menuitem"],
876+
.composer-reasoning-dropdown,
877+
.composer-reasoning-item,
878+
.model-selector-dropdown,
879+
.model-selector-item {
880+
animation: none !important;
881+
}
882+
883+
.composer-reasoning-trigger,
884+
.composer-reasoning-trigger > svg:last-child,
885+
.model-selector-trigger,
886+
.model-selector-trigger svg {
887+
transition: none !important;
888+
}
889+
}
890+
812891
/* Context checkpoint animations */
813892
@keyframes checkpointSlideIn {
814893
from {

0 commit comments

Comments
 (0)