ChatInputArea.vue 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458
  1. <template>
  2. <div class="chat-input-area">
  3. <div v-if="pendingFile" class="pending-file-display">
  4. <div class="pending-file-card">
  5. <div class="file-icon-pending"></div>
  6. <div class="file-info-pending">
  7. <div class="file-name-pending">{{ pendingFile.name }}</div>
  8. <div class="file-size-pending">{{ formatFileSize(pendingFile.size) }}</div>
  9. </div>
  10. <div class="remove-pending" @click="emit('remove-pending-file')">
  11. <CloseOutlined />
  12. </div>
  13. </div>
  14. </div>
  15. <!-- 已选会话引用标签 -->
  16. <div v-if="selectedSessions.length > 0" class="session-chips">
  17. <div v-for="session in selectedSessions" :key="session.id" class="session-chip">
  18. <div class="session-chip-icon"></div>
  19. <span class="session-chip-name">{{ session.name }}</span>
  20. <CloseOutlined class="session-chip-remove" @click.stop="removeSession(session.id)" />
  21. </div>
  22. </div>
  23. <a-textarea
  24. ref="textareaRef"
  25. :value="modelValue"
  26. @update:value="handleTextareaInput"
  27. :rows="3"
  28. class="chat-input"
  29. placeholder="请输入你的问题"
  30. :auto-size="{ minRows: 3, maxRows: 6 }"
  31. @keydown="handleSessionKeydown"
  32. @keydown.enter.prevent="handleEnterKey"
  33. @keydown.backspace="handleBackspace"
  34. />
  35. <div class="input-actions">
  36. <div class="action-left">
  37. <div class="btn-upload-wrapper">
  38. <a-tooltip title="添加上下文">
  39. <div class="btn-upload" @click="togglePopup">
  40. <div class="plus-icon"></div>
  41. </div>
  42. </a-tooltip>
  43. <div v-if="showPopup" ref="popupRef" class="upload-popup" @click.stop>
  44. <div class="popup-item" @click="handleAddAttachment">
  45. <div class="popup-item-icon attach-icon"></div>
  46. <span class="popup-item-text">添加附件</span>
  47. </div>
  48. <!-- <div class="popup-item" @click="handleInsertMention">
  49. <div class="popup-item-icon mention-icon"></div>
  50. <span class="popup-item-text">插入 @ 提及</span>
  51. </div> -->
  52. <div class="popup-item" @click="handleInsertSession">
  53. <div class="popup-item-icon session-icon"></div>
  54. <span class="popup-item-text">插入 # 会话</span>
  55. </div>
  56. <!-- <div class="popup-item" @click="handleInsertCommand">
  57. <div class="popup-item-icon command-icon"></div>
  58. <span class="popup-item-text">插入 / 命令</span>
  59. </div> -->
  60. </div>
  61. </div>
  62. <div class="action-divider"></div>
  63. <div class="btn-edit-mode-wrapper">
  64. <a-tooltip title="切换模式">
  65. <div class="btn-edit-mode" @click="toggleEditModePopup">
  66. <div class="btn-icon" :class="`mode-icon-${selectedEditMode.key}`"></div>
  67. <span class="btn-text">{{ selectedEditMode.label }}</span>
  68. <div class="btn-arrow"></div>
  69. </div>
  70. </a-tooltip>
  71. <div v-if="showEditModePopup" ref="editModePopupRef" class="edit-mode-popup">
  72. <div
  73. v-for="item in editModeOptions"
  74. :key="item.key"
  75. class="edit-mode-item"
  76. :class="{ active: selectedEditMode.key === item.key }"
  77. @click="handleSelectEditMode(item)"
  78. >
  79. <div class="edit-mode-item-icon" :class="`mode-icon-${item.key}`"></div>
  80. <div class="edit-mode-item-main">
  81. <span class="edit-mode-item-label">{{ item.label }}</span>
  82. <span class="edit-mode-item-desc">{{ item.desc }}</span>
  83. </div>
  84. <div class="edit-mode-check" :class="{ visible: selectedEditMode.key === item.key }"></div>
  85. </div>
  86. </div>
  87. </div>
  88. <input ref="fileInputRef" type="file" style="display: none" @change="emit('file-change', $event)" />
  89. </div>
  90. <div class="action-right">
  91. <div class="btn-context-wrapper" ref="contextPopupRef" @mouseenter="showContextPopup = true" @mouseleave="showContextPopup = false">
  92. <div class="btn-context" @click="toggleContextPopup">
  93. <svg class="context-ring" viewBox="0 0 32 32">
  94. <circle class="context-ring-bg" cx="16" cy="16" r="13" />
  95. <circle
  96. class="context-ring-fill"
  97. cx="16"
  98. cy="16"
  99. r="13"
  100. :stroke-dasharray="contextCircumference"
  101. :stroke-dashoffset="contextOffset"
  102. :style="{ stroke: contextColor }"
  103. />
  104. </svg>
  105. <span class="context-percent">{{ contextPercent }}%</span>
  106. </div>
  107. <div v-if="showContextPopup" class="context-popup" @click.stop>
  108. <div class="context-popup-title">上下文容量</div>
  109. <div class="context-popup-info">
  110. <span class="context-used">{{ contextDisplayUsed }}</span>
  111. <span class="context-sep">/</span>
  112. <span class="context-max">{{ contextDisplayMax }}</span>
  113. <span class="context-pct">({{ contextPercent }}%)</span>
  114. </div>
  115. <div class="context-popup-bar-track">
  116. <div class="context-popup-bar-fill" :style="{ width: contextPercent + '%', background: contextColor }"></div>
  117. </div>
  118. <div class="context-breakdown">
  119. <div class="breakdown-item">
  120. <span class="breakdown-label">历史消息</span>
  121. <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.messages) }}</span>
  122. </div>
  123. <div class="breakdown-item">
  124. <span class="breakdown-label">MCP工具</span>
  125. <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.mcp) }}</span>
  126. </div>
  127. <div class="breakdown-item">
  128. <span class="breakdown-label">技能描述</span>
  129. <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.skills) }}</span>
  130. </div>
  131. <div class="breakdown-item">
  132. <span class="breakdown-label">系统提示词</span>
  133. <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.system_prompt) }}</span>
  134. </div>
  135. <div class="breakdown-item">
  136. <span class="breakdown-label">其他</span>
  137. <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.other) }}</span>
  138. </div>
  139. </div>
  140. </div>
  141. </div>
  142. <!-- <div class="action-divider"></div>
  143. <div class="btn-model-wrapper" ref="modelPopupRef">
  144. <a-tooltip title="选择模型">
  145. <div class="btn-model" @click="toggleModelPopup">
  146. <span class="btn-text">{{ selectedModel.label }}</span>
  147. <div class="btn-arrow"></div>
  148. </div>
  149. </a-tooltip>
  150. <div v-if="showModelPopup" class="model-popup" @click.stop>
  151. <div
  152. v-for="item in modelOptions"
  153. :key="item.key"
  154. class="model-popup-item"
  155. :class="{ active: selectedModel.key === item.key }"
  156. @click="handleSelectModel(item)"
  157. >
  158. <div class="model-item-main">
  159. <span class="model-item-label">{{ item.label }}</span>
  160. <span class="model-item-desc">{{ item.desc }}</span>
  161. </div>
  162. <div class="model-item-check" :class="{ visible: selectedModel.key === item.key }"></div>
  163. </div>
  164. </div>
  165. </div> -->
  166. <div class="action-divider"></div>
  167. <div class="btn-think-wrapper" ref="thinkPopupRef">
  168. <a-tooltip title="思考级别">
  169. <div class="btn-think" @click="toggleThinkPopup">
  170. <div class="btn-icon think-icon"></div>
  171. <span class="btn-text">{{ selectedThinkLevel.label }}</span>
  172. <div class="btn-arrow"></div>
  173. </div>
  174. </a-tooltip>
  175. <div v-if="showThinkPopup" class="think-popup" @click.stop>
  176. <div
  177. v-for="item in thinkLevelOptions"
  178. :key="item.key"
  179. class="think-popup-item"
  180. :class="{ active: selectedThinkLevel.key === item.key }"
  181. @click="handleSelectThinkLevel(item)"
  182. >
  183. <div class="think-item-main">
  184. <span class="think-item-label">{{ item.label }}</span>
  185. <span class="think-item-desc">{{ item.desc }}</span>
  186. </div>
  187. <div class="think-item-check" :class="{ visible: selectedThinkLevel.key === item.key }"></div>
  188. </div>
  189. </div>
  190. </div>
  191. <div class="action-divider"></div>
  192. <div class="btn-send" :class="{ disabled: !modelValue.trim() || loading }" @click="handleSend">
  193. <div class="send-icon"></div>
  194. </div>
  195. </div>
  196. <!-- 会话列表弹框 -->
  197. <div v-if="showSessionPopup" ref="sessionPopupRef" class="session-popup" @click.stop @pointerdown.stop>
  198. <div class="session-popup-header">
  199. <span>选择会话</span>
  200. <span class="session-count">{{ selectedSessions.length }}/5</span>
  201. </div>
  202. <div class="session-list">
  203. <div
  204. v-for="(task, index) in filteredSessionList"
  205. :key="task.id"
  206. class="session-item"
  207. :class="{
  208. active: index === activeSessionIndex,
  209. selected: isSessionSelected(task.id),
  210. disabled: (selectedSessions.length >= 5 && !isSessionSelected(task.id)) || task.id === props.currentTaskId,
  211. }"
  212. @click="handleSelectSession(task)"
  213. >
  214. <div class="session-checkbox" :class="{ checked: isSessionSelected(task.id) }">
  215. <span v-if="isSessionSelected(task.id)" class="check-mark">✓</span>
  216. </div>
  217. <div class="session-item-icon"></div>
  218. <span class="session-item-name">{{ task.name }}</span>
  219. </div>
  220. <div v-if="filteredSessionList.length === 0" class="session-empty">暂无匹配会话</div>
  221. </div>
  222. <div class="session-popup-footer">
  223. <span v-if="selectedSessions.length >= 5" class="max-hint">最多选择5个会话</span>
  224. <span v-else>点击选择会话(最多5个)</span>
  225. </div>
  226. </div>
  227. </div>
  228. </div>
  229. </template>
  230. <script setup lang="ts">
  231. import { ref, computed, watch, nextTick } from 'vue';
  232. import { onClickOutside } from '@vueuse/core';
  233. import { CloseOutlined } from '@ant-design/icons-vue';
  234. import { formatFileSize } from './utils';
  235. import type { AttachedFile, Task } from './types';
  236. const props = withDefaults(
  237. defineProps<{
  238. modelValue: string;
  239. pendingFile: AttachedFile | null;
  240. loading: boolean;
  241. contextUsed?: number;
  242. contextMax?: number;
  243. contextPercent?: number;
  244. contextBreakdown?: { messages: number; mcp: number; skills: number; system_prompt: number; other: number };
  245. initialThinkLevel?: string;
  246. initialEditMode?: string;
  247. taskList?: Task[];
  248. currentTaskId?: string;
  249. }>(),
  250. {
  251. contextUsed: 0,
  252. contextMax: 1000000,
  253. contextPercent: 0,
  254. contextBreakdown: () => ({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 }),
  255. initialThinkLevel: 'off',
  256. initialEditMode: 'full',
  257. taskList: () => [],
  258. currentTaskId: '',
  259. }
  260. );
  261. const emit = defineEmits<{
  262. (e: 'update:modelValue', value: string): void;
  263. (e: 'send'): void;
  264. (e: 'file-upload'): void;
  265. (e: 'file-change', event: Event): void;
  266. (e: 'remove-pending-file'): void;
  267. (e: 'model-change', modelKey: string): void;
  268. (e: 'think-level-change', levelKey: string): void;
  269. (e: 'edit-mode-change', modeKey: string): void;
  270. (e: 'update:selectedSessions', sessions: Array<{ id: string; name: string }>): void;
  271. }>();
  272. const handleSelectModel = (item: ModelOption) => {
  273. selectedModel.value = item;
  274. showModelPopup.value = false;
  275. emit('model-change', item.key);
  276. };
  277. const fileInputRef = ref<HTMLInputElement>();
  278. const popupRef = ref<HTMLElement>();
  279. const showPopup = ref(false);
  280. // 上下文容量
  281. const contextPopupRef = ref<HTMLElement>();
  282. const showContextPopup = ref(false);
  283. const contextPercent = computed(() => {
  284. return props.contextPercent;
  285. });
  286. const contextCircumference = computed(() => 2 * Math.PI * 13);
  287. const contextOffset = computed(() => contextCircumference.value * (1 - contextPercent.value / 100));
  288. const contextColor = computed(() => {
  289. if (contextPercent.value >= 90) return '#ff4d4f';
  290. if (contextPercent.value >= 70) return '#faad14';
  291. return '#52c41a';
  292. });
  293. const contextDisplayUsed = computed(() => {
  294. if (props.contextUsed >= 10000) return (props.contextUsed / 10000).toFixed(1) + '万';
  295. return props.contextUsed.toLocaleString();
  296. });
  297. const contextDisplayMax = computed(() => {
  298. if (props.contextMax >= 10000) return (props.contextMax / 10000).toFixed(0) + '万';
  299. return props.contextMax.toLocaleString();
  300. });
  301. const formatTokens = (tokens: number) => {
  302. if (tokens >= 10000) return (tokens / 10000).toFixed(1) + '万';
  303. return tokens.toLocaleString();
  304. };
  305. const toggleContextPopup = () => {
  306. showContextPopup.value = !showContextPopup.value;
  307. };
  308. onClickOutside(contextPopupRef, () => {
  309. showContextPopup.value = false;
  310. });
  311. // 模型选择
  312. const modelPopupRef = ref<HTMLElement>();
  313. const showModelPopup = ref(false);
  314. interface ModelOption {
  315. key: string;
  316. label: string;
  317. desc: string;
  318. }
  319. const modelOptions: ModelOption[] = [
  320. { key: 'deepseek-v4-pro', label: 'DeepSeek-V4-Pro', desc: '默认模型,综合能力强' },
  321. { key: 'deepseek-v4', label: 'DeepSeek-V4', desc: '轻量快速,适合简单任务' },
  322. { key: 'gpt-4o', label: 'GPT-4o', desc: 'OpenAI 多模态模型' },
  323. { key: 'gpt-4o-mini', label: 'GPT-4o Mini', desc: 'OpenAI 轻量模型' },
  324. { key: 'claude-sonnet-4', label: 'Claude Sonnet 4', desc: 'Anthropic 高性能模型' },
  325. { key: 'qwen-max', label: '通义千问 Max', desc: '阿里云旗舰模型' },
  326. ];
  327. const selectedModel = ref<ModelOption>(modelOptions[0]);
  328. const toggleModelPopup = () => {
  329. showModelPopup.value = !showModelPopup.value;
  330. };
  331. onClickOutside(modelPopupRef, () => {
  332. showModelPopup.value = false;
  333. });
  334. // 思考级别
  335. const thinkPopupRef = ref<HTMLElement>();
  336. const showThinkPopup = ref(false);
  337. interface ThinkLevelOption {
  338. key: string;
  339. label: string;
  340. desc: string;
  341. }
  342. const thinkLevelOptions: ThinkLevelOption[] = [
  343. { key: 'off', label: '关闭', desc: '不使用深度思考,响应最快' },
  344. { key: 'high', label: '高', desc: '深度思考,平衡速度与质量' },
  345. { key: 'highest', label: '最高', desc: '极致思考,质量最高但较慢' },
  346. ];
  347. const selectedThinkLevel = ref<ThinkLevelOption>(thinkLevelOptions[0]);
  348. watch(
  349. () => props.initialThinkLevel,
  350. (level) => {
  351. const found = thinkLevelOptions.find((opt) => opt.key === level);
  352. if (found) selectedThinkLevel.value = found;
  353. },
  354. { immediate: true }
  355. );
  356. const toggleThinkPopup = () => {
  357. showThinkPopup.value = !showThinkPopup.value;
  358. };
  359. const handleSelectThinkLevel = (item: ThinkLevelOption) => {
  360. selectedThinkLevel.value = item;
  361. showThinkPopup.value = false;
  362. emit('think-level-change', item.key);
  363. };
  364. onClickOutside(thinkPopupRef, () => {
  365. showThinkPopup.value = false;
  366. });
  367. const editModePopupRef = ref<HTMLElement>();
  368. const showEditModePopup = ref(false);
  369. interface EditModeOption {
  370. key: string;
  371. label: string;
  372. desc: string;
  373. }
  374. const editModeOptions: EditModeOption[] = [
  375. // { key: 'confirm', label: '变更前确认', desc: '改文件前先问我' },
  376. // { key: 'auto', label: '自动编辑', desc: '自动编辑文件' },
  377. { key: 'plan', label: '计划模式', desc: '编辑前先出计划' },
  378. { key: 'full', label: '完全访问', desc: '减少确认次数' },
  379. ];
  380. const selectedEditMode = ref<EditModeOption>(editModeOptions[1]);
  381. watch(
  382. () => props.initialEditMode,
  383. (mode) => {
  384. const found = editModeOptions.find((opt) => opt.key === mode);
  385. if (found) selectedEditMode.value = found;
  386. },
  387. { immediate: true }
  388. );
  389. onClickOutside(editModePopupRef, () => {
  390. showEditModePopup.value = false;
  391. });
  392. const toggleEditModePopup = () => {
  393. showEditModePopup.value = !showEditModePopup.value;
  394. };
  395. const handleSelectEditMode = (item: EditModeOption) => {
  396. selectedEditMode.value = item;
  397. showEditModePopup.value = false;
  398. emit('edit-mode-change', item.key);
  399. };
  400. onClickOutside(popupRef, () => {
  401. showPopup.value = false;
  402. });
  403. const togglePopup = () => {
  404. showPopup.value = !showPopup.value;
  405. };
  406. const handleAddAttachment = () => {
  407. showPopup.value = false;
  408. emit('file-upload');
  409. };
  410. const handleInsertMention = () => {
  411. showPopup.value = false;
  412. };
  413. // 会话列表弹框
  414. const textareaRef = ref<InstanceType<any>>();
  415. const sessionPopupRef = ref<HTMLElement>();
  416. const showSessionPopup = ref(false);
  417. const activeSessionIndex = ref(0);
  418. const selectedSessions = ref<Array<{ id: string; name: string }>>([]);
  419. const sessionPopupManual = ref(false);
  420. let isRemovingTag = false;
  421. let sessionJustSelected = false;
  422. const isSessionSelected = (sessionId: string) => {
  423. return selectedSessions.value.some((s) => s.id === sessionId);
  424. };
  425. const sessionSearchText = computed(() => {
  426. const val = props.modelValue || '';
  427. const hashIndex = val.lastIndexOf('#');
  428. if (hashIndex === -1) return '';
  429. const afterHash = val.substring(hashIndex + 1);
  430. if (afterHash.includes(' ')) return '';
  431. return afterHash.toLowerCase();
  432. });
  433. const filteredSessionList = computed(() => {
  434. const list = props.taskList.filter((t) => t.sessionId);
  435. if (!sessionSearchText.value) return list;
  436. return list.filter((t) => t.name.toLowerCase().includes(sessionSearchText.value));
  437. });
  438. watch(filteredSessionList, () => {
  439. activeSessionIndex.value = 0;
  440. });
  441. watch(showSessionPopup, (val) => {
  442. if (val) activeSessionIndex.value = 0;
  443. });
  444. const handleSessionKeydown = (e: KeyboardEvent) => {
  445. if (!showSessionPopup.value || filteredSessionList.value.length === 0) return;
  446. if (e.key === 'ArrowDown') {
  447. e.preventDefault();
  448. activeSessionIndex.value = (activeSessionIndex.value + 1) % filteredSessionList.value.length;
  449. } else if (e.key === 'ArrowUp') {
  450. e.preventDefault();
  451. activeSessionIndex.value = (activeSessionIndex.value - 1 + filteredSessionList.value.length) % filteredSessionList.value.length;
  452. } else if (e.key === 'Enter') {
  453. e.preventDefault();
  454. const task = filteredSessionList.value[activeSessionIndex.value];
  455. if (task) {
  456. sessionJustSelected = true;
  457. handleSelectSession(task);
  458. }
  459. }
  460. };
  461. const handleInsertSession = () => {
  462. showPopup.value = false;
  463. sessionPopupManual.value = true;
  464. showSessionPopup.value = true;
  465. const cur = props.modelValue || '';
  466. const needsSpace = cur && !cur.endsWith(' ') ? ' ' : '';
  467. emit('update:modelValue', `${cur}${needsSpace}#`);
  468. nextTick(() => {
  469. const el = textareaRef.value?.$el?.querySelector('textarea') || textareaRef.value?.$el;
  470. if (el) el.focus();
  471. });
  472. };
  473. const handleSelectSession = (task: Task) => {
  474. if (task.id === props.currentTaskId) return;
  475. const idx = selectedSessions.value.findIndex((s) => s.id === task.id);
  476. if (idx >= 0) {
  477. selectedSessions.value.splice(idx, 1);
  478. } else {
  479. if (selectedSessions.value.length >= 5) return;
  480. selectedSessions.value.push({ id: task.id, name: task.name });
  481. }
  482. removeSearchTag();
  483. emit('update:selectedSessions', [...selectedSessions.value]);
  484. };
  485. const removeSearchTag = () => {
  486. isRemovingTag = true;
  487. const val = props.modelValue || '';
  488. const hashIndex = val.lastIndexOf('#');
  489. if (hashIndex === -1) {
  490. isRemovingTag = false;
  491. return;
  492. }
  493. emit('update:modelValue', val.substring(0, hashIndex).trimEnd());
  494. nextTick(() => {
  495. isRemovingTag = false;
  496. });
  497. };
  498. watch(
  499. () => props.modelValue,
  500. (newVal, oldVal) => {
  501. if (isRemovingTag) return;
  502. if (oldVal && !newVal) selectedSessions.value = [];
  503. }
  504. );
  505. const removeSession = (sessionId: string) => {
  506. selectedSessions.value = selectedSessions.value.filter((s) => s.id !== sessionId);
  507. emit('update:selectedSessions', [...selectedSessions.value]);
  508. };
  509. const handleTextareaInput = (val: string) => {
  510. emit('update:modelValue', val);
  511. if (sessionPopupManual.value) return;
  512. const hashIndex = val.lastIndexOf('#');
  513. if (hashIndex === -1) {
  514. showSessionPopup.value = false;
  515. } else {
  516. const afterHash = val.substring(hashIndex + 1);
  517. if (afterHash.includes(' ')) {
  518. showSessionPopup.value = false;
  519. } else {
  520. showSessionPopup.value = true;
  521. }
  522. }
  523. };
  524. const handleBackspace = (e: KeyboardEvent) => {
  525. const val = props.modelValue || '';
  526. if (val) return;
  527. if (selectedSessions.value.length > 0) {
  528. e.preventDefault();
  529. selectedSessions.value.pop();
  530. emit('update:selectedSessions', [...selectedSessions.value]);
  531. }
  532. };
  533. onClickOutside(sessionPopupRef, () => {
  534. showSessionPopup.value = false;
  535. sessionPopupManual.value = false;
  536. });
  537. const handleInsertCommand = () => {
  538. showPopup.value = false;
  539. };
  540. const handleEnterKey = (e: KeyboardEvent) => {
  541. if (showSessionPopup.value) return;
  542. if (sessionJustSelected) {
  543. sessionJustSelected = false;
  544. return;
  545. }
  546. if (!e.shiftKey && props.modelValue.trim() && !props.loading) {
  547. emit('send');
  548. }
  549. };
  550. const handleSend = () => {
  551. if (props.modelValue.trim() && !props.loading) {
  552. emit('send');
  553. }
  554. };
  555. defineExpose({ fileInputRef });
  556. </script>
  557. <style scoped lang="less">
  558. .chat-input-area {
  559. position: relative;
  560. padding: 5px 10px;
  561. flex-shrink: 0;
  562. background-color: #002f4a;
  563. border: 1px solid #005482;
  564. border-radius: 4px;
  565. margin: 10px 16px 16px 16px;
  566. .pending-file-display {
  567. margin-bottom: 5px;
  568. .pending-file-card {
  569. display: flex;
  570. align-items: center;
  571. gap: 10px;
  572. padding: 10px 12px;
  573. background: rgba(10, 132, 255, 0.06);
  574. border: 1px solid rgba(63, 80, 106, 0.5);
  575. border-radius: 6px;
  576. transition: all 0.3s;
  577. &:hover {
  578. background: rgba(10, 132, 255, 0.1);
  579. }
  580. .file-icon-pending {
  581. width: 32px;
  582. height: 32px;
  583. background-image: var(--img-chat-attach-icon);
  584. background-repeat: no-repeat;
  585. background-size: 100% 100%;
  586. flex-shrink: 0;
  587. }
  588. .file-info-pending {
  589. flex: 1;
  590. min-width: 0;
  591. .file-name-pending {
  592. font-size: 12px;
  593. font-weight: 400;
  594. line-height: 1.4;
  595. color: #e0e6ed;
  596. overflow: hidden;
  597. text-overflow: ellipsis;
  598. white-space: nowrap;
  599. margin-bottom: 2px;
  600. }
  601. .file-size-pending {
  602. font-size: 11px;
  603. font-weight: 400;
  604. line-height: 1.3;
  605. color: #8b949e;
  606. }
  607. }
  608. .remove-pending {
  609. cursor: pointer;
  610. color: #8b949e;
  611. font-size: 14px;
  612. padding: 4px;
  613. border-radius: 4px;
  614. transition: all 0.3s;
  615. &:hover {
  616. color: #ff4d4f;
  617. background: rgba(220, 38, 38, 0.1);
  618. }
  619. }
  620. }
  621. }
  622. :deep(.chat-input) {
  623. color: #e0e6ed;
  624. background-color: transparent !important;
  625. border: none !important;
  626. &::placeholder {
  627. color: #8b949e !important;
  628. opacity: 1 !important;
  629. }
  630. &:focus,
  631. &:hover,
  632. &:active {
  633. border: none !important;
  634. box-shadow: none !important;
  635. outline: none !important;
  636. }
  637. }
  638. .input-actions {
  639. display: flex;
  640. justify-content: space-between;
  641. align-items: center;
  642. margin-top: 12px;
  643. .mode-icon-confirm {
  644. background-image: var(--img-chat-mode-confirm-icon);
  645. }
  646. .mode-icon-auto {
  647. background-image: var(--img-chat-mode-auto-icon);
  648. }
  649. .mode-icon-plan {
  650. background-image: var(--img-chat-mode-plan-icon);
  651. }
  652. .mode-icon-full {
  653. background-image: var(--img-chat-mode-full-icon);
  654. }
  655. .action-left,
  656. .action-right {
  657. display: flex;
  658. gap: 8px;
  659. .action-divider {
  660. width: 1px;
  661. height: 18px;
  662. background: rgba(255, 255, 255, 0.15);
  663. align-self: center;
  664. flex-shrink: 0;
  665. }
  666. .btn-icon {
  667. width: 14px;
  668. height: 14px;
  669. background-repeat: no-repeat;
  670. background-size: 100% 100%;
  671. flex-shrink: 0;
  672. }
  673. .think-icon {
  674. background-image: var(--img-chat-thinking-icon);
  675. }
  676. .btn-text {
  677. font-size: 12px;
  678. color: #e0e6ed;
  679. white-space: nowrap;
  680. }
  681. .btn-arrow {
  682. width: 0;
  683. height: 0;
  684. border-left: 4px solid transparent;
  685. border-right: 4px solid transparent;
  686. border-top: 4px solid #8b949e;
  687. flex-shrink: 0;
  688. }
  689. .btn-upload-wrapper {
  690. position: relative;
  691. }
  692. .btn-upload {
  693. width: 30px;
  694. height: 30px;
  695. border-radius: 6px;
  696. display: flex;
  697. align-items: center;
  698. justify-content: center;
  699. cursor: pointer;
  700. }
  701. .plus-icon {
  702. width: 20px;
  703. height: 20px;
  704. background-image: var(--img-chat-input-bg);
  705. background-repeat: no-repeat;
  706. background-size: 100% 100%;
  707. cursor: pointer;
  708. }
  709. .upload-popup {
  710. position: absolute;
  711. bottom: 38px;
  712. left: 0;
  713. background: #0a2a3f;
  714. border: 1px solid #1a4a6f;
  715. border-radius: 8px;
  716. min-width: 160px;
  717. z-index: 1000;
  718. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
  719. .popup-item {
  720. display: flex;
  721. align-items: center;
  722. gap: 10px;
  723. padding: 9px 16px;
  724. cursor: pointer;
  725. transition: background 0.2s;
  726. border-radius: 8px;
  727. &:hover {
  728. background: rgba(10, 132, 255, 0.15);
  729. }
  730. .popup-item-icon {
  731. width: 18px;
  732. height: 18px;
  733. background-repeat: no-repeat;
  734. background-size: 100% 100%;
  735. flex-shrink: 0;
  736. }
  737. .attach-icon {
  738. background-image: var(--img-chat-popup-attach-icon);
  739. }
  740. .mention-icon {
  741. background-image: var(--img-chat-popup-mention-icon);
  742. }
  743. .session-icon {
  744. background-image: var(--img-chat-popup-session-icon);
  745. }
  746. .command-icon {
  747. background-image: var(--img-chat-popup-command-icon);
  748. }
  749. .popup-item-text {
  750. font-size: 13px;
  751. color: #e0e6ed;
  752. white-space: nowrap;
  753. }
  754. }
  755. }
  756. .btn-edit-mode-wrapper {
  757. position: relative;
  758. }
  759. .btn-edit-mode {
  760. height: 28px;
  761. padding: 0 10px;
  762. border-radius: 6px;
  763. display: flex;
  764. align-items: center;
  765. gap: 4px;
  766. cursor: pointer;
  767. transition: all 0.2s;
  768. border: 1px solid rgba(255, 255, 255, 0.12);
  769. &:hover {
  770. border-color: #3a8fd4;
  771. background: rgba(10, 132, 255, 0.1);
  772. }
  773. }
  774. .edit-mode-popup {
  775. position: absolute;
  776. bottom: 38px;
  777. left: 50%;
  778. transform: translateX(-50%);
  779. background: #0a2a3f;
  780. border: 1px solid #1a4a6f;
  781. border-radius: 8px;
  782. min-width: 200px;
  783. z-index: 1000;
  784. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
  785. .edit-mode-item {
  786. display: flex;
  787. align-items: center;
  788. justify-content: space-between;
  789. padding: 10px 12px;
  790. cursor: pointer;
  791. transition: background 0.2s;
  792. border-radius: 6px;
  793. &:hover {
  794. background: rgba(10, 132, 255, 0.15);
  795. }
  796. &.active {
  797. background: rgba(10, 132, 255, 0.1);
  798. }
  799. .edit-mode-item-icon {
  800. width: 18px;
  801. height: 18px;
  802. background-repeat: no-repeat;
  803. background-size: 100% 100%;
  804. flex-shrink: 0;
  805. margin-right: 10px;
  806. }
  807. .edit-mode-item-main {
  808. display: flex;
  809. flex-direction: column;
  810. gap: 2px;
  811. .edit-mode-item-label {
  812. font-size: 13px;
  813. color: #e0e6ed;
  814. white-space: nowrap;
  815. }
  816. .edit-mode-item-desc {
  817. font-size: 11px;
  818. color: #8b949e;
  819. white-space: nowrap;
  820. }
  821. }
  822. .edit-mode-check {
  823. width: 16px;
  824. height: 16px;
  825. flex-shrink: 0;
  826. display: flex;
  827. align-items: center;
  828. justify-content: center;
  829. visibility: hidden;
  830. &.visible {
  831. visibility: visible;
  832. }
  833. &::after {
  834. content: '✓';
  835. font-size: 12px;
  836. color: #3a8fd4;
  837. }
  838. }
  839. }
  840. }
  841. .btn-context-wrapper {
  842. position: relative;
  843. display: flex;
  844. align-items: center;
  845. }
  846. .btn-context {
  847. width: 30px;
  848. height: 30px;
  849. display: flex;
  850. align-items: center;
  851. justify-content: center;
  852. cursor: pointer;
  853. position: relative;
  854. border-radius: 6px;
  855. transition: background 0.2s;
  856. &:hover {
  857. background: rgba(10, 132, 255, 0.1);
  858. }
  859. .context-ring {
  860. width: 24px;
  861. height: 24px;
  862. transform: rotate(-90deg);
  863. .context-ring-bg {
  864. fill: none;
  865. stroke: rgba(255, 255, 255, 0.1);
  866. stroke-width: 3;
  867. }
  868. .context-ring-fill {
  869. fill: none;
  870. stroke-width: 3;
  871. stroke-linecap: round;
  872. transition: stroke-dashoffset 0.3s ease;
  873. }
  874. }
  875. .context-percent {
  876. position: absolute;
  877. font-size: 8px;
  878. color: #e0e6ed;
  879. font-weight: 500;
  880. line-height: 1;
  881. }
  882. }
  883. .context-popup {
  884. position: absolute;
  885. bottom: 42px;
  886. left: 50%;
  887. transform: translateX(-50%);
  888. background: #0a2a3f;
  889. border: 1px solid #1a4a6f;
  890. border-radius: 8px;
  891. min-width: 200px;
  892. padding: 14px 16px;
  893. &::before {
  894. content: '';
  895. position: absolute;
  896. bottom: -12px;
  897. left: 0;
  898. right: 0;
  899. height: 12px;
  900. }
  901. z-index: 1000;
  902. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
  903. .context-popup-title {
  904. font-size: 13px;
  905. color: #8b949e;
  906. margin-bottom: 8px;
  907. }
  908. .context-popup-info {
  909. display: flex;
  910. align-items: baseline;
  911. gap: 2px;
  912. margin-bottom: 10px;
  913. .context-used {
  914. font-size: 18px;
  915. font-weight: 600;
  916. color: #e0e6ed;
  917. }
  918. .context-sep {
  919. font-size: 14px;
  920. color: #8b949e;
  921. }
  922. .context-max {
  923. font-size: 14px;
  924. color: #8b949e;
  925. }
  926. .context-pct {
  927. font-size: 13px;
  928. color: #8b949e;
  929. }
  930. }
  931. .context-popup-bar-track {
  932. width: 100%;
  933. height: 6px;
  934. background: rgba(255, 255, 255, 0.08);
  935. border-radius: 3px;
  936. overflow: hidden;
  937. .context-popup-bar-fill {
  938. height: 100%;
  939. border-radius: 3px;
  940. transition: width 0.3s ease;
  941. }
  942. }
  943. .context-breakdown {
  944. margin-top: 10px;
  945. padding-top: 10px;
  946. border-top: 1px solid rgba(255, 255, 255, 0.08);
  947. .breakdown-item {
  948. display: flex;
  949. justify-content: space-between;
  950. align-items: center;
  951. padding: 3px 0;
  952. .breakdown-label {
  953. font-size: 12px;
  954. color: #8b949e;
  955. }
  956. .breakdown-value {
  957. font-size: 12px;
  958. color: #e0e6ed;
  959. }
  960. }
  961. }
  962. }
  963. .btn-model-wrapper {
  964. position: relative;
  965. display: flex;
  966. align-items: center;
  967. }
  968. .btn-model {
  969. height: 28px;
  970. padding: 0 10px;
  971. border-radius: 6px;
  972. display: flex;
  973. align-items: center;
  974. gap: 4px;
  975. cursor: pointer;
  976. transition: all 0.2s;
  977. border: 1px solid rgba(255, 255, 255, 0.12);
  978. &:hover {
  979. border-color: #3a8fd4;
  980. background: rgba(10, 132, 255, 0.1);
  981. }
  982. }
  983. .model-popup {
  984. position: absolute;
  985. bottom: 38px;
  986. left: 50%;
  987. transform: translateX(-50%);
  988. background: #0a2a3f;
  989. border: 1px solid #1a4a6f;
  990. border-radius: 8px;
  991. min-width: 220px;
  992. z-index: 1000;
  993. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
  994. .model-popup-item {
  995. display: flex;
  996. align-items: center;
  997. justify-content: space-between;
  998. padding: 10px 12px;
  999. cursor: pointer;
  1000. transition: background 0.2s;
  1001. border-radius: 6px;
  1002. &:hover {
  1003. background: rgba(10, 132, 255, 0.15);
  1004. }
  1005. &.active {
  1006. background: rgba(10, 132, 255, 0.1);
  1007. }
  1008. .model-item-main {
  1009. display: flex;
  1010. flex-direction: column;
  1011. gap: 2px;
  1012. .model-item-label {
  1013. font-size: 13px;
  1014. color: #e0e6ed;
  1015. white-space: nowrap;
  1016. }
  1017. .model-item-desc {
  1018. font-size: 11px;
  1019. color: #8b949e;
  1020. white-space: nowrap;
  1021. }
  1022. }
  1023. .model-item-check {
  1024. width: 16px;
  1025. height: 16px;
  1026. flex-shrink: 0;
  1027. display: flex;
  1028. align-items: center;
  1029. justify-content: center;
  1030. visibility: hidden;
  1031. &.visible {
  1032. visibility: visible;
  1033. }
  1034. &::after {
  1035. content: '✓';
  1036. font-size: 12px;
  1037. color: #3a8fd4;
  1038. }
  1039. }
  1040. }
  1041. }
  1042. .btn-think-wrapper {
  1043. position: relative;
  1044. display: flex;
  1045. align-items: center;
  1046. }
  1047. .btn-think {
  1048. height: 28px;
  1049. padding: 0 10px;
  1050. border-radius: 6px;
  1051. display: flex;
  1052. align-items: center;
  1053. gap: 4px;
  1054. cursor: pointer;
  1055. transition: all 0.2s;
  1056. border: 1px solid rgba(255, 255, 255, 0.12);
  1057. &:hover {
  1058. border-color: #3a8fd4;
  1059. background: rgba(10, 132, 255, 0.1);
  1060. }
  1061. }
  1062. .think-popup {
  1063. position: absolute;
  1064. bottom: 38px;
  1065. left: 50%;
  1066. transform: translateX(-50%);
  1067. background: #0a2a3f;
  1068. border: 1px solid #1a4a6f;
  1069. border-radius: 8px;
  1070. min-width: 200px;
  1071. z-index: 1000;
  1072. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
  1073. .think-popup-item {
  1074. display: flex;
  1075. align-items: center;
  1076. justify-content: space-between;
  1077. padding: 10px 12px;
  1078. cursor: pointer;
  1079. transition: background 0.2s;
  1080. border-radius: 6px;
  1081. &:hover {
  1082. background: rgba(10, 132, 255, 0.15);
  1083. }
  1084. &.active {
  1085. background: rgba(10, 132, 255, 0.1);
  1086. }
  1087. .think-item-main {
  1088. display: flex;
  1089. flex-direction: column;
  1090. gap: 2px;
  1091. .think-item-label {
  1092. font-size: 13px;
  1093. color: #e0e6ed;
  1094. white-space: nowrap;
  1095. }
  1096. .think-item-desc {
  1097. font-size: 11px;
  1098. color: #8b949e;
  1099. white-space: nowrap;
  1100. }
  1101. }
  1102. .think-item-check {
  1103. width: 16px;
  1104. height: 16px;
  1105. flex-shrink: 0;
  1106. display: flex;
  1107. align-items: center;
  1108. justify-content: center;
  1109. visibility: hidden;
  1110. &.visible {
  1111. visibility: visible;
  1112. }
  1113. &::after {
  1114. content: '✓';
  1115. font-size: 12px;
  1116. color: #3a8fd4;
  1117. }
  1118. }
  1119. }
  1120. }
  1121. .btn-send {
  1122. width: 32px;
  1123. height: 32px;
  1124. display: flex;
  1125. align-items: center;
  1126. justify-content: center;
  1127. cursor: pointer;
  1128. opacity: 1;
  1129. transition: opacity 0.2s;
  1130. &.disabled {
  1131. opacity: 0.4;
  1132. cursor: not-allowed;
  1133. }
  1134. .send-icon {
  1135. width: 24px;
  1136. height: 24px;
  1137. background-image: var(--img-chat-send-icon);
  1138. background-repeat: no-repeat;
  1139. background-size: 100% 100%;
  1140. }
  1141. }
  1142. }
  1143. }
  1144. .session-chips {
  1145. display: flex;
  1146. flex-wrap: wrap;
  1147. gap: 6px;
  1148. margin-bottom: 5px;
  1149. .session-chip {
  1150. display: inline-flex;
  1151. align-items: center;
  1152. gap: 4px;
  1153. padding: 3px 6px 3px 4px;
  1154. background: rgba(10, 132, 255, 0.15);
  1155. border: 1px solid rgba(10, 132, 255, 0.3);
  1156. border-radius: 4px;
  1157. font-size: 12px;
  1158. color: #e0e6ed;
  1159. cursor: default;
  1160. .session-chip-icon {
  1161. width: 16px;
  1162. height: 16px;
  1163. background-image: var(--img-chat-popup-session-icon);
  1164. background-repeat: no-repeat;
  1165. background-size: 100% 100%;
  1166. flex-shrink: 0;
  1167. }
  1168. .session-chip-name {
  1169. max-width: 150px;
  1170. overflow: hidden;
  1171. text-overflow: ellipsis;
  1172. white-space: nowrap;
  1173. }
  1174. .session-chip-remove {
  1175. font-size: 10px;
  1176. color: #8b949e;
  1177. cursor: pointer;
  1178. padding: 0 2px;
  1179. transition: color 0.2s;
  1180. &:hover {
  1181. color: #ff4d4f;
  1182. }
  1183. }
  1184. }
  1185. }
  1186. .session-popup {
  1187. position: absolute;
  1188. bottom: 100%;
  1189. left: 0;
  1190. right: 0;
  1191. margin-bottom: 8px;
  1192. background: #0a2a3f;
  1193. border: 1px solid #1a4a6f;
  1194. border-radius: 8px;
  1195. max-height: 300px;
  1196. display: flex;
  1197. flex-direction: column;
  1198. z-index: 1000;
  1199. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
  1200. .session-popup-header {
  1201. display: flex;
  1202. justify-content: space-between;
  1203. align-items: center;
  1204. padding: 10px 14px 8px;
  1205. font-size: 13px;
  1206. color: #e0e6ed;
  1207. border-bottom: 1px solid rgba(63, 80, 106, 0.3);
  1208. flex-shrink: 0;
  1209. .session-count {
  1210. font-size: 12px;
  1211. color: #3a8fd4;
  1212. font-weight: 500;
  1213. }
  1214. }
  1215. .session-list {
  1216. flex: 1;
  1217. overflow-y: auto;
  1218. padding: 6px;
  1219. .session-item {
  1220. display: flex;
  1221. align-items: center;
  1222. gap: 10px;
  1223. padding: 8px 10px;
  1224. cursor: pointer;
  1225. border-radius: 6px;
  1226. transition: background 0.2s;
  1227. &:hover {
  1228. background: rgba(10, 132, 255, 0.15);
  1229. }
  1230. &.active {
  1231. background: rgba(10, 132, 255, 0.2);
  1232. }
  1233. &.selected {
  1234. background: rgba(10, 132, 255, 0.12);
  1235. }
  1236. &.disabled {
  1237. opacity: 0.4;
  1238. cursor: not-allowed;
  1239. &:hover {
  1240. background: transparent;
  1241. }
  1242. }
  1243. .session-checkbox {
  1244. width: 16px;
  1245. height: 16px;
  1246. border: 1.5px solid rgba(255, 255, 255, 0.25);
  1247. border-radius: 3px;
  1248. flex-shrink: 0;
  1249. display: flex;
  1250. align-items: center;
  1251. justify-content: center;
  1252. transition: all 0.2s;
  1253. &.checked {
  1254. background: #3a8fd4;
  1255. border-color: #3a8fd4;
  1256. }
  1257. .check-mark {
  1258. font-size: 11px;
  1259. color: #fff;
  1260. line-height: 1;
  1261. }
  1262. }
  1263. .session-item-icon {
  1264. width: 18px;
  1265. height: 18px;
  1266. background-image: var(--img-chat-popup-session-icon);
  1267. background-repeat: no-repeat;
  1268. background-size: 100% 100%;
  1269. flex-shrink: 0;
  1270. }
  1271. .session-item-name {
  1272. font-size: 13px;
  1273. color: #e0e6ed;
  1274. overflow: hidden;
  1275. text-overflow: ellipsis;
  1276. white-space: nowrap;
  1277. }
  1278. }
  1279. .session-empty {
  1280. text-align: center;
  1281. color: #8b949e;
  1282. font-size: 13px;
  1283. padding: 20px 0;
  1284. }
  1285. }
  1286. .session-popup-footer {
  1287. padding: 8px 14px;
  1288. font-size: 12px;
  1289. color: #8b949e;
  1290. text-align: center;
  1291. border-top: 1px solid rgba(63, 80, 106, 0.3);
  1292. flex-shrink: 0;
  1293. .max-hint {
  1294. color: #faad14;
  1295. }
  1296. }
  1297. }
  1298. }
  1299. </style>