diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx
index 7722af3..dadae74 100644
--- a/web/pages/Assistants.tsx
+++ b/web/pages/Assistants.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
+import { createPortal } from 'react-dom';
import { Plus, Search, Play, Square, Copy, Trash2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, Rocket, AlertTriangle, PhoneCall, CameraOff, Image, Images, CloudSun, Calendar, TrendingUp, Coins, Wrench, Globe, Terminal, X, ClipboardCheck, Sparkles, Volume2, Timer, ChevronDown, Database, Server, Zap, ExternalLink, Key, BrainCircuit, Ear, Book, Filter } from 'lucide-react';
import { Button, Input, Badge, Drawer, Dialog } from '../components/UI';
import { ASRModel, Assistant, KnowledgeBase, LLMModel, TabValue, Tool, Voice } from '../types';
@@ -791,30 +792,34 @@ export const AssistantsPage: React.FC = () => {
}}
placeholder="设定小助手的人设、语气、行为规范以及业务逻辑..."
/>
- {templateSuggestion?.field === 'prompt' && filteredSystemTemplateVariables.length > 0 && (
-
- {filteredSystemTemplateVariables.map((item) => (
-
- ))}
-
- )}
+ {templateSuggestion?.field === 'prompt' &&
+ filteredSystemTemplateVariables.length > 0 &&
+ typeof document !== 'undefined' &&
+ createPortal(
+
+ {filteredSystemTemplateVariables.map((item) => (
+
+ ))}
+
,
+ document.body
+ )}
@@ -914,30 +919,35 @@ export const AssistantsPage: React.FC = () => {
disabled={selectedAssistant.generatedOpenerEnabled === true}
className="bg-white/5 border-white/10 focus:border-primary/50 disabled:opacity-50 disabled:cursor-not-allowed"
/>
- {templateSuggestion?.field === 'opener' && filteredSystemTemplateVariables.length > 0 && selectedAssistant.generatedOpenerEnabled !== true && (
-
- {filteredSystemTemplateVariables.map((item) => (
-
- ))}
-
- )}
+ {templateSuggestion?.field === 'opener' &&
+ filteredSystemTemplateVariables.length > 0 &&
+ selectedAssistant.generatedOpenerEnabled !== true &&
+ typeof document !== 'undefined' &&
+ createPortal(
+
+ {filteredSystemTemplateVariables.map((item) => (
+
+ ))}
+
,
+ document.body
+ )}
{selectedAssistant.generatedOpenerEnabled === true
@@ -1754,12 +1764,15 @@ const getTemplateSuggestionAnchor = (
}
const computedStyle = window.getComputedStyle(control);
+ const isInput = control instanceof HTMLInputElement;
const mirror = document.createElement('div');
const marker = document.createElement('span');
const propertiesToCopy = [
+ 'direction',
'boxSizing',
'width',
- 'height',
+ 'overflowX',
+ 'overflowY',
'paddingTop',
'paddingRight',
'paddingBottom',
@@ -1768,28 +1781,41 @@ const getTemplateSuggestionAnchor = (
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
+ 'borderTopStyle',
+ 'borderRightStyle',
+ 'borderBottomStyle',
+ 'borderLeftStyle',
'fontFamily',
'fontSize',
'fontStyle',
'fontVariant',
'fontWeight',
+ 'fontStretch',
'letterSpacing',
'lineHeight',
'textAlign',
'textIndent',
'textTransform',
+ 'textDecoration',
'wordSpacing',
'tabSize',
+ 'overflowWrap',
] as const;
- mirror.style.position = 'fixed';
- mirror.style.left = `${rect.left}px`;
- mirror.style.top = `${rect.top}px`;
+ mirror.style.position = 'absolute';
+ mirror.style.left = '0px';
+ mirror.style.top = '0px';
mirror.style.visibility = 'hidden';
mirror.style.pointerEvents = 'none';
- mirror.style.overflow = 'hidden';
- mirror.style.whiteSpace = control instanceof HTMLInputElement ? 'pre' : 'pre-wrap';
+ mirror.style.whiteSpace = isInput ? 'pre' : 'pre-wrap';
mirror.style.wordBreak = 'break-word';
+ mirror.style.wordWrap = 'break-word';
+ mirror.style.overflow = 'hidden';
+
+ if (isInput) {
+ mirror.style.height = computedStyle.height;
+ mirror.style.lineHeight = computedStyle.height;
+ }
for (const property of propertiesToCopy) {
(mirror.style as any)[property] = (computedStyle as any)[property];
@@ -1798,16 +1824,16 @@ const getTemplateSuggestionAnchor = (
const value = control.value || '';
const safeCaret = Math.min(caret, value.length);
mirror.textContent = value.slice(0, safeCaret);
- marker.textContent = value.slice(safeCaret, safeCaret + 1) || '\u200b';
+ marker.textContent = value.slice(safeCaret) || '.';
+ marker.style.display = 'inline-block';
+ marker.style.width = '1px';
mirror.appendChild(marker);
document.body.appendChild(mirror);
- mirror.scrollTop = control.scrollTop;
- mirror.scrollLeft = control.scrollLeft;
-
- const markerRect = marker.getBoundingClientRect();
+ const markerLeft = marker.offsetLeft;
+ const markerTop = marker.offsetTop;
document.body.removeChild(mirror);
- if (!Number.isFinite(markerRect.left) || !Number.isFinite(markerRect.top)) {
+ if (!Number.isFinite(markerLeft) || !Number.isFinite(markerTop)) {
return fallback;
}
@@ -1818,7 +1844,9 @@ const getTemplateSuggestionAnchor = (
: Number.isFinite(fontSizeRaw)
? fontSizeRaw * 1.2
: 16;
- return clampTemplateSuggestionPosition(markerRect.left, markerRect.top + lineHeight + 6);
+ const caretLeft = rect.left + markerLeft - control.scrollLeft;
+ const caretTop = rect.top + markerTop - control.scrollTop;
+ return clampTemplateSuggestionPosition(caretLeft, caretTop + lineHeight + 6);
};
const extractDynamicTemplateKeys = (text: string): string[] => {
@@ -1833,10 +1861,17 @@ const extractDynamicTemplateKeys = (text: string): string[] => {
return Array.from(keys);
};
+type DebugTranscriptMessage = {
+ role: 'user' | 'model' | 'tool';
+ text: string;
+ responseId?: string;
+ ttfbMs?: number;
+};
+
// Stable transcription log so the scroll container is not recreated on every render (avoids scroll jumping)
const TranscriptionLog: React.FC<{
scrollRef: React.RefObject;
- messages: { role: 'user' | 'model' | 'tool'; text: string }[];
+ messages: DebugTranscriptMessage[];
isLoading: boolean;
className?: string;
}> = ({ scrollRef, messages, isLoading, className = '' }) => (
@@ -1845,7 +1880,14 @@ const TranscriptionLog: React.FC<{
{messages.map((m, i) => (
-
{m.role === 'user' ? 'Me' : m.role === 'tool' ? 'Tool' : 'AI'}
+
+ {m.role === 'user' ? 'Me' : m.role === 'tool' ? 'Tool' : 'AI'}
+ {m.role === 'model' && typeof m.ttfbMs === 'number' && Number.isFinite(m.ttfbMs) && (
+
+ TTFB {Math.round(m.ttfbMs)}ms
+
+ )}
+
{m.text}
@@ -1911,7 +1953,7 @@ export const DebugDrawer: React.FC<{
};
const [mode, setMode] = useState<'text' | 'voice' | 'video'>('text');
- const [messages, setMessages] = useState<{role: 'user' | 'model' | 'tool', text: string}[]>([]);
+ const [messages, setMessages] = useState([]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [callStatus, setCallStatus] = useState<'idle' | 'calling' | 'active'>('idle');
@@ -1973,6 +2015,8 @@ export const DebugDrawer: React.FC<{
const pendingRejectRef = useRef<((e: Error) => void) | null>(null);
const submittedMetadataRef = useRef | null>(null);
const assistantDraftIndexRef = useRef(null);
+ const assistantResponseIndexByIdRef = useRef