Compare commits

..
4 Commits
Author SHA1 Message Date
gozeros 8bc8b0e082 sync: 鍚屾灞€鍩熺綉鏈€鏂颁唬鐮?(source master @ b69568e4) 2026-10-21 14:51:31 +08:00
董宏凯 4f7f07504a Merge branch 'master-nj' into 'master'
fix(buttons): 行动分析宿主路由支持切换任务id/行动id

See merge request knowledge-base-bj/deerflow-code!39
2026-09-07 09:53:09 +00:00
吉鑫升 3ca5528640 feat(knowledge): support standalone file ingestion and configurable wiki entry
Use neutral knowledge-service labels and sanitize user-facing task errors.
2026-09-07 17:07:27 +08:00
董宏凯 f9b6d7e322 Merge branch 'master-nj' into 'master'
feat: 新增报告协作页,并强化 Deep Research 运行诊断与报告兜底

See merge request knowledge-base-bj/deerflow-code!38
2026-09-07 06:58:42 +00:00
1656 changed files with 279118 additions and 11440 deletions

No files matched your search

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="188" height="241" viewBox="0 0 188 241"><defs><style>.a{fill:url(#a);}.b{fill:#fff;opacity:0.47;}</style><pattern id="a" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 376 482"><image width="376" height="482" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXgAAAHiCAYAAADiYNeDAAAgAElEQVR4nOy993csOZYmdoFIw6R73pav6qrqrvY9flatmdldrWa1+nv1m46OjrSr7ZnZdtW2ulxX1Xt8ho+eTBcB6CACQFzcQEQaMskkeb938iUzvP3w4cPFhdDpGK41hGg+ez3jxZm0veg69r85Vj0zzHqe/thnOmgJAIn9xh8zrQsAqwBwAwA2ybf7rNqPWbaH1nG/V+zfCZRX02y/BQBtO90gAwDz4KcAoOw0bacPAWAAAH3794n9xr/NZx99DiD8dutkdvv4k6F9Toae88Zc+LOk53umZj3fSec57/W7Imhd67NnnCc6loAdETsy7iKiXgOADQBYtyTvvjft9B4i9hW7Tfy7a6dhghcNNKBR0eYIfoRI3hH7iPw2n0NL7OZzhL7N9GNE8kO0PVdwDOw2GYyFggmesWiYZ+wmADwEgHsAcB8AbgPAHft9Cyl2R9DtyHfbbqtlCRx/5JznQMk/sftdt7+d4saf1H7G9jOKfA+Rot8FgB0AeGW/XwDASwB4BgB7dlsMxkLABM84K6zZzyr53LREPongO0t4J5yN1J5j3dEUBL9jSf6EfI7th8E4FdiDZw++wPwefMcS9+sA8BoAPAaABwDwyJL4LUT4MYsmmWIvlxlZjUXjSH3Xkv8WADwHgKcA8AQAvskLAq3ns3LYg59ve1cMrOAZs2DN2hfryCu/awk9RvC3llSZnycSVMDFMLIkHyN483sbeftH9sPqnjEVWMGzgi/QfJ7SEvYbAPCW/X7TEvl9a7GsEZume34HfyXgInSO0efAWjqG6L8CgK8B4M/2e6sxGocV/Hzbu2JggmeCLxCe5xoJVbxrSR0T/BuW4Ncu7qCvBY4twX9NCP5rq+5xiGap7Jng59veFQMTPBN8Ae2P5JFV5+8BwLcA4B077S6yZ9xn3ugVxmxQyJ5xn1fWzvkCAD4FgM+syt/K7yYT/Hzbu2JggmeCv5V/dB7tctcq9LcIwd+5kCNjTMIrQvB/tp9tEHl0zq79nC+Y4JcGTPDXl+DbVpl/GwC+CwAf2obSB6hz0Q0b9cJYXvSRRXNg7RzTQPtHAPgtAPzBqvrze9GZ4JcGTPDXi+BvothzZ8Vggn/AkVWXHqkleUzwzrrZRbH3iwMT/NKACf56ELywZP4hIvN3bPz6Lfu5sbC9My4C+8iieWmtHEf6f7Skvxj2Y4JfGjDBX12C71jv/I5V5h8AwHcswX9gY9YvsimOcX7QNrb+E0vwv7d/P7c+/qszzY3DBL80YIK/mgTfsw2kP7Af02D6trVnbrNav7bYtxaN+XxpG2Z/bT+fWj//9GCCXxowwV8dgu/ZKJh71o75DiL4tzhenUFwbCNuHMH/3to2L218/fxkzwS/NGCCvxoEv2oJ/ScA8GOr3h8ji2aeZFmMq48xsmieWhX/CwD4uW2cnS8lAhP80oAJ/vISfBcpdmO//NAS/I9suCN3QmLMAmXDK39pCf7XtmHWKfrh1Ntigl8aMMFfToJfsQ2lP7GfD23qgHuW9BmMebFtSf1rG23zc6vqP5natmGCXxowwV8egpcoIuZt6607S+ataNrd6/1sM6ZF/LnLrEf/C/txit5F3tQnOmOCXxowwV8OgheW1I398jcA8D0bx37XfkI7RgOzO2NGRJ8/ZRX9tiX33wDAz6yN82XtQ8YEvzTgXovLjRWr2E1UzPcB4C8swX9rqnS8zPGMaVBPktKmg76PQm3v2V7QH1sb55kdwISxhGAFv7wKftX67H8PAH9pOyi5wTTCqBiq2JnYGfMgeP6iin6MBiUxHab+DQD+P+vVn/ilWMEvDVjBLx82LIl/aP31v7fWzKPokeo5XyQGo/IsRX6EgqVtI7Ret436D2270GuW5J/ZkacYSwJW8Mul4Dcsmf8tAPydTQT2wPY+Jcely+Njz51x5rAK3j3O8ed6xyp6EzP/362a/xVok9WSFfwygBX8cmAzt190Tuh/Z332H+fT6QOMiR34AWYsCo6g3QMYVfS3Qefi4zXUqe6u7RW7ZVMjMC4QrOAvXsHfsSGPPwWRe+3fRjnZ64+HiZ1xnnDPdf3jfWgtmj+Chn8FgP/XhlZuz3SUrODPFKzgLw43rPL5nlXsPwUNH03MGcPEzrgI+OeullE37McFAty0ETcf20yWrOYvAKzgL0bB37Ahj39vPx/YRtTV6v6Z0BlLjPjzfuLVPMB/s5//MRXJs4I/U7CCP1/0rMIxqv1/tuT+gyixMxiXF+Z5ftdG2bgBZTZtR6knZ5aWmDERrODPT8EbcjcWzD9aYv++Jfu4JcPKnXGZUP/cH9sG199YJf9/2b/jnaNYwZ8pWMEvHm3bE/A7ltj/yWZ+vHXVT5zBsALmW2h4yFXrz5tImxfnOhj4NQQr+MUq+Jbt4m3i2v+97ZH6ejxChhU74yoh+l4d2vQGxo//P21em0/tQOEFWMGfKVjBLw63rQ9pyP0frHp/fFVPlsGYAhvWprxlLctNO+0z22mKccZgBb8YBX/bhj6ahtR/Z9MO3A+36zqOzLh9BuMywUdXVh70FzbH/H8FgP8bAP4lT0PMCv5MwQr+bNG2se0m3cB/sOT+0VSZHxmM64X7Nlx43fr0Kzbv/BP25c8OTPBnB5E3Jmn9H6wl82NryYTk7hUHSTnAYFwHhM971/bc3rD9QF6z3vwf+M04GzDBnw3MA/q+tWT+2XZiah46j6T4YDCuJCZbkCs2EMGp+Zb15//EmSlPDyb402PNhj3+bzYE8sNoCKSoYXL24BnXAqTNSVcefCOI/toSvbFv/g9r2Rzz8zE/mOBPh7vIb/9fbeclck01p/NlMCg0SpNd4pYNJe7aGSt2eMDZEpYxPJjg54fx1/8KAP5LniisGM6suJ5epWjQ+YAcPCgHgxGiYPg8uMZF2BTvSMt2CuzZTKumhvyvdhQpxoxggp8dLlLGhEH+R9uB6d18K1iMeGLX1z5Ui8GYCCFwu9SKbXztICX/M46wmR1M8LOhZcn8761y/+to56VAuTO5MxhRmPfETi/cmoov/5a1Pl2Kg/9mO0WlfEGnAxP89FixNoyJlPlfAOB/snmvS/jRlpjcGYypEH1HPNEnduzXjjVw2nbml7XJyhgBmOCnQ9fmbP+pVe4/CcjdKRGtUXw7kzuDMRWC90f44WCRon9g3701S/L/j42VH/EFbgYT/GSs2Tjdf7Ke+18FMe4Vtc7EzmDMDCyIzN9+iMCA5I0lmiHe+ozDKJvBBN+Mnm3R/0fbgemHlNwDr51j2hmMU0KXkcVVb/6OTd7Xs9xlrJvf8gAi9WCCr8ea7Z36T9Zz/4s8j7UAFNfOXjuDsRBUkvFZ30bnAsu8iwoApFX0n7CSj4MJPo5VG6b1TyDyHqo/tIMUWCDlDqzcGYwzBcrX5HnedYoq5t20HaI6dkHTGPs7OxYsA4EJvooVOwLNP1rl/qOA3B2xK0XqkWy/MxhnAvw+5danBpCymFbaNbfsuzm0S46skufoGgQm+BBda8uYbJD/2WaEDMndELvSIJxRKJjYGYwzBX6f3N+ZApCiIPqS5G/YoIcEEf0n9u9rD2CCD2Cqe++gOPcf2YE7CuTkbj9Bi/8FHzWDcdXh2ryU/ZZRJX9ifXnT0/VzDqEswARfoG3J/ac2K+Rf2Rb7Aki5c4Mqg3FBcO8hVJS8i65JLLFrS/LXPq0BE3xhsjy2oy/9J9tCf8/P9cpdlR0xGAzGxUAboSUsyQdK/p59d09sZI3x4r+67nVsJniAh1ax/2er4MuxU51iMP4fcKw7g3HhcDXoLCuiJEMlf9++wwYHVs1vXeebdt0J/o4l9/9ke8kV6Qe854eVO1szDMZSwJG8t2vAhVAK1ON11xL8f80H876muM4Ev5E3zmj9v9tG1df8HPP8qKwgeCDkzh48g7EEsFFsRsmb0aFkgg/pNftOg+0A9S/Xdfi/60rwa7bl3WWFfNvPCTx3YOXOYCwrvJK3bWMmjLKwaxL7Tpt3e8cq+Z9fx96u15HgE9tL9b/YiJl3/RyOlmEwLhmsko9H17xr33Gwja+/tA2w1wbXjeDbNjPkP9gcM98JrkHeS1UxuTMYlw35eyuKT0nwXfuOm2RkL62C/+w6hU9eN4J/3Q6Q/c8BueOqHm1QrY4yw2AwlgW006GyqQ3Kd9eN8frPtoer+XxxXe5f60p6zP6UfHIiYSNmfmzHUP2RbWQtkVfx1AUdMIPBOBsom2fS5q4psGHf+UMbNnloI2t00M52BbVc68q2IebtLi5fjLhtwyEdud8LlnPWjELqnZU7g7H8cO+pH5fBkLu1a0Bg0r5r3/0ta9n8DLQuwievcE6pq6vg89PKb/CKbWwx2SH/znZsssshYqfZIbnTKoNxORAMpqZRVI0uOkOFo0IZDjjynryGQTX3/NXBVffguzbHzN/aVAQf2lzvtvXd9lJVqmyNd+AskQzG5YEm728eHy+LmDkhHXuvWg4wETXfWKL//Cpnn7zqBH8HtOnVpv+xGDRbrBWTrcTPQyKzOJGzgmcwLgfo++uVfFa8w17BO5LXH+Q1ep178SalwZOreqevLsELWAWtv1tEzYh/VzSy2ichL+1tT1WqLine truncated

After

Width:  |  Height:  |  Size: 68 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68"><defs><style>.a{fill:#fff;stroke:#707070;stroke-width:3.75px;opacity:0;}.b{fill:url(#a);}.c{stroke:none;}.d{fill:none;}</style><linearGradient id="a" x1="0.431" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f3212c"/><stop offset="1" stop-color="#aa071a"/></linearGradient></defs><g transform="translate(-71.077 -67.746)"><g class="a" transform="translate(71.077 67.746)"><rect class="c" width="68" height="68"/><rect class="d" x="1.875" y="1.875" width="64.25" height="64.25"/></g><path class="b" d="M114.406,106.791a7.707,7.707,0,0,0-4.354,1.353L98.7,99.937a13.3,13.3,0,0,0,1.742-6.583c0-.421-.03-.842-.06-1.263L112,85.207a4.687,4.687,0,1,0-1.952-2.345L99.691,88.995a13.678,13.678,0,0,0-6.1-7.365l2.072-10.341c.15,0,.3.03.451.03a4.66,4.66,0,1,0-4.655-4.659,4.727,4.727,0,0,0,1.412,3.367l-2.1,10.462A13.479,13.479,0,0,0,80.05,81.87l-6.4-5.712A6.129,6.129,0,1,0,71.7,78.443L77.677,83.7a13.456,13.456,0,0,0,.691,19.991l-5.316,5.622a5.948,5.948,0,0,0-3.394-1.022,6.163,6.163,0,1,0,6.156,6.162,6.343,6.343,0,0,0-.721-2.916l5.826-6.193a13.464,13.464,0,0,0,16.1-2.946l11.022,7.966a7.77,7.77,0,0,0-1.2,4.118,7.658,7.658,0,1,0,15.316,0A7.817,7.817,0,0,0,114.406,106.791Zm0,0" transform="translate(13 9.671)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68">
<defs>
<linearGradient id="ap-idea" x1="0.405" x2="1" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#f3212c"/>
<stop offset="1" stop-color="#aa071a"/>
</linearGradient>
</defs>
<g fill="url(#ap-idea)" transform="translate(12 10)">
<path d="M22 2.2A12.8 12.8 0 0 0 9.2 15c0 5.1 2.9 9.5 7.1 11.6v3.8a2.4 2.4 0 0 0 2.4 2.4h6.6a2.4 2.4 0 0 0 2.4-2.4v-3.8A12.8 12.8 0 0 0 34.8 15 12.8 12.8 0 0 0 22 2.2Zm3.3 26.8h-6.6v-2.1h6.6Zm-3.3-26.8A10.4 10.4 0 0 1 32.4 15a10.4 10.4 0 0 1-6.3 9.5 2.4 2.4 0 0 0-1.3 2.1v.3h-5.6v-.3a2.4 2.4 0 0 0-1.3-2.1A10.4 10.4 0 0 1 11.6 15 10.4 10.4 0 0 1 22 2.2Z"/>
<path d="M17.6 39.2a2 2 0 0 0 2 2h4.8a2 2 0 0 0 2-2v-1.4h-8.8Zm-1.8-3.8h12.4a1.6 1.6 0 1 0 0-3.2H15.8a1.6 1.6 0 1 0 0 3.2Z"/>
<path d="M8.4 22.6a1.6 1.6 0 0 0 0-3.2H3.8a1.6 1.6 0 1 0 0 3.2Zm31.8 0a1.6 1.6 0 0 0 0-3.2h-4.6a1.6 1.6 0 1 0 0 3.2ZM7.2 10.8a1.6 1.6 0 0 0 2.3-2.3L6.2 5.3A1.6 1.6 0 0 0 4 7.6Zm29.3-2.3a1.6 1.6 0 0 0 2.3 2.3l3.2-3.2A1.6 1.6 0 1 0 39.7 5.3Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:url(#a);}</style><linearGradient id="a" x1="0.405" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f3212c"/><stop offset="1" stop-color="#aa071a"/></linearGradient></defs><g transform="translate(14482.893 -23044.904)"><rect class="a" width="68" height="68" transform="translate(-14482.893 23044.904)"/><path class="b" d="M43.013,57.336h-.255l-.074,0a14.064,14.064,0,0,1-8.911-3.467,6.216,6.216,0,0,1,6.1-5.029h6.278a6.218,6.218,0,0,1,6.1,5.03A14.073,14.073,0,0,1,43.6,57.324h-.009l-.067,0H43.5l-.063,0h-.424ZM30.159,54.9H8.5a1.717,1.717,0,0,1-1.715-1.716v-10.1A1.717,1.717,0,0,1,8.5,41.363H20.6a1.717,1.717,0,0,0,1.716-1.715V37.73H11.824a1.717,1.717,0,0,1-1.716-1.715V8.8a1.718,1.718,0,0,1,1.716-1.716h8.768V3.437A3.44,3.44,0,0,1,24.029,0h6.93A3.441,3.441,0,0,1,34.4,3.437V7.089H42.87A1.718,1.718,0,0,1,44.586,8.8V26.022c-.515-.046-1.044-.07-1.574-.07A17.327,17.327,0,0,0,30.157,54.9Zm4.219-36.07a3.437,3.437,0,1,0,3.438,3.437A3.441,3.441,0,0,0,34.378,18.828Zm-13.619,0A3.437,3.437,0,1,0,24.2,22.265,3.441,3.441,0,0,0,20.758,18.828ZM24.029,3.437V7.083h6.924V3.437Zm7.256,47.59v0a14.059,14.059,0,1,1,23.456,0,9.508,9.508,0,0,0-8.589-5.455H44.686a7.193,7.193,0,1,0-3.347,0H39.873a9.5,9.5,0,0,0-8.587,5.457ZM43.013,42.5a3.923,3.923,0,1,1,3.923-3.924A3.928,3.928,0,0,1,43.013,42.5ZM6.844,31.07a1.717,1.717,0,0,1-1.716-1.715V15.176a1.715,1.715,0,1,1,3.431,0V29.355A1.717,1.717,0,0,1,6.844,31.07ZM49.8,27.327h0a17.187,17.187,0,0,0-3.429-1.051v-11.1a1.715,1.715,0,1,1,3.431,0v12.15Zm3.406-.007a1.717,1.717,0,0,1-1.715-1.715V18.724a1.715,1.715,0,0,1,3.431,0v6.881A1.717,1.717,0,0,1,53.206,27.32Zm-51.491,0A1.717,1.717,0,0,1,0,25.605V18.724a1.715,1.715,0,0,1,3.431,0v6.881A1.717,1.717,0,0,1,1.715,27.32Z" transform="translate(-14477.664 23050.15)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68">
<defs>
<linearGradient id="ap-task" x1="0.405" x2="1" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#f3212c"/>
<stop offset="1" stop-color="#aa071a"/>
</linearGradient>
</defs>
<g fill="url(#ap-task)" transform="translate(14 12)">
<path d="M28.4 2.2H15.6A3.4 3.4 0 0 0 12.2 5.6v2.8H8.6A3.4 3.4 0 0 0 5.2 11.8v28.8A3.4 3.4 0 0 0 8.6 44h22.8a3.4 3.4 0 0 0 3.4-3.4V11.8a3.4 3.4 0 0 0-3.4-3.4h-3.6V5.6A3.4 3.4 0 0 0 28.4 2.2Zm-12.8 3.4h12.8v2.8H15.6Zm19.2 35.8H8.6V11.8h3.6v2.2a1.6 1.6 0 0 0 1.6 1.6h12.8a1.6 1.6 0 0 0 1.6-1.6v-2.2h3.6Z"/>
<rect x="11.4" y="24.2" width="3.6" height="11.6" rx="1.2"/>
<rect x="18.2" y="20.4" width="3.6" height="15.4" rx="1.2"/>
<rect x="25" y="27.2" width="3.6" height="8.6" rx="1.2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 924 B

@@ -18,7 +18,9 @@ import {
} from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
import type { StreamdownProps } from "streamdown";
import { SafeStreamdown } from "@/core/streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
@@ -302,11 +304,11 @@ export const MessageBranchPage = ({
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export type MessageResponseProps = StreamdownProps;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
<SafeStreamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className,
@@ -34,9 +34,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { PromptInputFilePart } from "@/core/uploads";
import { splitUnsupportedUploadFiles } from "@/core/uploads";
import { isIMEComposing } from "@/lib/ime";
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
import {
DEFAULT_UPLOAD_POLICY,
splitUnsupportedUploadFiles,
useUploadPolicy,
validateChatUploadFiles,
} from "@/core/uploads";
import { imeEnterAction } from "@/lib/ime";
import { cn } from "@/lib/utils";
import type { ChatStatus } from "ai";
import {
@@ -56,11 +61,12 @@ import {
type ChangeEventHandler,
Children,
type ClipboardEventHandler,
type ComponentProps,
createContext,
type FormEvent,
type FormEventHandler,
type HTMLAttributes,
type ComponentProps,
type CompositionEventHandler,
createContext,
type FormEvent,
type FormEventHandler,
type HTMLAttributes,
type KeyboardEventHandler,
type PropsWithChildren,
type ReactNode,
@@ -123,11 +129,11 @@ export const usePromptInputController = () => {
return ctx;
};
// Optional variants (do NOT throw). Useful for dual-mode components and for
// Fast Refresh transitions where a page can briefly retain the previous
// provider context instance.
export const useOptionalPromptInputController = () =>
useContext(PromptInputController);
// Optional variants (do NOT throw). Useful for dual-mode components and for
// Fast Refresh transitions where a page can briefly retain the previous
// provider context instance.
export const useOptionalPromptInputController = () =>
useContext(PromptInputController);
export const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext);
@@ -145,6 +151,7 @@ const usePromptInputValidation = () => useContext(PromptInputValidationContext);
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
uploadPolicy?: UploadPolicy;
}>;
/**
@@ -153,8 +160,12 @@ export type PromptInputProviderProps = PropsWithChildren<{
*/
export function PromptInputProvider({
initialInput: initialTextInput = "",
uploadPolicy,
children,
}: PromptInputProviderProps) {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
// ----- textInput state
const [textInput, setTextInput] = useState(initialTextInput);
const clearInput = useCallback(() => setTextInput(""), []);
@@ -163,6 +174,7 @@ export function PromptInputProvider({
const [attachmentFiles, setAttachmentFiles] = useState<
(PromptInputFilePart & { id: string })[]
>([]);
const attachmentsRef = useRef(attachmentFiles);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const openRef = useRef<() => void>(() => {});
@@ -172,9 +184,26 @@ export function PromptInputProvider({
return;
}
setAttachmentFiles((prev) =>
prev.concat(
incoming.map((file) => ({
const current = attachmentsRef.current;
const existing = current.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
incoming,
existing,
effectiveUploadPolicy,
);
for (const message of new Set(
validation.issues.map((issue) => issue.message),
)) {
toast.error(message);
}
if (validation.accepted.length === 0) return;
const next = current.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file" as const,
url: URL.createObjectURL(file),
@@ -182,33 +211,31 @@ export function PromptInputProvider({
filename: file.name,
file,
})),
),
);
}, []);
attachmentsRef.current = next;
setAttachmentFiles(next);
}, [effectiveUploadPolicy]);
const remove = useCallback((id: string) => {
setAttachmentFiles((prev) => {
const found = prev.find((f) => f.id === id);
if (found?.url) {
URL.revokeObjectURL(found.url);
}
return prev.filter((f) => f.id !== id);
});
const current = attachmentsRef.current;
const found = current.find((f) => f.id === id);
if (found?.url) URL.revokeObjectURL(found.url);
const next = current.filter((f) => f.id !== id);
attachmentsRef.current = next;
setAttachmentFiles(next);
}, []);
const clear = useCallback(() => {
setAttachmentFiles((prev) => {
for (const f of prev) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
for (const f of attachmentsRef.current) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
return [];
});
}
attachmentsRef.current = [];
setAttachmentFiles([]);
}, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
const attachmentsRef = useRef(attachmentFiles);
attachmentsRef.current = attachmentFiles;
// Cleanup blob URLs on unmount to prevent memory leaks
@@ -404,14 +431,14 @@ export function PromptInputAttachments({
return (
<div
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
{...props}
>
{attachments.files.map((file) => (
<div key={file.id} className="max-w-60">
{children(file)}
</div>
))}
</div>
{...props}
>
{attachments.files.map((file) => (
<div key={file.id} className="max-w-60">
{children(file)}
</div>
))}
</div>
);
}
@@ -459,8 +486,15 @@ export type PromptInputProps = Omit<
// Minimal constraints
maxFiles?: number;
maxFileSize?: number; // bytes
uploadPolicy?: UploadPolicy;
onError?: (err: {
code: "max_files" | "max_file_size" | "accept" | "unsupported_package";
code:
| "max_files"
| "max_file_size"
| "max_total_size"
| "unsupported_type"
| "accept"
| "unsupported_package";
message: string;
}) => void;
onSubmit: (
@@ -478,11 +512,22 @@ export const PromptInput = ({
syncHiddenInput,
maxFiles,
maxFileSize,
uploadPolicy,
onError,
onSubmit,
children,
...props
}: PromptInputProps) => {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
const policyAccept = useMemo(
() =>
Object.values(effectiveUploadPolicy.groups)
.flatMap((group) => group.extensions)
.join(","),
[effectiveUploadPolicy],
);
// Try to use a provider controller if present
const controller = useOptionalPromptInputController();
const usingProvider = !!controller;
@@ -517,6 +562,9 @@ export const PromptInput = ({
.filter(Boolean);
return patterns.some((pattern) => {
if (pattern.startsWith(".")) {
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
}
if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
return f.type.startsWith(prefix);
@@ -531,52 +579,67 @@ export const PromptInput = ({
(fileList: File[] | FileList) => {
const incoming = Array.from(fileList);
const accepted = incoming.filter((f) => matchesAccept(f));
if (incoming.length && accepted.length === 0) {
onError?.({
if (accepted.length < incoming.length) {
const error = {
code: "accept",
message: "No files match the accepted types.",
});
return;
message: "部分文件不符合当前输入框允许的格式。",
} as const;
onError?.(error);
if (!onError) toast.error(error.message);
}
const withinSize = (f: File) =>
maxFileSize ? f.size <= maxFileSize : true;
const sized = accepted.filter(withinSize);
if (accepted.length > 0 && sized.length === 0) {
onError?.({
code: "max_file_size",
message: "All files exceed the maximum size.",
});
return;
}
setItems((prev) => {
const capacity =
const localPolicy: UploadPolicy = {
...effectiveUploadPolicy,
max_files_per_message:
typeof maxFiles === "number"
? Math.max(0, maxFiles - prev.length)
: undefined;
const capped =
typeof capacity === "number" ? sized.slice(0, capacity) : sized;
if (typeof capacity === "number" && sized.length > capacity) {
onError?.({
code: "max_files",
message: "Too many files. Some were not added.",
});
}
const next: (PromptInputFilePart & { id: string })[] = [];
for (const file of capped) {
next.push({
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
: effectiveUploadPolicy.max_files_per_message,
groups: Object.fromEntries(
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
key,
{
...group,
max_bytes:
typeof maxFileSize === "number"
? Math.min(group.max_bytes, maxFileSize)
: group.max_bytes,
},
]),
) as UploadPolicy["groups"],
};
const existing = items.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
accepted,
existing,
localPolicy,
);
for (const issue of Array.from(
new Map(
validation.issues.map((item) => [item.message, item]),
).values(),
)) {
onError?.({ code: issue.code, message: issue.message });
if (!onError) toast.error(issue.message);
}
if (validation.accepted.length === 0) return;
setItems((prev) =>
prev.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file",
type: "file" as const,
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
});
}
return prev.concat(next);
});
})),
),
);
},
[matchesAccept, maxFiles, maxFileSize, onError],
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
);
const removeLocal = useCallback(
@@ -765,11 +828,17 @@ export const PromptInput = ({
event.preventDefault();
const form = event.currentTarget;
// Prefer the live textarea. Firefox 102 applies the chosen candidate to
// the DOM before React state catches up, and a send click in that gap
// would otherwise submit the previous pinyin-less value.
const liveText = form.querySelector<HTMLTextAreaElement>(
"textarea[name='message']",
)?.value;
const text = usingProvider
? controller.textInput.value
? (liveText ?? controller.textInput.value)
: (() => {
const formData = new FormData(form);
return (formData.get("message") as string) || "";
return (formData.get("message") as string) || liveText || "";
})();
// Reset form immediately after capturing text to avoid race condition
@@ -832,7 +901,7 @@ export const PromptInput = ({
const inner = (
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
<input
accept={accept}
accept={accept ?? policyAccept}
aria-label="Upload files"
className="hidden"
multiple={multiple}
@@ -847,10 +916,12 @@ export const PromptInput = ({
ref={formRef}
{...props}
>
{/* Do not rely on InputGroup's :has(textarea) selector here: Chrome
versions before 105 ignore :has(), retain h-9, and clip the whole
multi-line composer to a single row. */}
<InputGroup className="h-auto min-h-16">{children}</InputGroup>
{/* Do not rely on InputGroup's :has() selectors here: Chrome versions
before 105 ignore them, keep the horizontal flex layout, and
squeeze the textarea beside the footer controls. */}
<InputGroup className="h-auto min-h-16 flex-col items-stretch">
{children}
</InputGroup>
</form>
</PromptInputValidationContext.Provider>
);
@@ -877,58 +948,100 @@ export type PromptInputTextareaProps = ComponentProps<
typeof InputGroupTextarea
>;
export const PromptInputTextarea = ({
onChange,
onInput,
className,
placeholder = "What would you like to know?",
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation();
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
if (
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("field-sizing", "content")
) {
return;
}
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
// stuck at its minimum height.
textarea.style.height = "auto";
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
textarea.style.height = `${height}px`;
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
}, []);
// onInput does not run for a controlled value filled by a suggested question
// or restored draft. Resize once after every render in legacy Chrome too.
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const frame = window.requestAnimationFrame(() => {
resizeForLegacyChrome(textarea);
});
return () => window.cancelAnimationFrame(frame);
}, [controller?.textInput.value, resizeForLegacyChrome]);
export const PromptInputTextarea = ({
onChange,
onInput,
onKeyDown,
onCompositionStart,
onCompositionEnd,
className,
placeholder = "What would you like to know?",
defaultValue,
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const composingRef = useRef(false);
const compositionEndedAtRef = useRef(0);
const onChangeRef = useRef(onChange);
const onInputRef = useRef(onInput);
const onKeyDownRef = useRef(onKeyDown);
const onCompositionStartRef = useRef(onCompositionStart);
const onCompositionEndRef = useRef(onCompositionEnd);
const setInputRef = useRef(controller?.textInput.setInput);
const attachmentsRef = useRef(attachments);
onChangeRef.current = onChange;
onInputRef.current = onInput;
onKeyDownRef.current = onKeyDown;
onCompositionStartRef.current = onCompositionStart;
onCompositionEndRef.current = onCompositionEnd;
setInputRef.current = controller?.textInput.setInput;
attachmentsRef.current = attachments;
const publishValue = useCallback((value: string) => {
setInputRef.current?.(value);
}, []);
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
// Firefox 102 commits an open IME composition as raw pinyin when the
// textarea's height or overflow changes mid-composition.
if (composingRef.current) return;
if (
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("field-sizing", "content")
) {
return;
}
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
// stuck at its minimum height.
textarea.style.height = "auto";
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
textarea.style.height = `${height}px`;
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
}, []);
// onInput does not run for a controlled value filled by a suggested question
// or restored draft. Resize once after every render in legacy Chrome too.
useEffect(() => {
if (composingRef.current) return;
const textarea = textareaRef.current;
if (!textarea) return;
const frame = window.requestAnimationFrame(() => {
resizeForLegacyChrome(textarea);
});
return () => window.cancelAnimationFrame(frame);
}, [controller?.textInput.value, resizeForLegacyChrome]);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") {
if (isIMEComposing(e, isComposing)) {
const action = imeEnterAction({
isComposing: composingRef.current,
nativeIsComposing: e.nativeEvent.isComposing || e.key === "Process",
keyCode: e.keyCode,
compositionEndedAt: compositionEndedAtRef.current,
now: performance.now(),
});
// Firefox 102 confirms the candidate on this key. preventDefault here
// cancels that confirmation and leaves the pinyin in the box.
if (action === "ignore") {
return;
}
if (e.shiftKey) {
onKeyDownRef.current?.(e);
if (e.defaultPrevented || e.shiftKey) {
return;
}
// The confirming Enter already finished the candidate. Swallow it so
// Firefox does not also insert a newline or send the message.
e.preventDefault();
if (action === "swallow") {
return;
}
// Check if the submit button is disabled before submitting
const form = e.currentTarget.form;
@@ -940,22 +1053,59 @@ export const PromptInputTextarea = ({
}
form?.requestSubmit();
return;
}
onKeyDownRef.current?.(e);
if (e.defaultPrevented) return;
if (composingRef.current) return;
// Remove last attachment when Backspace is pressed and textarea is empty
if (
e.key === "Backspace" &&
e.currentTarget.value === "" &&
attachments.files.length > 0
attachmentsRef.current.files.length > 0
) {
e.preventDefault();
const lastAttachment = attachments.files.at(-1);
const lastAttachment = attachmentsRef.current.files.at(-1);
if (lastAttachment) {
attachments.remove(lastAttachment.id);
attachmentsRef.current.remove(lastAttachment.id);
}
}
};
const handleCompositionStart: CompositionEventHandler<HTMLTextAreaElement> = (
event,
) => {
composingRef.current = true;
compositionEndedAtRef.current = 0;
onCompositionStartRef.current?.(event);
};
const handleCompositionEnd: CompositionEventHandler<HTMLTextAreaElement> = (
event,
) => {
onCompositionEndRef.current?.(event);
composingRef.current = false;
compositionEndedAtRef.current = performance.now();
const el = event.currentTarget;
// Firefox 102 dispatches compositionend before the committed candidate is
// visible, and before the input event that carries it. Syncing the
// compositionend value here would write the raw pinyin back over 汉字.
window.setTimeout(() => {
if (el.isConnected) publishValue(el.value);
}, 0);
};
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = (event) => {
// Keep the controlled value identical to the DOM, including the pinyin
// still being composed. Otherwise React restores the previous value at
// the end of the input event and Firefox 102 cannot replace it.
publishValue(event.currentTarget.value);
onChangeRef.current?.(event);
};
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
const items = event.clipboardData?.items;
@@ -985,35 +1135,28 @@ export const PromptInputTextarea = ({
}
};
const controlledProps = controller
? {
value: controller.textInput.value,
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
controller.textInput.setInput(e.currentTarget.value);
onChange?.(e);
},
}
: {
onChange,
};
return (
<InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
ref={textareaRef}
rows={3}
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
onInput={(event) => {
resizeForLegacyChrome(event.currentTarget);
onInput?.(event);
}}
<InputGroupTextarea
{...props}
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
ref={textareaRef}
rows={3}
onChange={handleChange}
onCompositionEnd={handleCompositionEnd}
onCompositionStart={handleCompositionStart}
onKeyDown={handleKeyDown}
onInput={(event) => {
if (!composingRef.current) {
resizeForLegacyChrome(event.currentTarget);
}
onInputRef.current?.(event);
}}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
{...controlledProps}
{...(controller
? { value: controller.textInput.value }
: { defaultValue })}
/>
);
};
@@ -1131,20 +1274,24 @@ export const PromptInputSubmit = ({
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <ArrowUpIcon className="size-4" />;
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
}
return (
<InputGroupButton
aria-label="Submit"
}: PromptInputSubmitProps) => {
let Icon = <ArrowUpIcon className="size-4" />;
let ariaLabel = "发送消息";
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
ariaLabel = "正在发送";
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
ariaLabel = "停止生成";
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
ariaLabel = "重新发送";
}
return (
<InputGroupButton
aria-label={ariaLabel}
className={cn(
"transition-all duration-300",
status === "streaming" &&
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
import { SafeStreamdown } from "@/core/streamdown";
import { reasoningPlugins } from "@/core/streamdown/plugins";
import { Shimmer } from "./shimmer";
@@ -179,7 +179,7 @@ export const ReasoningContent = memo(
{...props}
>
{typeof children === "string" ? (
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
<SafeStreamdown {...reasoningPlugins}>{children}</SafeStreamdown>
) : (
children
)}
@@ -31,6 +31,11 @@ import { queryClient } from "@/components/query-client-provider";
import { Checkbox } from "@/components/ui/checkbox";
import Empty from "./empty";
import { NetworkActor } from "./network-actor";
import {
normalizeV2UploadSources,
type StudioUploadSource,
type V2UploadResponse,
} from "@/core/studio/document-upload";
// ── 适配层 ─────────────────────────────────────────────────
@@ -86,22 +91,7 @@ interface DocumentUploadDialogContextType {
data?: { query: string; mode: string }
) => Promise<void>;
// 上传成功回调,返回上传的来源信息
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
}
// ── 任务轮询类型 ─────────────────────────────────────────────
interface TaskSource {
task_id: string;
title: string;
status: 'processing' | 'success' | 'error';
source_type: string;
}
interface UploadResponse {
status: string;
uploaded: number;
skipped: number;
sources: TaskSource[];
onUploadComplete?: (sources: StudioUploadSource[]) => void;
}
interface listDataItem {
@@ -224,7 +214,7 @@ export const DocumentUploadDialogProvider: FC<{
) => void | Promise<void>;
searchSpace: { id: number | string;[key: string]: unknown };
onUploadSuccess?: () => void;
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
onUploadComplete?: (sources: StudioUploadSource[]) => void;
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
const [isOpen, setIsOpen] = useState(false);
const [initialSearchText, setInitialSearchText] = useState("");
@@ -353,27 +343,23 @@ const DocumentUploadPopupContent: FC<{
for (const file of files) {
formData.append("files", file);
}
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload-v2`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (res.ok) {
const data = await res.json() as UploadResponse;
const data = await res.json() as V2UploadResponse;
// 调用上传完成回调,传递来源信息
if (data.sources && data.sources.length > 0) {
onUploadComplete?.(data.sources.map(s => ({
id: undefined, // 后端返回的数据中暂时没有 id
title: s.title,
task_id: s.task_id,
status: s.status,
})));
onUploadComplete?.(normalizeV2UploadSources(data));
}
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
// 只关闭上传弹窗
onOpenChange(false);
} else {
toast.error("上传失败,请重试");
const payload = await res.json().catch(() => null) as { detail?: string } | null;
toast.error(payload?.detail || `上传失败(HTTP ${res.status})`);
}
} catch {
toast.error("上传失败,请重试");
@@ -410,7 +396,6 @@ const DocumentUploadPopupContent: FC<{
selectedSearchSources.map((s) => ({
id: undefined,
title: s.title,
task_id: undefined,
status: "success" as const,
}))
);
+34 -18
View File
@@ -1,14 +1,19 @@
"use client";
import { useEffect } from "react";
import { useLayoutEffect } from "react";
import { useLocalSettings } from "@/core/settings";
import {
applyFontScaleToDocument,
normalizeFontScale,
normalizeFontScaleMode,
} from "@/core/ui/page-zoom";
/**
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变;
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。
* `fontScale === 1` 即默认 16px。
*/
export const BASE_FONT_PX = 16;
@@ -21,25 +26,36 @@ export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[]
{ value: 1.25, label: "特大", px: 20 },
];
export const FONT_SCALE_MODE_OPTIONS: {
value: "text" | "page";
label: string;
description: string;
}[] = [
{
value: "text",
label: "仅文字",
description: "只放大文字,间距和布局保持原样。",
},
{
value: "page",
label: "整页缩放",
description: "文字、间距、图标和写死的像素一起放大。",
},
];
/**
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
* 不渲染任何 DOM。
* 把字号设置写到文档根节点。挂在应用根部,刷新后、绘制前生效。不渲染任何 DOM。
*/
export function FontScaleApplier() {
const [settings] = useLocalSettings();
const { fontScale } = settings.display;
const { fontScale, fontScaleMode } = settings.display;
useEffect(() => {
const scale =
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
? fontScale
: 1;
const root = document.documentElement;
root.style.setProperty("--ui-font-scale", String(scale));
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
root.style.removeProperty("font-size");
}, [fontScale]);
useLayoutEffect(() => {
applyFontScaleToDocument(
normalizeFontScaleMode(fontScaleMode),
normalizeFontScale(fontScale),
);
}, [fontScale, fontScaleMode]);
return null;
}
@@ -9,8 +9,8 @@ import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
* a real document URL, so fragment links scroll within the frame as expected —
* all without enabling scripts (the sandbox stays script-free for security; the
* server already sanitizes script vectors and this is defense-in-depth).
* The sandbox permits scripts so the preview can use the app-bundled HTML
* visualization runtimes, while still withholding same-origin privileges.
*
* The `charset=utf-8` on the blob type is required: a blob: URL whose
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
@@ -26,7 +26,9 @@ export function HtmlPreviewFrame({
const [url, setUrl] = useState<string>();
useEffect(() => {
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
const blob = new Blob([
prepareGeneratedHtml(html ?? ""),
], {
type: "text/html;charset=utf-8",
});
const objectUrl = URL.createObjectURL(blob);
@@ -1,3 +1,5 @@
import { toCssPx } from "@/core/ui/page-zoom";
import type { TooltipState } from "./graphTypes";
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
@@ -9,8 +11,8 @@ export function GraphTooltip({ tooltip }: GraphTooltipProps) {
if (!tooltip) return null;
const meta = NODE_KIND_META[tooltip.kind];
// Keep the tooltip inside the viewport.
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
const left = Math.min(toCssPx(tooltip.x) + 14, window.innerWidth - 336);
const top = Math.min(toCssPx(tooltip.y) + 14, window.innerHeight - 120);
return (
<div className="kg-tooltip" style={{ left, top }}>
+8 -8
View File
@@ -4,19 +4,19 @@
top: 4rem;
z-index: 40;
width: 98px;
height: calc(100vh - 4rem);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
flex-shrink: 0;
}
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
.sidebar-contnet--header-hidden {
top: 0;
height: 100vh;
height: calc(100vh / var(--df-page-zoom, 1));
}
.sidebar-contnet--header-hidden .item-nav-content {
height: calc(100vh - 20px);
min-height: calc(100vh - 20px);
height: calc(100vh / var(--df-page-zoom, 1) - 20px);
min-height: calc(100vh / var(--df-page-zoom, 1) - 20px);
}
.sidebar-contents {
@@ -160,8 +160,8 @@
.item-nav-content {
width: 155px;
height: calc(100vh - 4rem - 20px);
min-height: calc(100vh - 4rem - 20px);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
min-height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
@@ -634,7 +634,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
top: 4rem;
z-index: 40;
width: 96px;
height: calc(100vh - 4rem);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
display: flex;
flex-direction: row;
background: #ffffff;
@@ -653,7 +653,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
.light-sidebar--header-hidden {
top: 0;
height: 100vh;
height: calc(100vh / var(--df-page-zoom, 1));
}
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
@@ -194,7 +194,7 @@ export function PageSidebar({
onOpenChange={setNotificationOpen}
/>
<aside
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh/var(--df-page-zoom,1)-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
+47 -15
View File
@@ -16,6 +16,8 @@ import {
Settings,
ShieldAlert,
ShieldCheck,
ScrollText,
Timer,
User,
Wrench,
} from "lucide-react";
@@ -222,14 +224,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
<User className="size-4" />
个人信息
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTree className="size-4" />
菜单管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTree className="size-4" />
{isAdmin ? "默认菜单管理" : "我的菜单"}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/task-buttons")}
@@ -270,6 +270,22 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skill-timing")}
>
<Timer className="size-4" />
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollText className="size-4" />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
@@ -484,7 +500,9 @@ function LegacyAccordionSidebar({
/>
<aside
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
headerHidden
? "top-0 h-[calc(100vh/var(--df-page-zoom,1))]"
: "top-16 h-[calc(100vh/var(--df-page-zoom,1)-4rem)]"
} ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
@@ -561,12 +579,10 @@ function LegacyAccordionSidebar({
<User className="size-4" />
个人信息
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
<ListTree className="size-4" />
菜单管理
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
<ListTree className="size-4" />
{isAdmin ? "默认菜单管理" : "我的菜单"}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/task-buttons")}>
<MousePointerClick className="size-4" />
@@ -591,6 +607,22 @@ function LegacyAccordionSidebar({
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skill-timing")}
>
<Timer className="size-4" />
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollText className="size-4" />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
@@ -0,0 +1,54 @@
import { useLayoutEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { ReactUiErrorBoundary } from "./react-ui-error-boundary";
// Offline browser QA: real React production render/commit recursion, not a
// fabricated #185 thrown by a unit-test stub. A manual button click reloads
// this same page without submitting any conversation or model request.
const key = "deerflow.boundary-browser-check";
let mounts = 0;
function InfiniteCommitLoop() {
const [, update] = useState(0);
useState(() => { mounts += 1; return 0; });
useLayoutEffect(() => { update((value) => value + 1); });
return <div>Loop fixture</div>;
}
function result(passed: boolean, details: Record<string, unknown>) {
const output = document.createElement("pre");
output.id = "boundary-check-result";
output.textContent = JSON.stringify({ passed, ...details });
document.body.append(output);
}
const resumed = sessionStorage.getItem(key) === "manual-reload";
createRoot(document.getElementById("root")!).render(
<ReactUiErrorBoundary>
{resumed ? <div id="healthy">Existing page restored</div> : <InfiniteCommitLoop />}
</ReactUiErrorBoundary>,
);
if (resumed) {
sessionStorage.removeItem(key);
window.setTimeout(() => result(Boolean(document.getElementById("healthy")), {
phase: "manual-reload-restored", renderLoopCaught: true, noAutomaticRemount: true,
}), 100);
} else {
window.setTimeout(() => {
const alert = document.querySelector('[role="alert"]');
const before = mounts;
if (!alert || /#185|react\.dev|Maximum update depth/.test(alert.textContent ?? "")) {
result(false, { phase: "friendly-fallback", hasAlert: Boolean(alert) });
return;
}
window.setTimeout(() => {
if (mounts !== before || before !== 1) {
result(false, { phase: "unexpected-automatic-remount", mounts, before });
return;
}
sessionStorage.setItem(key, "manual-reload");
(alert.querySelector("button") as HTMLButtonElement).click();
}, 250);
}, 100);
}
@@ -0,0 +1,45 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { isReactMaximumUpdateDepthError } from "@/core/threads/stream-error-classification";
import { REACT_UI_ERROR_MESSAGE } from "@/core/threads/react-ui-error";
/** Last-resort render/commit guard. It deliberately never auto-remounts a broken
* tree, which could otherwise repeatedly resume the same run and crash again. */
export class ReactUiErrorBoundary extends Component<
{ children: ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError(error: unknown) {
if (!isReactMaximumUpdateDepthError(error)) throw error;
return { failed: true };
}
componentDidCatch(_error: unknown, info: ErrorInfo) {
console.warn("[deer-flow] react_ui_render_error", {
errorType: "react_maximum_update_depth",
componentStack: info.componentStack,
recovery: "manual_reload",
});
}
render() {
if (!this.state.failed) return this.props.children;
return (
<main className="flex min-h-screen items-center justify-center p-6">
<section role="alert" className="max-w-lg rounded-lg border p-6 text-center">
<h1 className="mb-3 text-lg font-medium">页面暂时无法显示</h1>
<p className="mb-5 text-sm text-muted-foreground">{REACT_UI_ERROR_MESSAGE}</p>
<button
className="rounded-md bg-primary px-4 py-2 text-primary-foreground"
onClick={() => window.location.reload()}
type="button"
>
重新载入页面
</button>
</section>
</main>
);
}
}
@@ -0,0 +1,16 @@
/** Lightweight, dependency-free fallback used while a route chunk loads. */
export function RouteLoadingFallback() {
return (
<div
className="text-muted-foreground flex size-full min-h-32 items-center justify-center gap-2 bg-background"
role="status"
aria-live="polite"
>
<span
className="border-muted-foreground/30 border-t-foreground size-4 animate-spin rounded-full border-2"
aria-hidden="true"
/>
<span className="text-sm">正在加载页面…</span>
</div>
);
}
+36 -31
View File
@@ -61,7 +61,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"legacy-dialog-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[200] bg-black/50",
className,
)}
{...props}
@@ -71,37 +71,42 @@ const DialogOverlay = React.forwardRef<
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
overlayClassName?: string;
}
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
overlayClassName?: string;
}
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<div
data-slot="dialog-viewport"
className={cn("legacy-dialog-viewport", overlayClassName)}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-[200] grid min-h-0 max-h-full w-full max-w-[calc(100%-2rem)] pointer-events-auto gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</div>
</DialogPortal>
);
});
+102 -102
View File
@@ -20,42 +20,42 @@ function DropdownMenuPortal({
);
}
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DropdownMenuPrimitive.Trigger
ref={ref}
data-slot="dropdown-menu-trigger"
{...props}
/>
);
});
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DropdownMenuPrimitive.Trigger
ref={ref}
data-slot="dropdown-menu-trigger"
{...props}
/>
);
});
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
function DropdownMenuGroup({
...props
@@ -65,29 +65,29 @@ function DropdownMenuGroup({
);
}
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}
>(({ className, inset, variant = "default", ...props }, ref) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
});
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}
>(({ className, inset, variant = "default", ...props }, ref) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
});
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
function DropdownMenuCheckboxItem({
className,
@@ -205,51 +205,51 @@ function DropdownMenuSub({
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
});
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
});
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
});
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
});
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
export {
DropdownMenu,
+1 -1
View File
@@ -62,7 +62,7 @@ const SelectContent = React.forwardRef<
ref={ref}
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -1,5 +1,4 @@
import { ChevronRight } from "lucide-react";
import { Fragment } from "react";
import { cn } from "@/lib/utils";
@@ -21,12 +20,12 @@ export function Breadcrumb({
)}
>
{segments.map((segment, i) => (
<Fragment key={`${segment}-${i}`}>
<div className="contents" key={`${segment}-${i}`}>
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
{segment}
</span>
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
</Fragment>
</div>
))}
<span
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
@@ -1,8 +1,10 @@
"use client";
import { Loader2 } from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { useAllUsers } from "@/core/auth/hooks";
import { useI18n } from "@/core/i18n/hooks";
@@ -17,6 +19,11 @@ export function UserManagementPage() {
title={t.adminUsers.title}
description={t.adminUsers.description}
>
<div className="mb-4 flex justify-end">
<Button variant="outline" asChild>
<Link to="/page/strategy/admin/login-whitelist">管理密码与登录口令</Link>
</Button>
</div>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -534,11 +534,6 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
}
}
const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
// both the card header and the compact list row.
const statusBadges = (
@@ -965,9 +960,9 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
已选 {selectedSkills.length} 个
</span>
</div>
<SkillTransferSelector
skills={enabledSkills}
selected={selectedSkills}
<SkillTransferSelector
skills={skills}
selected={selectedSkills}
onChange={setSelectedSkills}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
@@ -51,6 +51,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useTheme } from "@/contexts/ThemeContext";
import { useAgentsPaginated } from "@/core/agents";
import { getAPIClient } from "@/core/api";
@@ -69,6 +73,8 @@ import {
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import {
bootstrapResumePath,
isBootstrapThread,
@@ -379,6 +385,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads],
);
const projectThreadNames = useProjectThreadNames(visibleThreads);
const filteredThreads = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -479,6 +486,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
) : (
filteredThreads.map((thread) => {
const isBoot = isBootstrapThread(thread);
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
const isActive = href === pathname;
const pinned = isThreadPinned(thread);
@@ -492,6 +503,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
href={href}
className={cn(
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
projectInfo && "h-auto min-h-12 py-1.5",
isActive
? "border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
@@ -505,6 +517,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
)}
aria-label="智能体创建会话"
/>
) : projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : (
<MessageSquare
className={cn(
@@ -516,8 +530,22 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
{pinned && (
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
<span
className={cn(
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-4 max-w-[calc(100%_-_1rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
@@ -152,6 +152,8 @@ export function AgentDetailPanel({
positionName,
skillsIcon,
skillsTitle,
belowSkillsSlot,
feedbackScoreSlot,
showCommonQuestions = false,
jobResponsibilities,
panelTitle,
@@ -181,6 +183,10 @@ export function AgentDetailPanel({
positionName?: string;
skillsIcon?: ReactNode;
skillsTitle?: string;
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
belowSkillsSlot?: ReactNode;
/** Optional answer-feedback summary rendered near the agent identity. */
feedbackScoreSlot?: ReactNode;
showCommonQuestions?: boolean;
/**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
@@ -744,6 +750,8 @@ export function AgentDetailPanel({
)}
</section>
{!editing && feedbackScoreSlot}
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
{jobResponsibilities !== undefined && (
@@ -905,7 +913,7 @@ export function AgentDetailPanel({
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
<Section
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
title={skillsTitle ?? `已配置技能 ${skillNamesShown.length} 个`}
className="position-roundtable-skills-section"
extra={
editing ? (
@@ -940,6 +948,9 @@ export function AgentDetailPanel({
/>
</Section>
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
</div>
@@ -0,0 +1,213 @@
"use client";
import { BarChart3Icon, MessageSquareTextIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
useAgentFeedbackScore,
type AnswerFeedbackReason,
type FeedbackDimensionKey,
} from "@/strategy-components/api/answer-feedback";
const DIMENSIONS: Array<{
key: FeedbackDimensionKey;
label: string;
fallbackWeight: number;
}> = [
{ key: "correctness", label: "正确性", fallbackWeight: 30 },
{ key: "completeness", label: "完整性", fallbackWeight: 20 },
{ key: "evidence_grounding", label: "证据与依据", fallbackWeight: 20 },
{ key: "relevance", label: "相关性", fallbackWeight: 15 },
{ key: "actionability", label: "可操作性", fallbackWeight: 15 },
];
const REASON_LABELS: Record<AnswerFeedbackReason, string> = {
incorrect_or_incomplete: "不正确或不完整",
unmet_expectations: "与期望不符",
slow_or_problematic: "速度慢或存在问题",
style_or_tone: "风格或语气",
safety_or_legal: "安全或法律疑虑",
other: "其他",
};
const CONFIDENCE_LABELS = {
none: "暂无置信度",
low: "低置信度",
medium: "中等置信度",
high: "高置信度",
} as const;
function scoreText(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value)
? value.toFixed(1)
: "—";
}
function percent(value: number | null | undefined) {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
const normalized = value <= 1 ? value * 100 : value;
return Math.max(0, Math.min(100, normalized));
}
function weightPercent(value: number | undefined, fallback: number) {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return value <= 1 ? Math.round(value * 100) : Math.round(value);
}
export function AgentFeedbackScoreCard({ agentId }: { agentId: string }) {
const { data, isLoading, isError } = useAgentFeedbackScore(agentId);
if (isLoading) {
return (
<section className="bg-card/60 rounded-xl border p-4" aria-label="回答评价加载中">
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
<div className="bg-muted mt-4 h-12 w-28 animate-pulse rounded-lg" />
<div className="mt-4 space-y-2">
{DIMENSIONS.map((item) => (
<div key={item.key} className="bg-muted h-3 animate-pulse rounded" />
))}
</div>
</section>
);
}
if (isError || !data) {
return (
<section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-center gap-1.5 text-sm font-semibold">
<BarChart3Icon className="size-3.5" />
回答评价
</div>
<p className="text-muted-foreground mt-2 text-xs">评价数据暂不可用</p>
</section>
);
}
const positiveRate = data.sample_count
? (data.positive_count / data.sample_count) * 100
: null;
const taskSuccessRate = percent(data.task_success_rate);
const topReasons = Object.entries(data.reason_breakdown ?? {})
.filter((entry): entry is [AnswerFeedbackReason, { count: number; rate: number }] =>
Boolean(entry[1]?.count),
)
.sort((left, right) => right[1].count - left[1].count)
.slice(0, 3);
return (
<section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
<BarChart3Icon className="size-3.5" />
回答评价
</h4>
<p className="text-muted-foreground mt-1 text-xs">用户评价分</p>
</div>
{data.score_band && data.score !== null && (
<span className="border-primary/20 bg-primary/5 text-primary rounded-full border px-2 py-1 text-[11px] font-medium">
{data.score_band.label}
</span>
)}
</div>
<div className="mt-3 flex items-end gap-2">
{data.score === null ? (
<span className="text-muted-foreground text-xl font-semibold">暂无评价</span>
) : (
<>
<span className="text-3xl font-bold tracking-tight tabular-nums">
{scoreText(data.score)}
</span>
<span className="text-muted-foreground mb-1 text-xs">/ 100</span>
</>
)}
</div>
<div className="text-muted-foreground mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px]">
<span>{data.sample_count} 条评价</span>
<span>
{CONFIDENCE_LABELS[data.confidence_level]}
{` ${Math.round(data.confidence * 100)}%`}
</span>
<span>
好评率 {positiveRate === null ? "—" : `${positiveRate.toFixed(0)}%`}
</span>
</div>
{data.sample_count > 0 && (
<div className="mt-4 space-y-3" aria-label="回答评价维度">
{DIMENSIONS.map((item) => {
const dimension = data.dimensions[item.key];
const value = percent(dimension?.score);
return (
<div key={item.key}>
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
<span className="text-foreground">
{item.label}
<span className="text-muted-foreground ml-1">
{weightPercent(dimension?.weight, item.fallbackWeight)}%
</span>
</span>
<span className="text-muted-foreground tabular-nums">
{value === null ? "—" : value.toFixed(1)}
{dimension?.evidence_count
? ` · ${dimension.evidence_count} 条依据`
: ""}
</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className={cn(
"h-full rounded-full transition-[width]",
value !== null && value >= 80
? "bg-emerald-500"
: value !== null && value >= 60
? "bg-amber-500"
: "bg-rose-500",
)}
style={{ width: `${value ?? 0}%` }}
/>
</div>
</div>
);
})}
</div>
)}
{(data.task_score !== null || taskSuccessRate !== null) && (
<div className="bg-muted/50 mt-4 flex items-center justify-between rounded-lg px-3 py-2 text-xs">
<span className="flex items-center gap-1.5 font-medium">
<MessageSquareTextIcon className="size-3.5" />
任务成功与稳定性
</span>
<span className="text-muted-foreground tabular-nums">
{data.task_score !== null && `评分 ${scoreText(data.task_score)}`}
{data.task_score !== null && taskSuccessRate !== null && " · "}
{taskSuccessRate !== null && `成功率 ${taskSuccessRate.toFixed(0)}%`}
</span>
</div>
)}
{topReasons.length > 0 && (
<div className="mt-4 border-t pt-3">
<p className="text-muted-foreground mb-2 text-[11px] font-medium">主要改进反馈</p>
<div className="flex flex-wrap gap-1.5">
{topReasons.map(([reason, breakdown]) => (
<span
key={reason}
className="bg-muted text-muted-foreground rounded-full px-2 py-1 text-[11px]"
>
{REASON_LABELS[reason]} · {breakdown.count}
</span>
))}
</div>
</div>
)}
<p className="text-muted-foreground/80 mt-4 border-t pt-3 text-[10px] leading-relaxed">
该分数仅反映用户对回答结果的评价,不代表完整任务轨迹、工具调用或技能能力。
</p>
</section>
);
}
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { canViewAgentFeedbackScore } from "./agent-feedback-visibility.ts";
test("allows only the canonical admin role to view agent feedback scores", () => {
assert.equal(canViewAgentFeedbackScore({ system_role: "admin" }), true);
assert.equal(canViewAgentFeedbackScore({ system_role: "user" }), false);
assert.equal(canViewAgentFeedbackScore({ system_role: "Admin" }), false);
});
test("fails closed when authentication or its role is unavailable", () => {
assert.equal(canViewAgentFeedbackScore(null), false);
assert.equal(canViewAgentFeedbackScore(undefined), false);
assert.equal(canViewAgentFeedbackScore({ system_role: "" }), false);
});
@@ -0,0 +1,12 @@
import type { LoginByUsernameResponse } from "@/core/auth";
/**
* Agent-level answer scores are an administrative quality-control view.
* Keep the role check strict so missing, stale or unknown auth roles fail
* closed instead of exposing aggregate evaluation data.
*/
export function canViewAgentFeedbackScore(
auth: Pick<LoginByUsernameResponse, "system_role"> | null | undefined,
): boolean {
return auth?.system_role === "admin";
}
@@ -16,8 +16,16 @@ import { getStoredAuth } from "@/core/auth";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useRouteBase } from "@/core/route-base";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
import {
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
WX_RESTRICTED_TAB_NANJING_DB,
isWxRestrictedPlaceholderTab,
wxRestrictedDefaultSquareTabValue,
wxRestrictedNanjingTabValue,
} from "@/core/system-settings/wx-type-filter";
import { AgentCard } from "./agent-card";
import { SentimentAgentCard } from "./sentiment-agent-card";
@@ -34,9 +42,15 @@ const PAGE_SIZE = 12;
// password never persists beyond the browsing session.
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
export function AgentGallery() {
export function AgentGallery({
restrictWxTypes = false,
}: {
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
restrictWxTypes?: boolean;
} = {}) {
const { t } = useI18n();
const router = useRouter();
const routeBase = useRouteBase();
const sentimentAgentVisible = isSentimentAgentVisible();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin";
@@ -52,6 +66,12 @@ export function AgentGallery() {
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
// 其余自定义广场继续保持后端给出的顺序。
const [defaultSquare, ...additionalSquares] = squareTabs;
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
publishSquares?.default_square_id,
);
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
const wxPlaceholderTab =
restrictWxTypes && isWxRestrictedPlaceholderTab(activeTab);
// Publisher filter: click a card's 发布者 to keep only that user's agents.
// {id} is the owner user_id (server filter), {label} the desensitized name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
@@ -72,6 +92,30 @@ export function AgentGallery() {
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the gallery never points at a dead tab.
useEffect(() => {
if (restrictWxTypes) {
if (
activeTab === WX_RESTRICTED_TAB_NANJING_DB &&
nanjingTabValue !== WX_RESTRICTED_TAB_NANJING_DB
) {
setActiveTab(nanjingTabValue);
return;
}
if (
activeTab === WX_RESTRICTED_TAB_DEFAULT_SQUARE &&
defaultSquareTabValue !== WX_RESTRICTED_TAB_DEFAULT_SQUARE
) {
setActiveTab(defaultSquareTabValue);
return;
}
if (
activeTab !== "mine" &&
activeTab !== defaultSquareTabValue &&
activeTab !== nanjingTabValue
) {
setActiveTab("mine");
}
return;
}
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
if (activeTab === "builtin" && !isAdmin) {
setActiveTab("mine");
@@ -87,7 +131,14 @@ export function AgentGallery() {
) {
setActiveTab("mine");
}
}, [activeTab, squareTabs, isAdmin]);
}, [
activeTab,
squareTabs,
isAdmin,
restrictWxTypes,
defaultSquareTabValue,
nanjingTabValue,
]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -128,10 +179,13 @@ export function AgentGallery() {
sort,
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private" && activeTab !== "sentiment",
enabled:
activeTab !== "private" &&
activeTab !== "sentiment" &&
!wxPlaceholderTab,
owner: publisher?.id,
// Each square tab fetches only that square's agents.
square: isSquareTab ? activeTab : undefined,
square: isSquareTab && !wxPlaceholderTab ? activeTab : undefined,
});
const {
@@ -169,11 +223,13 @@ export function AgentGallery() {
(activeTab !== "private" || privateUnlocked);
const activeTotal =
activeTab === "sentiment"
? 1
: activeTab === "private"
? privateTotal
: mainTotal;
wxPlaceholderTab
? 0
: activeTab === "sentiment"
? 1
: activeTab === "private"
? privateTotal
: mainTotal;
const setTabPage = (tab: string, page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
@@ -404,6 +460,14 @@ export function AgentGallery() {
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
title="打开智能体广场视图"
onClick={() => router.push(`${routeBase}/agents/plaza`)}
>
广场视图
</Button>
<Button
variant="ghost"
size="sm"
@@ -442,6 +506,14 @@ export function AgentGallery() {
className="w-auto max-w-full flex-wrap justify-start self-start"
data-agent-guide="tabs"
>
{restrictWxTypes ? (
<>
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value={defaultSquareTabValue}>默认广场</TabsTrigger>
<TabsTrigger value={nanjingTabValue}>南京-db</TabsTrigger>
</>
) : (
<>
<TabsTrigger value="mine">我的</TabsTrigger>
{defaultSquare && (
<TabsTrigger
@@ -475,6 +547,8 @@ export function AgentGallery() {
内置智能体
</TabsTrigger>
)}
</>
)}
</TabsList>
<div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm whitespace-nowrap">
@@ -545,7 +619,41 @@ export function AgentGallery() {
)}
</div>
{squareTabs.map((sq) => (
{restrictWxTypes ? (
<>
<TabsContent
value={defaultSquareTabValue}
className="flex-1 overflow-y-auto p-6 mt-0"
>
{wxPlaceholderTab && activeTab === defaultSquareTabValue
? renderEmpty("该广场暂无智能体")
: renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: defaultSquareTabValue,
emptyDescription: "该广场暂无智能体",
})}
</TabsContent>
<TabsContent
value={nanjingTabValue}
className="flex-1 overflow-y-auto p-6 mt-0"
>
{wxPlaceholderTab && activeTab === nanjingTabValue
? renderEmpty("该类型暂无智能体")
: renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: nanjingTabValue,
emptyDescription: "该类型暂无智能体",
})}
</TabsContent>
</>
) : (
squareTabs.map((sq) => (
<TabsContent
key={sq.id}
value={sq.id}
@@ -560,7 +668,8 @@ export function AgentGallery() {
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
})}
</TabsContent>
))}
))
)}
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
@@ -576,7 +685,7 @@ export function AgentGallery() {
{/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑),
接口地址与鉴权在 runtime-config.js 配置。 */}
{sentimentAgentVisible && (
{!restrictWxTypes && sentimentAgentVisible && (
<TabsContent
value="sentiment"
className="flex-1 overflow-y-auto p-6 mt-0"
@@ -593,7 +702,7 @@ export function AgentGallery() {
</TabsContent>
)}
{isAdmin && (
{!restrictWxTypes && isAdmin && (
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
list: mainAgents,
@@ -606,7 +715,7 @@ export function AgentGallery() {
</TabsContent>
)}
{privateFeatureAvailable && (
{!restrictWxTypes && privateFeatureAvailable && (
<TabsContent
value="private"
className="flex-1 overflow-y-auto p-6 mt-0"
@@ -278,6 +278,7 @@ export function AgentGuide({
autoStart?: boolean;
}) {
const [current, setCurrent] = useState(-1);
const [appDialogOpen, setAppDialogOpen] = useState(false);
const stepsRef = useRef<GuideStep[]>([]);
const start = useCallback(() => {
@@ -317,7 +318,32 @@ export function AgentGuide({
setCurrent(-1);
}, [stage]);
if (!eligible) return null;
// TDesign Guide 遮罩是 z-index 999997+。编辑智能体等业务弹窗只有 z-200,
// 引导若继续高亮页头按钮,会把弹窗连同关闭按钮一起盖住。业务 Dialog 打开时
// 先卸掉引导层,关掉后再从当前步恢复。
useEffect(() => {
if (!eligible) return;
const sync = () => {
setAppDialogOpen(
Boolean(document.querySelector('[data-slot="dialog-content"]')),
);
};
sync();
const observer = new MutationObserver(sync);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, [eligible]);
// TDesign 1.18 still handles resize when current=-1 and dereferences an
// undefined step. Keep its listener mounted only during an active guide.
if (
!eligible ||
appDialogOpen ||
current < 0 ||
current >= stepsRef.current.length
) {
return null;
}
return (
<Guide
@@ -4,7 +4,13 @@ import { MessageSquareIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useThreadsPage } from "@/core/threads/hooks";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -26,6 +32,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
const threads = (data?.threads ?? []).filter(
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
);
const projectThreadNames = useProjectThreadNames(threads);
return (
<section className="bg-card/60 rounded-xl border p-4">
@@ -45,6 +52,10 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
{threads.map((thread) => {
const href = pathOfThread(thread);
const isActive = href === pathname;
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
return (
<Link
key={thread.thread_id}
@@ -52,19 +63,37 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
title={desensitize(titleOfThread(thread, "新对话"))}
className={cn(
"flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors",
projectInfo && "min-h-12",
isActive
? "bg-primary/5 text-primary font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<MessageSquareIcon
{projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : (
<MessageSquareIcon
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
)}
<span
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, "新对话"))}
</span>
{projectInfo && (
<MyProjectThreadBadge
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
/>
<span className="truncate">
{desensitize(titleOfThread(thread, "新对话"))}
</span>
</Link>
);
@@ -6,6 +6,8 @@ import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Skill } from "@/core/skills";
import { useI18n } from "@/core/i18n/hooks";
import { isSkillOffline } from "@/core/skill-availability";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -53,6 +55,7 @@ export function EnabledSkillList({
onViewDetail?: (name: string) => void;
isLoading?: boolean;
}) {
const { t } = useI18n();
const byName = useMemo(
() => new Map(skills.map((s) => [s.name, s])),
[skills],
@@ -79,12 +82,14 @@ export function EnabledSkillList({
<div className="space-y-2">
{skillNames.map((name) => {
const skill = byName.get(name);
const offline = isSkillOffline(skill);
return (
<div
key={name}
className={cn(
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
onViewDetail && "hover:border-primary/40 hover:bg-accent/40",
offline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
)}
>
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold">
@@ -108,6 +113,15 @@ export function EnabledSkillList({
{skillSourceLabel(skill)}
</Badge>
)}
{offline && (
<Badge
variant="outline"
className="shrink-0 border-red-500/35 bg-red-500/5 text-[10px] text-red-700 dark:text-red-300"
title={t.settings.skills.badgeOfflineTitle}
>
{t.settings.skills.badgeOffline}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
{skill
@@ -21,6 +21,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { Skill } from "@/core/skills";
import { isSkillOffline } from "@/core/skill-availability";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -28,7 +29,11 @@ const PAGE_SIZE = 5;
const ALL_CATEGORIES = "__all__";
interface SkillTransferSelectorProps {
/** Full pool of selectable skills (caller pre-filters by `enabled`). */
/**
* Full visible skill pool. Offline skills are never offered on the left,
* but an already-selected offline skill stays visible on the right so it
* can be removed without silently changing the persisted whitelist.
*/
skills: Skill[];
/** Currently selected skill names — the persisted `skills: string[]`. */
selected: string[];
@@ -91,7 +96,9 @@ export function SkillTransferSelector({
const categories = useMemo(() => {
const set = new Set<string>();
for (const skill of skills) {
if (skill.category?.trim()) set.add(skill.category.trim());
if (!isSkillOffline(skill) && skill.category?.trim()) {
set.add(skill.category.trim());
}
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [skills]);
@@ -102,6 +109,7 @@ export function SkillTransferSelector({
const availableSkills = useMemo(() => {
return skills.filter(
(skill) =>
!isSkillOffline(skill) &&
!selectedSet.has(skill.name) &&
matchesQuery(skill, query) &&
(category === ALL_CATEGORIES || skill.category?.trim() === category),
@@ -384,6 +392,7 @@ function SkillItemCard({
onPick,
onViewDetail,
}: SkillItemCardProps) {
const isOffline = isSkillOffline(skill);
return (
<div
role="button"
@@ -398,6 +407,7 @@ function SkillItemCard({
title={isSelected ? "点击移除" : "点击添加"}
className={cn(
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
isOffline && "border-dashed bg-muted/50",
isSelected
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
@@ -425,6 +435,11 @@ function SkillItemCard({
>
{skillLabel(skill)}
</div>
{isOffline && (
<div className="text-destructive truncate text-[10px]">
已下线,仅可移除
</div>
)}
{skill.description?.trim() && (
<div className="text-muted-foreground truncate text-[11px]">
{desensitize(skill.description)}
@@ -8,6 +8,7 @@ import {
ExternalLinkIcon,
EyeIcon,
FileType2Icon,
GlobeIcon,
LoaderIcon,
PackageIcon,
SaveIcon,
@@ -17,11 +18,11 @@ import {
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { defaultUrlTransform } from "react-markdown";
import { Streamdown } from "streamdown";
import {
Artifact,
@@ -58,17 +59,13 @@ import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
import { copyTextToClipboard } from "@/core/clipboard";
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
import {
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_STARTED_EVENT,
DOCUMENT_REWRITE_UNDONE_EVENT,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
completeArtifactRewriteFallback,
primeArtifactRewriteFallback,
} from "@/core/document-rewrite/local-fallback";
import {
downloadArtifactFile,
} from "@/core/artifacts/download";
@@ -85,7 +82,11 @@ import {
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
import {
SafeStreamdown,
streamdownPlugins,
useStreamdownMermaidPngDownload,
} from "@/core/streamdown";
import {
checkCodeFile,
getFileName,
@@ -209,6 +210,22 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
);
}
/**
* 识别 Web App 站点清单:`outputs/<site>/site.json`(兼容 `write-file:` 虚拟 URL
* 与查询串)→ 返回站点目录名;其它文件返回 null(按钮不渲染,零行为变化)。
*/
function extractSiteStudioTarget(filepath: string): string | null {
const normalized = filepath.replace(/^write-file:/, "").split("?")[0];
if (!/(^|\/)site\.json$/.test(normalized)) return null;
const parts = normalized.split("/").filter(Boolean);
const idx = parts.lastIndexOf("site.json");
if (idx <= 0) return null;
const siteName = parts[idx - 1];
if (!siteName || siteName === "outputs") return null;
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(siteName)) return null;
return siteName;
}
export function ArtifactFileDetail({
className,
filepath: filepathFromProps,
@@ -266,8 +283,16 @@ export function ArtifactFileDetail({
headerLeadingActionBare?: boolean;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { artifacts, setOpen, select, setGeneratingArtifact } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread();
const navigate = useNavigate();
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
// capability 不可用(未启用/未部署)时按钮不渲染,原 UI 一字不变。
const siteStudioTarget = useMemo(
() => extractSiteStudioTarget(filepathFromProps),
[filepathFromProps],
);
const webAppCapabilityQuery = useWebAppCapability(!!siteStudioTarget);
const isWriteFileVirtual = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
@@ -413,16 +438,23 @@ export function ArtifactFileDetail({
presentationId: null,
thinking: "",
});
const documentRewriteActive = documentRewrite.isActive;
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
? draftContent
: (content ?? ""));
const streamPerfEnabled = isSandboxStreamPerfEnabled();
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
const documentRewriteActive = documentRewrite.isActive;
const generationLockId = useId();
useLayoutEffect(() => {
const owner = `preview:${generationLockId}`;
const generating = (writeFileInFlight && thread.isLoading) || documentRewriteActive;
setGeneratingArtifact(owner, generating ? filepathFromProps : null);
return () => setGeneratingArtifact(owner, null);
}, [documentRewriteActive, filepathFromProps, generationLockId, setGeneratingArtifact, thread.isLoading, writeFileInFlight]);
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
? draftContent
: (content ?? ""));
const streamPerfEnabled = isSandboxStreamPerfEnabled();
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
const sandboxDisplayText = useMemo(() => {
const raw = displayContent ?? "";
if (disableDesensitize) return raw;
@@ -445,8 +477,8 @@ export function ArtifactFileDetail({
!isArtifactContentLoading;
const isAutoPreviewLanguage =
language === "html" || language === "markdown" || language === "mermaid";
const isPreviewStreaming =
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
const isPreviewStreaming =
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
@@ -471,16 +503,16 @@ export function ArtifactFileDetail({
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
if (!documentRewriteActive) return;
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code");
}, [documentRewriteActive]);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
if (!documentRewriteActive) return;
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code");
}, [documentRewriteActive]);
useEffect(() => {
if (viewMode !== "edit" || canUseAiEditor) return;
@@ -525,13 +557,20 @@ export function ArtifactFileDetail({
presentationId: string,
event: DocumentRewriteStreamEvent,
) => {
if (!rewritePresentation) return;
switch (event.type) {
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
originalCharCount: event.originalCharCount,
if (!rewritePresentation) return;
switch (event.type) {
case "job_started":
rewritePresentation.update(presentationId, {
jobId: event.jobId,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
});
break;
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
...(event.outputPath ? { outputPath: event.outputPath } : {}),
originalCharCount: event.originalCharCount,
requirementsPlan: "",
});
break;
@@ -592,10 +631,11 @@ export function ArtifactFileDetail({
case "commit_started":
rewritePresentation.update(presentationId, { stage: "committing" });
break;
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
versionId: event.versionId ?? null,
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
@@ -611,9 +651,13 @@ export function ArtifactFileDetail({
case "cancelled":
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
break;
case "error":
console.error("AI 全文改写历史恢复失败:", event.message);
break;
case "error":
rewritePresentation.update(presentationId, {
stage: "error",
error: event.message,
diagnostic: event.diagnostic ?? null,
});
break;
default:
break;
}
@@ -635,10 +679,18 @@ export function ArtifactFileDetail({
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
? historicalResult.summary as DocumentRewriteSummary
: null;
const historicalPresentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
fileName: getFileName(filepath),
const historicalPresentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
jobId: historical.id,
outputPath: typeof historicalResult.outputPath === "string"
? historicalResult.outputPath
: historical.outputPath,
fileName: getFileName(
typeof historicalResult.outputPath === "string"
? historicalResult.outputPath
: historical.outputPath ?? filepath,
),
instruction: historical.request.instruction,
style: historical.request.style,
modelName: historical.request.modelName,
@@ -659,11 +711,14 @@ export function ArtifactFileDetail({
summary: historicalSummary,
warnings: historicalSummary?.validationWarnings ?? [],
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
error: historicalResult.status === "conflict"
? "文件在改写期间已变化,未覆盖原文件。"
: historical.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null,
error: historicalResult.status === "conflict"
? "文件在改写期间已变化,未覆盖原文件。"
: historical.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: historical.status === "failed"
? historical.errorMessage ?? "全文改写未完成,原文件未被修改。"
: null,
diagnostic: historical.diagnostic ?? null,
});
}
const result = latest.result;
@@ -672,36 +727,25 @@ export function ArtifactFileDetail({
: null;
const originalContent = latest.request.originalContent || draftContent;
const rewrittenContent = typeof result.content === "string" ? result.content : null;
const versionSnapshotFailed = result.versionSnapshotFailed === true;
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
const localFallbackAvailable =
versionSnapshotFailed &&
latest.status === "completed" &&
result.status !== "conflict" &&
committedHash !== null &&
completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
fileName: getFileName(filepath),
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
const outputPath = typeof result.outputPath === "string"
? result.outputPath
: latest.outputPath;
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
jobId: latest.id,
outputPath,
fileName: getFileName(outputPath ?? filepath),
instruction: latest.request.instruction,
style: latest.request.style,
modelName: latest.request.modelName,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
originalContent,
rewrittenContent,
committedHash,
localFallbackAvailable,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
rewrittenContent,
committedHash,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
});
if (!presentationId || !rewritePresentation) return;
setDocumentRewritePresentationId(presentationId);
@@ -710,32 +754,22 @@ export function ArtifactFileDetail({
rewritePresentation.update(presentationId, {
stage: result.status === "conflict" ? "conflict" : "completed",
summary,
warnings: summary?.validationWarnings ?? [],
versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
localFallbackAvailable,
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
});
if (rewrittenContent && result.status !== "conflict") {
setDraftContent(rewrittenContent);
setHasLocalEdits(versionSnapshotFailed);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewrittenContent, url: undefined },
);
}
return;
warnings: summary?.validationWarnings ?? [],
versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
error: result.status === "conflict" ? "改写版文件未生成。" : null,
});
return;
}
if (latest.status === "failed" || latest.status === "cancelled") {
rewritePresentation.update(presentationId, {
stage: latest.status === "cancelled" ? "cancelled" : "error",
summary,
});
if (latest.status === "failed" || latest.status === "cancelled") {
rewritePresentation.update(presentationId, {
stage: latest.status === "cancelled" ? "cancelled" : "error",
summary,
error: latest.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: latest.errorMessage ?? "全文改写未完成,原文件未被修改。",
diagnostic: latest.status === "failed" ? latest.diagnostic ?? null : null,
});
return;
}
@@ -757,35 +791,22 @@ export function ArtifactFileDetail({
signal,
),
onEvent: (event) => updatePresentation(presentationId, event),
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewritten, url: undefined },
);
},
},
);
onCommitted: (rewritten, { committedHash, outputPath }) => {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
...(outputPath ? { outputPath } : {}),
});
},
onFailure: ({ error, diagnostic }) => {
rewritePresentation.update(presentationId, {
stage: "error",
error,
diagnostic: diagnostic ?? null,
});
},
},
);
} catch {
// Existing artifacts remain available even if history hydration fails.
}
@@ -908,8 +929,8 @@ export function ArtifactFileDetail({
return;
}
if (language === "html") {
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
if (language === "html") {
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
return;
}
@@ -919,9 +940,9 @@ export function ArtifactFileDetail({
}
setViewMode("code");
}, [
documentRewriteActive,
isPreviewStreaming,
}, [
documentRewriteActive,
isPreviewStreaming,
isSupportPreview,
language,
writeFileInFlight,
@@ -932,7 +953,7 @@ export function ArtifactFileDetail({
return;
}
if (writeFileInFlight || documentRewriteActive) {
if (writeFileInFlight || documentRewriteActive) {
setViewMode("code");
return;
}
@@ -944,10 +965,10 @@ export function ArtifactFileDetail({
}, 1400);
return () => window.clearTimeout(timeoutId);
}, [
allowAutoViewSwitch,
documentRewriteActive,
isAutoPreviewLanguage,
}, [
allowAutoViewSwitch,
documentRewriteActive,
isAutoPreviewLanguage,
isSupportPreview,
writeFileInFlight,
]);
@@ -1108,16 +1129,17 @@ export function ArtifactFileDetail({
generateImages: boolean;
maxGeneratedImages: number;
}) => {
if (!canEditArtifact) {
toast.error("当前文件暂不支持全文改写。");
return;
}
setDocumentRewriteDialogOpen(false);
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setViewMode("code");
// Do not make users manually save before using AI rewrite. Persist their
if (!canEditArtifact) {
toast.error("当前文件暂不支持全文改写。");
return;
}
setDocumentRewriteDialogOpen(false);
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code");
// Do not make users manually save before using AI rewrite. Persist their
// current draft first so the rewrite starts from exactly what they see,
// and the optimistic hash remains valid on the server.
if (hasLocalEdits) {
@@ -1136,12 +1158,7 @@ export function ArtifactFileDetail({
}
}
primeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
});
const expectedHash = await sha256Content(draftContent);
const expectedHash = await sha256Content(draftContent);
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
@@ -1173,14 +1190,21 @@ export function ArtifactFileDetail({
expectedHash,
},
{
onEvent: (event) => {
if (!presentationId || !rewritePresentation) return;
switch (event.type) {
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
originalCharCount: event.originalCharCount,
onEvent: (event) => {
if (!presentationId || !rewritePresentation) return;
switch (event.type) {
case "job_started":
rewritePresentation.update(presentationId, {
jobId: event.jobId,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
});
break;
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
...(event.outputPath ? { outputPath: event.outputPath } : {}),
originalCharCount: event.originalCharCount,
requirementsPlan: "",
});
break;
@@ -1244,9 +1268,11 @@ export function ArtifactFileDetail({
case "commit_started":
rewritePresentation.update(presentationId, { stage: "committing" });
break;
case "committed":
rewritePresentation.update(presentationId, {
versionId: event.versionId ?? null,
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
@@ -1256,77 +1282,48 @@ export function ArtifactFileDetail({
versionId: event.versionId ?? null,
});
break;
case "conflict":
rewritePresentation.update(presentationId, {
stage: "conflict",
error: event.message,
});
break;
case "error":
// The sandbox retains the visible draft and exposes manual
// save instead of showing an error state to the user.
console.error("AI 全文改写接口失败:", event.message);
break;
case "conflict":
rewritePresentation.update(presentationId, {
stage: "conflict",
error: event.message,
});
break;
case "cancelled":
rewritePresentation.update(presentationId, {
stage: "cancelled",
error: event.message,
});
break;
case "error":
rewritePresentation.update(presentationId, {
stage: "error",
error: event.message,
diagnostic: event.diagnostic ?? null,
});
break;
default:
break;
}
},
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
}
queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
},
{ content: rewritten, url: undefined },
);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
} else {
toast.success("Markdown 全文改写完成,已安全替换原文件。");
}
},
onFailure: ({ content, error }) => {
// The rewrite transport can fail after the server has already
// streamed text (or before the first token on a 404). Keep that
// visible content in the sandbox, make Save actionable, and leave
// diagnostics to the developer console only.
const retainedContent = content || draftContent;
setDraftContent(retainedContent);
setHasLocalEdits(true);
queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
},
{ content: retainedContent, url: undefined },
);
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
stage: "completed",
rewrittenContent: retainedContent,
error: null,
});
}
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
},
onCommitted: (rewritten, { committedHash, outputPath }) => {
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
...(outputPath ? { outputPath } : {}),
});
}
toast.success("AI 改写完成,已生成独立的改写版文件。原文件保持不变。");
},
onFailure: ({ error, diagnostic }) => {
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
stage: "error",
error,
diagnostic: diagnostic ?? null,
});
}
},
},
);
},
@@ -1344,15 +1341,37 @@ export function ArtifactFileDetail({
],
);
const handleCancelDocumentRewrite = useCallback(() => {
documentRewrite.cancel();
const handleCancelDocumentRewrite = useCallback(() => {
documentRewrite.cancel();
if (documentRewritePresentationId && rewritePresentation) {
rewritePresentation.update(documentRewritePresentationId, {
stage: "cancelled",
error: "已停止全文改写,原文件未被修改。",
});
}
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
// The docked conversation composer is outside this artifact pane. Register
// the same cancellation path so its standard square stop button can abort
// both the local SSE reader and the durable rewrite job.
useEffect(() => {
if (
!documentRewriteActive ||
!documentRewritePresentationId ||
!rewritePresentation
) {
return;
}
return rewritePresentation.registerCancellation(
documentRewritePresentationId,
handleCancelDocumentRewrite,
);
}, [
documentRewriteActive,
documentRewritePresentationId,
handleCancelDocumentRewrite,
rewritePresentation,
]);
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
@@ -1427,7 +1446,9 @@ export function ArtifactFileDetail({
toast.error("文件内容为空,暂时无法在新窗口打开");
return;
}
const blob = new Blob([prepareGeneratedHtml(source)], {
const blob = new Blob([
prepareGeneratedHtml(source),
], {
type: "text/html;charset=utf-8",
});
const url = URL.createObjectURL(blob);
@@ -1454,7 +1475,11 @@ export function ArtifactFileDetail({
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([source], { type: blobType });
const downloadSource =
language === "html"
? prepareGeneratedHtml(source)
: source;
const blob = new Blob([downloadSource], { type: blobType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -1585,19 +1610,25 @@ export function ArtifactFileDetail({
{canEditArtifact && (
<ArtifactAction
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成临时草稿,原文件尚未被覆盖"
: hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown"
}
// Keep the entry available after a completed rewrite and when
// the user has local edits. Starting with local edits saves
// them automatically in handleStartDocumentRewrite.
onClick={() => setDocumentRewriteDialogOpen(true)}
/>
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成独立改写版文件,原文件保持不变;点击停止"
: hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown"
}
// Keep the entry available after a completed rewrite and when
// the user has local edits. Starting with local edits saves
// them automatically in handleStartDocumentRewrite.
onClick={() => {
if (documentRewriteActive) {
handleCancelDocumentRewrite();
return;
}
setDocumentRewriteDialogOpen(true);
}}
/>
)}
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
@@ -1647,6 +1678,16 @@ export function ArtifactFileDetail({
onClick={handleOpenInNewWindow}
/>
)}
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
<ArtifactAction
icon={GlobeIcon}
label="站点预览"
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
onClick={() =>
navigate(siteStudioRoute(threadId, siteStudioTarget))
}
/>
)}
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
@@ -1713,27 +1754,6 @@ export function ArtifactFileDetail({
正在改写 Markdown
</div>
)}
{documentRewriteActive && (
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
<LoaderIcon className="size-3.5 animate-spin" />
{documentRewrite.run.stage === "requirements"
? "正在理解改写需求,原文件未被修改"
: documentRewrite.run.stage === "validation"
? "正在校验 Markdown"
: documentRewrite.run.stage === "committing"
? "正在安全替换原文件"
: "正在写入临时草稿 · " +
documentRewrite.run.writtenCharCount.toLocaleString() +
" 字"}
<button
type="button"
className="ml-1 rounded px-1.5 py-0.5 font-medium hover:bg-[#ead9a6]"
onClick={handleCancelDocumentRewrite}
>
停止
</button>
</div>
)}
{showResumeAutoScroll && (
<button
type="button"
@@ -1744,7 +1764,7 @@ export function ArtifactFileDetail({
滑动到底部并继续自动下滑
</button>
)}
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
<MarkdownAiEditor
// 编辑用原始内容(与源码模式一致,不脱敏);有 artifacts 保存能力时走头部「保存 Markdown」。
// write-file 虚拟预览(如深度研究)仅本地 draft,切换预览/源码仍可见本次改动。
@@ -1801,16 +1821,16 @@ export function ArtifactFileDetail({
? (displayContent ?? "")
: sandboxDisplayText
}
readonly={!canEditArtifact || documentRewriteActive}
onChange={
canEditArtifact && !documentRewriteActive
readonly={!canEditArtifact || documentRewriteActive}
onChange={
canEditArtifact && !documentRewriteActive
? (value) => {
setDraftContent(value);
setHasLocalEdits(true);
}
: undefined
}
isStreaming={writeFileInFlight || documentRewriteActive}
isStreaming={writeFileInFlight || documentRewriteActive}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
@@ -2094,7 +2114,9 @@ export function ArtifactFilePreview({
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
// Intercept in-page anchor clicks so they scroll within the iframe instead
// of navigating the sandboxed blob: document (which blanks the frame).
const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
const blob = new Blob([
prepareGeneratedHtml(content ?? ""),
], {
type: "text/html;charset=utf-8",
});
const url = URL.createObjectURL(blob);
@@ -2147,7 +2169,7 @@ export function ArtifactFilePreview({
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
<Streamdown
<SafeStreamdown
className="size-full"
{...streamdownPlugins}
// react-markdown's safe default strips unknown protocols before the
@@ -2172,7 +2194,7 @@ export function ArtifactFilePreview({
}}
>
{previewContent}
</Streamdown>
</SafeStreamdown>
{activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)}
@@ -16,6 +16,7 @@ import {
getFileExtensionDisplayName,
getFileIcon,
getFileName,
isWordDocumentFile,
} from "@/core/utils/files";
import { cn } from "@/lib/utils";
@@ -93,12 +94,12 @@ export function ArtifactFileList({
return (
<ul className={cn("flex w-full flex-col gap-4", className)}>
{files.map((file) => (
<Card
key={file}
className="relative cursor-pointer p-3"
onClick={() => handleClick(file)}
>
{files.map((file) => (
<Card
key={file}
className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")}
onClick={isWordDocumentFile(file) ? undefined : () => handleClick(file)}
>
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
<div className="min-w-0">{getFileName(file)}</div>
@@ -133,7 +134,7 @@ export function ArtifactFileList({
</Button>
</CardAction>
</CardHeader>
</Card>
</Card>
))}
<SkillInstallDialog
open={!!installFile}
@@ -7,9 +7,11 @@ import {
useState,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { useSidebarSafe } from "@/components/ui/sidebar";
import { env } from "@/env";
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
export function artifactPreviewKey(artifact: string): string {
if (!artifact.startsWith("write-file:")) {
@@ -38,6 +40,8 @@ export interface ArtifactsContextType {
select: (artifact: string, autoSelect?: boolean) => void;
/** Open/update a streamed write without overriding a manual close of that call. */
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
/** Register the file being generated; remove this owner's lock on completion. */
setGeneratingArtifact: (owner: string, artifact: string | null) => void;
deselect: () => void;
open: boolean;
@@ -86,6 +90,26 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [preOpen, setPreOpen] = useState(false);
const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
const generatingArtifactsRef = useRef(new Map<string, string>());
const setGeneratingArtifact = useCallback((owner: string, artifact: string | null) => {
if (artifact) generatingArtifactsRef.current.set(owner, artifact);
else generatingArtifactsRef.current.delete(owner);
}, []);
const canSwitchArtifact = useCallback((artifact: string, notify: boolean) => {
for (const generating of generatingArtifactsRef.current.values()) {
if (
artifactPreviewKey(generating) === artifactPreviewKey(artifact) ||
resolveWriteFilePath(generating) === resolveWriteFilePath(artifact)
) continue;
if (notify) {
toast.info("当前文件正在生成,请稍等片刻,完成后就可以切换查看其他文件。", {
id: "artifact-generation-switch-locked",
});
}
return false;
}
return true;
}, []);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
@@ -129,6 +153,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const select = useCallback(
(artifact: string, autoSelect = false) => {
if (!canSwitchArtifact(artifact, !autoSelect)) return;
const previewKey = artifactPreviewKey(artifact);
selectedArtifactKeyRef.current = previewKey;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
@@ -140,11 +165,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
setAutoSelect((previous) => (previous ? false : previous));
}
},
[sidebar],
[canSwitchArtifact, sidebar],
);
const openStreamedArtifact = useCallback(
(artifact: string, previewKey: string) => {
if (!canSwitchArtifact(artifact, false)) return false;
if (suppressedAutoOpenKeyRef.current === previewKey) {
return false;
}
@@ -180,10 +206,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
}
return true;
},
[applyOpen, cancelPendingStreamedOpen, sidebar],
[applyOpen, canSwitchArtifact, cancelPendingStreamedOpen, sidebar],
);
const deselect = useCallback(() => {
generatingArtifactsRef.current.clear();
selectedArtifactKeyRef.current = null;
setSelectedArtifact((previous) => (previous === null ? previous : null));
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
@@ -224,6 +251,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
selectedArtifactKey,
select,
openStreamedArtifact,
setGeneratingArtifact,
deselect,
};
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { useUploadPolicy } from "@/core/uploads";
/**
* Shared providers required by every full chat surface.
*
* Keep this in a small standalone module: importing it from a route must not
* eagerly evaluate the complete WorkspaceRoutes tree and all of its pages.
*/
export function ChatRuntime({ children }: { children: ReactNode }) {
const { data: uploadPolicy } = useUploadPolicy();
return (
<SubtasksProvider>
<DocumentRewritePresentationProvider>
<ArtifactsProvider>
<PromptInputProvider uploadPolicy={uploadPolicy}>
{children}
</PromptInputProvider>
</ArtifactsProvider>
</DocumentRewritePresentationProvider>
</SubtasksProvider>
);
}
@@ -1,8 +1,8 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FilesIcon, XIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
@@ -19,11 +19,22 @@ import {
listThreadArtifacts,
mergeArtifactPaths,
} from "@/core/artifacts/listing";
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
import {
latestCompletedArtifactMutationKey,
latestInFlightWriteFilePreview,
latestPreparedHtmlArtifact,
isWriteFileInFlight,
} from "@/core/artifacts/write-file-in-flight";
import {
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
} from "@/core/artifacts/streaming-write-preview";
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { useIsMobile } from "@/hooks/use-mobile";
import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { useSkills } from "@/core/skills/hooks";
import type { SkillDisplayConfig } from "@/core/skills/type";
@@ -35,6 +46,7 @@ import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime";
import { isWordDocumentFile } from "@/core/utils/files";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -45,6 +57,7 @@ import {
useArtifacts,
} from "../artifacts";
import { ReferenceBatchesContext, useThread } from "../messages/context";
import { KnowledgeWikiPanel } from "../knowledge-wiki-panel";
import { SkillDisplaySources } from "../skill-display-sources";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
@@ -83,15 +96,30 @@ const ChatBox: React.FC<{
children: React.ReactNode;
threadId: string;
disableArtifacts?: boolean;
writingMode?: boolean;
/** Prefer and automatically reveal a specific artifact once it is available. */
preferredArtifact?: string | null;
/** Keep scanning until the preferred artifact appears (for delayed file persistence). */
pollForPreferredArtifact?: boolean;
/** Report the real artifact discovered on disk back to the page state. */
onPreferredArtifactAvailabilityChange?: (artifact: string | null) => void;
/** Stack the artifact below chat on small screens. */
responsiveArtifactLayout?: boolean;
}> = ({
children,
threadId,
disableArtifacts = false,
writingMode,
preferredArtifact = null,
pollForPreferredArtifact = false,
onPreferredArtifactAvailabilityChange,
responsiveArtifactLayout = false,
}) => {
const {
isMock,
thread,
knowledgeReferencesEnabled = false,
knowledgeReferences,
} = useThread();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -101,6 +129,8 @@ const ChatBox: React.FC<{
}, [searchParams]);
const threadIdRef = useRef(threadId);
const openedRequestedArtifactRef = useRef<string | null>(null);
const openedPreferredArtifactRef = useRef<string | null>(null);
const preferredRunWasLoadingRef = useRef(false);
const artifactsCountRef = useRef(0);
const layoutRef = useRef<GroupImperativeHandle>(null);
const latestReferenceBatchIdRef = useRef<string | null>(null);
@@ -112,9 +142,12 @@ const ChatBox: React.FC<{
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const stackArtifactPanel = responsiveArtifactLayout && isMobile;
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = !!settings.context.writing_mode;
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings();
@@ -123,7 +156,8 @@ const ChatBox: React.FC<{
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled;
knowledgeReferencesEnabled ||
!!knowledgeReferences;
// Frontend RAG modes write their own hidden source tool messages. They use
// the existing reference panel, but must not request or write reference
// batches through DeerFlow's backend APIs.
@@ -140,7 +174,7 @@ const ChatBox: React.FC<{
const { skills } = useSkills();
const canScanArtifactDirectory =
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
(artifactDirectoryModeEnabled || !!requestedArtifact || !!preferredArtifact) &&
!disableArtifacts &&
!isMock &&
!!threadId &&
@@ -149,7 +183,8 @@ const ChatBox: React.FC<{
queryKey: ["artifact-directory", threadId],
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
enabled: canScanArtifactDirectory,
refetchInterval: thread.isLoading ? 1_500 : false,
refetchInterval:
thread.isLoading || pollForPreferredArtifact ? 1_500 : false,
retry: false,
});
const checkpointArtifacts = useMemo(
@@ -159,7 +194,7 @@ const ChatBox: React.FC<{
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
const availableArtifacts = useMemo(
() => {
if (!artifactDirectoryModeEnabled && !requestedArtifact) {
if (!artifactDirectoryModeEnabled && !requestedArtifact && !preferredArtifact) {
return checkpointArtifacts;
}
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
@@ -172,9 +207,28 @@ const ChatBox: React.FC<{
artifactDirectoryModeEnabled,
artifactListingQuery.data?.files,
checkpointArtifacts,
preferredArtifact,
requestedArtifact,
],
);
const availablePreferredArtifact = useMemo(() => {
if (!preferredArtifact) return null;
const preferredPath = artifactPathname(preferredArtifact);
return (
[...availableArtifacts]
.reverse()
.find((artifact) => artifactPathname(artifact) === preferredPath) ?? null
);
}, [availableArtifacts, preferredArtifact]);
useEffect(() => {
if (!preferredArtifact) return;
onPreferredArtifactAvailabilityChange?.(availablePreferredArtifact);
}, [
availablePreferredArtifact,
onPreferredArtifactAvailabilityChange,
preferredArtifact,
]);
useEffect(() => {
if (!canScanArtifactDirectory || thread.isLoading) return;
@@ -199,8 +253,20 @@ const ChatBox: React.FC<{
selectedArtifact,
selectedArtifactKey,
openStreamedArtifact,
setGeneratingArtifact,
} = useArtifacts();
// Keep the lock even when the user closes the preview while writing.
const selectedFileGenerating = Boolean(
selectedArtifact && thread.isLoading &&
isWriteFileInFlight({ messages: thread.messages ?? [] }, selectedArtifact),
);
useLayoutEffect(() => {
const owner = `chat:${threadId}`;
setGeneratingArtifact(owner, selectedFileGenerating ? selectedArtifact : null);
return () => setGeneratingArtifact(owner, null);
}, [selectedArtifact, selectedFileGenerating, setGeneratingArtifact, threadId]);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
@@ -239,6 +305,41 @@ const ChatBox: React.FC<{
threadId,
]);
useEffect(() => {
const completedNow =
preferredRunWasLoadingRef.current && !thread.isLoading;
preferredRunWasLoadingRef.current = thread.isLoading;
if (disableArtifacts || !preferredArtifact) {
openedPreferredArtifactRef.current = null;
return;
}
if (!availablePreferredArtifact) return;
const preferredKey = `${threadId}:${artifactPathname(availablePreferredArtifact)}`;
if (openedPreferredArtifactRef.current === preferredKey && !completedNow) {
return;
}
openedPreferredArtifactRef.current = preferredKey;
selectArtifact(availablePreferredArtifact, true);
setArtifactsOpen(true);
setSideTab("document");
// The same canonical report path may be overwritten by a later run while
// its content query is still inside the five-minute cache window. Invalidate
// the real selected path before the detail panel mounts/reopens so it never
// shows the previous report after the assistant announces completion.
void queryClient.invalidateQueries({
queryKey: ["artifact", availablePreferredArtifact, threadId],
});
}, [
availablePreferredArtifact,
disableArtifacts,
preferredArtifact,
queryClient,
selectArtifact,
setArtifactsOpen,
thread.isLoading,
threadId,
]);
useEffect(() => {
if (!shouldFetchBackendReferenceBatches) {
setBackendReferenceBatches([]);
@@ -290,7 +391,7 @@ const ChatBox: React.FC<{
),
[backendReferenceBatches, skillDisplayMap],
);
const referenceBatches = useMemo(
const resolvedReferenceBatches = useMemo(
() => {
if (
shouldFetchBackendReferenceBatches &&
@@ -306,6 +407,18 @@ const ChatBox: React.FC<{
shouldFetchBackendReferenceBatches,
],
);
const referenceBatches = useMemo(() => {
if (!knowledgeReferences) return resolvedReferenceBatches;
return resolvedReferenceBatches
.map((batch) => ({
...batch,
sources: filterKnowledgeWikiSources(
batch.sources,
knowledgeReferences,
),
}))
.filter((batch) => batch.sources.length > 0);
}, [knowledgeReferences, resolvedReferenceBatches]);
useEffect(() => {
if (!shouldFetchBackendReferenceBatches) {
return;
@@ -434,13 +547,50 @@ const ChatBox: React.FC<{
!disableArtifacts &&
nextArtifacts.length > 0 &&
((artifactDirectoryModeEnabled && threadChanged) ||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
(hasNewArtifact || !hadArtifacts))
) {
const newest = nextArtifacts[nextArtifacts.length - 1]!;
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
selectArtifact(newest);
setArtifactsOpen(true);
setSideTab("document");
// The preferred deliverable wins over the generic "newest artifact"
// heuristic. Both effects can run in the same commit with a stale null
// selectedArtifact; choosing the preferred path here prevents a sibling
// file such as research-plan.json from overwriting report.md.
const automaticTarget =
availablePreferredArtifact ?? nextArtifacts[nextArtifacts.length - 1]!;
const isGeneratedHtml =
languageOfStreamingArtifact(automaticTarget) === "html" &&
isAutoPreviewableArtifactPath(automaticTarget);
if (
shouldAutoOpenWritingArtifact(
automaticTarget,
thread.messages,
writingModeEnabled,
) &&
(isGeneratedHtml || !selectedArtifact)
) {
if (isGeneratedHtml) {
// HTML is the requested end product, so a later webpage should
// replace a previously selected document automatically. Preserve a
// matching in-flight write key so a user-closed live preview stays
// closed after the tool call completes; otherwise a completed
// tool-call id makes a later write to the same path a new preview.
const revision = latestCompletedArtifactMutationKey(
{ messages: thread.messages ?? [] },
automaticTarget,
);
const previewKey =
selectedArtifact?.startsWith("write-file:") &&
artifactPathname(selectedArtifact) === artifactPathname(automaticTarget) &&
selectedArtifactKey
? selectedArtifactKey
: revision ?? `artifact:${threadId}:${artifactPathname(automaticTarget)}`;
openStreamedArtifact(
automaticTarget,
previewKey,
);
} else {
selectArtifact(automaticTarget, true);
setArtifactsOpen(true);
setSideTab("document");
}
}
}
@@ -459,7 +609,12 @@ const ChatBox: React.FC<{
) {
if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
const firstPreviewableArtifact = thread.values.artifacts.find(
(artifact) => !isWordDocumentFile(artifact),
);
if (firstPreviewableArtifact) {
selectArtifact(firstPreviewableArtifact, true);
}
}
}
}, [
@@ -468,17 +623,21 @@ const ChatBox: React.FC<{
autoSelectFirstArtifact,
deselect,
selectArtifact,
openStreamedArtifact,
selectedArtifact,
selectedArtifactKey,
artifacts,
setArtifacts,
setArtifactsOpen,
availableArtifacts,
availablePreferredArtifact,
disableArtifacts,
thread.messages,
writingModeEnabled,
]);
// Live sandbox: open as soon as any in-flight write_file has a path or
// the first content bytes — not only when that tool is the last CoT step.
// Live sandbox: open when an in-flight write_file has a usable output path.
// Pathless content stays inline in the CoT, including content-first streams.
// Agentfx (and other agents) often emit write_file + present_files/bash in
// the same turn; the collapsed earlier write would otherwise never auto-open.
const streamingWritePreview = useMemo(
@@ -497,6 +656,25 @@ const ChatBox: React.FC<{
setSideTab("document");
}, [openStreamedArtifact, streamingWritePreview]);
// page_report_template(prepare) creates the output HTML before content
// editing starts. Open that real artifact immediately; this also covers
// models that use str_replace or whose write_file arguments arrive at once.
const preparedHtmlPreview = useMemo(
() =>
disableArtifacts || !thread.isLoading
? null
: latestPreparedHtmlArtifact(thread.messages ?? []),
[disableArtifacts, thread.isLoading, thread.messages],
);
useEffect(() => {
if (!preparedHtmlPreview) return;
openStreamedArtifact(
preparedHtmlPreview.artifactPath,
preparedHtmlPreview.previewKey,
);
setSideTab("document");
}, [openStreamedArtifact, preparedHtmlPreview]);
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current;
@@ -504,12 +682,13 @@ const ChatBox: React.FC<{
if (
wasEnabled ||
!artifactDirectoryModeEnabled ||
availableArtifacts.length === 0
availableArtifacts.length === 0 ||
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
) {
return;
}
if (!selectedArtifact) {
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!, true);
}
setArtifactsOpen(true);
setSideTab("document");
@@ -664,7 +843,7 @@ const ChatBox: React.FC<{
<ReferenceBatchesContext.Provider value={referenceBatches}>
<ResizablePanelGroup
id={`${resizableIdBase}-panels`}
orientation="horizontal"
orientation={stackArtifactPanel ? "vertical" : "horizontal"}
defaultLayout={{ chat: 100, artifacts: 0 }}
groupRef={layoutRef}
>
@@ -675,6 +854,8 @@ const ChatBox: React.FC<{
id={`${resizableIdBase}-separator`}
className={cn(
"opacity-33 hover:opacity-100",
stackArtifactPanel &&
"h-px w-full after:inset-x-0 after:inset-y-auto after:top-1/2 after:h-4 after:w-full after:-translate-x-0 after:-translate-y-1/2 after:cursor-row-resize",
!sidePanelOpen && "pointer-events-none opacity-0",
)}
/>
@@ -696,7 +877,9 @@ const ChatBox: React.FC<{
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
<TabsList variant="line">
<TabsTrigger value="document">文档</TabsTrigger>
<TabsTrigger value="references">参考文献</TabsTrigger>
<TabsTrigger value="references">
{knowledgeReferences ? "Wiki" : "参考文献"}
</TabsTrigger>
</TabsList>
<Button
size="icon-sm"
@@ -712,11 +895,19 @@ const ChatBox: React.FC<{
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
{artifactDocumentPanel}
</TabsContent>
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
<TabsContent value="references" className="min-h-0 flex-1 overflow-hidden p-0">
{knowledgeReferences ? (
<KnowledgeWikiPanel
highlightedReferenceId={pinnedReferenceId}
selection={knowledgeReferences}
sources={referenceSources}
/>
) : (
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
)}
</TabsContent>
</Tabs>
) : artifactPanelOpen ? (
@@ -735,10 +926,18 @@ const ChatBox: React.FC<{
<XIcon />
</Button>
</div>
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
{knowledgeReferences ? (
<KnowledgeWikiPanel
highlightedReferenceId={pinnedReferenceId}
selection={knowledgeReferences}
sources={referenceSources}
/>
) : (
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
)}
</div>
) : null}
</div>
@@ -304,7 +304,9 @@ export function CompactModeGuide({
setCurrent(-1);
}, [clearPollTimer, releasePromptDialog]);
if (!eligible) return null;
// TDesign 1.18 still handles resize when current=-1 and dereferences an
// undefined step. Keep its listener mounted only during an active guide.
if (!eligible || current < 0 || current >= stepsRef.current.length) return null;
return (
<Guide
@@ -22,6 +22,7 @@ import {
Download,
FileJson,
FileText,
Loader2,
MessageSquare,
MoreHorizontal,
Pencil,
@@ -32,14 +33,17 @@ import {
Share2,
Star,
Trash2,
X,
} from "lucide-react";
import Link from "next/link";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Select } from "tdesign-react";
import { useTheme } from "@/contexts/ThemeContext";
import { findNanjingDbSquareId } from "@/core/system-settings/wx-type-filter";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -60,6 +64,10 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
@@ -75,6 +83,8 @@ import {
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import {
isBootstrapThread,
isHiddenFromRecentChats,
@@ -87,10 +97,17 @@ import {
} from "@/core/threads/utils";
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
import { useRouteBase } from "@/core/route-base";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { SENTIMENT_AGENT_ID } from "@/core/sentiment-agent/config";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
import {
mergeRecentWorkspaceItems,
useRecentStrategyProjects,
} from "@/strategy-selection/recent-projects";
import { agentColor } from "./agent-icon-color";
import { SidebarCollapseButton } from "./workspace-header";
@@ -114,30 +131,74 @@ function SectionLabel({
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
>
<span className="flex items-center gap-1.5">
<span>{label}</span>
{count != null && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{count}
</span>
)}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
collapsed && "-rotate-90",
)}
/>
</button>
<div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
<button
type="button"
onClick={onToggle}
className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none"
>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{label}</span>
{count != null && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{count}
</span>
)}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
collapsed && "-rotate-90",
)}
/>
</button>
</div>
);
}
export function GeneralQaSidebar() {
type CommonAgent = Pick<Agent, "id" | "name" | "description">;
/** wx 左栏固定的智能体类型;南京-db 广场尚未创建时筛空列表。 */
const WX_AGENT_TYPE_MINE = "mine";
const WX_AGENT_TYPE_DEFAULT_SQUARE = "default_square";
const WX_AGENT_TYPE_NANJING_DB = "nanjing-db";
const WX_AGENT_TYPE_STORAGE_KEY = "gqa-sidebar:wx-agent-type";
const WX_AGENT_LIST_PAGE_SIZE = 50;
const WX_AGENT_TYPE_OPTIONS = [
{ value: WX_AGENT_TYPE_MINE, label: "我的" },
{ value: WX_AGENT_TYPE_DEFAULT_SQUARE, label: "默认广场" },
{ value: WX_AGENT_TYPE_NANJING_DB, label: "南京-db" },
] as const;
type WxAgentType = (typeof WX_AGENT_TYPE_OPTIONS)[number]["value"];
function isWxAgentType(value: string | null | undefined): value is WxAgentType {
return WX_AGENT_TYPE_OPTIONS.some((option) => option.value === value);
}
function readStoredWxAgentType(): WxAgentType {
if (typeof window === "undefined") return WX_AGENT_TYPE_MINE;
const stored = window.localStorage.getItem(WX_AGENT_TYPE_STORAGE_KEY);
return isWxAgentType(stored) ? stored : WX_AGENT_TYPE_MINE;
}
function resolveWxSquareId(
agentType: WxAgentType,
squares: Array<{ id: string; name: string }> | undefined,
defaultSquareId: string | undefined,
): string | undefined {
if (agentType === WX_AGENT_TYPE_MINE) return undefined;
if (agentType === WX_AGENT_TYPE_DEFAULT_SQUARE) return defaultSquareId;
return findNanjingDbSquareId(squares);
}
export function GeneralQaSidebar({
wxCommonAgents,
}: {
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
} = {}) {
const { t } = useI18n();
const routeBase = useRouteBase();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
@@ -158,17 +219,70 @@ export function GeneralQaSidebar() {
setAgentsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
return !v;
});
});
const { data: publishSquares, isLoading: wxSquaresLoading } = usePublishSquares({
enabled: !!wxCommonAgents,
});
const [wxAgentType, setWxAgentTypeState] = useState<WxAgentType>(
readStoredWxAgentType,
);
const setWxAgentType = useCallback((next: WxAgentType) => {
setWxAgentTypeState(next);
window.localStorage.setItem(WX_AGENT_TYPE_STORAGE_KEY, next);
}, []);
const [wxSearchInput, setWxSearchInput] = useState("");
const [wxSearch, setWxSearch] = useState("");
useEffect(() => {
const timer = window.setTimeout(() => setWxSearch(wxSearchInput.trim()), 300);
return () => window.clearTimeout(timer);
}, [wxSearchInput]);
const wxSquareId = wxCommonAgents
? resolveWxSquareId(
wxAgentType,
publishSquares?.squares,
publishSquares?.default_square_id,
)
: undefined;
const wxFetchEnabled =
!!wxCommonAgents &&
(wxAgentType === WX_AGENT_TYPE_MINE || Boolean(wxSquareId));
const toggleChats = () =>
setChatsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
return !v;
});
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
const frequent = useMemo<Agent[]>(() => {
// ── 常用智能体取数:wx 走类型筛选;其它页面仍是置顶 → 我的 → 广场 ──
const { agents: mine } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 20,
enabled: !wxCommonAgents,
});
const { agents: square } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 20,
enabled: !wxCommonAgents,
});
const {
agents: wxAgents,
total: wxTotal,
isLoading: wxAgentsLoading,
isFetching: wxAgentsFetching,
} = useAgentsPaginated({
scope: wxAgentType === WX_AGENT_TYPE_MINE ? "mine" : "square",
page: 1,
pageSize: WX_AGENT_LIST_PAGE_SIZE,
square: wxSquareId,
search: wxSearch || undefined,
enabled: wxFetchEnabled,
});
const frequent = useMemo<CommonAgent[]>(() => {
if (wxCommonAgents) {
return wxFetchEnabled ? wxAgents : [];
}
const out: Agent[] = [];
const seen = new Set<string>();
const add = (agent: Agent | undefined) => {
@@ -180,7 +294,15 @@ export function GeneralQaSidebar() {
mine.forEach(add);
square.forEach(add);
return out;
}, [mine, square]);
}, [mine, square, wxAgents, wxCommonAgents, wxFetchEnabled]);
const wxEmptyHint =
wxAgentType === WX_AGENT_TYPE_NANJING_DB && !wxSquareId
? "该类型暂无智能体"
: wxSearch
? "未找到匹配的智能体"
: wxAgentType === WX_AGENT_TYPE_MINE
? "暂无智能体"
: "该广场暂无智能体";
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
const searchParams = useSearchParams();
@@ -193,6 +315,12 @@ export function GeneralQaSidebar() {
`${routeBase}/agents`,
currentSearch,
);
const moreSkillsHref = buildFullChatNavigationPath(
`${routeBase}/skills`,
currentSearch,
);
const wxAgentsPageActive = pathname === `${routeBase}/agents`;
const wxSkillsPageActive = pathname === `${routeBase}/skills`;
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
@@ -200,21 +328,31 @@ export function GeneralQaSidebar() {
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
const handleSelectAgent = useCallback(
(agentId: string) => {
if (agentId === SENTIMENT_AGENT_ID) {
navigate(
buildFullChatNavigationPath(
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`,
currentSearch,
),
);
return;
}
navigate(
buildFullChatNavigationPath(
routeBase === "/page/workspace"
routeBase === "/page/workspace" || wxCommonAgents
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
currentSearch,
),
);
},
[currentSearch, navigate, newChatPath, routeBase],
[currentSearch, navigate, newChatPath, routeBase, wxCommonAgents],
);
// ── 最近的对话取数 + 搜索 ──
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const { data: threads = [] } = useThreads();
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin();
@@ -241,17 +379,28 @@ export function GeneralQaSidebar() {
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads],
);
const projectThreadNames = useProjectThreadNames(visibleThreads);
const recentItems = useMemo(
() =>
mergeRecentWorkspaceItems(visibleThreads, recentStrategyProjects),
[recentStrategyProjects, visibleThreads],
);
const filteredThreads = useMemo(() => {
const filteredRecentItems = useMemo(() => {
const q = query.trim().toLowerCase();
const list = q
? visibleThreads.filter((thread) =>
titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q),
? recentItems.filter((item) =>
(item.kind === "thread"
? titleOfThread(item.thread, t.pages.newChat)
: item.project.title
)
.toLowerCase()
.includes(q),
)
: visibleThreads;
: recentItems;
// 未搜索时与旧版一致:最多展示 10 条
return q ? list : list.slice(0, 10);
}, [visibleThreads, query, t.pages.newChat]);
}, [query, recentItems, t.pages.newChat]);
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
@@ -354,17 +503,88 @@ export function GeneralQaSidebar() {
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{/* ── 常用智能体 ── */}
{frequent.length > 0 && (
{(wxCommonAgents || frequent.length > 0) && (
<div className="pt-2">
<SectionLabel
label="常用智能体"
count={wxCommonAgents ? frequent.length : undefined}
collapsed={agentsCollapsed}
onToggle={toggleAgents}
/>
{!agentsCollapsed && (
<div className="mt-1 flex flex-col gap-1">
{wxCommonAgents && (
<div className="space-y-2 px-1 pb-1">
<Select
size="medium"
value={wxAgentType}
placeholder="智能体类型"
onChange={(value) => {
if (isWxAgentType(String(value))) {
setWxAgentType(String(value) as WxAgentType);
}
}}
className="w-full [&_.t-input]:!h-9 [&_.t-input]:!rounded-lg [&_.t-input]:!border-border [&_.t-input]:!bg-card [&_.t-input]:!px-2.5 [&_.t-input]:!text-[13px]"
popupProps={{
overlayClassName: "z-[80]",
attach: () => document.body,
}}
>
{WX_AGENT_TYPE_OPTIONS.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground/60" />
<Input
value={wxSearchInput}
onChange={(e) => setWxSearchInput(e.target.value)}
placeholder="搜索智能体"
className="h-8 rounded-lg border-border bg-card pl-8 pr-8 text-[12px]"
aria-label="搜索智能体"
/>
{wxSearchInput && (
<button
type="button"
onClick={() => setWxSearchInput("")}
className="absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
title="清除"
aria-label="清除搜索"
>
<X className="size-3.5" />
</button>
)}
</div>
</div>
)}
{wxCommonAgents &&
(wxAgentsLoading ||
(wxAgentType !== WX_AGENT_TYPE_MINE && wxSquaresLoading)) &&
frequent.length === 0 ? (
<div className="flex h-20 items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
加载中…
</div>
) : frequent.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground/70">
{wxCommonAgents ? wxEmptyHint : null}
</p>
) : (
<div
className={cn(
"flex max-h-[240px] flex-col gap-1 overflow-y-auto overscroll-contain pr-0.5",
wxCommonAgents && wxAgentsFetching && "opacity-60",
)}
>
{frequent.map((agent) => {
const isActive = activeAgentId === agent.id;
const isActive =
activeAgentId === agent.id ||
(agent.id === SENTIMENT_AGENT_ID &&
pathname.startsWith(
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`,
));
const { from, to, foreground } = agentColor(agent.id, theme);
return (
<button
@@ -437,13 +657,50 @@ export function GeneralQaSidebar() {
</button>
);
})}
<Link
href={moreAgentsHref}
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>{t.common.more}</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
</div>
)}
{wxCommonAgents ? (
<div className="grid grid-cols-2 gap-1.5 px-1 pt-0.5">
<Link
href={moreAgentsHref}
className={cn(
"flex h-8 items-center justify-center rounded-lg text-[12px] font-medium transition-colors",
wxAgentsPageActive
? isDark
? "bg-[#1a5dad] text-white"
: "bg-primary text-primary-foreground"
: isDark
? "bg-[#0c3a6e] text-[#d6eaff] hover:bg-[#124a86]"
: "bg-accent text-foreground hover:bg-accent/80",
)}
>
智能体
</Link>
<Link
href={moreSkillsHref}
className={cn(
"flex h-8 items-center justify-center rounded-lg text-[12px] font-medium transition-colors",
wxSkillsPageActive
? isDark
? "bg-[#1a5dad] text-white"
: "bg-primary text-primary-foreground"
: isDark
? "bg-[#0c3a6e] text-[#d6eaff] hover:bg-[#124a86]"
: "bg-accent text-foreground hover:bg-accent/80",
)}
>
技能
</Link>
</div>
) : (
<Link
href={moreAgentsHref}
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>{t.common.more}</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
)}
</div>
)}
</div>
@@ -453,7 +710,7 @@ export function GeneralQaSidebar() {
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
<SectionLabel
label={t.sidebar.recentChats}
count={visibleThreads.length}
count={recentItems.length}
collapsed={chatsCollapsed}
onToggle={toggleChats}
/>
@@ -471,29 +728,75 @@ export function GeneralQaSidebar() {
</div>
<div className="flex flex-col gap-0.5">
{filteredThreads.length === 0 ? (
{filteredRecentItems.length === 0 ? (
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
{query ? "没有匹配的会话" : t.chats.noChats}
</div>
) : (
filteredThreads.map((thread) => {
filteredRecentItems.map((item) => {
if (item.kind === "strategy-project") {
const { project } = item;
const href = strategyProjectWorkbenchPath(
"/page/strategy/qa/projects",
project.id,
);
const isActive = href === pathname;
return (
<div
key={`strategy-project:${project.id}`}
className="group/item relative flex items-center"
>
<Link
href={href}
className={cn(
"gq-history-item relative flex min-h-12 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] transition-colors",
isActive
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<MyProjectThreadIcon type="strategy" />
<span className="min-w-0 flex-1 flex flex-col items-start gap-0.5">
<span className="w-full truncate">
{desensitize(project.title)}
</span>
<MyProjectThreadBadge
className="mr-4 max-w-[calc(100%_-_1rem)]"
projectName={project.title}
type="strategy"
/>
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</Link>
</div>
);
}
const { thread } = item;
const href = buildFullChatNavigationPath(
pathForThread(thread),
currentSearch,
);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<div
key={thread.thread_id}
key={`thread:${thread.thread_id}`}
className="group/item relative flex items-center"
>
<Link
href={href}
className={cn(
"gq-history-item relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
projectInfo && "h-auto min-h-12 py-1.5",
isActive
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
@@ -507,6 +810,8 @@ export function GeneralQaSidebar() {
)}
aria-label="智能体创建会话"
/>
) : projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : (
<MessageSquare
className={cn(
@@ -518,8 +823,22 @@ export function GeneralQaSidebar() {
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
<span
className={cn(
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-4 max-w-[calc(100%_-_1rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { hasPromptInputPayload } from "./input-box-submit.ts";
test("allows attachment-only prompt submissions", () => {
assert.equal(hasPromptInputPayload({ text: "", files: [{}] }), true);
});
test("rejects submissions with neither text nor attachments", () => {
assert.equal(hasPromptInputPayload({ text: "", files: [] }), false);
assert.equal(hasPromptInputPayload({ text: " \n", files: [] }), false);
});
test("allows text-only prompt submissions", () => {
assert.equal(hasPromptInputPayload({ text: "总结一下", files: [] }), true);
});
@@ -0,0 +1,9 @@
export type PromptInputPayload = {
text: string;
files: readonly unknown[];
};
/** A prompt may contain text, attachments, or both. */
export function hasPromptInputPayload(message: PromptInputPayload): boolean {
return message.text.trim().length > 0 || message.files.length > 0;
}
@@ -1,9 +1,10 @@
"use client";
import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
import type { ChatStatus } from "ai";
import { toast } from "sonner";
import {
BookMarkedIcon,
BrainCircuitIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
@@ -62,12 +63,23 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { getBackendBaseURL } from "@/core/config";
import {
isDocumentRewriteActiveStage,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import {
REASONING_EFFORTS,
resolveModelReasoningEffort,
type ReasoningEffort,
type StoredReasoningEffort,
} from "@/core/models/reasoning-effort";
import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads";
import type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -101,25 +113,11 @@ import {
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
function getResolvedMode(
mode: InputMode | undefined,
supportsThinking: boolean,
): InputMode {
if (!supportsThinking && mode !== "flash") {
return "flash";
}
if (mode) {
return mode;
}
return supportsThinking ? "pro" : "flash";
}
import { resolveInputMode, type InputMode } from "./input-mode";
import { hasPromptInputPayload } from "./input-box-submit";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
export function InputBox({
className,
@@ -130,20 +128,22 @@ export function InputBox({
extraHeader,
isNewThread,
threadId,
initialValue,
onContextChange,
onFollowupsVisibilityChange,
onSubmit,
initialValue,
onContextChange,
onFollowupsPresenceChange,
followupsDisplayAllowed = true,
onSubmit,
onStop,
promptPrefix,
agentSelector,
knowledgeSelector,
knowledgeSelector,
knowledgeRetrieval,
knowledgeSuggestedQuestions = [],
extraModels = [],
assistantId,
hideAdminRecommendedQuestions,
compact,
hideContextTools,
hideMemoryTool = true,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -151,23 +151,17 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean;
/**
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
* 写作和参考文献开关不受影响。
*/
hideMemoryTool?: boolean;
disabled?: boolean;
context: Omit<
AgentThreadContext,
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
reasoning_effort?: StoredReasoningEffort;
writing_mode?: boolean;
memory_injection_enabled?: boolean;
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
@@ -182,12 +176,18 @@ export function InputBox({
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
reasoning_effort?: StoredReasoningEffort;
writing_mode?: boolean;
memory_injection_enabled?: boolean;
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
) => void;
/**
* Whether generated follow-up questions may be rendered. Hosts with a
* scrollable transcript disable this while the user is reviewing history;
* the generated questions are retained and reappear on returning to bottom.
*/
followupsDisplayAllowed?: boolean;
/** Reports whether follow-ups need layout space, even while scroll-hidden. */
onFollowupsPresenceChange?: (present: boolean) => void;
onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void;
/**
@@ -209,8 +209,19 @@ export function InputBox({
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
};
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
/** Per-thread ordinary-knowledge retrieval mode. Omit when unavailable. */
knowledgeRetrieval?: {
mode: KnowledgeRetrievalMode;
smartAvailable: boolean;
smartAvailabilityReason: string;
/** The administrator policy used only when a new conversation is created. */
smartIsDefault: boolean;
onModeChange: (mode: KnowledgeRetrievalMode) => void;
};
/** High-value starter questions derived from the currently selected spaces. */
knowledgeSuggestedQuestions?: string[];
/** Page-scoped model choices implemented outside the DeerFlow model API. */
extraModels?: Model[];
}) {
@@ -228,11 +239,25 @@ export function InputBox({
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread();
const rewritePresentation = useDocumentRewritePresentation();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const documentRewriteActive = useMemo(
() =>
rewritePresentation?.forThread(threadId).some((item) =>
isDocumentRewriteActiveStage(item.stage),
) ?? false,
[rewritePresentation, threadId],
);
// Keep normal chat generation authoritative when both states overlap. In
// the usual rewrite-only case this gives the composer the same square stop
// affordance as a normal streamed reply.
const composerStatus: ChatStatus = documentRewriteActive
? "streaming"
: status;
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -247,8 +272,10 @@ export function InputBox({
const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences();
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
const followupsEnabled = userPreferences.followup_suggestions_enabled;
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
knowledgeSuggestedQuestions.length === 0 &&
!userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0;
@@ -264,12 +291,20 @@ export function InputBox({
const currentModel = modelOptions.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? modelOptions[0]!;
const supportsThinking = fallbackModel.supports_thinking ?? false;
const supportsReasoningEffort =
fallbackModel.supports_reasoning_effort ?? false;
const nextModelName = fallbackModel.name;
const nextMode = getResolvedMode(context.mode, supportsThinking);
const nextMode = resolveInputMode(context.mode, supportsThinking);
const nextReasoningEffort = resolveModelReasoningEffort(
supportsReasoningEffort,
context.reasoning_effort,
context.model_name !== nextModelName,
);
if (
context.model_name === nextModelName &&
context.mode === nextMode &&
context.reasoning_effort === nextReasoningEffort &&
context.execution_profile === fallbackModel.execution_profile
) {
return;
@@ -279,6 +314,7 @@ export function InputBox({
...context,
model_name: nextModelName,
mode: nextMode,
reasoning_effort: nextReasoningEffort,
execution_profile: fallbackModel.execution_profile,
});
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
@@ -303,6 +339,31 @@ export function InputBox({
[selectedModel],
);
const selectedReasoningEffort = useMemo(
() =>
resolveModelReasoningEffort(
supportReasoningEffort,
context.reasoning_effort,
),
[context.reasoning_effort, supportReasoningEffort],
);
const reasoningEffortIndex = Math.max(
0,
REASONING_EFFORTS.indexOf(selectedReasoningEffort ?? "xhigh"),
);
const reasoningEffortThumbLeft =
reasoningEffortIndex === 0
? "0.5rem"
: reasoningEffortIndex === 1
? "50%"
: "calc(100% - 0.5rem)";
const reasoningEffortLabel =
selectedReasoningEffort === "low"
? t.inputBox.reasoningEffortLow
: selectedReasoningEffort === "medium"
? t.inputBox.reasoningEffortMedium
: t.inputBox.reasoningEffortXHigh;
const handleModelSelect = useCallback(
(model_name: string) => {
const model = modelOptions.find((m) => m.name === model_name);
@@ -312,8 +373,12 @@ export function InputBox({
onContextChange?.({
...context,
model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort,
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: resolveModelReasoningEffort(
model.supports_reasoning_effort ?? false,
context.reasoning_effort,
model_name !== context.model_name,
),
execution_profile: model.execution_profile,
});
setModelDialogOpen(false);
@@ -328,22 +393,24 @@ export function InputBox({
onContextChange?.({
...context,
model_name: ordinaryModel?.name ?? context.model_name,
mode: getResolvedMode(
mode: resolveInputMode(
mode,
ordinaryModel?.supports_thinking ?? supportThinking,
),
reasoning_effort:
mode === "ultra"
? "high"
: mode === "pro"
? "medium"
: mode === "thinking"
? "low"
: "minimal",
reasoning_effort: resolveModelReasoningEffort(
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort,
context.reasoning_effort,
),
execution_profile: undefined,
});
},
[onContextChange, context, models, supportThinking],
[
onContextChange,
context,
models,
supportThinking,
supportReasoningEffort,
],
);
const handleSingleRoundRagSelect = useCallback(() => {
@@ -357,7 +424,7 @@ export function InputBox({
onContextChange?.({
...context,
model_name: ragModel.name,
mode: getResolvedMode(
mode: resolveInputMode(
context.mode,
ragModel.supports_thinking ?? false,
),
@@ -373,7 +440,7 @@ export function InputBox({
}, [context, onContextChange]);
const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => {
(effort: ReasoningEffort) => {
onContextChange?.({
...context,
reasoning_effort: effort,
@@ -397,16 +464,13 @@ export function InputBox({
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
onContextChange?.({
...context,
memory_injection_enabled: !memoryInjectionEnabled,
});
}, [context, memoryInjectionEnabled, onContextChange]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const smartKnowledgeRetrievalActive =
knowledgeRetrieval?.mode === "smart_reasoning";
const ragModeLocked =
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId);
const ragModeEnabled =
!smartKnowledgeRetrievalActive &&
(localSettings.display.ragModeEnabled || ragModeLocked);
const handleRagModeToggle = useCallback(() => {
if (ragModeLocked) {
return;
@@ -422,15 +486,19 @@ export function InputBox({
});
}, [contextCompressionEnabled, setLocalSettings]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (status === "streaming") {
onStop?.();
return;
}
if (!message.text) {
return;
}
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (composerStatus === "streaming") {
toast.info("正在生成中,请等待当前回复完成后再发送。", {
description: "已保留您输入的内容。",
});
// Reject rather than resolve: PromptInput treats a resolved submit as
// accepted and clears its draft. The current message has not been sent.
throw new Error("Cannot submit while a response is streaming.");
}
if (!hasPromptInputPayload(message)) {
return;
}
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
@@ -440,14 +508,19 @@ export function InputBox({
// Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state.
if (resolvedModelName && context.model_name !== resolvedModelName) {
if (
resolvedModelName &&
(context.model_name !== resolvedModelName ||
context.reasoning_effort !== selectedReasoningEffort)
) {
onContextChange?.({
...context,
model_name: resolvedModelName,
mode: getResolvedMode(
mode: resolveInputMode(
context.mode,
selectedModel?.supports_thinking ?? false,
),
reasoning_effort: selectedReasoningEffort,
execution_profile: selectedModel?.execution_profile,
});
setTimeout(() => onSubmit?.(message), 0);
@@ -458,26 +531,40 @@ export function InputBox({
},
[
context,
onContextChange,
onSubmit,
onStop,
resolvedModelName,
onContextChange,
onSubmit,
composerStatus,
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
selectedModel?.execution_profile,
selectedReasoningEffort,
status,
threadId,
],
);
const handleStopCurrentGeneration = useCallback(() => {
if (status === "streaming") {
onStop?.();
return;
}
if (!documentRewriteActive || !rewritePresentation) return;
void rewritePresentation.cancelActiveForThread(threadId).then((result) => {
if (!result.cancelled) {
toast.error(result.error ?? "停止全文改写失败,请稍后重试。");
}
});
}, [documentRewriteActive, onStop, rewritePresentation, status, threadId]);
const requestFormSubmit = useCallback(() => {
const form = promptRootRef.current?.querySelector("form");
form?.requestSubmit();
}, []);
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (status === "streaming") {
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (composerStatus === "streaming") {
return;
}
const current = (textInput.value ?? "").trim();
@@ -490,7 +577,7 @@ export function InputBox({
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
},
[requestFormSubmit, status, textInput],
[composerStatus, requestFormSubmit, textInput],
);
const confirmReplaceAndSend = useCallback(() => {
@@ -521,32 +608,43 @@ export function InputBox({
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
const showFollowups =
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange);
const followupsPresent =
followupsEnabled &&
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
const showFollowups = followupsDisplayAllowed && followupsPresent;
const followupsPresenceChangeRef = useRef(onFollowupsPresenceChange);
useEffect(() => {
followupsPresenceChangeRef.current = onFollowupsPresenceChange;
}, [onFollowupsPresenceChange]);
useEffect(() => {
followupsPresenceChangeRef.current?.(followupsPresent);
}, [followupsPresent]);
useEffect(() => {
return () => followupsPresenceChangeRef.current?.(false);
}, []);
useEffect(() => {
followupsVisibilityChangeRef.current = onFollowupsVisibilityChange;
}, [onFollowupsVisibilityChange]);
useEffect(() => {
followupsVisibilityChangeRef.current?.(showFollowups);
}, [showFollowups]);
useEffect(() => {
return () => followupsVisibilityChangeRef.current?.(false);
}, []);
if (followupsEnabled) return;
setFollowups([]);
setFollowupsLoading(false);
setFollowupsHidden(true);
setConfirmOpen(false);
setPendingSuggestion(null);
}, [followupsEnabled]);
useEffect(() => {
const streaming = status === "streaming";
const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = streaming;
if (!wasStreaming || streaming) {
if (!followupsEnabled || !wasStreaming || streaming) {
return;
}
@@ -598,6 +696,7 @@ export function InputBox({
return (await res.json()) as { suggestions?: string[] };
})
.then((data) => {
if (controller.signal.aborted) return;
const suggestions = (data.suggestions ?? [])
.map((s) => (typeof s === "string" ? s.trim() : ""))
.filter((s) => s.length > 0)
@@ -605,24 +704,51 @@ export function InputBox({
setFollowups(suggestions);
})
.catch(() => {
if (controller.signal.aborted) return;
setFollowups([]);
})
.finally(() => {
if (controller.signal.aborted) return;
setFollowupsLoading(false);
});
return () => controller.abort();
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return () => {
controller.abort();
setFollowupsLoading(false);
};
}, [context.model_name, disabled, followupsEnabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return (
<div ref={promptRootRef} className="relative flex flex-col gap-6">
{extraHeader && (
{extraHeader && (
<div className="flex items-center justify-center">
{extraHeader}
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
)}
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
<div className="px-4 mb-[10px]">
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
<div className="flex flex-wrap justify-center gap-2.5">
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
<button
key={question}
type="button"
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
onClick={() => {
textInput.setInput(question);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
}}
>
{question}
</button>
))}
</div>
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => (
<button
@@ -733,12 +859,12 @@ export function InputBox({
</PromptInputBody>
<PromptInputFooter
className={cn(
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
compact && "gap-2 px-3 pt-1.5 pb-3",
)}
>
<PromptInputTools
className={cn("gap-1.5", compact && "flex-1 flex-wrap gap-2")}
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
>
{/* TODO: Add more connectors here
<PromptInputActionMenu>
@@ -813,62 +939,42 @@ export function InputBox({
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
: "记忆注入已关闭:本次对话不注入记忆"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleMemoryInjectionToggle}
>
{memoryInjectionEnabled && (
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
)}
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleRagModeToggle}
>
{ragModeEnabled && (
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
)}
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled &&
!frontendRagActive &&
!smartKnowledgeRetrievalActive &&
!hideContextTools && (
<Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact &&
"rounded-lg px-2.5! py-1! text-[11px] font-medium",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleRagModeToggle}
>
{ragModeEnabled && (
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
)}
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>
)}
{!frontendRagActive && <Tooltip
content={
contextCompressionEnabled
@@ -1161,124 +1267,63 @@ export function InputBox({
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
<div className="text-xs font-normal">
{t.inputBox.reasoningEffort}:
{context.reasoning_effort === "minimal" &&
" " + t.inputBox.reasoningEffortMinimal}
{context.reasoning_effort === "low" &&
" " + t.inputBox.reasoningEffortLow}
{context.reasoning_effort === "medium" &&
" " + t.inputBox.reasoningEffortMedium}
{context.reasoning_effort === "high" &&
" " + t.inputBox.reasoningEffortHigh}
</div>
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent className="w-70">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-xs">
{t.inputBox.reasoningEffort}
</DropdownMenuLabel>
<PromptInputActionMenu>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "minimal"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("minimal")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortMinimal}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortMinimalDescription}
</div>
</div>
{context.reasoning_effort === "minimal" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "low"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("low")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortLow}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortLowDescription}
</div>
</div>
{context.reasoning_effort === "low" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "medium" ||
!context.reasoning_effort
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("medium")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortMedium}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortMediumDescription}
</div>
</div>
{context.reasoning_effort === "medium" ||
!context.reasoning_effort ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "high"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("high")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortHigh}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortHighDescription}
</div>
</div>
{context.reasoning_effort === "high" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</PromptInputActionMenu>
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
)}
{!frontendRagActive &&
supportReasoningEffort &&
context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger
aria-label={`${t.inputBox.reasoningEffortSelect}: ${reasoningEffortLabel}`}
className={cn(
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"rounded-lg border border-border px-2.5! py-1! text-[13px]",
)}
>
<div className="flex items-center gap-1.5 text-xs font-medium">
<SparklesIcon className="size-3" />
<span className="text-foreground">
{reasoningEffortLabel}
</span>
</div>
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent className="w-56 p-3">
<div className="flex items-center justify-between gap-3 px-1">
<span className="text-xs text-muted-foreground">
{t.inputBox.reasoningEffortSelect}
</span>
<span className="text-sm font-semibold text-violet-600 dark:text-violet-300">
{reasoningEffortLabel}
</span>
</div>
<div className="mt-2 px-1">
<div className="relative h-9 w-full">
<input
type="range"
aria-label={`${t.inputBox.reasoningEffortSelect}: ${selectedModel?.display_name || selectedModel?.name || ""}`}
aria-valuetext={reasoningEffortLabel}
className="peer absolute inset-x-0 -inset-y-1 z-10 h-auto w-full cursor-grab touch-none opacity-0 active:cursor-grabbing"
value={reasoningEffortIndex}
min={0}
max={2}
step={1}
onChange={(event) => {
const effort =
REASONING_EFFORTS[Number(event.currentTarget.value)];
if (effort) handleReasoningEffortSelect(effort);
}}
/>
<div className="pointer-events-none absolute inset-x-2 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-sky-400 via-violet-500 to-fuchsia-500 peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500/70 peer-focus-visible:ring-offset-2" />
<div
className="pointer-events-none absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-violet-600 shadow-md peer-active:scale-110 dark:border-slate-800"
style={{ left: reasoningEffortThumbLeft }}
/>
</div>
</div>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
)}
</PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
@@ -1288,6 +1333,100 @@ export function InputBox({
/>
)}
{!frontendRagActive && knowledgeSelector}
{!frontendRagActive && knowledgeRetrieval && (
<DropdownMenu>
<Tooltip
content={
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索已开启:直接提问即可,系统会检索原文分片并沿 Wiki 线索扩展背景资料"
: knowledgeRetrieval.smartAvailabilityReason
}
>
<DropdownMenuTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
aria-label={`普通知识库检索模式:${
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索"
: "经典检索"
}`}
aria-haspopup="menu"
className={cn(
"rounded-full px-3 shadow-none transition-colors",
knowledgeRetrieval.mode === "smart_reasoning"
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border border-transparent bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground",
)}
>
<BrainCircuitIcon className="size-3.5" />
<span className="max-w-[160px] truncate text-xs font-normal">
{knowledgeRetrieval.mode === "smart_reasoning" ? "智能检索" : "经典检索"}
</span>
</PromptInputButton>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-80 rounded-xl p-1.5"
>
<DropdownMenuLabel className="px-2 pb-1 text-xs text-muted-foreground">
普通知识库检索模式
</DropdownMenuLabel>
<DropdownMenuItem
className="min-h-16 items-start rounded-lg py-2"
disabled={!knowledgeRetrieval.smartAvailable}
onSelect={() =>
knowledgeRetrieval.onModeChange("smart_reasoning")
}
>
<BrainCircuitIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
智能检索
{knowledgeRetrieval.smartIsDefault ? "(新对话默认)" : ""}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
先检索原文分片,再按命中线索查阅并扩展 Wiki;资料仅作为回答背景
</div>
</div>
{knowledgeRetrieval.mode === "smart_reasoning" && (
<CheckIcon className="mt-0.5 size-4 text-emerald-600" />
)}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-14 items-start rounded-lg py-2"
onSelect={() => knowledgeRetrieval.onModeChange("classic")}
>
<BookMarkedIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
经典检索
{knowledgeRetrieval.smartIsDefault ? "(候选)" : "(新对话默认)"}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
沿用原有知识库检索与回答方式
</div>
</div>
{knowledgeRetrieval.mode === "classic" && (
<CheckIcon className="mt-0.5 size-4 text-primary" />
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<p
className={cn(
"px-2 py-1 text-xs leading-5",
knowledgeRetrieval.smartAvailable
? "text-muted-foreground"
: "text-amber-600 dark:text-amber-300",
)}
>
{knowledgeRetrieval.smartAvailabilityReason}
</p>
</DropdownMenuContent>
</DropdownMenu>
)}
{!frontendRagActive && promptPrefix && (
<Tooltip
content={
@@ -1372,11 +1511,21 @@ export function InputBox({
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
)}
disabled={disabled}
variant="outline"
status={status}
/>
)}
disabled={disabled}
onClick={(event) => {
if (composerStatus !== "streaming") return;
// A deliberate press of the square button stops the stream.
// Prevent the enclosing form from treating it as a new submit.
event.preventDefault();
handleStopCurrentGeneration();
}}
variant="outline"
status={composerStatus}
{...(documentRewriteActive
? { "aria-label": "停止 AI 全文改写" }
: {})}
/>
</PromptInputTools>
</PromptInputFooter>
{!isNewThread && (
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { resolveInputMode } from "./input-mode.ts";
test("keeps plan and subagent modes for models without native thinking", () => {
assert.equal(resolveInputMode("pro", false), "pro");
assert.equal(resolveInputMode("ultra", false), "ultra");
});
test("falls back only when native thinking mode is unsupported", () => {
assert.equal(resolveInputMode("thinking", false), "flash");
assert.equal(resolveInputMode("thinking", true), "thinking");
});
test("keeps the existing defaults when no mode has been chosen", () => {
assert.equal(resolveInputMode(undefined, false), "flash");
assert.equal(resolveInputMode(undefined, true), "pro");
});
@@ -0,0 +1,20 @@
export type InputMode = "flash" | "thinking" | "pro" | "ultra";
/**
* Keep plan/subagent modes available even when the selected model has no
* native thinking switch. The backend already falls back to non-thinking for
* that model, while `pro` and `ultra` still control planning and subagents.
* Only the model-specific `thinking` mode requires `supports_thinking`.
*/
export function resolveInputMode(
mode: InputMode | undefined,
supportsThinking: boolean,
): InputMode {
if (mode === "thinking" && !supportsThinking) {
return "flash";
}
if (mode) {
return mode;
}
return supportsThinking ? "pro" : "flash";
}
@@ -1,55 +1,100 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen, Database } from "lucide-react";
import { BookOpen /* , Database, SearchCheck */ } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getStoredAuth } from "@/core/auth";
// import { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
// import {
// useAssistantKnowledgeVisibility,
// useUpdateAssistantKnowledgeVisibility,
// } from "@/core/system-settings/hooks";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
loadKnowledgeBaseProductVersion,
type KnowledgeBaseProductVersion,
updateKnowledgeBaseProductVersion,
loadOrdinaryKnowledgeLanding,
saveOrdinaryKnowledgeLanding,
type OrdinaryKnowledgeLanding,
} from "@/core/system-settings/api";
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
export function useOrdinaryKnowledgeLanding(enabled = true) {
return useQuery({
queryKey: LANDING_KEY,
queryFn: ({ signal }) => loadOrdinaryKnowledgeLanding(signal),
enabled,
staleTime: 30_000,
retry: false,
});
}
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
export function useKnowledgeBaseProductVersion() {
return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 });
}
export function KnowledgeProductNav() {
const location = useLocation();
const navigate = useNavigate();
const client = useQueryClient();
const version = useKnowledgeBaseProductVersion();
// const assistantVisibility = useAssistantKnowledgeVisibility();
const isAdmin = getStoredAuth()?.system_role === "admin";
const save = useMutation({
mutationFn: updateKnowledgeBaseProductVersion,
onSuccess: (value) => {
client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
toast.success(`默认知识库体验已切换为 ${value}`);
},
const landing = useOrdinaryKnowledgeLanding(isAdmin);
const saveLanding = useMutation({
mutationFn: saveOrdinaryKnowledgeLanding,
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
onError: (error) => toast.error(error.message),
});
const runtime = useLlmWikiRuntime();
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
// const assistantAllowed = canAccessAssistantKnowledge(
// isAdmin,
// assistantVisibility.isError ? undefined : assistantVisibility.data,
// );
// const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
const assistant = location.pathname.includes("/knowledge/assistant");
const ordinary = !assistant && !retrievalDebug;
return (
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库
</Button>
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库
</Button>
{/* 助手知识库、检索调试暂时隐藏,后续放开时去掉这段注释。
{assistantAllowed && (
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库
</Button>
)}
{isAdmin && (
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
默认产品版本
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="v1">v1 普通知识库</SelectItem><SelectItem value="v2">v2 助手知识库</SelectItem></SelectContent>
<Button size="sm" variant={retrievalDebug ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/retrieval-debug")}>
<SearchCheck />检索调试
</Button>
)}
*/}
{isAdmin && (
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
普通知识库默认页面
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
</Select>
{/* 助手知识库日常展示暂时隐藏,和上面两个入口一起放开。
助手知识库日常展示
<Select
disabled={assistantVisibility.isLoading || saveAssistantVisibility.isPending}
value={assistantVisibility.data === true ? "visible" : "hidden"}
onValueChange={(value) =>
saveAssistantVisibility.mutate(value === "visible", {
onSuccess: (visible) => toast.success(visible ? "已向普通用户开放助手知识库" : "已隐藏普通用户的助手知识库"),
onError: (error) => toast.error(error.message),
})
}
>
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
</Select>
*/}
</div>
)}
</div>
@@ -0,0 +1,418 @@
import { BookOpenText, ChevronRight, FileText } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
import {
classifyKnowledgeWikiSource,
filterKnowledgeWikiSources,
} from "@/core/llmwiki/knowledge-reference";
import { resolveDisplaySourceChunk } from "@/core/llmwiki/source-excerpt";
import {
formatRetrievalScore,
retrievalModeLabel,
retrievalRankScoreLabel,
} from "@/core/llmwiki/retrieval-debug";
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { reasoningPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { MarkdownContent } from "./messages/markdown-content";
type KnowledgeWikiPanelProps = {
highlightedReferenceId?: string | null;
selection: KnowledgeWikiReferenceSelection;
sources: RuntimeDisplaySource[];
};
function sourceKey(source: RuntimeDisplaySource) {
return `${source.skillName}:${source.index}:${source.id ?? source.url ?? source.title}`;
}
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function sourceContent(source: RuntimeDisplaySource) {
return (source.content || source.snippet || "").trim();
}
function sourceMode(source: RuntimeDisplaySource) {
return stringValue(
source.raw?.retrieval_mode ??
(source as RuntimeDisplaySource & { retrieval_mode?: unknown })
.retrieval_mode,
).toLowerCase();
}
function matchedHeading(source: RuntimeDisplaySource) {
const sections = Array.isArray(source.raw?.matched_sections)
? source.raw.matched_sections
: [];
const firstSectionWithHeading = sections.find(
(section): section is Record<string, unknown> =>
!!section &&
typeof section === "object" &&
!Array.isArray(section) &&
Boolean(stringValue((section as Record<string, unknown>).heading)),
);
const matchedSection =
source.raw?.matched_section &&
typeof source.raw.matched_section === "object" &&
!Array.isArray(source.raw.matched_section)
? (source.raw.matched_section as Record<string, unknown>)
: null;
return stringValue(
source.raw?.matched_heading ??
source.raw?.heading ??
firstSectionWithHeading?.heading ??
matchedSection?.heading,
);
}
function knowledgeSpaceName(source: RuntimeDisplaySource) {
return (
stringValue(source.raw?.knowledge_base_name) ||
stringValue(source.raw?.base_name) ||
stringValue(source.raw?.source) ||
source.source ||
"知识空间"
);
}
function wikiPageTypeLabel(value: unknown) {
const normalized = stringValue(value).toLowerCase();
if (normalized === "directory") return "目录页";
if (normalized === "overview") return "概览页";
if (normalized === "article") return "文章页";
return normalized ? stringValue(value) : "";
}
function plainTextPreview(value: string) {
return value
.replace(/^---[\s\S]*?---\s*/m, "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+[.)]\s+/gm, "")
.replace(/[>*_`~|]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function cardPreview(source: RuntimeDisplaySource) {
const summary = stringValue(source.raw?.summary);
return plainTextPreview(sourceContent(source) || summary).slice(0, 420);
}
function updatedAtLabel(source: RuntimeDisplaySource) {
const value =
source.time ||
stringValue(source.raw?.updated_at) ||
stringValue(source.raw?.remote_updated_at);
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleString("zh-CN", { hour12: false });
}
function scoreLabel(source: RuntimeDisplaySource) {
const score = formatRetrievalScore(source.score);
if (!score) return "";
const label = retrievalRankScoreLabel(
source.raw?.rank_score_semantics,
Boolean(source.raw?.channel_score || source.raw?.vector_score),
);
return label ? `${label} ${score}` : "";
}
/**
* Knowledge-space references keep both evidence layers visible: formatted Wiki
* pages and their original document chunks. They deliberately use dedicated
* Wiki cards instead of the generic numbered reference-card presentation.
*/
export function KnowledgeWikiPanel({
highlightedReferenceId,
selection,
sources,
}: KnowledgeWikiPanelProps) {
const wikiSources = useMemo(
() => filterKnowledgeWikiSources(sources, selection),
[selection, sources],
);
const orderedSources = useMemo(() => {
const formattedWiki = wikiSources.filter(
(source) => classifyKnowledgeWikiSource(source) === "wiki",
);
const rawDocuments = wikiSources.filter(
(source) => classifyKnowledgeWikiSource(source) === "raw",
);
return [...formattedWiki, ...rawDocuments];
}, [wikiSources]);
const [openSourceKey, setOpenSourceKey] = useState<string | null>(null);
const openSource =
wikiSources.find((source) => sourceKey(source) === openSourceKey) ?? null;
const previewTarget = openSource
? resolveWeKnoraSourceTarget({
...(openSource.raw ?? {}),
url: openSource.url,
})
: null;
const openContent = openSource ? sourceContent(openSource) : "";
const openChunk = openSource ? resolveDisplaySourceChunk(openSource) : "";
const selectedSpaceCount =
selection.knowledgeBaseIds.length +
selection.assistantKnowledgeBaseIds.length;
const formattedWikiCount = wikiSources.filter(
(source) => classifyKnowledgeWikiSource(source) === "wiki",
).length;
const rawDocumentCount = wikiSources.length - formattedWikiCount;
return (
<div className="flex size-full min-h-0 flex-col overflow-hidden">
<header className="shrink-0 border-b border-emerald-200/60 px-1 pb-3 dark:border-emerald-900/50">
<div className="flex flex-wrap items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-emerald-600 text-white shadow-sm shadow-emerald-600/20">
<BookOpenText className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-2">
<h2 className="text-foreground text-base font-semibold tracking-tight">
Wiki 资料
</h2>
<span className="text-muted-foreground text-xs tabular-nums">
{wikiSources.length} 条
</span>
</div>
<p className="text-muted-foreground mt-0.5 text-xs leading-relaxed">
来自已选 {selectedSpaceCount} 个知识空间,点击卡片查看完整内容。
</p>
</div>
</div>
{wikiSources.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
<Badge className="border-emerald-200 bg-emerald-100 text-emerald-800 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
Wiki 页面 {formattedWikiCount}
</Badge>
<Badge variant="outline" className="bg-background/80">
原始资料 {rawDocumentCount}
</Badge>
</div>
)}
</header>
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-3 pb-6">
{orderedSources.length > 0 ? (
<div className="grid gap-3">
{orderedSources.map((source) => (
<KnowledgeWikiCard
key={sourceKey(source)}
highlighted={highlightedReferenceId === String(source.index)}
source={source}
onOpen={() => setOpenSourceKey(sourceKey(source))}
/>
))}
</div>
) : (
<div className="text-muted-foreground flex min-h-48 flex-col items-center justify-center rounded-xl border border-dashed bg-muted/20 px-6 text-center text-sm">
<BookOpenText className="mb-3 size-8 opacity-50" />
<span>本轮未检索到所选知识空间内的 Wiki 资料。</span>
</div>
)}
</main>
{openSource && previewTarget && (
<WeKnoraSourceDrawer
open
target={previewTarget}
title={openSource.title}
quote={openChunk}
collapseRetrievedExcerptByDefault={
classifyKnowledgeWikiSource(openSource) === "wiki"
}
onOpenChange={(open) => {
if (!open) setOpenSourceKey(null);
}}
/>
)}
<Sheet
open={Boolean(openSource && !previewTarget)}
onOpenChange={(open) => {
if (!open) setOpenSourceKey(null);
}}
>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b border-emerald-200/60 bg-emerald-50/60 px-5 py-4 pr-12 text-left dark:border-emerald-900/50 dark:bg-emerald-950/20">
<div className="flex items-start gap-3 pr-8">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-emerald-600 text-white">
<BookOpenText className="size-4.5" />
</div>
<div className="min-w-0">
<SheetTitle className="text-base leading-relaxed">
{openSource?.title || "Wiki 内容"}
</SheetTitle>
<SheetDescription className="mt-1">
{openSource ? knowledgeSpaceName(openSource) : "知识空间"}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
{openContent ? (
<article className="mx-auto max-w-3xl rounded-xl bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
<MarkdownContent
className="max-w-none"
content={openContent}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</article>
) : (
<div className="text-muted-foreground flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm">
当前 Wiki 暂无可展示内容。
</div>
)}
</div>
</SheetContent>
</Sheet>
</div>
);
}
function KnowledgeWikiCard({
highlighted,
source,
onOpen,
}: {
highlighted: boolean;
source: RuntimeDisplaySource;
onOpen: () => void;
}) {
const formattedWiki = classifyKnowledgeWikiSource(source) === "wiki";
const heading = matchedHeading(source);
const pageType = wikiPageTypeLabel(source.raw?.page_type);
const mode = sourceMode(source);
const modeLabel = mode ? retrievalModeLabel(mode) : "";
const preview = cardPreview(source);
const score = scoreLabel(source);
const updatedAt = updatedAtLabel(source);
const target = resolveWeKnoraSourceTarget({
...(source.raw ?? {}),
url: source.url,
});
const canOpen = Boolean(target || sourceContent(source));
return (
<button
id={`reference-${source.index}`}
type="button"
disabled={!canOpen}
className={cn(
"group w-full scroll-mt-12 rounded-xl border bg-background/90 p-4 text-left shadow-sm transition",
"focus-visible:ring-2 focus-visible:outline-none disabled:cursor-default disabled:opacity-70",
formattedWiki
? "border-emerald-200/80 hover:-translate-y-0.5 hover:border-emerald-400 hover:shadow-md focus-visible:ring-emerald-500/40 dark:border-emerald-900/70"
: "border-sky-200/80 hover:-translate-y-0.5 hover:border-sky-400 hover:shadow-md focus-visible:ring-sky-500/40 dark:border-sky-900/70",
highlighted &&
(formattedWiki
? "border-emerald-500 bg-emerald-50/80 ring-2 ring-emerald-500/20 dark:bg-emerald-950/25"
: "border-sky-500 bg-sky-50/80 ring-2 ring-sky-500/20 dark:bg-sky-950/25"),
)}
onClick={onOpen}
>
<div className="flex items-start gap-3">
<div
className={cn(
"mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg",
formattedWiki
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
: "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
)}
>
{formattedWiki ? (
<BookOpenText className="size-4.5" />
) : (
<FileText className="size-4.5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant="secondary"
className={cn(
formattedWiki
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
: "bg-sky-50 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
)}
>
{formattedWiki ? "Wiki 页面" : "原始资料"}
</Badge>
{formattedWiki && pageType && (
<Badge variant="outline">{pageType}</Badge>
)}
{modeLabel && <Badge variant="outline">{modeLabel}</Badge>}
<span className="text-muted-foreground min-w-0 truncate text-xs">
{knowledgeSpaceName(source)}
</span>
</div>
<h3 className="text-foreground mt-2 line-clamp-2 text-[15px] leading-6 font-semibold">
{source.title ||
(formattedWiki ? "未命名 Wiki 页面" : "未命名原始资料")}
</h3>
{heading && (
<div className="mt-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
命中章节:{heading}
</div>
)}
{preview && (
<p className="text-muted-foreground mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6">
{preview}
</p>
)}
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{score && <span>{score}</span>}
{updatedAt && <span>更新于 {updatedAt}</span>}
</div>
{canOpen && (
<span
className={cn(
"inline-flex items-center font-medium transition group-hover:translate-x-0.5",
formattedWiki
? "text-emerald-700 dark:text-emerald-300"
: "text-sky-700 dark:text-sky-300",
)}
>
{target
? formattedWiki
? "查看完整 Wiki"
: "查看原始资料"
: "查看命中内容"}
<ChevronRight className="ml-1 size-3.5" />
</span>
)}
</div>
</div>
</div>
</button>
);
}
@@ -21,6 +21,9 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { useAssistantKnowledgeAccess } from "@/core/llmwiki/assistant-knowledge-access";
import { ASSISTANT_KNOWLEDGE_UI_VISIBLE } from "@/core/llmwiki/assistant-knowledge-ui";
import { userFacingKnowledgeSuggestedQuestions } from "@/core/llmwiki/knowledge-suggested-questions";
import { cn } from "@/lib/utils";
import {
type AssistantKnowledgeBase,
@@ -35,6 +38,28 @@ import {
type SelectorSource = "all" | "agent";
type SelectorVariant = "popover" | "panel";
const EMPTY_IDS: string[] = [];
const IGNORE_ASSISTANT_CHANGE = () => undefined;
export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
const cleanNames = names.map((name) => name.trim()).filter(Boolean);
if (cleanNames.length === 0) return [];
if (cleanNames.length === 1) {
const name = cleanNames[0]!;
return [
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
`「${name}」包含哪些核心主题?请按重要性说明。`,
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
];
}
return [
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点。`,
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
"所选知识空间中有哪些相互冲突或证据不足的结论?",
];
}
function groupRows(rows: LlmWikiKnowledgeBase[]) {
return [
@@ -323,9 +348,14 @@ function SelectionContent({
</div>
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
<span className="text-[11px] text-muted-foreground">
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
</span>
<div>
<div className="text-[11px] text-muted-foreground">
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
</div>
{selectedCount > 0 && (
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
)}
</div>
{readOnly ? (
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
) : (
@@ -363,9 +393,10 @@ function SelectionContent({
export function LlmWikiKnowledgeSelector({
value,
assistantValue = [],
assistantValue = EMPTY_IDS,
onChange,
onAssistantChange = () => undefined,
onAssistantChange,
onSuggestedQuestionsChange,
source = "all",
agentId,
autoSelectAll = false,
@@ -373,12 +404,15 @@ export function LlmWikiKnowledgeSelector({
readOnly = false,
className,
label = "知识空间",
panelTitle = "绑定知识空间",
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
variant = "popover",
}: {
value: string[];
assistantValue?: string[];
onChange: (value: string[]) => void;
onAssistantChange?: (value: string[]) => void;
onSuggestedQuestionsChange?: (questions: string[]) => void;
source?: SelectorSource;
agentId?: string | null;
autoSelectAll?: boolean;
@@ -387,31 +421,59 @@ export function LlmWikiKnowledgeSelector({
readOnly?: boolean;
className?: string;
label?: string;
panelTitle?: string;
panelDescription?: string;
variant?: SelectorVariant;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const assistantAccess = useAssistantKnowledgeAccess({
enabled:
ASSISTANT_KNOWLEDGE_UI_VISIBLE && onAssistantChange !== undefined,
});
const assistantAllowed =
ASSISTANT_KNOWLEDGE_UI_VISIBLE &&
onAssistantChange !== undefined &&
assistantAccess.canAccess;
const runtime = useLlmWikiRuntime();
const weknoraEnabled = runtime.data?.provider === "weknora";
const shouldLoadOptions =
variant === "panel" ||
open ||
autoSelectAll ||
value.length > 0 ||
assistantValue.length > 0;
const all = useLlmWikiKnowledgeBases(
"all",
weknoraEnabled,
weknoraEnabled && shouldLoadOptions,
);
const agent = useAgentLlmWikiKnowledgeBases(
agentId,
weknoraEnabled && source === "agent" && !!agentId,
weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId,
);
const assistantBases = useAssistantKnowledgeBases();
const rows = weknoraEnabled ? (all.data ?? []) : [];
const assistantRows = assistantBases.data ?? [];
const assistantBases = useAssistantKnowledgeBases(
assistantAllowed &&
(shouldLoadOptions ||
runtime.isError ||
(!runtime.isLoading && !weknoraEnabled)),
);
// Stable empty results are essential: suggested questions are reported to
// the parent, so a fresh [] on every render creates an effect/update loop
// before lazy knowledge queries have loaded (or when they are disabled).
const rows = useMemo(() => weknoraEnabled ? (all.data ?? []) : [], [all.data, weknoraEnabled]);
const assistantRows = useMemo(
() => assistantAllowed ? (assistantBases.data ?? []) : [],
[assistantAllowed, assistantBases.data],
);
const effectiveAssistantValue = assistantAllowed ? assistantValue : EMPTY_IDS;
const defaultIds = useMemo(() => {
if (source !== "agent") {
return rows.map((item) => item.id);
}
return (agent.data ?? []).map((item) => item.id);
}, [agent.data, rows, source]);
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading);
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error;
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || (assistantAllowed && assistantBases.isLoading) || (source === "agent" && !!agentId && agent.isLoading);
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? (assistantAllowed ? assistantBases.error : null);
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
const assistantAvailableIds = useMemo(
() => new Set(assistantRows.map((item) => item.id)),
@@ -419,6 +481,41 @@ export function LlmWikiKnowledgeSelector({
);
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
const initialized = useRef<string | null>(null);
const selectedNames = useMemo(
() => value.map((id) => rows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
[rows, value],
);
const selectedAssistantNames = useMemo(
() => effectiveAssistantValue.map((id) => assistantRows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
[assistantRows, effectiveAssistantValue],
);
const allSelectedNames = useMemo(
() => [...selectedNames, ...selectedAssistantNames],
[selectedAssistantNames, selectedNames],
);
const selectedConfiguredQuestions = useMemo(
() =>
userFacingKnowledgeSuggestedQuestions(
value.flatMap(
(id) => rows.find((item) => item.id === id)?.test_questions ?? [],
),
),
[rows, value],
);
const suggestedQuestions = useMemo(
() => {
if (value.length === 1 && effectiveAssistantValue.length === 0 && selectedConfiguredQuestions.length > 0) {
return selectedConfiguredQuestions.slice(0, 12);
}
const general = buildKnowledgeSpaceQuestions(allSelectedNames);
return Array.from(new Set([...selectedConfiguredQuestions, ...general])).slice(0, 12);
},
[allSelectedNames, effectiveAssistantValue.length, selectedConfiguredQuestions, value.length],
);
useEffect(() => {
onSuggestedQuestionsChange?.(suggestedQuestions);
}, [onSuggestedQuestionsChange, suggestedQuestions]);
useEffect(() => {
if (!runtime.isLoading && !weknoraEnabled) {
@@ -437,27 +534,36 @@ export function LlmWikiKnowledgeSelector({
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]);
useEffect(() => {
if (!assistantBases.data) return;
const valid = assistantValue.filter((id) => assistantAvailableIds.has(id));
if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) {
if (!assistantAllowed || !assistantBases.data || !onAssistantChange) return;
const valid = effectiveAssistantValue.filter((id) => assistantAvailableIds.has(id));
if (valid.length !== effectiveAssistantValue.length || valid.some((id, index) => id !== effectiveAssistantValue[index])) {
onAssistantChange(valid);
}
}, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]);
}, [assistantAllowed, assistantAvailableIds, assistantBases.data, effectiveAssistantValue, onAssistantChange]);
useEffect(() => {
if (!open) setSearch("");
}, [open]);
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
if (
!runtime.isLoading &&
(runtime.isError || !weknoraEnabled) &&
(!assistantAllowed || !assistantBases.isLoading) &&
assistantRows.length === 0
) return null;
const content = (
<SelectionContent
rows={rows}
assistantRows={assistantRows}
value={value}
assistantValue={assistantValue}
assistantValue={effectiveAssistantValue}
onChange={onChange}
onAssistantChange={onAssistantChange}
onAssistantChange={
assistantAllowed
? (onAssistantChange ?? IGNORE_ASSISTANT_CHANGE)
: IGNORE_ASSISTANT_CHANGE
}
search={search}
onSearchChange={setSearch}
loading={loading}
@@ -474,27 +580,20 @@ export function LlmWikiKnowledgeSelector({
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Database className="size-4 text-primary" />绑定知识空间
<Database className="size-4 text-primary" />{panelTitle}
</div>
<p className="mt-1 text-xs text-muted-foreground">
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
{panelDescription}
</p>
</div>
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
<Badge variant="secondary" className="shrink-0">{value.length + effectiveAssistantValue.length} 个</Badge>
</div>
{content}
</div>
);
}
const selectedNames = value
.map((id) => rows.find((item) => item.id === id)?.name)
.filter((name): name is string => !!name);
const selectedAssistantNames = assistantValue
.map((id) => assistantRows.find((item) => item.id === id)?.name)
.filter((name): name is string => !!name);
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
const selectedCount = value.length + assistantValue.length;
const selectedCount = value.length + effectiveAssistantValue.length;
const selectedLabel = allSelectedNames.length > 0
? allSelectedNames.join("、")
: selectedCount > 0
@@ -0,0 +1,248 @@
"use client";
import {
CheckIcon,
Loader2Icon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
useUpsertAnswerFeedback,
type AnswerFeedbackContext,
type AnswerFeedbackReason,
} from "@/strategy-components/api/answer-feedback";
import { Tooltip } from "../tooltip";
const REASON_OPTIONS: Array<{
value: AnswerFeedbackReason;
label: string;
}> = [
{ value: "incorrect_or_incomplete", label: "不正确或不完整" },
{ value: "unmet_expectations", label: "与期望不符" },
{ value: "slow_or_problematic", label: "速度慢或存在问题" },
{ value: "style_or_tone", label: "风格或语气" },
{ value: "safety_or_legal", label: "安全或法律疑虑" },
{ value: "other", label: "其他" },
];
export function AnswerFeedbackControl({
threadId,
messageId,
context,
}: {
threadId: string;
messageId: string;
context: AnswerFeedbackContext;
}) {
const saveFeedback = useUpsertAnswerFeedback();
const feedback = context?.feedback ?? null;
const [poorDialogOpen, setPoorDialogOpen] = useState(false);
const [reasons, setReasons] = useState<AnswerFeedbackReason[]>([]);
const [detail, setDetail] = useState("");
useEffect(() => {
if (!poorDialogOpen) return;
setReasons(feedback?.rating === -1 ? feedback.reasons : []);
setDetail(
feedback?.rating === -1
? (feedback.detail ?? feedback.comment ?? "")
: "",
);
}, [feedback, poorDialogOpen]);
const submitPositive = async () => {
try {
await saveFeedback.mutateAsync({
threadId,
messageId,
input: { rating: 1, reasons: [], detail: null },
});
toast.success("感谢你的评价");
} catch (error) {
toast.error(error instanceof Error ? error.message : "评价提交失败");
}
};
const submitNegative = async () => {
if (reasons.length === 0) return;
try {
await saveFeedback.mutateAsync({
threadId,
messageId,
input: {
rating: -1,
reasons,
detail: detail.trim() || null,
},
});
setPoorDialogOpen(false);
toast.success("反馈已提交,感谢你的帮助");
} catch (error) {
toast.error(error instanceof Error ? error.message : "反馈提交失败");
}
};
const toggleReason = (reason: AnswerFeedbackReason) => {
setReasons((current) =>
current.includes(reason)
? current.filter((item) => item !== reason)
: [...current, reason],
);
};
const triggerIcon =
feedback?.rating === 1 ? (
<ThumbsUpIcon
size={12}
className="fill-emerald-500/15 text-emerald-600 dark:text-emerald-400"
/>
) : feedback?.rating === -1 ? (
<ThumbsDownIcon
size={12}
className="fill-amber-500/15 text-amber-600 dark:text-amber-400"
/>
) : (
<span className="flex items-center gap-px" aria-hidden="true">
<ThumbsUpIcon className="size-3" />
<ThumbsDownIcon className="size-3" />
</span>
);
return (
<>
<DropdownMenu>
<Tooltip content="评价回复">
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon-sm"
variant="ghost"
className={cn(
"size-6",
feedback?.rating === 1 &&
"bg-emerald-500/10 hover:bg-emerald-500/15",
feedback?.rating === -1 &&
"bg-amber-500/10 hover:bg-amber-500/15",
)}
disabled={saveFeedback.isPending}
aria-label={
feedback?.rating === 1
? "已评价为回答优秀"
: feedback?.rating === -1
? "已评价为回答不佳"
: "评价回复"
}
>
{saveFeedback.isPending ? (
<Loader2Icon className="size-3 animate-spin" />
) : (
triggerIcon
)}
</Button>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="start" sideOffset={6} className="min-w-44 p-1.5">
<DropdownMenuItem
className="gap-2.5 py-2"
onSelect={() => void submitPositive()}
>
<ThumbsUpIcon className="size-4" />
<span>回答优秀</span>
{feedback?.rating === 1 && (
<CheckIcon className="text-primary ml-auto size-4" />
)}
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 py-2"
onSelect={() => setPoorDialogOpen(true)}
>
<ThumbsDownIcon className="size-4" />
<span>回答不佳</span>
{feedback?.rating === -1 && (
<CheckIcon className="text-primary ml-auto size-4" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={poorDialogOpen} onOpenChange={setPoorDialogOpen}>
<DialogContent className="gap-5 sm:max-w-[min(48rem,calc(100vw-2rem))]">
<DialogHeader className="pr-8">
<DialogTitle className="text-xl font-medium">分享反馈</DialogTitle>
<DialogDescription>
可多选这条回答需要改进的方面。
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap gap-2.5">
{REASON_OPTIONS.map((option) => {
const selected = reasons.includes(option.value);
return (
<button
key={option.value}
type="button"
aria-pressed={selected}
onClick={() => toggleReason(option.value)}
className={cn(
"rounded-full border px-4 py-2 text-sm transition-colors",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background hover:bg-accent",
)}
>
{option.label}
</button>
);
})}
</div>
<Textarea
value={detail}
onChange={(event) => setDetail(event.currentTarget.value)}
maxLength={2000}
placeholder="分享详细信息(可选)"
className="min-h-28 resize-y"
/>
<div className="bg-muted/60 text-muted-foreground rounded-lg px-4 py-3 text-sm">
你的反馈将用于改进智能体的回答质量和稳定性。
</div>
<DialogFooter>
<Button
type="button"
onClick={() => void submitNegative()}
disabled={reasons.length === 0 || saveFeedback.isPending}
>
{saveFeedback.isPending && (
<Loader2Icon className="size-4 animate-spin" />
)}
提交
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility.ts";
function message(overrides: Partial<Message> = {}): Message {
return {
id: "answer-1",
type: "ai",
content: "A persisted answer",
additional_kwargs: {},
...overrides,
} as Message;
}
test("accepts a completed persisted assistant answer", () => {
assert.equal(isEligibleAnswerFeedbackMessage(message()), true);
});
test("rejects loading, user, optimistic and empty messages", () => {
assert.equal(isEligibleAnswerFeedbackMessage(message(), true), false);
assert.equal(
isEligibleAnswerFeedbackMessage(message({ type: "human" })),
false,
);
assert.equal(
isEligibleAnswerFeedbackMessage(message({ id: "opt-ai-1" })),
false,
);
assert.equal(
isEligibleAnswerFeedbackMessage(message({ content: " " })),
false,
);
});
test("rejects tool-call shells and virtual business cards", () => {
assert.equal(
isEligibleAnswerFeedbackMessage(
message({
tool_calls: [{ id: "tool-1", name: "search", args: {} }],
}),
),
false,
);
assert.equal(
isEligibleAnswerFeedbackMessage(
message({
additional_kwargs: { setup_research_approval: { stage: "plan" } },
}),
),
false,
);
});
test("rejects ids outside the message-scoped API contract", () => {
assert.equal(
isEligibleAnswerFeedbackMessage(message({ id: "x".repeat(65) })),
false,
);
});
@@ -0,0 +1,52 @@
import type { Message } from "@langchain/langgraph-sdk";
function hasTextContent(content: Message["content"]): boolean {
if (typeof content === "string") return content.trim().length > 0;
if (!Array.isArray(content)) return false;
return content.some((part) => {
if (!part || typeof part !== "object") return false;
const text = "text" in part ? part.text : undefined;
return typeof text === "string" && text.trim().length > 0;
});
}
/**
* Only completed, persisted, user-visible assistant answers can be rated.
* Tool-call shells, optimistic/transient rows and special virtual cards are
* deliberately excluded because they are not standalone answers.
*/
export function isEligibleAnswerFeedbackMessage(
message: Message,
isLoading = false,
): boolean {
if (
isLoading ||
message.type !== "ai" ||
!message.id ||
message.id.length > 64
) {
return false;
}
if (message.id.startsWith("opt-")) return false;
if (!hasTextContent(message.content)) return false;
const directToolCalls = "tool_calls" in message ? message.tool_calls : null;
const additional = message.additional_kwargs ?? {};
const additionalToolCalls = additional.tool_calls;
if (
(Array.isArray(directToolCalls) && directToolCalls.length > 0) ||
(Array.isArray(additionalToolCalls) && additionalToolCalls.length > 0)
) {
return false;
}
const virtualCardKeys = [
"setup_agent_approval",
"setup_writing_approval",
"setup_report_structure_approval",
"setup_research_approval",
"task_report_import_approval",
"report_collaboration_card",
];
return !virtualCardKeys.some((key) => additional[key] !== undefined);
}
@@ -1,7 +1,8 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { createContext, useContext } from "react";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { createContext, useContext } from "react";
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import type { AgentThreadState } from "@/core/threads";
export interface ThreadContextType {
@@ -13,6 +14,11 @@ export interface ThreadContextType {
* from the admin-controlled manual reference-mode button.
*/
knowledgeReferencesEnabled?: boolean;
/**
* Server-scoped knowledge selection for the dedicated Wiki reference view.
* Sources outside these ids are never displayed as knowledge evidence.
*/
knowledgeReferences?: KnowledgeWikiReferenceSelection;
/**
* When set, the conversation is rendered from a public read-only share.
* Artifact/image URLs then resolve to the unauthenticated
@@ -4,6 +4,7 @@ import {
CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
CopyIcon,
FilePenLineIcon,
LightbulbIcon,
Loader2Icon,
@@ -14,6 +15,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ArtifactFileList } from "@/components/workspace/artifacts/artifact-file-list";
import {
Collapsible,
CollapsibleContent,
@@ -22,6 +24,7 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { updateArtifactContent } from "@/core/artifacts/api";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { copyTextToClipboard } from "@/core/clipboard";
import {
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
@@ -42,7 +45,7 @@ import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
const REWRITE_STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "writing", label: "生成改写版 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
@@ -221,6 +224,9 @@ export function DocumentRewriteTimeline({
const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
const diagnosticText = item.diagnostic
? JSON.stringify(item.diagnostic, null, 2)
: "";
const reapplying = Boolean(item.revertedAt);
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
@@ -370,7 +376,7 @@ export function DocumentRewriteTimeline({
: "bg-primary/10 text-primary",
)}
>
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未生成改写版") : "进行中"}
</span>
</div>
<p className="text-muted-foreground mt-1 text-sm">
@@ -440,13 +446,13 @@ export function DocumentRewriteTimeline({
{step.key === "writing" && (
item.writtenCharCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
正在{isNewReportGeneration ? "生成新报告" : "生成改写版"},已输出 {item.writtenCharCount.toLocaleString()} 字。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{isNewReportGeneration
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
: "正在等待模型开始输出;完成后将生成一份独立的改写版文件。"}
</p>
) : null
)}
@@ -583,9 +589,42 @@ export function DocumentRewriteTimeline({
)}
{item.error && (
<div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
<XCircleIcon className="size-4 shrink-0" />
{item.error}
<div className="border-t px-5 py-3 text-sm text-destructive">
<div className="flex items-start gap-2">
<XCircleIcon className="mt-0.5 size-4 shrink-0" />
<span>{item.error}</span>
</div>
{item.diagnostic && (
<Collapsible className="mt-3">
<div className="flex flex-wrap items-center gap-2">
<CollapsibleTrigger asChild>
<Button className="h-8 gap-1.5 px-2.5 text-xs" size="sm" type="button" variant="outline">
<ChevronDownIcon className="size-3.5" />
查看后台诊断
</Button>
</CollapsibleTrigger>
<Button
className="h-8 gap-1.5 px-2.5 text-xs"
onClick={() => {
void copyTextToClipboard(diagnosticText)
.then(() => toast.success("诊断信息已复制"))
.catch(() => toast.error("复制诊断信息失败"));
}}
size="sm"
type="button"
variant="outline"
>
<CopyIcon className="size-3.5" />
复制诊断
</Button>
</div>
<CollapsibleContent>
<pre className="bg-muted/55 text-foreground mt-2 max-h-72 overflow-auto rounded-lg border p-3 font-mono text-xs leading-5 whitespace-pre-wrap break-words">
{diagnosticText}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</div>
)}
<DocumentRewriteDiffDialog
@@ -598,3 +637,36 @@ export function DocumentRewriteTimeline({
</article>
);
}
/** A standalone chat row for a sandbox document-rewrite task. */
export function DocumentRewriteMessage({
item,
threadId,
onOpenArtifact,
onDownloadArtifact,
}: {
item: DocumentRewritePresentation;
threadId: string;
onOpenArtifact?: (filepath: string) => void;
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
}) {
const artifactPath = item.source.kind === "artifact" ? item.outputPath : undefined;
const completed = item.stage === "completed";
return (
<section aria-label={`AI 全文改写:${item.fileName}`} className="w-full space-y-3">
<DocumentRewriteTimeline item={item} />
{completed && artifactPath && (
<ArtifactFileList
fileDescriptions={{
[artifactPath]: "AI 改写版 Markdown 文件",
}}
files={[artifactPath]}
onDownloadArtifact={onDownloadArtifact}
onOpenArtifact={onOpenArtifact}
threadId={threadId}
/>
)}
</section>
);
}
@@ -12,7 +12,13 @@ import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
import {
looksLikeHtmlDocument,
MarkdownFallback,
MarkdownRenderBoundary,
streamdownPlugins,
useStreamdownMermaidPngDownload,
} from "@/core/streamdown";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
@@ -20,6 +26,9 @@ import {
} from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import { VisualizationBlock } from "../visualizations/visualization-block";
import { splitVisualizationContent } from "../visualizations/visualization-spec";
import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean {
@@ -50,6 +59,7 @@ export type MarkdownContentProps = {
/** Renders markdown content. */
export function MarkdownContent({
content,
isLoading,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
@@ -59,6 +69,7 @@ export function MarkdownContent({
}: MarkdownContentProps) {
const rootRef = useRef<HTMLDivElement>(null);
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
useStreamdownMermaidPngDownload(rootRef);
const components = useMemo(() => {
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
@@ -139,17 +150,72 @@ export function MarkdownContent({
if (!content) return null;
// Full HTML pages belong in the artifact iframe. Feeding them to Streamdown
// mid-stream can recurse forever in marked and white-screen the chat.
if (looksLikeHtmlDocument(content)) {
return (
<MarkdownFallback message="正在生成 HTML 页面,请查看右侧预览。" />
);
}
// Preserve the exact legacy rendering path for ordinary Markdown. Heavy
// visualization renderers are lazy-loaded only when a dedicated fence exists.
if (
visualizationSegments.length === 1 &&
visualizationSegments[0]?.type === "markdown"
) {
return (
<div ref={rootRef} data-markdown-anchor-root className="contents">
<MarkdownRenderBoundary resetKey={`${content.length}:${content.slice(-32)}`}>
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components}
>
{content}
</MessageResponse>
</MarkdownRenderBoundary>
</div>
);
}
return (
<div ref={rootRef} data-markdown-anchor-root className="contents">
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components}
>
{content}
</MessageResponse>
{visualizationSegments.map((segment, index) =>
segment.type === "visualization" ? (
<VisualizationBlock
key={`visualization-${index}`}
language={segment.language}
source={segment.source}
complete={segment.complete}
isLoading={isLoading}
/>
) : segment.content ? (
looksLikeHtmlDocument(segment.content) ? (
<MarkdownFallback
key={`markdown-${index}`}
message="正在生成 HTML 页面,请查看右侧预览。"
/>
) : (
<MarkdownRenderBoundary
key={`markdown-${index}`}
resetKey={`${segment.content.length}:${segment.content.slice(-32)}`}
>
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components}
>
{segment.content}
</MessageResponse>
</MarkdownRenderBoundary>
)
) : null,
)}
</div>
);
}
@@ -32,21 +32,21 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
namedStreamingWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
@@ -57,9 +57,12 @@ import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isInternalToolCallName } from "@/core/tools/visibility";
import {
applyInlineCitations,
buildSkillDisplayMap,
@@ -82,7 +85,8 @@ import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item";
import { SingleRoundRagCitationDrawer } from "./message-list-item";
import { RetrievedFileList } from "./retrieved-file-list";
export function MessageGroup({
className,
@@ -90,14 +94,17 @@ export function MessageGroup({
isLoading = false,
displaySources = [],
ragModeEnabled = false,
threadId,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
threadId?: string;
}) {
const { t } = useI18n();
const { data: citationDisplay } = useCitationDisplaySettings();
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
@@ -112,7 +119,13 @@ export function MessageGroup({
() => convertToSteps(messages, writingModeEnabled),
[messages, writingModeEnabled],
);
const { skills } = useSkills();
const { skills } = useSkills();
const retrievedFiles = useMemo(
() => citationDisplay?.retrieved_files_enabled === true
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
: [],
[messages, skills, citationDisplay?.retrieved_files_enabled],
);
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
@@ -210,6 +223,7 @@ export function MessageGroup({
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
),
@@ -222,6 +236,7 @@ export function MessageGroup({
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
</FlipDisplay>
@@ -271,7 +286,8 @@ export function MessageGroup({
</>
)}
</ChainOfThought>
<SingleRoundRagCitationDialog
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
<SingleRoundRagCitationDrawer
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
@@ -336,6 +352,7 @@ function ToolCall({
isLast = false,
isLoading = false,
skillDisplayMap = new Map(),
displaySources = [],
ragModeEnabled = false,
}: {
id?: string;
@@ -346,6 +363,7 @@ function ToolCall({
isLast?: boolean;
isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
@@ -356,19 +374,42 @@ function ToolCall({
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
? syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDisplaySources(result, displayMatch.display).map(
(item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}),
),
),
displaySources,
)
: [];
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
const isLlmWikiTool =
name === "llmwiki_search" ||
name === "llmwiki_read_wiki_page" ||
name === "llmwiki_browse_wiki_index";
const wikiQuery = isLlmWikiTool
? resolveSearchQuery(args, result) ||
(name === "llmwiki_read_wiki_page"
? normalizeSearchResults(result)[0]?.title ||
(typeof args.wiki_slug === "string" ? args.wiki_slug : "")
: "")
: "";
const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: null;
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
@@ -394,12 +435,17 @@ function ToolCall({
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
}));
const defaultItems = syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
})),
),
displaySources,
);
return (
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
@@ -781,17 +827,10 @@ function ArtifactWriteToolCall({
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const sourcePath = namedStreamingWriteFilePath(rawPath);
const showInlineContent = name === "write_file" && !sourcePath;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Never select a bare provisional name like `未命名文件.md` — the artifacts
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
// tool call has ids so we can open a write-file: memory preview instead.
// Until a real path arrives, both auto-open and manual clicks stay inline.
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
@@ -832,7 +871,9 @@ function ArtifactWriteToolCall({
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
{showInlineContent ? (
content && <InlineWritePreview content={content} />
) : (normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
</ChainOfThoughtSearchResult>
@@ -841,7 +882,29 @@ function ArtifactWriteToolCall({
);
}
interface GenericCoTStep<T extends string = string> {
function InlineWritePreview({ content }: { content: string }) {
const ref = useRef<HTMLPreElement>(null);
const follow = useRef(true);
useLayoutEffect(() => {
if (follow.current && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight;
}
}, [content]);
return (
<pre
ref={ref}
onScroll={(event) => {
const node = event.currentTarget;
follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
}}
className="border-border m-0 max-h-64 overflow-auto border-l pl-3 font-mono text-xs leading-6 whitespace-pre-wrap break-words text-emerald-600 dark:text-emerald-400"
>
<code>{content}</code>
</pre>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
@@ -861,6 +924,10 @@ type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = [];
// Some streaming providers omit tool_call_id on ToolMessages. Keep a
// per-name cursor so parallel searches consume distinct unbound results in
// order instead of every step reusing the first page.
const toolCallNameFallbackIndexes = new Map<string, number>();
for (const message of messages) {
if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message);
@@ -873,14 +940,37 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
};
steps.push(step);
}
for (const tool_call of message.tool_calls ?? []) {
if (tool_call.name === "task") {
const writes = new Map(
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
);
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
.tool_call_chunks;
const toolCalls = [...(message.tool_calls ?? [])];
for (const write of writes.values()) {
if (
!toolCalls.some((call) => call.id === write.id) &&
chunks?.some(
(chunk) => chunk.id === write.id && chunk.name === "write_file",
)
) {
toolCalls.push({
id: write.id,
name: "write_file",
args: { path: write.path, content: write.content },
});
}
}
for (const tool_call of toolCalls) {
if (tool_call.name === "task" || isInternalToolCallName(tool_call.name)) {
continue;
}
const writePath =
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
? (tool_call.args as { path: string }).path
: "";
const write = tool_call.name === "write_file" && tool_call.id
? writes.get(tool_call.id)
: undefined;
const args = write
? { ...tool_call.args, path: write.path, content: write.content }
: tool_call.args;
const writePath = typeof args?.path === "string" ? args.path : "";
if (
(
tool_call.name === "write_file" ||
@@ -898,15 +988,27 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
messageId: message.id,
type: "toolCall",
name: tool_call.name,
args: tool_call.args,
args,
};
const toolCallId = tool_call.id;
if (toolCallId) {
const toolCallResult = findToolCallResult(
let toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: false },
);
if (toolCallResult === undefined) {
const nameKey = tool_call.name.toLowerCase();
const fallbackIndex = toolCallNameFallbackIndexes.get(nameKey) ?? 0;
toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: true, nameFallbackIndex: fallbackIndex },
);
toolCallNameFallbackIndexes.set(nameKey, fallbackIndex + 1);
}
if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") {
try {
@@ -973,6 +1075,48 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
);
}
function rankDisplaySources(
sources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
return sources
.map((source, originalOrder) => ({ source, originalOrder }))
.sort((left, right) => {
const leftScore =
typeof left.source.score === "number" && Number.isFinite(left.source.score)
? left.source.score
: Number.NEGATIVE_INFINITY;
const rightScore =
typeof right.source.score === "number" && Number.isFinite(right.source.score)
? right.source.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }, index) => ({ ...source, index: index + 1 }));
}
function syncDisplaySourceIndexes(
sources: RuntimeDisplaySource[],
globalSources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
const indexByCitationId = new Map(
globalSources.flatMap((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
return citationId ? [[citationId, source.index] as const] : [];
}),
);
return sources.map((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
const index = citationId ? indexByCitationId.get(citationId) : undefined;
return index ? { ...source, index } : source;
});
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
@@ -1010,32 +1154,46 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
</div>
)}
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="pr-6 text-base leading-relaxed">
{item.title ?? "检索结果"}
</DialogTitle>
{(sourceLabel || date) && (
<DialogDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</DialogDescription>
)}
</DialogHeader>
{content && (
<div className="text-foreground max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{content}
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</DialogContent>
</Dialog>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="text-base leading-relaxed">
{item.title ?? "检索结果"}
</SheetTitle>
{(sourceLabel || date) && (
<SheetDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</SheetDescription>
)}
{!sourceLabel && !date && (
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
)}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
<div className="mx-auto max-w-3xl space-y-4">
{content ? (
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
{content}
</article>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的检索内容
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
</>
);
}
@@ -1,9 +1,10 @@
import type { Message } from '@langchain/langgraph-sdk';
import {
BookMarkedIcon,
ChevronDownIcon,
ClockIcon,
ExternalLinkIcon,
import {
ArrowLeftIcon,
BookMarkedIcon,
ChevronRightIcon,
ClockIcon,
ExternalLinkIcon,
FileIcon,
Loader2Icon,
PencilIcon,
@@ -25,7 +26,6 @@ import { Loader } from '@/components/ai-elements/loader';
import {
Message as AIElementMessage,
MessageContent as AIElementMessageContent,
MessageResponse as AIElementMessageResponse,
MessageToolbar,
} from '@/components/ai-elements/message';
import {
@@ -34,20 +34,15 @@ import {
ReasoningTrigger,
} from '@/components/ai-elements/reasoning';
import { Task, TaskTrigger } from '@/components/ai-elements/task';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import {
UploadedFilePreviewDialog,
@@ -55,10 +50,11 @@ import {
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
import { resolveArtifactURL } from '@/core/artifacts/utils';
import { useChatAskTimes } from '@/core/chat-timestamps';
import { getBackendBaseURL } from '@/core/config';
import { readRuntimeEnv } from '@/env';
import { useI18n } from '@/core/i18n/hooks';
import {
import { getBackendBaseURL } from '@/core/config';
import { readRuntimeEnv } from '@/env';
import { useI18n } from '@/core/i18n/hooks';
import { stripKnowledgeRetrievalDiagnostics } from '@/core/llmwiki/answer-display';
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
parseUploadedFiles,
@@ -71,18 +67,20 @@ import {
applyInlineCitations,
type RuntimeDisplaySource,
} from '@/core/skill-display/runtime';
import { humanMessagePlugins } from '@/core/streamdown';
import {
getMultiRoundRagTrace,
isMultiRoundRagMessage,
} from '@/core/threads/use-multi-round-rag';
import { cn } from '@/lib/utils';
import type { AnswerFeedbackContext } from '@/strategy-components/api/answer-feedback';
import { CitationLink } from '../citations/citation-link';
import { CopyButton } from '../copy-button';
import { Tooltip } from '../tooltip';
import { SkillDisplayBlock } from '../skill-display-block';
import { ThreadContext } from './context';
import { AnswerFeedbackControl } from './answer-feedback-control';
import { isEligibleAnswerFeedbackMessage } from './answer-feedback-eligibility';
import { DeleteMessageButton } from './delete-message-button';
import { DownloadWordButton } from './download-word-button';
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
@@ -168,6 +166,8 @@ export function MessageListItem({
onEditSave,
onMessageDeleted,
turnTime,
answerFeedbackEnabled = false,
answerFeedbackContext,
}: {
className?: string;
fitRowToContent?: boolean;
@@ -194,6 +194,10 @@ export function MessageListItem({
* wall clock at render time and appear to "drift" to now on every reload.
*/
turnTime?: string;
/** Opt-in feedback affordance for completed, persisted assistant answers. */
answerFeedbackEnabled?: boolean;
/** Authoritative run/agent mapping hydrated in one conversation batch. */
answerFeedbackContext?: AnswerFeedbackContext;
}) {
const { t } = useI18n();
const isHuman = message.type === 'human';
@@ -218,6 +222,9 @@ export function MessageListItem({
!!message.id &&
!message.id.startsWith('opt-') &&
!!messageMarkdown;
const canEvaluateAnswer =
answerFeedbackEnabled &&
isEligibleAnswerFeedbackMessage(message, Boolean(isLoading));
const confirmAssistantEdit = useCallback(async () => {
const value = assistantDraft.trim();
if (!value || !message.id || !onEditSave || assistantSaving) {
@@ -313,20 +320,27 @@ export function MessageListItem({
onMessageDeleted={onMessageDeleted}
turnTime={turnTime}
/>
{/*
Human messages carry their own inline copy/edit toolbar inside
MessageContent (above the timestamp). Only assistant answers use the
floating bottom toolbar here.
*/}
{/*
Human messages carry their own inline copy/edit toolbar inside
MessageContent (above the timestamp). Only assistant answers use the
always-visible floating bottom toolbar here.
*/}
{!isLoading && !isHuman && (
<MessageToolbar
className={cn(
'-bottom-8',
'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100',
)}
className={cn(
'-bottom-8',
'absolute right-0 left-0 z-20',
)}
>
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{canEvaluateAnswer && message.id && answerFeedbackContext && (
<AnswerFeedbackControl
threadId={threadId}
messageId={message.id}
context={answerFeedbackContext}
/>
)}
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
@@ -358,7 +372,7 @@ export function MessageListItem({
/**
* Inline copy/edit affordances + editor for the user's own question bubble.
*
* Layout: bubble → hover toolbar (copy + edit) → timestamp. The toolbar sits
* Layout: bubble → toolbar (copy + edit) → timestamp. The toolbar sits
* in normal flow above the timestamp (not floating) so it never covers the
* following message. Clicking edit turns the bubble into a textarea seeded with
* the question; confirming sends the edited text as a brand-new turn (the
@@ -370,7 +384,6 @@ function HumanMessageBlock({
pageContext,
filesList,
messageTime,
components,
onEditResubmit,
onEditSave,
deleteButton,
@@ -380,7 +393,7 @@ function HumanMessageBlock({
pageContext?: EmbeddedPageContext | null;
filesList: ReactNode;
messageTime: { label: string; full: string } | null;
components: MarkdownContentProps["components"];
components?: MarkdownContentProps["components"];
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
deleteButton?: ReactNode;
@@ -460,14 +473,9 @@ function HumanMessageBlock({
}
const messageResponse = text ? (
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
parseIncompleteMarkdown={false}
>
<div className="whitespace-pre-wrap break-words text-[15px] leading-7">
{text}
</AIElementMessageResponse>
</div>
) : null;
return (
@@ -479,9 +487,9 @@ function HumanMessageBlock({
</AIElementMessageContent>
)}
<MessageTimeStamp time={messageTime} align="right" />
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
{(text || deleteButton) && (
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
{(text || deleteButton) && (
<div className="flex items-center gap-0.5">
{text && <CopyButton clipboardData={text} className="size-6" />}
{text && (onEditResubmit || onEditSave) && (
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
@@ -686,22 +694,25 @@ function MessageContent_({
return files as FileInMessage[];
}, [message.additional_kwargs?.files, rawContent]);
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent
? stripArtifactTag(stripUploadedFilesTag(rawContent))
: '';
}
if (isMultiRoundRag) {
return stripIncompleteCitationMarker(rawContent ?? '');
}
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
const markdownRehypePlugins = useMemo(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
);
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent
? stripArtifactTag(stripUploadedFilesTag(rawContent))
: '';
}
const sanitizedContent = stripKnowledgeRetrievalDiagnostics(rawContent ?? '');
if (isMultiRoundRag) {
return stripIncompleteCitationMarker(sanitizedContent);
}
const withCitations = applyInlineCitations(sanitizedContent, displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
const markdownRehypePlugins = useMemo<
NonNullable<MarkdownContentProps['rehypePlugins']>
>(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
);
const parsedHumanContent = useMemo(
() =>
isHuman
@@ -807,11 +818,8 @@ function MessageContent_({
/>
)}
</div>
{isSingleRoundRag && displaySources.length > 0 && (
<FrontendRagSourcesPanel
sources={displaySources}
onSelect={setSelectedCitation}
/>
{isSingleRoundRag && displaySources.length > 0 && (
<FrontendRagSourcesPanel sources={displaySources} />
)}
<MessageTokenUsage
enabled={tokenUsageEnabled}
@@ -823,7 +831,7 @@ function MessageContent_({
) : null}
</AIElementMessageContent>
{isSingleRoundRag && (
<SingleRoundRagCitationDialog
<SingleRoundRagCitationDrawer
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
@@ -834,112 +842,176 @@ function MessageContent_({
);
}
/**
* 单轮 RAG 专属的「引用来源」折叠面板。
*
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
*/
function FrontendRagSourcesPanel({
sources,
onSelect,
}: {
sources: RuntimeDisplaySource[];
onSelect: (source: RuntimeDisplaySource) => void;
}) {
const [open, setOpen] = useState(false);
if (sources.length === 0) return null;
return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<CollapsibleTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-xs transition-colors"
>
<BookMarkedIcon className="size-3.5" />
<span className="font-medium">引用来源</span>
<span className="text-muted-foreground/70">({sources.length})</span>
<ChevronDownIcon
className={cn(
'size-3.5 transition-transform duration-200',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 grid gap-2">
{sources.map((source, idx) => (
<button
key={source.id ?? `${source.title}-${idx}`}
type="button"
onClick={() => onSelect(source)}
className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
>
<div className="flex items-center gap-2">
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{source.index}
</span>
<span
className="text-foreground line-clamp-1 min-w-0 flex-1 text-sm font-medium"
title={source.title}
>
{source.title}
</span>
{source.type && (
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
{source.type}
</span>
)}
</div>
{source.snippet && (
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
{source.snippet}
</p>
)}
</button>
))}
</CollapsibleContent>
</Collapsible>
);
}
export function SingleRoundRagCitationDialog({
source,
onOpenChange,
}: {
/**
* 单轮 RAG 的参考文献入口。先打开右侧抽屉查看列表,
* 选中条目后在同一抽屉中阅读详情,避免跳出居中弹框打断问答。
*/
function FrontendRagSourcesPanel({ sources }: { sources: RuntimeDisplaySource[] }) {
const [open, setOpen] = useState(false);
const [selectedSource, setSelectedSource] = useState<RuntimeDisplaySource | null>(null);
if (sources.length === 0) return null;
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) setSelectedSource(null);
};
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="text-muted-foreground hover:text-foreground mt-3 inline-flex items-center gap-1.5 rounded-md py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
aria-label={`查看 ${sources.length} 条参考文献`}
>
<BookMarkedIcon className="size-3.5" />
<span className="font-medium">参考文献</span>
<span className="text-muted-foreground/70">({sources.length})</span>
<span>查看</span>
<ChevronRightIcon className="size-3.5" />
</button>
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="flex h-full min-h-0 flex-col">
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<div className="flex items-start gap-3">
{selectedSource && (
<Button
type="button"
size="icon-sm"
variant="ghost"
className="mt-0.5 shrink-0"
onClick={() => setSelectedSource(null)}
aria-label="返回参考文献列表"
>
<ArrowLeftIcon className="size-4" />
</Button>
)}
<div className="min-w-0">
<SheetTitle className="truncate text-base">
{selectedSource?.title || "参考文献"}
</SheetTitle>
<SheetDescription className="mt-1 text-xs">
{selectedSource
? "引用详情;可返回列表继续查看其他文献。"
: `本次回答共引用 ${sources.length} 条资料,点击条目查看具体内容。`}
</SheetDescription>
</div>
</div>
</SheetHeader>
{selectedSource ? (
<CitationSourceDetail source={selectedSource} />
) : (
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-5">
<div className="grid gap-2">
{sources.map((source, idx) => (
<button
key={source.id ?? `${source.title}-${idx}`}
type="button"
onClick={() => setSelectedSource(source)}
className="group bg-card hover:border-primary/40 hover:bg-accent block w-full cursor-pointer rounded-xl border p-3.5 text-left shadow-sm transition-colors"
>
<div className="flex items-start gap-2.5">
<span className="bg-primary/10 text-primary mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{source.index}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-foreground line-clamp-2 min-w-0 flex-1 text-sm font-medium">
{source.title}
</span>
{source.type && (
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-[11px]">
{source.type}
</span>
)}
</div>
{(source.source || source.time) && (
<p className="text-muted-foreground mt-1 text-[11px]">
{[source.source, source.time].filter(Boolean).join(" · ")}
</p>
)}
{(source.snippet || source.content) && (
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
{source.snippet || source.content}
</p>
)}
</div>
<ChevronRightIcon className="text-muted-foreground mt-1 size-4 shrink-0 transition-transform group-hover:translate-x-0.5" />
</div>
</button>
))}
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
</>
);
}
export function SingleRoundRagCitationDrawer({
source,
onOpenChange,
}: {
source: RuntimeDisplaySource | null;
onOpenChange: (open: boolean) => void;
}) {
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
return (
<Dialog open={!!source} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
<DialogHeader className="pr-8">
<DialogTitle className="leading-relaxed">
{source?.title || "引用资料"}
</DialogTitle>
<DialogDescription className="space-y-1 text-xs">
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
{source?.time && <span className="block">时间:{source.time}</span>}
</DialogDescription>
</DialogHeader>
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="border-input hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
>
<ExternalLinkIcon className="size-4" />
查看原文链接
</a>
)}
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-lg p-3 text-sm leading-7 whitespace-pre-wrap">
{source?.content || source?.snippet || "暂无引用内容"}
</div>
</DialogContent>
</Dialog>
);
}
}) {
return (
<Sheet open={!!source} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="leading-relaxed">
{source?.title || "引用资料"}
</SheetTitle>
<SheetDescription className="space-y-1 text-xs">
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
{source?.time && <span className="block">时间:{source.time}</span>}
</SheetDescription>
</SheetHeader>
{source && <CitationSourceDetail source={source} />}
</SheetContent>
</Sheet>
);
}
function CitationSourceDetail({ source }: { source: RuntimeDisplaySource }) {
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
return (
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
<div className="mx-auto max-w-3xl space-y-4">
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="border-input bg-background hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
>
<ExternalLinkIcon className="size-4" />
查看原文链接
</a>
)}
<article className="bg-background rounded-xl border px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
{source.content || source.snippet || "暂无引用内容"}
</article>
{sourceUrl && (
<p className="text-muted-foreground border-t pt-3 text-xs break-all">
{sourceUrl}
</p>
)}
</div>
</div>
);
}
function resolveSingleRoundRagSourceUrl(
source: RuntimeDisplaySource | null,
@@ -1160,10 +1232,12 @@ function RichFileCard({
if (!fileUrl) return null;
const preview = (
<UploadedFilePreviewDialog
filename={file.filename}
fileUrl={fileUrl}
open={previewOpen}
<UploadedFilePreviewDialog
filename={file.filename}
fileUrl={fileUrl}
threadId={threadId}
virtualPath={file.path}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
);
@@ -32,6 +32,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
@@ -40,20 +41,35 @@ import {
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import {
shouldRenderUnattachedKnowledgeRetrievalProgress,
useKnowledgeRetrievalProgress,
} from "@/core/threads/knowledge-retrieval-progress";
import {
buildSmartKnowledgeRetrievalDisplaySegments,
collectSmartKnowledgeRetrievalTurnAssistantMessageIds,
hasSmartKnowledgeRetrievalCalls,
latestSmartKnowledgeRetrievalMessageId,
type SmartKnowledgeRetrievalResultItem,
} from "@/core/threads/smart-knowledge-retrieval";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { useAnswerFeedbackContexts } from "@/strategy-components/api/answer-feedback";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { getQuestionAnchorId } from "../question-navigator";
import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { DocumentRewriteMessage, DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
import {
MessageMetricsProvider,
MessageTokenUsageList,
@@ -65,7 +81,7 @@ export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
function ScrollToBottomOnUserMessage({
function ScrollToBottomOnUserMessage({
threadId,
messages,
}: {
@@ -109,10 +125,54 @@ function ScrollToBottomOnUserMessage({
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
}, [scrollToBottom, threadId]);
return null;
}
export function MessageList({
return null;
}
/**
* Exposes the conversation's scroll position to hosts that render controls
* outside the scroll container (for example the docked follow-up questions).
*/
function ConversationBottomObserver({
onAtBottomChange,
}: {
onAtBottomChange?: (isAtBottom: boolean) => void;
}) {
const { scrollRef, contentRef, escapedFromLock } = useStickToBottomContext();
const [isExactlyAtBottom, setIsExactlyAtBottom] = useState(true);
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
const update = () => {
const distance =
scrollElement.scrollHeight -
scrollElement.scrollTop -
scrollElement.clientHeight;
setIsExactlyAtBottom(distance <= 2);
};
update();
scrollElement.addEventListener("scroll", update, { passive: true });
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
resizeObserver?.observe(scrollElement);
if (contentRef.current) resizeObserver?.observe(contentRef.current);
return () => {
scrollElement.removeEventListener("scroll", update);
resizeObserver?.disconnect();
};
}, [contentRef, scrollRef]);
const displayControlsAtBottom = isExactlyAtBottom && !escapedFromLock;
useEffect(() => {
onAtBottomChange?.(displayControlsAtBottom);
}, [displayControlsAtBottom, onAtBottomChange]);
return null;
}
export function MessageList({
className,
contentClassName,
fitMessageRowsToContent = false,
@@ -120,9 +180,10 @@ export function MessageList({
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
extraRehypePlugins,
scrollButtonClassName,
onClarificationSubmit,
extraRehypePlugins,
scrollButtonClassName,
onAtBottomChange,
onClarificationSubmit,
onEditResubmit,
onEditSave,
messageContentOverrides,
@@ -139,9 +200,10 @@ export function MessageList({
businessCardSlot,
speakerShell,
onOpenArtifact,
onDownloadArtifact,
afterDocumentRewriteItems,
}: {
onDownloadArtifact,
afterDocumentRewriteItems,
answerFeedbackEnabled = false,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
@@ -149,8 +211,10 @@ export function MessageList({
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
/** Reports whether the message viewport is currently at its bottom edge. */
onAtBottomChange?: (isAtBottom: boolean) => void;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
@@ -194,9 +258,15 @@ export function MessageList({
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
}) {
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
/**
* Enables per-answer user feedback for persisted assistant replies. Kept
* opt-in because MessageList is also reused by read-only previews, setup
* flows and virtual business-card feeds.
*/
answerFeedbackEnabled?: boolean;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
@@ -217,33 +287,65 @@ export function MessageList({
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
() => new Set(),
);
const [selectedRetrievalResult, setSelectedRetrievalResult] = useState<{
item: SmartKnowledgeRetrievalResultItem;
target: WeKnoraSourceTarget;
} | null>(null);
useEffect(() => {
setHiddenMessageIds(new Set());
setSelectedRetrievalResult(null);
}, [threadId]);
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
if (deletedIds.length === 0) return;
setHiddenMessageIds((prev) => {
const next = new Set(prev);
for (const id of deletedIds) next.add(id);
return next;
});
}, []);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
return thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
});
}, []);
const documentRewriteItems = useMemo(
() => documentRewritePresentation?.forThread(threadId) ?? [],
[documentRewritePresentation, threadId],
);
const artifactRewriteItems = useMemo(
() => documentRewriteItems.filter(
(item) => item.source.kind === "artifact" && item.operation === "rewrite",
),
[documentRewriteItems],
);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
const persistedMessages = thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
.map((message) => {
const override =
hasOverrides && message.id
? messageContentOverrides?.[message.id]
: undefined;
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const messages = useMemo(
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
const rewriteMessages = artifactRewriteItems.flatMap((item) => [
{
id: `document-rewrite-input-${item.id}`,
type: "human",
content: "AI改写",
} as unknown as Message,
{
id: `document-rewrite-${item.id}`,
type: "ai",
content: "",
additional_kwargs: {
document_rewrite_presentation_id: item.id,
},
} as unknown as Message,
]);
return [...persistedMessages, ...rewriteMessages];
}, [artifactRewriteItems, messageContentOverrides, thread.messages]);
const messages = useMemo(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
@@ -255,10 +357,58 @@ export function MessageList({
);
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
const askTimes = useChatAskTimes();
);
const knowledgeRetrievalProgress = useKnowledgeRetrievalProgress(threadId);
const hasHumanMessage = messages.some((message) => message.type === "human");
const currentTurnMessages = useMemo(() => {
let lastHumanIndex = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.type === "human") {
lastHumanIndex = index;
break;
}
}
return messages.slice(lastHumanIndex + 1);
}, [messages]);
const latestCurrentSmartMessageId = useMemo(
() => latestSmartKnowledgeRetrievalMessageId(currentTurnMessages),
[currentTurnMessages],
);
const lastCurrentTurnMessageId = currentTurnMessages.at(-1)?.id;
const feedbackAvailable = answerFeedbackEnabled && allowDelete;
const answerFeedbackMessageIds = useMemo(() => {
if (!feedbackAvailable) return [];
const streamingAssistantId = thread.isLoading
? [...messages].reverse().find((message) => message.type === "ai")?.id
: undefined;
return messages.flatMap((message) => {
const kwargs = message.additional_kwargs ?? {};
const explicitlyStreaming =
kwargs.deep_research_summary_streaming === true ||
kwargs.deep_research_follow_up_streaming === true;
const loading =
explicitlyStreaming ||
(Boolean(streamingAssistantId) && message.id === streamingAssistantId);
return isEligibleAnswerFeedbackMessage(message, loading) && message.id
? [message.id]
: [];
});
}, [feedbackAvailable, messages, thread.isLoading]);
const { data: answerFeedbackContexts } = useAnswerFeedbackContexts(
threadId,
answerFeedbackMessageIds,
feedbackAvailable,
);
const ratableAnswerMessageIds = useMemo(() => {
const lastMessageByRun = new Map<string, string>();
for (const message of messages) {
if (!message.id) continue;
const context = answerFeedbackContexts?.[message.id];
if (context) lastMessageByRun.set(context.run_id, message.id);
}
return new Set(lastMessageByRun.values());
}, [answerFeedbackContexts, messages]);
const askTimes = useChatAskTimes();
// Stamp a wall-clock time onto every *question* the first time it is seen, in
// a single batched commit. Questions sent this session already carry an
@@ -320,8 +470,9 @@ export function MessageList({
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true ||
!!threadContext?.knowledgeReferences;
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
@@ -383,10 +534,29 @@ export function MessageList({
return map;
}, [thread.messages]);
const smartRetrievalAssistantMessageIds = useMemo(() => {
return collectSmartKnowledgeRetrievalTurnAssistantMessageIds(messages);
}, [messages]);
const resolveDisplaySources = useCallback(
(msg: Message | undefined): RuntimeDisplaySource[] => {
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
if (isMultiRoundRagMessage(msg)) return [];
const previousHumanId = findPreviousHumanMessageId(messages, msg.id);
// Smart retrieval has its own clickable evidence trace and explicitly
// does not require numbered references. Do not feed the same transport
// results into the legacy citation layer, which would append [1][2] to
// an otherwise complete answer.
const isSmartRetrievalTurn = msg.id
? smartRetrievalAssistantMessageIds.has(msg.id)
: hasSmartKnowledgeRetrievalCalls(
messages.slice(
previousHumanId
? messages.findIndex((message) => message.id === previousHumanId) + 1
: 0,
),
);
if (isSmartRetrievalTurn) return [];
const batchSources =
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
?.sources ?? [];
@@ -396,7 +566,7 @@ export function MessageList({
if (batchSources.length > 0) return batchSources;
if (frontendRagSources.length > 0) return frontendRagSources;
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
afterMessageId: previousHumanId,
beforeMessageId: msg.id,
includeBeforeMessage: true,
});
@@ -407,6 +577,7 @@ export function MessageList({
referenceBatches,
referencesEnabled,
skillDisplayMap,
smartRetrievalAssistantMessageIds,
],
);
@@ -522,24 +693,19 @@ export function MessageList({
return (
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{(() => {
const rewriteItemsAboveFile = documentRewriteItems.filter(
(item) => item.operation !== "generate",
);
const rewriteItemsBelowFile = documentRewriteItems.filter(
(item) => item.operation === "generate",
);
let didRenderRewriteAboveFile = false;
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{(() => {
let didRenderLiveKnowledgeRetrieval = false;
const grouped = groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
const items = group.messages.map((msg) => {
@@ -584,8 +750,14 @@ export function MessageList({
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
}
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
/>
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
answerFeedbackEnabled={answerFeedbackEnabled}
answerFeedbackContext={
msg.id && ratableAnswerMessageIds.has(msg.id)
? answerFeedbackContexts?.[msg.id]
: undefined
}
/>
);
});
if (speakerShell && group.type === "assistant") {
@@ -711,15 +883,30 @@ export function MessageList({
{taskReportImportSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:business-card") {
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
const message = group.messages[0];
return businessCardSlot && message ? (
<div key={group.id} className="w-full pb-2">
{businessCardSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:present-files") {
} else if (group.type === "assistant:business-card") {
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
const message = group.messages[0];
return businessCardSlot && message ? (
<div key={group.id} className="w-full pb-2">
{businessCardSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:document-rewrite") {
const presentationId = group.messages[0]?.additional_kwargs?.document_rewrite_presentation_id;
const item = typeof presentationId === "string"
? artifactRewriteItems.find((candidate) => candidate.id === presentationId)
: undefined;
return item ? (
<div className="w-full" key={group.id}>
<DocumentRewriteMessage
item={item}
onDownloadArtifact={onDownloadArtifact}
onOpenArtifact={onOpenArtifact}
threadId={threadId}
/>
</div>
) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
if (hasPresentFiles(message)) {
@@ -727,16 +914,9 @@ export function MessageList({
files.push(...presentFiles);
}
}
const rewriteAbove = !didRenderRewriteAboveFile
? rewriteItemsAboveFile
: [];
didRenderRewriteAboveFile = true;
return (
<div className="w-full" key={group.id}>
{rewriteAbove.map((item) => (
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
))}
{group.messages[0] && hasContent(group.messages[0]) && (
return (
<div className="w-full" key={group.id}>
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
@@ -815,6 +995,7 @@ export function MessageList({
results.push(
<MessageGroup
key={"thinking-group-" + message.id}
threadId={threadId}
messages={[message]}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)}
@@ -865,14 +1046,95 @@ export function MessageList({
const processingAi = [...group.messages]
.reverse()
.find((message) => message.type === "ai");
const processingBody = (
<div className="w-full">
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)}
ragModeEnabled={ragModeEnabled}
/>
const hasSmartRetrievalCalls = hasSmartKnowledgeRetrievalCalls(
group.messages,
);
const isCurrentTurnRetrievalGroup = group.messages.some(
(message) =>
message.id ===
(latestCurrentSmartMessageId ?? lastCurrentTurnMessageId),
);
const receivesLiveRetrievalProgress =
knowledgeRetrievalProgress.length > 0 &&
isCurrentTurnRetrievalGroup;
const retrievalDisplaySegments =
hasSmartRetrievalCalls || receivesLiveRetrievalProgress
? buildSmartKnowledgeRetrievalDisplaySegments(
group.messages,
receivesLiveRetrievalProgress
? knowledgeRetrievalProgress
: [],
)
: [
{
kind: "processing" as const,
id: `processing-${group.id ?? "group"}`,
messages: group.messages,
},
];
if (
retrievalDisplaySegments.some(
(segment) =>
segment.kind === "retrieval" &&
segment.progressFrames.length > 0,
)
) {
didRenderLiveKnowledgeRetrieval = true;
}
const processingBody = (
<div className="w-full space-y-2">
{retrievalDisplaySegments.map((segment) => {
if (segment.kind === "retrieval") {
return (
<SmartKnowledgeRetrievalTrace
key={segment.id}
messages={segment.messages}
progressFrames={segment.progressFrames}
dedupeScopeMessages={group.messages}
dedupeScopeProgressFrames={
receivesLiveRetrievalProgress
? knowledgeRetrievalProgress
: []
}
roundOffset={segment.roundOffset}
// The AI tool-call message can arrive before its custom
// `started` frame. Keep the current turn active through
// that short gap instead of misclassifying the pending
// call as an interrupted historical action.
isLoading={
isCurrentTurnRetrievalGroup && thread.isLoading
}
onOpenResult={(item, target) =>
setSelectedRetrievalResult({ item, target })
}
/>
);
}
const segmentHasVisibleProcessing = segment.messages.some(
(message) =>
message.type === "tool" ||
(message.type === "ai" &&
(hasReasoning(message) ||
hasContent(message) ||
hasToolCalls(message))),
);
if (!segmentHasVisibleProcessing) return null;
const segmentAi = [...segment.messages]
.reverse()
.find((message) => message.type === "ai");
return (
<MessageGroup
key={segment.id}
threadId={threadId}
messages={segment.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(
segmentAi ?? processingAi,
)}
ragModeEnabled={ragModeEnabled}
/>
);
})}
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -886,12 +1148,27 @@ export function MessageList({
</div>
);
});
const leftoverRewriteItems = didRenderRewriteAboveFile
? rewriteItemsBelowFile
: documentRewriteItems;
const leftoverRewriteItems = documentRewriteItems.filter(
(item) => item.source.kind !== "artifact" || item.operation !== "rewrite",
);
return (
<>
{grouped}
{shouldRenderUnattachedKnowledgeRetrievalProgress({
frameCount: knowledgeRetrievalProgress.length,
isLoading: thread.isLoading,
hasHumanMessage,
}) &&
!didRenderLiveKnowledgeRetrieval && (
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={knowledgeRetrievalProgress}
isLoading={thread.isLoading}
onOpenResult={(item, target) =>
setSelectedRetrievalResult({ item, target })
}
/>
)}
{leftoverRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
@@ -913,6 +1190,16 @@ export function MessageList({
aria-label="回到最新"
title="回到最新"
/>
<WeKnoraSourceDrawer
open={selectedRetrievalResult !== null}
target={selectedRetrievalResult?.target ?? null}
title={selectedRetrievalResult?.item.title}
quote={selectedRetrievalResult?.item.preview ?? ""}
collapseRetrievedExcerptByDefault
onOpenChange={(open) => {
if (!open) setSelectedRetrievalResult(null);
}}
/>
</Conversation>
);
}
@@ -956,8 +1243,9 @@ export type SetupWritingApprovalPayload = {
outline?: string;
};
export type SetupReportStructureApprovalPayload = {
title?: string;
export type SetupReportStructureApprovalPayload = {
kind?: "report_structure" | "gongwen_template";
title?: string;
name?: string;
content?: string;
structure_mode?: string;
@@ -1060,8 +1348,8 @@ function SetupAgentApprovalCard({
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const skills = approvalPayload.skills ?? [];
async function run(action: "approve" | "reject") {
@@ -1357,7 +1645,7 @@ const STRUCTURE_MODE_LABELS: Record<string, string> = {
adaptive: "弹性大纲",
};
function SetupReportStructureApprovalCard({
function SetupReportStructureApprovalCard({
disabled,
message,
onApprove,
@@ -1375,7 +1663,8 @@ function SetupReportStructureApprovalCard({
return null;
}
const approvalPayload = payload;
const approvalPayload = payload;
const isGongwenTemplate = approvalPayload.kind === "gongwen_template";
const busy = disabled || submitting !== null;
const directions = approvalPayload.retrieval_directions ?? [];
const skills = approvalPayload.retrieval_skills ?? [];
@@ -1400,8 +1689,8 @@ function SetupReportStructureApprovalCard({
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
<div className="text-muted-foreground text-xs font-medium">{isGongwenTemplate ? "公文模板结构建议" : "报告结构建议"}</div>
<div className="text-sm font-medium">{isGongwenTemplate ? "确认后将应用到右侧模板表单,再保存到模板中心。" : "确认后将应用到右侧表单,再点保存入库。"}</div>
</div>
{approvalPayload.title ? (
@@ -1443,7 +1732,7 @@ function SetupReportStructureApprovalCard({
{approvalPayload.content ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">报告大纲</div>
<div className="text-muted-foreground text-xs">{isGongwenTemplate ? "模板正文结构" : "报告大纲"}</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.content}
</pre>
@@ -74,6 +74,18 @@ export function SpeedLegend({
}
export function SpeedPill({ metric }: { metric: MessageMetric }) {
if (metric.isPaused) {
return (
<span
className="inline-flex items-center gap-1.5"
title="本段生成停顿,窗口内无新 token"
>
<GaugeIcon className="text-muted-foreground size-3" />
<span className="text-muted-foreground">暂停</span>
</span>
);
}
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec);
const display =
@@ -85,7 +97,7 @@ export function SpeedPill({ metric }: { metric: MessageMetric }) {
return (
<span
className="inline-flex items-center gap-1.5"
title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
title={`${label} · ${metric.fromUsage ? "按本段 output_tokens / 本段生成时长" : "字符估算 ≈ 2 字符 / token"}`}
>
<GaugeIcon className="size-3" style={{ color }} />
<span className="font-mono tabular-nums" style={{ color }}>
@@ -0,0 +1,207 @@
import { ChevronDownIcon, DownloadIcon, EyeIcon, Loader2Icon } from "lucide-react";
import { useContext, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { downloadArtifactFile } from "@/core/artifacts/download";
import { urlOfArtifact } from "@/core/artifacts/utils";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import type { RetrievedFile } from "@/core/messages/retrieved-files";
import { getFileIcon } from "@/core/utils/files";
import {
getLlmWikiDocument,
getLlmWikiDocumentPreview,
getLlmWikiSourceContext,
resolveLlmWikiSourceKnowledgeBase,
} from "@/strategy-components/api/llmwiki";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { ThreadContext } from "./context";
import { UploadedFilePreviewDialog } from "./uploaded-file-preview-dialog";
// File links belong to the upstream service. Never attach DeerFlow auth headers.
const fetchExternalFile: typeof fetch = (input, init) =>
fetch(input, { ...init, credentials: "omit" });
export function RetrievedFileList({ files, threadId }: { files: RetrievedFile[]; threadId: string }) {
const { isMock, publicShareCode } = useContext(ThreadContext) ?? {};
const [expanded, setExpanded] = useState(false);
const visible = files.filter((file) => !(publicShareCode && file.kind === "knowledge"));
// Results arrive in retrieval order; keep the newest file outside the fold.
const latest = visible.at(-1);
if (!latest) return null;
const older = visible.slice(0, -1).reverse();
return (
<Collapsible asChild open={expanded} onOpenChange={setExpanded}>
<section aria-label="检索到的文件" className="not-prose mt-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-muted-foreground text-xs">检索到的文件 · {visible.length} 个</div>
{older.length > 0 && (
<CollapsibleTrigger asChild>
<Button type="button" size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs">
{expanded ? "收起" : "展开"}
<ChevronDownIcon className={`size-3.5 transition-transform ${expanded ? "rotate-180" : ""}`} />
</Button>
</CollapsibleTrigger>
)}
</div>
<ul className="grid gap-2">
<RetrievedFileCard
key={latest.key}
file={latest}
threadId={threadId}
isMock={isMock}
publicShareCode={publicShareCode}
/>
</ul>
<CollapsibleContent>
<ul className="grid gap-2">
{older.map((file) => (
<RetrievedFileCard
key={file.key}
file={file}
threadId={threadId}
isMock={isMock}
publicShareCode={publicShareCode}
/>
))}
</ul>
</CollapsibleContent>
</section>
</Collapsible>
);
}
function RetrievedFileCard({ file, threadId, isMock, publicShareCode }: {
file: RetrievedFile;
threadId: string;
isMock?: boolean;
publicShareCode?: string;
}) {
const [previewOpen, setPreviewOpen] = useState(false);
const [preparingPreview, setPreparingPreview] = useState(false);
const [downloading, setDownloading] = useState(false);
const [knowledgeTarget, setKnowledgeTarget] = useState<WeKnoraSourceTarget | null>(null);
const fileUrl = file.kind === "artifact"
? urlOfArtifact({ filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode })
: file.kind === "external" ? file.url : "";
const resolveKnowledgeTarget = async () => {
if (file.kind !== "knowledge") throw new Error("当前文件不是知识库文档");
if (knowledgeTarget) return knowledgeTarget;
const knowledgeBaseId = await resolveLlmWikiSourceKnowledgeBase(file.target.knowledgeBaseId);
const target = { ...file.target, knowledgeBaseId };
setKnowledgeTarget(target);
return target;
};
const preview = async () => {
if (preparingPreview) return;
setPreparingPreview(true);
try {
if (file.kind === "knowledge") await resolveKnowledgeTarget();
setPreviewOpen(true);
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件预览失败");
} finally {
setPreparingPreview(false);
}
};
const download = async () => {
if (downloading || file.kind === "external") return;
setDownloading(true);
try {
if (file.kind === "artifact") {
await downloadArtifactFile({
filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode,
});
} else {
const target = await resolveKnowledgeTarget();
let documentId = target.documentId;
if (!documentId && target.chunkId) {
const context = await getLlmWikiSourceContext(target.knowledgeBaseId, target.chunkId);
documentId = context.document_id || undefined;
}
if (!documentId) throw new Error("当前检索结果没有可下载的文件原件,请先查看预览");
const [blob, document] = await Promise.all([
getLlmWikiDocumentPreview(target.knowledgeBaseId, documentId),
getLlmWikiDocument(target.knowledgeBaseId, documentId).catch(() => null),
]);
const objectUrl = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = objectUrl;
anchor.download = document?.file_name || file.name;
anchor.rel = "noopener";
window.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件下载失败");
} finally {
setDownloading(false);
}
};
return (
<li className="bg-card flex flex-wrap items-center gap-3 rounded-lg border p-3">
{getFileIcon(file.name, "text-muted-foreground size-5 shrink-0")}
<div className="min-w-0 flex-1 basis-40">
<div className="text-sm font-medium break-words">{file.name}</div>
{file.source && <div className="text-muted-foreground mt-0.5 truncate text-xs">{file.source}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{file.kind === "external" && file.previewUrl ? (
<Button asChild size="sm" variant="ghost">
<a href={file.previewUrl} target="_blank" rel="noopener noreferrer">
<EyeIcon className="size-4" />预览
</a>
</Button>
) : (
<Button type="button" size="sm" variant="ghost" disabled={preparingPreview} onClick={() => void preview()}>
{preparingPreview ? <Loader2Icon className="size-4 animate-spin" /> : <EyeIcon className="size-4" />}预览
</Button>
)}
{file.kind === "external" ? (
<Button asChild size="sm" variant="ghost">
<a href={file.url} download={file.name} target="_blank" rel="noopener noreferrer">
<DownloadIcon className="size-4" />下载
</a>
</Button>
) : (
<Button type="button" size="sm" variant="ghost" disabled={downloading} onClick={() => void download()}>
{downloading ? <Loader2Icon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}下载
</Button>
)}
</div>
{previewOpen && (file.kind === "knowledge" ? (
<WeKnoraSourceDrawer
open
target={knowledgeTarget || file.target}
title={file.name}
quote={file.quote || ""}
onOpenChange={setPreviewOpen}
/>
) : (
<UploadedFilePreviewDialog
filename={file.name}
fileUrl={fileUrl}
open
onOpenChange={setPreviewOpen}
threadId={file.kind === "artifact" && !publicShareCode ? threadId : undefined}
virtualPath={file.kind === "artifact" ? file.path : undefined}
requestFile={file.kind === "external" ? fetchExternalFile : undefined}
onDownload={file.kind === "artifact" ? download : undefined}
downloading={downloading}
/>
))}
</li>
);
}
@@ -0,0 +1,406 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { Message } from "@langchain/langgraph-sdk";
import type { KnowledgeRetrievalProgressFrame } from "@/core/threads/smart-knowledge-retrieval";
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
const completedFrames: KnowledgeRetrievalProgressFrame[] = [
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "started",
queries: ["成都世运会"],
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "completed",
result_count: 3,
elapsed_ms: 420,
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "started",
pattern: "火炬|开幕|闭幕",
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "completed",
result_count: 4,
elapsed_ms: 1380,
},
];
test("completed and replayed retrieval traces start collapsed with their hit summary visible", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={completedFrames}
isLoading={false}
/>,
);
assert.match(html, /aria-label="展开智能检索过程"/);
assert.match(html, /aria-expanded="false"/);
assert.match(html, /调用知识库 2 次 · 原始文档命中 3 条 · Wiki 命中 4 条/);
assert.match(html, /累计耗时 1\.8 秒/);
assert.doesNotMatch(html, /共\s*\d+\s*轮|第\s*\d+\s*轮检索/);
});
test("keeps a current pending tool call in loading state before its started frame arrives", () => {
const messages = [
{
type: "ai",
id: "ai-pending-wiki",
content: "",
tool_calls: [
{
id: "wiki-pending",
name: "ordinary_wiki_search",
args: { pattern: "数字中国|数据要素" },
},
],
},
] as unknown as Message[];
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={messages}
progressFrames={[]}
isLoading
/>,
);
assert.match(html, /检索中/);
assert.match(html, /正在查 Wiki/);
assert.doesNotMatch(html, /部分步骤失败|部分步骤未完成|已中止/);
});
test("labels and normalizes a visible model-authored regex", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[
{
type: "ai",
id: "ai-regex",
content: "",
tool_calls: [
{
id: "wiki-regex",
name: "ordinary_wiki_search",
args: { pattern: "^# [一-龥]{2,10}篇�" },
},
],
},
] as unknown as Message[]}
progressFrames={[]}
isLoading
/>,
);
assert.match(html, /正则:\^# \\p\{Han\}\{2,10\}篇/);
assert.doesNotMatch(html, /一-龥|�/);
});
test("presents an interrupted historical action as neutral instead of failed", () => {
const messages = [
{
type: "ai",
id: "ai-interrupted-wiki",
content: "",
tool_calls: [
{
id: "wiki-interrupted",
name: "ordinary_wiki_search",
args: { pattern: "数字中国" },
},
],
},
] as unknown as Message[];
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={messages}
progressFrames={[]}
isLoading={false}
/>,
);
assert.match(html, /检索已结束(部分步骤未完成)/);
assert.doesNotMatch(html, /部分步骤失败|部分步骤异常/);
});
test("an in-flight retrieval stays expanded and does not expose a collapse control", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[completedFrames[0]!]}
isLoading
/>,
);
assert.match(html, /data-state="open"/);
assert.match(html, /检索中/);
assert.match(html, /成都世运会/);
assert.doesNotMatch(html, /收起智能检索过程|展开智能检索过程/);
});
test("completed retrieval can be collapsed while the final answer is still streaming", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={completedFrames}
isLoading
/>,
);
assert.match(html, /data-state="open"/);
assert.match(html, /检索完成/);
assert.match(html, /aria-label="收起智能检索过程"/);
assert.match(html, /检索 Wiki/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
assert.doesNotMatch(html, /检索中/);
});
test("does not expose internal round numbering when retrieval cards are interleaved with skills", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[completedFrames[0]!]}
isLoading
roundOffset={2}
/>,
);
assert.match(html, /检索原始文档/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
});
test("renders stable retrieval results as right-drawer controls", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "completed",
result_count: 1,
result_items: [
{
kind: "wiki",
knowledge_base_id: "kb-news",
slug: "news/opening",
title: "开幕新闻 Wiki",
preview: "页面摘要",
},
],
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.match(html, /data-retrieval-result-detail="true"/);
assert.match(html, /在右侧查看:开幕新闻 Wiki/);
assert.doesNotMatch(html, /data-retrieval-result-summary="true"/);
});
test("renders a Wiki read as a preview card with elapsed time and right-drawer entry", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_read_page",
status: "completed",
result_count: 1,
knowledge_base_count: 1,
coverage: [
{ knowledge_base: "数字中国政策目录智能检索远端验收" },
],
elapsed_ms: 1260,
result_items: [
{
kind: "wiki",
knowledge_base_id: "kb-digital-china",
knowledge_base: "数字中国政策目录智能检索远端验收",
slug: "digital-china/timeline",
page_ref: "wiki-page:v1:digital-china-timeline",
title: "数字中国建设时间线",
content_markdown:
"页面正文概览,包含建设目标、关键年份、量化指标和制度保障。",
},
],
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.match(html, /读取结果/);
assert.match(html, /数字中国建设时间线/);
assert.match(html, /页面正文概览,包含建设目标/);
assert.match(html, /右侧查看/);
assert.match(html, /耗时 1\.3 秒/);
assert.match(html, /覆盖 1 个知识库:数字中国政策目录智能检索远端验收/);
assert.doesNotMatch(html, /知识库知识库/);
assert.match(html, /data-retrieval-result-detail="true"/);
});
test("shows all usable document-read results without a generic partial warning", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "document_chunk_read",
status: "completed",
result_count: 20,
partial: true,
warning_codes: ["PROVIDER_CONTEXT_PARTIAL"],
message: "原文上下文读取完成,找到 20 条结果",
result_items: Array.from({ length: 20 }, (_, index) => ({
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_base: "国内新闻",
knowledge_id: "document-long-news",
chunk_id: `chunk-${index + 1}`,
title: `长篇新闻第 ${index + 1} 段`,
content: `原文上下文第 ${index + 1} 段`,
})),
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.equal(
(html.match(/data-retrieval-result-detail="true"/g) ?? []).length,
20,
);
assert.match(html, /长篇新闻第 1 段/);
assert.match(html, /长篇新闻第 20 段/);
assert.doesNotMatch(html, /部分数据源返回警告/);
assert.doesNotMatch(html, /部分完成/);
});
test("keeps historical results without stable ids as non-clickable summaries", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "completed",
result_count: 1,
result_items: [
{
kind: "raw",
knowledge_base: "旧知识库",
title: "旧检索结果",
},
],
},
]}
isLoading
/>,
);
assert.match(html, /data-retrieval-result-summary="true"/);
assert.match(html, /该历史检索结果缺少稳定详情标识/);
assert.doesNotMatch(html, /data-retrieval-result-detail="true"/);
});
test("uses the whole turn as the dedupe scope for interleaved retrieval cards", () => {
const firstFrame: KnowledgeRetrievalProgressFrame = {
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
retrieval_id: "raw-first",
status: "completed",
result_count: 1,
result_items: [
{
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_id: "document-trump",
chunk_id: "chunk-1",
title: "特朗普生平.txt",
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
},
],
};
const repeatedFrame: KnowledgeRetrievalProgressFrame = {
...firstFrame,
retrieval_id: "raw-second",
result_items: [
{
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_id: "document-trump",
chunk_id: "chunk-provider-remapped",
title: "特朗普生平.txt",
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
},
],
};
const firstHtml = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[firstFrame]}
dedupeScopeMessages={[]}
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
isLoading
onOpenResult={() => undefined}
/>,
);
const repeatedHtml = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[repeatedFrame]}
dedupeScopeMessages={[]}
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.equal(
(firstHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
1,
);
assert.equal(
(repeatedHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
0,
);
assert.match(repeatedHtml, /本次查询结果均已在前面展示/);
assert.doesNotMatch(repeatedHtml, /命中 1 条/);
});
@@ -0,0 +1,525 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
AlertCircleIcon,
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
CirclePauseIcon,
DatabaseIcon,
Loader2Icon,
PanelRightOpenIcon,
SearchCheckIcon,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
buildSmartKnowledgeRetrievalRounds,
type KnowledgeRetrievalProgressFrame,
type SmartKnowledgeRetrievalAction,
type SmartKnowledgeRetrievalResultItem,
} from "@/core/threads/smart-knowledge-retrieval";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import { cn } from "@/lib/utils";
function actionStatusText(action: SmartKnowledgeRetrievalAction): string {
if (action.status === "failed") return "检索失败";
if (action.status === "interrupted") return "已中止";
if (action.status === "running") {
return action.surface === "wiki" ? "正在查 Wiki" : "正在查知识库";
}
if (action.allResultsAlreadyShown) return "均为已展示结果";
const hasUsableResults =
action.resultItems.length > 0 || (action.resultCount ?? 0) > 0;
const partialPrefix = action.partial && !hasUsableResults ? "部分完成 · " : "";
if (action.kind === "search") {
return `${partialPrefix}命中 ${action.resultCount ?? 0} 条`;
}
return typeof action.resultCount === "number"
? `${partialPrefix}读取完成 · ${action.resultCount} 条`
: `${partialPrefix}读取完成`;
}
function formatElapsedTime(elapsedMs: number): string {
const normalized = Math.max(0, elapsedMs);
if (normalized < 1_000) return `${Math.round(normalized)} 毫秒`;
if (normalized < 60_000) {
const seconds = (normalized / 1_000).toFixed(1).replace(/\.0$/, "");
return `${seconds} 秒`;
}
const roundedSeconds = Math.round(normalized / 1_000);
const minutes = Math.floor(roundedSeconds / 60);
const seconds = roundedSeconds % 60;
return seconds > 0 ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`;
}
function channelText(channel: string | undefined): string | null {
if (!channel) return null;
if (channel === "local_wiki_mirror") return "本地 Wiki 镜像";
if (channel === "provider") return "知识库 Wiki";
if (channel === "mixed") return "本地镜像 + 知识库 Wiki";
return channel;
}
function ActionStatusIcon({ action }: { action: SmartKnowledgeRetrievalAction }) {
if (action.status === "running") {
return <Loader2Icon className="size-4 animate-spin text-primary" />;
}
if (action.status === "completed") {
return <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />;
}
if (action.status === "failed") {
return <AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400" />;
}
return <CirclePauseIcon className="size-4 text-muted-foreground" />;
}
function resultDetailTarget(
item: SmartKnowledgeRetrievalResultItem,
): WeKnoraSourceTarget | null {
if (!item.knowledgeBaseId) return null;
if (item.kind === "wiki" && item.wikiSlug) {
return {
knowledgeBaseId: item.knowledgeBaseId,
wikiSlug: item.wikiSlug,
tab: "wiki",
};
}
if (item.kind === "raw" && (item.chunkId || item.documentId)) {
return {
knowledgeBaseId: item.knowledgeBaseId,
chunkId: item.chunkId,
documentId: item.documentId,
tab: "documents",
};
}
return null;
}
function RetrievalAction({
action,
onOpenResult,
}: {
action: SmartKnowledgeRetrievalAction;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
target: WeKnoraSourceTarget,
) => void;
}) {
const SurfaceIcon = action.surface === "wiki" ? BookOpenTextIcon : DatabaseIcon;
const channel = channelText(action.channel);
return (
<div
className="rounded-lg border border-border/70 bg-background/75 px-3 py-2.5"
data-retrieval-operation={action.operation}
>
<div className="flex min-w-0 items-start gap-2.5">
<SurfaceIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<span className="text-sm font-medium text-foreground">{action.label}</span>
<span
className={cn(
"inline-flex items-center gap-1 text-xs",
action.status === "running"
? "text-primary"
: action.status === "completed"
? "text-emerald-700 dark:text-emerald-300"
: action.status === "failed"
? "text-amber-700 dark:text-amber-300"
: "text-muted-foreground",
)}
>
<ActionStatusIcon action={action} />
{actionStatusText(action)}
</span>
</div>
{action.queries.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-muted-foreground">查询</span>
{action.queries.map((query) => (
<span
key={query}
className="max-w-full rounded-md bg-muted px-2 py-0.5 text-foreground/85 break-all"
>
{query}
</span>
))}
</div>
)}
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{typeof action.knowledgeBaseCount === "number" ? (
<span>
覆盖 {action.knowledgeBaseCount} 个知识库
{action.knowledgeBases.length > 0
? `:${action.knowledgeBases.join("、")}`
: ""}
</span>
) : action.knowledgeBases.length > 0 ? (
<span>知识库:{action.knowledgeBases.join("、")}</span>
) : null}
{typeof action.scannedPages === "number" && (
<span>扫描 {action.scannedPages} 个 Wiki 页面</span>
)}
{typeof action.scannedChunks === "number" && (
<span>扫描 {action.scannedChunks} 个原文片段</span>
)}
{action.kind === "read" && typeof action.total === "number" && (
<span>共 {action.total} 个片段</span>
)}
{channel && <span>通道:{channel}</span>}
{typeof action.elapsedMs === "number" && (
<span>耗时 {formatElapsedTime(action.elapsedMs)}</span>
)}
</div>
{action.allResultsAlreadyShown ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
本次查询结果均已在前面展示
</p>
) : action.resultItems.length > 0 ? (
<div className="mt-2">
<div className="mb-1.5 text-xs text-muted-foreground">
{action.kind === "read" ? "读取结果" : "查询结果"}
</div>
{action.kind === "read" ? (
<div className="space-y-2">
{action.resultItems.map((item) => {
const target = onOpenResult
? resultDetailTarget(item)
: null;
const preview = item.preview?.trim().slice(0, 600);
const content = (
<>
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-foreground">
{item.title}
</span>
{target && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
右侧查看
<PanelRightOpenIcon className="size-3.5" />
</span>
)}
</div>
{preview && (
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">
{preview}
</p>
)}
</>
);
return target ? (
<button
key={item.id}
type="button"
data-retrieval-result-detail
className="block w-full rounded-lg border border-primary/20 bg-primary/[0.045] px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-primary/[0.075] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={`在右侧查看:${item.title}`}
onClick={() => onOpenResult?.(item, target)}
>
{content}
</button>
) : (
<div
key={item.id}
aria-disabled="true"
data-retrieval-result-summary
className="rounded-lg border border-border/70 bg-muted/45 px-3 py-2.5"
title="该历史检索结果缺少稳定详情标识"
>
{content}
</div>
);
})}
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{action.resultItems.map((item) => {
const target = onOpenResult
? resultDetailTarget(item)
: null;
return target ? (
<button
key={item.id}
type="button"
data-retrieval-result-detail
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.055] px-2 py-1 text-left text-xs leading-5 text-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={`在右侧查看:${item.title}`}
onClick={() => onOpenResult?.(item, target)}
>
<span className="truncate">{item.title}</span>
<PanelRightOpenIcon className="size-3.5 shrink-0 text-primary" />
</button>
) : (
<span
key={item.id}
aria-disabled="true"
data-retrieval-result-summary
className="inline-flex max-w-full rounded-md bg-muted px-2 py-1 text-xs leading-5 text-muted-foreground"
title="该历史检索结果缺少稳定详情标识"
>
<span className="truncate">{item.title}</span>
</span>
);
})}
{(action.resultCount ?? 0) > action.resultItems.length && (
<span className="inline-flex items-center px-1 text-xs text-muted-foreground">
另有 {(action.resultCount ?? 0) - action.resultItems.length} 条结果
</span>
)}
</div>
)}
</div>
) : action.resultTitles.length > 0 ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
结果摘要:{action.resultTitles.join(";")}
{(action.resultCount ?? 0) > action.resultTitles.length ? ";…" : ""}
</p>
) : null}
{action.message && (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
{action.message}
</p>
)}
{action.error && (
<p className="mt-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300">
{action.error}
</p>
)}
{/* Provider warning codes stay in the structured trace for
diagnostics. The old generic warning was misleading both when
useful evidence existed and when the result set was empty. */}
</div>
</div>
</div>
);
}
export function SmartKnowledgeRetrievalTrace({
messages,
progressFrames = [],
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
onOpenResult,
}: {
messages: Message[];
progressFrames?: KnowledgeRetrievalProgressFrame[];
/**
* The whole current user-turn group. Interleaved rendering splits one
* retrieval invocation into one component, but result dedupe must still see
* every invocation in the turn.
*/
dedupeScopeMessages?: Message[];
dedupeScopeProgressFrames?: KnowledgeRetrievalProgressFrame[];
isLoading: boolean;
/** @deprecated 仅为兼容历史分段调用,展示层不再暴露检索轮次。 */
roundOffset?: number;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
target: WeKnoraSourceTarget,
) => void;
}) {
const rounds = useMemo(
() => {
const localRounds = buildSmartKnowledgeRetrievalRounds(
messages,
progressFrames,
isLoading,
);
if (!dedupeScopeMessages) return localRounds;
const globallyDedupedActions = new Map(
buildSmartKnowledgeRetrievalRounds(
dedupeScopeMessages,
dedupeScopeProgressFrames ?? [],
isLoading,
)
.flatMap((round) => round.actions)
.map((action) => [action.id, action] as const),
);
return localRounds.map((round) => ({
...round,
actions: round.actions.map((action) => {
const globalAction = globallyDedupedActions.get(action.id);
return globalAction ? { ...globalAction, round: action.round } : action;
}),
}));
},
[
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
messages,
progressFrames,
],
);
const actions = rounds.flatMap((round) => round.actions);
// The chat can keep streaming its final answer after retrieval has already
// finished. Only an unfinished retrieval action owns the "检索中" state;
// the broader thread loading flag must not keep the spinner alive or block
// the collapse control during answer generation.
const isActive = actions.some((action) => action.status === "running");
const hasFailure = actions.some((action) => action.status === "failed");
const hasInterrupted = actions.some(
(action) => action.status === "interrupted",
);
const rawHits = actions
.filter((action) => action.surface === "raw" && action.kind === "search")
.reduce(
(total, action) =>
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
0,
);
const wikiHits = actions
.filter((action) => action.surface === "wiki" && action.kind === "search")
.reduce(
(total, action) =>
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
0,
);
const elapsedActions = actions.filter(
(action) => typeof action.elapsedMs === "number",
);
const cumulativeElapsedMs =
elapsedActions.length > 0
? elapsedActions.reduce(
(total, action) => total + (action.elapsedMs ?? 0),
0,
)
: undefined;
// A live trace opens so newly completed result cards remain visible while
// the final answer starts streaming. Replayed history starts collapsed.
const [isExpanded, setIsExpanded] = useState(
isLoading && actions.length > 0,
);
const wasActive = useRef(isActive);
const wasLoading = useRef(isLoading);
useEffect(() => {
if (!wasActive.current && isActive) {
setIsExpanded(true);
}
if (wasLoading.current && !isLoading) {
setIsExpanded(false);
}
wasActive.current = isActive;
wasLoading.current = isLoading;
}, [isActive, isLoading]);
// An in-flight retrieval always stays visible. A just-completed live trace
// remains open and user-collapsible while the answer streams; replayed
// history starts collapsed and can be reopened for inspection.
const isOpen = isActive || isExpanded;
if (actions.length === 0) return null;
return (
<Collapsible
open={isOpen}
onOpenChange={(open) => {
if (!isActive) setIsExpanded(open);
}}
>
<section
aria-label="智能检索过程"
aria-live={isActive ? "polite" : undefined}
className="w-full rounded-xl border border-primary/20 bg-primary/[0.035] p-3.5 shadow-sm"
data-smart-knowledge-retrieval-trace
>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
{isActive ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SearchCheckIcon className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">智能检索过程</h3>
<div className="flex items-center gap-1.5">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
isActive
? "bg-primary/10 text-primary"
: hasFailure
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: hasInterrupted
? "bg-muted text-muted-foreground"
: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
)}
>
{isActive
? "检索中"
: hasFailure
? "已完成(部分步骤失败)"
: hasInterrupted
? "检索已结束(部分步骤未完成)"
: "检索完成"}
</span>
{!isActive && (
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-primary/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
aria-label={isOpen ? "收起智能检索过程" : "展开智能检索过程"}
>
<span>{isOpen ? "收起" : "展开"}</span>
<ChevronDownIcon
className={cn(
"size-3.5 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
)}
</div>
</div>
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
调用知识库 {actions.length} 次 · 原始文档命中 {rawHits} 条 · Wiki 命中 {wikiHits} 条
{typeof cumulativeElapsedMs === "number"
? ` · 累计耗时 ${formatElapsedTime(cumulativeElapsedMs)}`
: ""}
</p>
)}
{isActive && (
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
</p>
)}
</div>
</div>
<CollapsibleContent className="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1">
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
</p>
)}
<div className="mt-3 space-y-3">
{actions.map((action) => (
<RetrievalAction
key={action.id}
action={action}
onOpenResult={onOpenResult}
/>
))}
</div>
</CollapsibleContent>
</section>
</Collapsible>
);
}
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import {
GENERATION_IDLE_MS,
ROLLING_WINDOW_MS,
charsToTokensPerSec,
messageHasToolCalls,
rollingTokensPerSec,
shouldFreezeGeneration,
usageTokensPerSec,
} from "./speed-metrics.ts";
test("rolling window reports the recent rate while characters grow", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 500, chars: 100 },
{ at: 1000, chars: 200 },
];
const rate = rollingTokensPerSec(samples, 200, 1000, 1000);
// 200 chars / 1000ms / 2 chars-per-token = 100 tok/s
assert.equal(rate, 100);
});
test("rolling window drops to 0 when every sample is older than the window", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 200, chars: 80 },
{ at: 400, chars: 160 },
];
const now = 400 + ROLLING_WINDOW_MS + 50;
assert.equal(rollingTokensPerSec(samples, 160, now), 0);
});
test("rolling window decays toward 0 after a mid-stream pause", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 400, chars: 80 },
];
const live = rollingTokensPerSec(samples, 80, 500, 1000);
const paused = rollingTokensPerSec(samples, 80, 900, 1000);
assert.ok(live > paused);
assert.ok(paused > 0);
assert.equal(rollingTokensPerSec(samples, 80, 400 + 1000 + 1, 1000), 0);
});
test("usageTokensPerSec is output tokens over the segment duration", () => {
assert.equal(usageTokensPerSec(278, 6200), (278 / 6200) * 1000);
assert.equal(usageTokensPerSec(0, 1000), 0);
assert.equal(usageTokensPerSec(10, 0), 0);
});
test("charsToTokensPerSec uses the 2-chars-per-token heuristic", () => {
assert.equal(charsToTokensPerSec(100, 1000), 50);
});
test("shouldFreezeGeneration ends the segment on the next call, tools, idle usage, or run stop", () => {
const base = {
endedAt: null,
isLoading: true,
isLatestGenerating: true,
hasToolCalls: false,
grewThisTick: false,
lastGrewAt: 1000,
now: 1100,
outputTokens: 0,
};
assert.equal(shouldFreezeGeneration(base), false);
assert.equal(
shouldFreezeGeneration({ ...base, isLatestGenerating: false }),
true,
);
assert.equal(shouldFreezeGeneration({ ...base, hasToolCalls: true }), true);
assert.equal(
shouldFreezeGeneration({
...base,
hasToolCalls: true,
grewThisTick: true,
}),
false,
);
assert.equal(
shouldFreezeGeneration({
...base,
outputTokens: 40,
now: 1000 + GENERATION_IDLE_MS,
}),
true,
);
assert.equal(
shouldFreezeGeneration({
...base,
outputTokens: 40,
now: 1000 + GENERATION_IDLE_MS - 1,
}),
false,
);
assert.equal(shouldFreezeGeneration({ ...base, isLoading: false }), true);
assert.equal(shouldFreezeGeneration({ ...base, endedAt: 1000 }), false);
});
test("messageHasToolCalls reads both top-level and additional_kwargs", () => {
assert.equal(messageHasToolCalls({}), false);
assert.equal(
messageHasToolCalls({
tool_calls: [{ id: "1", name: "search" }],
}),
true,
);
assert.equal(
messageHasToolCalls({
additional_kwargs: { tool_calls: [{ id: "1" }] },
}),
true,
);
});
@@ -0,0 +1,108 @@
/**
* Shared tok/s helpers for the chat / roundtable speed pills.
*
* Live rate uses a rolling window with a virtual tail at "now" so a pause
* (no new characters) decays toward 0 instead of freezing on the last two
* samples. Final rate prefers provider `output_tokens` over the segment
* duration, and only after that model-call segment has ended.
*/
export const CHARS_PER_TOKEN = 2;
export const SAMPLE_THROTTLE_MS = 60;
export const ROLLING_WINDOW_MS = 1000;
export const MAX_SPEED_SAMPLES = 256;
/** Idle after last visible growth before we treat this model call as finished. */
export const GENERATION_IDLE_MS = 400;
export type SpeedSample = { at: number; chars: number };
export function estimateTokensFromChars(chars: number): number {
return Math.max(0, chars) / CHARS_PER_TOKEN;
}
export function charsToTokensPerSec(chars: number, elapsedMs: number): number {
if (elapsedMs <= 0) return 0;
return (estimateTokensFromChars(chars) / elapsedMs) * 1000;
}
export function usageTokensPerSec(
outputTokens: number,
durationMs: number,
): number {
if (outputTokens <= 0 || durationMs <= 0) return 0;
return (outputTokens / durationMs) * 1000;
}
/**
* Instantaneous tok/s over the last `windowMs`.
*
* The tail is always `(now, lastChars)`. If every sample is older than the
* window, the result is 0 — a generation pause must not keep the last
* observed rate pinned on screen.
*/
export function rollingTokensPerSec(
samples: SpeedSample[],
lastChars: number,
now: number,
windowMs: number = ROLLING_WINDOW_MS,
): number {
if (samples.length === 0 || windowMs <= 0) return 0;
const windowStart = now - windowMs;
let headIdx = samples.findIndex((sample) => sample.at >= windowStart);
if (headIdx === -1) {
return 0;
}
if (headIdx > 0) {
headIdx -= 1;
}
const head = samples[headIdx];
if (!head) return 0;
const elapsedMs = now - head.at;
if (elapsedMs <= 0) return 0;
return charsToTokensPerSec(Math.max(0, lastChars - head.chars), elapsedMs);
}
export function pushSpeedSample(
samples: SpeedSample[],
at: number,
chars: number,
): void {
samples.push({ at, chars });
if (samples.length > MAX_SPEED_SAMPLES) {
samples.splice(0, samples.length - MAX_SPEED_SAMPLES);
}
}
export function shouldFreezeGeneration(args: {
endedAt: number | null;
isLoading: boolean;
isLatestGenerating: boolean;
hasToolCalls: boolean;
grewThisTick: boolean;
lastGrewAt: number;
now: number;
outputTokens: number;
}): boolean {
if (args.endedAt != null) return false;
if (!args.isLatestGenerating) return true;
if (args.hasToolCalls && !args.grewThisTick) return true;
if (
args.outputTokens > 0 &&
args.now - args.lastGrewAt >= GENERATION_IDLE_MS
) {
return true;
}
return !args.isLoading;
}
export function messageHasToolCalls(message: {
tool_calls?: unknown[] | null;
additional_kwargs?: { tool_calls?: unknown[] } | null;
}): boolean {
const direct = message.tool_calls;
if (Array.isArray(direct) && direct.length > 0) return true;
const extra = message.additional_kwargs?.tool_calls;
return Array.isArray(extra) && extra.length > 0;
}
@@ -6,8 +6,6 @@ import {
XCircleIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Streamdown } from "streamdown";
import {
ChainOfThought,
ChainOfThoughtContent,
@@ -19,7 +17,10 @@ import { ShineBorder } from "@/components/ui/shine-border";
import { useI18n } from "@/core/i18n/hooks";
import { hasToolCalls } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { streamdownPluginsWithWordAnimation } from "@/core/streamdown";
import {
SafeStreamdown,
streamdownPluginsWithWordAnimation,
} from "@/core/streamdown";
import { useSubtask } from "@/core/tasks/context";
import { explainLastToolCall } from "@/core/tools/utils";
import { cn } from "@/lib/utils";
@@ -126,12 +127,12 @@ export function SubtaskCard({
{task.prompt && (
<ChainOfThoughtStep
label={
<Streamdown
<SafeStreamdown
{...streamdownPluginsWithWordAnimation}
components={{ a: CitationLink }}
>
{task.prompt}
</Streamdown>
</SafeStreamdown>
}
></ChainOfThoughtStep>
)}
@@ -1,6 +1,6 @@
"use client";
import { DownloadIcon, Loader2 } from "lucide-react";
import { DownloadIcon, InfoIcon, Loader2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -13,8 +13,10 @@ import {
} from "@/components/ui/dialog";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { reasoningPlugins } from "@/core/streamdown";
import { DEFAULT_UPLOAD_POLICY, useUploadPolicy } from "@/core/uploads";
const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
@@ -56,20 +58,111 @@ function kindOf(filename: string): PreviewKind {
return "binary";
}
function withReplacedExtension(url: string): string {
// Replace the path extension with .md, keeping any query string intact.
function withAppendedMarkdownExtension(url: string): string {
const [base, query] = url.split("?");
const appended = `${base}.md`;
return query ? `${appended}?${query}` : appended;
}
function withLegacyReplacedExtension(url: string): string {
// Compatibility with uploads converted before sidecars switched from
// `report.md` to the collision-safe `report.docx.md` naming scheme.
const [base, query] = url.split("?");
const i = base.lastIndexOf(".");
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
return query ? `${swapped}?${query}` : swapped;
}
type ArtifactPreviewResponse = {
path: string;
name: string;
total_bytes: number;
content: string;
truncated: boolean;
strategy: string;
stats?: Record<string, unknown> | null;
warnings?: string[];
};
type PreviewMeta = Pick<
ArtifactPreviewResponse,
"total_bytes" | "truncated" | "strategy" | "warnings"
>;
function parseTotalBytes(response: Response): number | undefined {
const contentRange = response.headers.get("Content-Range");
const rangeTotal = contentRange?.match(/\/(\d+)$/)?.[1];
if (rangeTotal) return Number(rangeTotal);
const contentLengthHeader = response.headers.get("Content-Length");
if (!contentLengthHeader) return undefined;
const contentLength = Number(contentLengthHeader);
return Number.isFinite(contentLength) ? contentLength : undefined;
}
/** Read a streamed response up to maxBytes and cancel the rest. */
async function readBoundedTextResponse(
response: Response,
maxBytes: number,
): Promise<{ content: string; totalBytes: number; truncated: boolean }> {
if (!response.body) throw new Error("浏览器不支持流式预览");
const reader = response.body.getReader();
const decoder = new TextDecoder();
const declaredTotal = parseTotalBytes(response);
let received = 0;
let content = "";
let completed = false;
while (received < maxBytes) {
const { done, value } = await reader.read();
if (done) {
completed = true;
break;
}
if (!value) continue;
const remaining = maxBytes - received;
const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value;
received += slice.byteLength;
content += decoder.decode(slice, { stream: received < maxBytes });
if (value.byteLength > remaining || received >= maxBytes) {
await reader.cancel();
break;
}
}
content += decoder.decode();
const totalBytes = declaredTotal ?? received;
return {
content,
totalBytes,
truncated: !completed || totalBytes > received,
};
}
function strategyLabel(strategy: string): string {
if (strategy.includes("sampling") || strategy.includes("sample")) {
return "流式统计 + 开头/中段/结尾分块采样";
}
if (strategy === "complete-text") return "完整文本";
if (strategy === "bounded-prefix") return "有界前缀";
if (strategy === "client-bounded-prefix") return "兼容模式有界前缀";
return strategy;
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KiB`;
return `${bytes} B`;
}
/**
* Fetch an auth-gated artifact (the artifact route requires an
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
* hence the 401s) and expose it as an object URL for inline display.
*/
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
export function useAuthedObjectUrl(
url: string | null,
enabled: boolean,
requestFile: typeof apiFetch = apiFetch,
) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -81,9 +174,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
}
let cancelled = false;
let created: string | null = null;
const controller = new AbortController();
setLoading(true);
setError(null);
apiFetch(url)
requestFile(url, { signal: controller.signal })
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
@@ -102,9 +196,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
});
return () => {
cancelled = true;
controller.abort();
if (created) URL.revokeObjectURL(created);
};
}, [url, enabled]);
}, [url, enabled, requestFile]);
return { objectUrl, loading, error };
}
@@ -122,47 +217,152 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
export function UploadedFilePreviewDialog({
filename,
fileUrl,
threadId,
virtualPath,
requestFile = apiFetch,
onDownload,
downloading = false,
open,
onOpenChange,
}: {
filename: string;
/** Absolute artifact URL for the original uploaded file. */
fileUrl: string;
/** Used by the bounded preview endpoint; optional for old/public messages. */
threadId?: string;
virtualPath?: string;
/** External skill links supply an unauthenticated fetch, avoiding token leakage. */
requestFile?: typeof apiFetch;
/** Authenticated original-file download used by retrieved-file cards. */
onDownload?: () => void | Promise<void>;
downloading?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const { data: uploadPolicy } = useUploadPolicy();
const effectivePolicy = uploadPolicy ?? DEFAULT_UPLOAD_POLICY;
const maxPreviewChars = Math.max(
256,
Math.min(
32_000,
effectivePolicy.large_file.max_result_chars,
effectivePolicy.large_file.hard_max_result_chars,
),
);
const kind = useMemo(() => kindOf(filename), [filename]);
const ext = useMemo(() => extOf(filename), [filename]);
// image / pdf → fetch as authed blob and display via object URL.
const blobEnabled = open && (kind === "image" || kind === "pdf");
const { objectUrl, loading: blobLoading, error: blobError } =
useAuthedObjectUrl(fileUrl, blobEnabled);
useAuthedObjectUrl(fileUrl, blobEnabled, requestFile);
// text / office → fetch text (office reads the converted sibling .md).
const textUrl = useMemo(
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
const textUrls = useMemo(
() =>
kind === "office"
? Array.from(
new Set([
withAppendedMarkdownExtension(fileUrl),
withLegacyReplacedExtension(fileUrl),
]),
)
: [fileUrl],
[kind, fileUrl],
);
const previewPaths = useMemo(
() =>
virtualPath
? kind === "office"
? Array.from(
new Set([
withAppendedMarkdownExtension(virtualPath),
withLegacyReplacedExtension(virtualPath),
]),
)
: [virtualPath]
: [],
[kind, virtualPath],
);
const [text, setText] = useState<string | null>(null);
const [previewMeta, setPreviewMeta] = useState<PreviewMeta | null>(null);
const [textLoading, setTextLoading] = useState(false);
const [textError, setTextError] = useState<string | null>(null);
useEffect(() => {
if (!open || (kind !== "text" && kind !== "office")) return;
let cancelled = false;
const controller = new AbortController();
setTextLoading(true);
setTextError(null);
setText(null);
apiFetch(textUrl)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then((body) => {
if (!cancelled) setText(body);
})
setPreviewMeta(null);
const load = async () => {
if (threadId && previewPaths.length > 0) {
for (const previewPath of previewPaths) {
try {
const endpoint = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/artifact-preview?path=${encodeURIComponent(
previewPath,
)}&max_chars=${maxPreviewChars}`;
const response = await apiFetch(endpoint, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const body = (await response.json()) as ArtifactPreviewResponse;
const rawContent = String(body.content ?? "");
const content = rawContent.slice(0, maxPreviewChars);
if (!cancelled) {
setText(content);
setPreviewMeta({
total_bytes: Number(body.total_bytes) || 0,
truncated:
Boolean(body.truncated) || rawContent.length > content.length,
strategy: String(body.strategy || "bounded-prefix"),
warnings: Array.isArray(body.warnings) ? body.warnings : [],
});
}
return;
} catch {
// Try a legacy Office sidecar, then fall through to the bounded
// Range fallback during a rolling upgrade or public share.
}
}
}
const maxBytes = Math.max(4_096, maxPreviewChars * 4);
let lastError: Error | null = null;
for (const textUrl of textUrls) {
try {
const response = await requestFile(textUrl, {
signal: controller.signal,
headers: { Range: `bytes=0-${maxBytes - 1}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bounded = await readBoundedTextResponse(response, maxBytes);
if (!cancelled) {
const content = bounded.content.slice(0, maxPreviewChars);
setText(content);
setPreviewMeta({
total_bytes: bounded.totalBytes,
truncated:
bounded.truncated || bounded.content.length > content.length,
strategy: "client-bounded-prefix",
warnings: [
"采样预览接口暂不可用,当前仅显示受限的文件开头,未读取剩余内容。",
],
});
}
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}
}
throw lastError ?? new Error("无法读取文件预览");
};
void load()
.catch((err: unknown) => {
if (!cancelled)
setTextError(err instanceof Error ? err.message : String(err));
@@ -172,8 +372,9 @@ export function UploadedFilePreviewDialog({
});
return () => {
cancelled = true;
controller.abort();
};
}, [open, kind, textUrl]);
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId, requestFile]);
const renderAsMarkdown =
kind === "office" || ext === "md" || ext === "markdown";
@@ -192,21 +393,36 @@ export function UploadedFilePreviewDialog({
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
minHeight: "min(420px, calc(100vh - 2rem))",
height: "min(720px, calc(100vh - 2rem))",
maxHeight: "min(720px, calc(100vh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
{onDownload ? (
<Button
type="button"
size="sm"
variant="ghost"
disabled={downloading}
onClick={() => void onDownload()}
>
<DownloadIcon className="size-3" />
{t.common.download}
</Button>
) : (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
)}
</DialogDescription>
</DialogHeader>
@@ -215,7 +431,7 @@ export function UploadedFilePreviewDialog({
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<div className="flex items-center justify-center">
<img
@@ -229,7 +445,7 @@ export function UploadedFilePreviewDialog({
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<iframe
src={objectUrl}
@@ -241,22 +457,45 @@ export function UploadedFilePreviewDialog({
textLoading ? (
loadingView
) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : renderAsMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={text ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{text ?? ""}
</pre>
<div className="space-y-3">
{previewMeta &&
(previewMeta.truncated ||
previewMeta.strategy !== "complete-text" ||
(previewMeta.warnings?.length ?? 0) > 0) && (
<div className="border-amber-300/70 bg-amber-50/70 text-amber-950 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100 rounded-lg border px-3 py-2 text-xs">
<div className="flex items-start gap-2 font-medium">
<InfoIcon className="mt-0.5 size-3.5 shrink-0" />
<span>
{previewMeta.truncated ? "当前为截断/采样预览" : "预览读取方式"}
:{strategyLabel(previewMeta.strategy)};文件大小 {formatBytes(previewMeta.total_bytes)},本次最多展示 {maxPreviewChars.toLocaleString()} 个字符。
</span>
</div>
{previewMeta.warnings?.map((warning) => (
<p key={warning} className="mt-1 pl-5 opacity-80">
{warning}
</p>
))}
</div>
)}
{renderAsMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={text ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{text ?? ""}
</pre>
)}
</div>
)
) : (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
)}
</div>
</DialogContent>
@@ -264,15 +503,34 @@ export function UploadedFilePreviewDialog({
);
}
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
function BinaryFallback({ fileUrl, label, onDownload, downloading }: {
fileUrl: string;
label: string;
onDownload?: () => void | Promise<void>;
downloading?: boolean;
}) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<p>暂不支持在线预览,请下载原文件查看。</p>
{onDownload ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={downloading}
onClick={() => void onDownload()}
>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
</Button>
) : (
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
)}
</div>
);
}
@@ -7,29 +7,37 @@ import { extractReasoningContentFromMessage } from "@/core/messages/utils";
import { getUsageMetadata } from "@/core/messages/usage";
import { textOfMessage } from "@/core/threads/utils";
/**
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
* speed indicator for mixed Chinese/English replies without dragging in a
* real tokeniser.
*/
const CHARS_PER_TOKEN = 2;
/** Sample throttle while a message is streaming. */
const SAMPLE_THROTTLE_MS = 60;
import {
GENERATION_IDLE_MS,
SAMPLE_THROTTLE_MS,
charsToTokensPerSec,
messageHasToolCalls,
pushSpeedSample,
rollingTokensPerSec,
shouldFreezeGeneration,
usageTokensPerSec,
type SpeedSample,
} from "./speed-metrics";
/**
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
* Per-AI-message metric: live rolling rate during that model-call segment,
* frozen once the segment ends (not when the whole agent turn ends).
*/
export interface MessageMetric {
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
tokensPerSec: number;
/**
* Wall-clock duration from when the message first appeared with text to
* either *now* (active) or to when the stream ended (frozen).
* Wall-clock duration of this model-call segment: first visible character
* → last visible character (tool-wait after the call is excluded).
*/
durationMs: number;
/** True while this is the currently streaming message. */
/** True while this message is still generating visible tokens. */
isActive: boolean;
/**
* True while this message is the live generator but the rolling window
* has seen no new characters — the pill should not keep a stale tok/s.
*/
isPaused: boolean;
/**
* Whether the speed value was computed from real usage_metadata output
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
@@ -45,15 +53,12 @@ export interface MessageMetric {
isTurnFinal: boolean;
}
type Sample = { at: number; chars: number };
type TimingEntry = {
firstSeenAt: number;
endedAt: number | null;
samples: Sample[];
lastGrewAt: number;
samples: SpeedSample[];
lastChars: number;
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
hadUsage: boolean;
};
function isAiMessage(message: Message): boolean {
@@ -82,11 +87,12 @@ function messageText(message: Message): string {
*
* Recording strategy:
* - Whenever a message renders with text, we anchor `firstSeenAt`.
* - On every text-length change we push a sample and recompute the rate.
* - When `isLoading` flips to false we mark *all* still-active messages as
* ended so subsequent renders return a frozen duration.
* - Token totals come from usage_metadata when present (most accurate);
* otherwise we fall back to char-count / 2.
* - On every text-length change we push a sample.
* - The segment freezes when this model call ends (next AI message, tool
* calls, usage + idle, or the run stops) so tool-wait is not in the
* denominator.
* - Live rate is a rolling window that decays to 0 on pause.
* - `usage_metadata.output_tokens` only corrects the frozen final value.
*/
export function useMessageMetrics(
messages: Message[],
@@ -104,7 +110,8 @@ export function useMessageMetrics(
// synchronously inside an effect so React isn't tracking the map identity.
useEffect(() => {
const now = performance.now();
let activeId: string | null = null;
const grewIds = new Set<string>();
let latestId: string | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
@@ -118,50 +125,62 @@ export function useMessageMetrics(
entry = {
firstSeenAt: now,
endedAt: null,
lastGrewAt: now,
samples: [{ at: now, chars: text.length }],
lastChars: text.length,
hadUsage: false,
};
timingRef.current.set(id, entry);
}
// Track the most recent AI message as the active one (while streaming).
activeId = id;
latestId = id;
// Sample only when actively streaming OR when content actually grew —
// throttle to keep arrays bounded.
const grew = text.length !== entry.lastChars;
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
entry.samples.push({ at: now, chars: text.length });
entry.lastChars = text.length;
if (entry.samples.length > 256) {
entry.samples.splice(0, entry.samples.length - 256);
if (grew) {
grewIds.add(id);
entry.lastGrewAt = now;
if (now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
pushSpeedSample(entry.samples, now, text.length);
lastSampleAtRef.current = now;
}
lastSampleAtRef.current = now;
} else if (grew) {
// Still update lastChars so the next render captures it eventually.
entry.lastChars = text.length;
}
if (getUsageMetadata(message)) {
entry.hadUsage = true;
}
}
// When streaming stops, freeze any non-ended entry to the current time.
if (!isLoading) {
for (const entry of timingRef.current.values()) {
if (entry.endedAt === null) {
entry.endedAt = now;
}
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const entry = timingRef.current.get(id);
if (!entry) continue;
const grewThisTick = grewIds.has(id);
if (grewThisTick && entry.endedAt != null && isLoading) {
entry.endedAt = null;
continue;
}
const usage = getUsageMetadata(message);
if (
shouldFreezeGeneration({
endedAt: entry.endedAt,
isLoading,
isLatestGenerating: id === latestId,
hasToolCalls: messageHasToolCalls(message),
grewThisTick,
lastGrewAt: entry.lastGrewAt,
now,
outputTokens: usage?.outputTokens ?? 0,
})
) {
entry.endedAt = entry.lastGrewAt;
}
}
lastActiveIdRef.current = activeId;
lastActiveIdRef.current = latestId;
setTick((v) => v + 1);
}, [messages, isLoading]);
// Drive a slow tick while streaming so live durations refresh smoothly.
// Drive a slow tick while streaming so live durations / pause decay refresh.
useEffect(() => {
if (!isLoading) return;
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
@@ -198,45 +217,36 @@ export function useMessageMetrics(
const entry = timingRef.current.get(id);
if (!entry) continue;
const isActive =
isLoading && id === lastActiveIdRef.current && entry.endedAt === null;
const endedAt = entry.endedAt ?? now;
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
const isActive = isLoading && id === lastActiveIdRef.current;
let tokensPerSec = 0;
let fromUsage = false;
let isPaused = false;
const usage = getUsageMetadata(message);
if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
fromUsage = true;
} else {
// Char-count fallback. Uses the rolling window during streaming so a
// mid-stream snapshot reflects current speed rather than average since
// the start.
if (isActive && entry.samples.length >= 2) {
const samples = entry.samples;
let start = samples.findIndex((s) => s.at >= now - 1500);
if (start === -1) start = samples.length - 2;
if (start < 0) start = 0;
const head = samples[start];
const tail = samples[samples.length - 1];
if (head && tail) {
const elapsedMs = tail.at - head.at;
const chars = Math.max(0, tail.chars - head.chars);
if (elapsedMs > 0) {
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
}
}
if (entry.endedAt != null) {
if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = usageTokensPerSec(usage.outputTokens, durationMs);
fromUsage = true;
} else if (durationMs > 0) {
// Finished message without usage data: average over full duration.
const chars = entry.lastChars;
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
tokensPerSec = charsToTokensPerSec(entry.lastChars, durationMs);
}
} else {
tokensPerSec = rollingTokensPerSec(entry.samples, entry.lastChars, now);
isPaused =
isActive &&
tokensPerSec <= 0 &&
now - entry.lastGrewAt >= GENERATION_IDLE_MS;
}
out.set(id, {
tokensPerSec,
durationMs,
isActive,
isPaused,
fromUsage,
isTurnFinal: turnFinalIds.has(id),
});
@@ -25,6 +25,7 @@ export function ModelNameSelect({
className,
triggerClassName,
defaultLabel = "默认模型",
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
}: {
value: string | null;
onChange: (modelName: string | null) => void;
@@ -32,6 +33,7 @@ export function ModelNameSelect({
className?: string;
triggerClassName?: string;
defaultLabel?: string;
title?: string;
}) {
const { models } = useModels();
return (
@@ -42,7 +44,7 @@ export function ModelNameSelect({
>
<SelectTrigger
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
title={title}
>
<SelectValue placeholder={defaultLabel} />
</SelectTrigger>
@@ -0,0 +1,86 @@
import {
FileText,
MessageSquareText,
Sparkles,
type LucideIcon,
} from "lucide-react";
import {
MY_PROJECT_CONVERSATION_LABELS,
type MyProjectConversationType,
} from "@/core/threads/project-metadata";
import { cn } from "@/lib/utils";
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
strategy:
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
solution:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
};
const ICONS: Record<MyProjectConversationType, LucideIcon> = {
qa: MessageSquareText,
strategy: Sparkles,
solution: FileText,
};
const ICON_STYLES: Record<MyProjectConversationType, string> = {
qa: "bg-sky-50 text-sky-600 dark:bg-sky-950/70 dark:text-sky-300",
strategy:
"bg-violet-50 text-violet-600 dark:bg-violet-950/70 dark:text-violet-300",
solution:
"bg-emerald-50 text-emerald-600 dark:bg-emerald-950/70 dark:text-emerald-300",
};
export function MyProjectThreadIcon({
type,
className,
}: {
type: MyProjectConversationType;
className?: string;
}) {
const Icon = ICONS[type];
const label = `我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`;
return (
<span
aria-label={label}
className={cn(
"inline-flex size-5 shrink-0 items-center justify-center rounded-md",
ICON_STYLES[type],
className,
)}
role="img"
title={label}
>
<Icon aria-hidden="true" className="size-3.5" />
</span>
);
}
export function MyProjectThreadBadge({
type,
projectName,
className,
}: {
type: MyProjectConversationType;
projectName?: string | null;
className?: string;
}) {
const typeLabel = `${MY_PROJECT_CONVERSATION_LABELS[type]}项目`;
const ownerLabel = projectName?.trim() || "我的项目";
return (
<span
className={cn(
"inline-flex min-w-0 max-w-full items-center gap-1 self-start rounded border px-1.5 py-0.5 text-[9px] font-medium leading-none",
BADGE_STYLES[type],
className,
)}
title={`来自我的项目 · ${typeLabel} · ${ownerLabel}`}
>
<span className="shrink-0">{typeLabel}</span>
<span aria-hidden="true" className="opacity-55">·</span>
<span className="min-w-0 truncate">{ownerLabel}</span>
</span>
);
}
@@ -0,0 +1,49 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
extractContentFromMessage,
isHiddenFromUIMessage,
stripArtifactTag,
stripUploadedFilesTag,
} from "@/core/messages/utils";
const SUMMARY_MAX_LENGTH = 80;
export type QuestionItem = {
anchorId: string;
summary: string;
};
/** Preserve the raw message index for historical messages without an id. */
export function getQuestionAnchorId(message: Pick<Message, "id">, index: number): string {
return `message-${message.id ?? index}`;
}
function summarizeQuestion(raw: string): string {
let text = stripArtifactTag(stripUploadedFilesTag(raw));
text = text
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/(\*\*|__|~~)/g, "")
.replace(/\s+/g, " ")
.trim();
return text.length > SUMMARY_MAX_LENGTH ? `${text.slice(0, SUMMARY_MAX_LENGTH)}…` : text;
}
/** Index only real, visible questions, using the same visibility policy as chat. */
export function buildQuestionItems(messages: Message[]): QuestionItem[] {
const items: QuestionItem[] = [];
messages.forEach((message, index) => {
if (message.type !== "human" || isHiddenFromUIMessage(message)) return;
const summary = summarizeQuestion(extractContentFromMessage(message));
if (summary) items.push({ anchorId: getQuestionAnchorId(message, index), summary });
});
return items;
}
@@ -11,68 +11,21 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
extractContentFromMessage,
stripArtifactTag,
stripUploadedFilesTag,
} from "@/core/messages/utils";
import { cn } from "@/lib/utils";
import { buildQuestionItems, type QuestionItem } from "./question-navigator-data";
import { Tooltip } from "./tooltip";
export { getQuestionAnchorId } from "./question-navigator-data";
/** Width of the expanded floating panel, in px. */
const PANEL_WIDTH = 300;
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
const PANEL_GUTTER_MARGIN = 24;
/** Max characters kept in a question summary card. */
const SUMMARY_MAX_LENGTH = 80;
/** Questions needed before the navigator shows up at all. */
const MIN_QUESTIONS = 2;
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
/**
* Stable DOM anchor id for a message. Prefers the server-side message id and
* falls back to the render index so messages without ids still get a target
* (requirement: `message-${index}` style fallback).
*/
export function getQuestionAnchorId(
message: Pick<Message, "id">,
index: number,
): string {
return `message-${message.id ?? index}`;
}
/**
* Turn the raw markdown of a user question into a short plain-text summary:
* strips upload/artifact/page-context envelopes and common Markdown syntax,
* collapses whitespace, and truncates with an ellipsis.
*/
function summarizeQuestion(raw: string): string {
let text = stripArtifactTag(stripUploadedFilesTag(raw));
text = text
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/(\*\*|__|~~)/g, "")
.replace(/\s+/g, " ")
.trim();
if (text.length > SUMMARY_MAX_LENGTH) {
return `${text.slice(0, SUMMARY_MAX_LENGTH)}…`;
}
return text;
}
type QuestionItem = {
anchorId: string;
summary: string;
};
/** Walk up from a message anchor to the element that actually scrolls. */
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null;
@@ -128,16 +81,7 @@ export function QuestionNavigator({
const [sheetOpen, setSheetOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const questions = useMemo(() => {
const items: QuestionItem[] = [];
messages.forEach((message, index) => {
if (message.type !== "human") return;
const summary = summarizeQuestion(extractContentFromMessage(message));
if (!summary) return;
items.push({ anchorId: getQuestionAnchorId(message, index), summary });
});
return items;
}, [messages]);
const questions = useMemo(() => buildQuestionItems(messages), [messages]);
const visible = questions.length >= MIN_QUESTIONS;
@@ -47,9 +47,14 @@ import {
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -63,7 +68,9 @@ import {
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import {
isBootstrapThread,
isHiddenFromRecentChats,
@@ -75,9 +82,14 @@ import {
titleOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
import {
mergeRecentWorkspaceItems,
useRecentStrategyProjects,
} from "@/strategy-selection/recent-projects";
export function RecentChatList({
compact = false,
@@ -92,7 +104,8 @@ export function RecentChatList({
}) {
const { t } = useI18n();
const router = useRouter();
const pathname = usePathname();
const pathname = usePathname();
const { isMobile, setOpenMobile } = useSidebar();
const [internalCollapsed, setInternalCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
);
@@ -109,9 +122,10 @@ export function RecentChatList({
useParams<{
thread_id: string;
agent_id?: string;
}>();
const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread();
}>();
const { data: threads = [] } = useThreads();
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
@@ -134,15 +148,15 @@ export function RecentChatList({
});
if (threadIndex > -1) {
if (threads[threadIndex + 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname);
} else if (threads[threadIndex - 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname);
}
}
void router.push(nextThreadPath);
}
},
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads],
);
const handleRenameClick = useCallback(
@@ -195,15 +209,19 @@ export function RecentChatList({
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
const visibleThreads = sortThreadsPinnedFirst(
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
);
if (visibleThreads.length === 0) {
return null;
}
const shownThreads =
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
const visibleThreads = sortThreadsPinnedFirst(
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
);
const projectThreadNames = useProjectThreadNames(visibleThreads);
const recentItems = mergeRecentWorkspaceItems(
visibleThreads,
recentStrategyProjects,
);
if (recentItems.length === 0) {
return null;
}
const shownItems = limit != null ? recentItems.slice(0, limit) : recentItems;
return (
<>
<SidebarGroup
@@ -224,9 +242,9 @@ export function RecentChatList({
: t.sidebar.demoChats}
</span>
{compact && (
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
{visibleThreads.length}
</span>
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
{recentItems.length}
</span>
)}
</span>
{!compact && (
@@ -242,32 +260,84 @@ export function RecentChatList({
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{shownThreads.map((thread) => {
const href = listPathOfThread(thread);
{shownItems.map((item) => {
if (item.kind === "strategy-project") {
const { project } = item;
const href = strategyProjectWorkbenchPath(
"/page/strategy/qa/projects",
project.id,
);
return (
<SidebarMenuItem
key={`strategy-project:${project.id}`}
className="group/side-menu-item"
>
<SidebarMenuButton
isActive={href === pathname}
asChild
className={cn(
"h-auto min-h-11 rounded-lg px-3 py-1.5 text-[13px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)]",
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none",
compact &&
"min-h-11 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border",
)}
>
<Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={href}
onClick={() => {
if (isMobile) setOpenMobile(false);
}}
>
<MyProjectThreadIcon type="strategy" />
<span className="min-w-0 flex-1 flex flex-col items-start gap-0.5">
<span className="w-full truncate">
{desensitize(project.title)}
</span>
<MyProjectThreadBadge
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
projectName={project.title}
type="strategy"
/>
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
const { thread } = item;
const href = listPathOfThread(thread, pathname);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const isBoot = isBootstrapThread(thread);
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<SidebarMenuItem
key={thread.thread_id}
<SidebarMenuItem
key={`thread:${thread.thread_id}`}
className="group/side-menu-item"
>
<SidebarMenuButton
isActive={isActive}
asChild
className={
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none" +
(compact
? " h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border"
: "")
}
className={cn(
"h-7 rounded-lg px-3 text-[13px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)]",
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none",
compact &&
"h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border",
projectInfo && "h-auto min-h-11 py-1.5",
)}
>
<Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={href}
href={href}
onClick={() => { if (isMobile) setOpenMobile(false); }}
>
{isBoot ? (
<Bot
@@ -279,8 +349,10 @@ export function RecentChatList({
}
aria-label="智能体创建会话"
/>
) : (
compact && (
) : projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : (
compact && (
<MessageSquare
className={
"size-3.5 shrink-0 " +
@@ -294,9 +366,23 @@ export function RecentChatList({
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
<span
className={cn(
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span>
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -5,12 +5,15 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { apiFetch } from "@/core/api/fetch-client";
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import { clearYLogToken } from "@/core/log/y-log-token";
import { SettingsSection } from "./settings-section";
import { PasswordSettings } from "./password-settings";
function roleLabel(role?: string) {
if (role === "admin") return "管理员";
@@ -57,14 +60,15 @@ export function AccountSettingsPage() {
// Match the rest of the app's logout flow: blow away the cookie session
// by hitting the gateway logout endpoint and then forcing a full reload
// so any cached query state goes too.
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
void apiFetch(`${getBackendBaseURL()}/api/v1/auth/logout`, { method: "POST" })
.catch(() => undefined)
.finally(() => {
window.location.assign("/");
window.location.assign(`${window.location.pathname}#/login`);
});
};
return (
<div className="space-y-8">
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
<div className="flex items-center gap-3">
@@ -106,6 +110,8 @@ export function AccountSettingsPage() {
</div>
</div>
</SettingsSection>
<PasswordSettings key={auth.user_id} userId={auth.user_id} />
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,102 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { AUTH_STATE_CHANGED_EVENT, getStoredAuth } from "@/core/auth";
import {
getAuthenticationSettings,
saveAuthenticationSettings,
type AuthenticationSettings,
} from "@/core/auth/account-settings-api";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { SettingsSection } from "./settings-section";
export function AuthenticationSettingsPage({ onClose }: { onClose?: () => void }) {
const navigate = useNavigate();
const live = useLiveAccount();
const isAdmin = getStoredAuth()?.system_role === "admin";
const queryClient = useQueryClient();
const queryKey = ["authentication-settings", live.userId];
const query = useQuery({ queryKey, queryFn: getAuthenticationSettings, enabled: isAdmin });
const [draft, setDraft] = useState<AuthenticationSettings | null>(null);
const mutation = useMutation({ mutationFn: saveAuthenticationSettings });
const settings = draft ?? query.data;
if (!isAdmin) return null;
const update = (key: keyof AuthenticationSettings, value: boolean) => {
if (!settings) return;
const next = { ...settings, [key]: value };
if (key === "password_login_enabled" && !value) next.registration_enabled = false;
setDraft(next);
};
const save = async () => {
if (!settings || mutation.isPending) return;
try {
const saved = await mutation.mutateAsync(settings);
queryClient.setQueryData(queryKey, saved);
setDraft(null);
void queryClient.invalidateQueries({ queryKey: ["auth-options"] });
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
toast.success("登录与注册设置已保存,即时生效");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const invalid = settings && !settings.password_login_enabled && !settings.sso_enabled;
return (
<SettingsSection title="登录与注册" description="管理登录页面、自主注册与账号在线限制,保存后即时生效。">
{query.isPending ? (
<p className="text-muted-foreground text-sm">正在加载设置…</p>
) : query.isError ? (
<div role="alert" className="space-y-3 text-sm">
<p className="text-destructive">{query.error.message}</p>
<Button variant="outline" onClick={() => void query.refetch()}>重新加载</Button>
</div>
) : settings && (
<div className="space-y-5">
<div className="divide-y rounded-xl border px-5">
<SettingSwitch id="password-login-enabled" title="账号密码登录" description="默认关闭。开启后须登录才能进入系统。未设置个人密码的账号使用现有系统口令。" checked={settings.password_login_enabled} disabled={mutation.isPending} onChange={(value) => update("password_login_enabled", value)} />
<SettingSwitch id="registration-enabled" title="用户自主注册" description="允许用户在注册页创建自己的普通账号。需先开启账号密码登录。" checked={settings.registration_enabled} disabled={mutation.isPending || !settings.password_login_enabled} onChange={(value) => update("registration_enabled", value)} />
<SettingSwitch id="single-session-enabled" title="单点登录" description="默认关闭。开启后同一账号只允许一处在线,新登录会使其他登录会话自动退出。" checked={settings.single_session_enabled ?? false} disabled={mutation.isPending} onChange={(value) => update("single_session_enabled", value)} />
<SettingSwitch id="sso-enabled" title="外部系统免登" description="允许外部系统通过票据、令牌或口令链接登录,与单点登录的在线限制独立。" checked={settings.sso_enabled} disabled={mutation.isPending} onChange={(value) => update("sso_enabled", value)} />
</div>
<p className="text-muted-foreground text-sm">密码与登录口令共用。至少保留账号密码登录或外部系统免登中的一种;关闭自主注册不影响已有账号。</p>
<Button variant="outline" onClick={() => { onClose?.(); navigate("/page/strategy/admin/login-whitelist"); }}>管理用户密码与登录口令</Button>
{invalid && <p role="alert" className="text-destructive text-sm">请至少开启账号密码登录或外部系统免登。</p>}
<div className="flex justify-end">
<Button disabled={!draft || Boolean(invalid) || mutation.isPending} onClick={() => void save()}>{mutation.isPending ? "保存中…" : "保存设置"}</Button>
</div>
</div>
)}
</SettingsSection>
);
}
function SettingSwitch({ id, title, description, checked, disabled, onChange }: {
id: string;
title: string;
description: string;
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-5 py-5">
<div className="space-y-1">
<label htmlFor={id} className="cursor-pointer text-sm font-medium">{title}</label>
<p id={`${id}-description`} className="text-muted-foreground text-sm">{description}</p>
</div>
<Switch id={id} aria-describedby={`${id}-description`} checked={checked} disabled={disabled} onCheckedChange={onChange} />
</div>
);
}
@@ -0,0 +1,86 @@
"use client";
import { Loader2Icon, ShrinkIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useConversationContextSettings,
useUpdateConversationContextSettings,
} from "@/core/system-settings/hooks";
export function ContextWindowSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useConversationContextSettings();
const update = useUpdateConversationContextSettings();
const enabled = data?.recent_turn_window_enabled ?? false;
const recentTurns = data?.recent_turns ?? 3;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ recent_turn_window_enabled: checked });
toast.success(
checked
? `近 ${recentTurns} 轮上下文模式已开启`
: `近 ${recentTurns} 轮上下文模式已关闭`,
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<ShrinkIcon className="size-5" />
上下文管理
</h3>
<p className="text-muted-foreground text-sm">
控制长对话发给模型的历史范围。该模式默认关闭,只有管理员显式开启后才会生效。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">仅保留最近 {recentTurns} 轮对话</div>
<p className="text-muted-foreground text-sm">
开启后,主问答引擎的模型请求只携带最近 {recentTurns}
个真实用户回合(含当前轮)及其回复和工具结果,不再调用摘要模型。完整聊天记录仍会保存并显示;若这三轮本身仍超过模型上限,最终安全限制会继续裁剪超长内容。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label={`仅保留最近 ${recentTurns} 轮对话`}
/>
)}
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
无法读取当前上下文模式,本页暂不能修改;服务端会继续保持原设置。
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={isFetching}
onClick={() => void refetch()}
>
{isFetching ? "重新加载中…" : "重新加载"}
</Button>
</div>
)}
<p className="text-muted-foreground text-xs">
开启后优先于聊天框中的“上下文压缩”选项,并从下一次新问答或运行开始生效,无需重启服务。
</p>
</section>
);
}
@@ -0,0 +1,120 @@
"use client";
import { Loader2Icon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useOrdinaryKnowledgeRetrievalSettings,
useUpdateOrdinaryKnowledgeRetrievalSettings,
} from "@/core/system-settings/hooks";
export function KnowledgeRetrievalSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useOrdinaryKnowledgeRetrievalSettings();
const update = useUpdateOrdinaryKnowledgeRetrievalSettings();
const enabled = data?.smart_reasoning_enabled ?? false;
const advancedManagementEnabled =
data?.advanced_management_enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ smart_reasoning_enabled: checked });
toast.success(
checked ? "普通知识库智能检索已开启" : "普通知识库智能检索已关闭",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleAdvancedManagementChange = async (checked: boolean) => {
try {
await update.mutateAsync({ advanced_management_enabled: checked });
toast.success(
checked ? "知识库高级管理入口已开放" : "知识库高级管理入口已隐藏",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<SearchIcon className="size-5" />
知识检索
</h3>
<p className="text-muted-foreground text-sm">
控制普通知识库问答是否默认使用智能检索。该能力默认关闭,经典检索模式始终保留为候选。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">普通知识库智能推理检索</div>
<p className="text-muted-foreground text-sm">
开启后,新对话默认使用智能检索:先召回原文分片,再根据命中线索查阅并扩展
Wiki,把知识库资料作为回答背景。已有对话继续使用各自保存的模式,用户仍可在
输入框的检索模式菜单中切换到经典检索。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="普通知识库智能推理检索"
/>
)}
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">知识库高级管理入口</div>
<p className="text-muted-foreground text-sm">
默认隐藏检索测试、增量同步和知识库数据包导入导出。开启后按原有账号权限显示;
基础知识库管理和查看 Wiki 索引始终可用。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={advancedManagementEnabled}
disabled={isError || update.isPending}
onCheckedChange={handleAdvancedManagementChange}
aria-label="知识库高级管理入口"
/>
)}
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
无法读取当前知识检索设置,客户端按关闭处理,本页暂不能修改。
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={isFetching}
onClick={() => void refetch()}
>
{isFetching ? "重新加载中…" : "重新加载"}
</Button>
</div>
)}
<p className="text-muted-foreground text-xs">
用户只需直接提问,无需填写“先检索什么、再检索什么”的固定模板;系统不会向输入框
注入检索提示词。智能检索将检索资料与模型原有能力结合后回答,不强制生成参考文献编号。
关闭后不会删除历史对话;已保存为智能检索的线程需要切回经典模式后才能继续发送。
</p>
</section>
);
}
@@ -0,0 +1,67 @@
"use client";
import { BrainIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useMemoryRuntimeSettings,
useUpdateMemoryRuntimeSettings,
} from "@/core/system-settings/hooks";
export function MemoryRuntimeSettingsPage() {
const { data, isLoading, isError } = useMemoryRuntimeSettings();
const update = useUpdateMemoryRuntimeSettings();
const enabled = data?.enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(checked ? "问答记忆已开启" : "问答记忆已关闭");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<BrainIcon className="size-5" />
问答记忆
</h3>
<p className="text-muted-foreground text-sm">
全局控制问答是否读取和写入长期记忆。系统默认永久关闭,只有管理员在这里显式开启后才会生效。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">启用问答记忆</div>
<p className="text-muted-foreground text-sm">
关闭时不会加载记忆中间件、不会向提示词注入记忆,也不会在回答结束后调用模型提取记忆。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="启用问答记忆"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取记忆开关,系统将按关闭处理。
</p>
)}
<p className="text-muted-foreground text-xs">
修改对下一次发起的问答生效;已生成的历史记忆文件不会被自动删除。
</p>
</section>
);
}
@@ -0,0 +1,102 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getStoredAuth, setStoredAuth } from "@/core/auth";
import { changeAccountPassword, getAccountPasswordStatus } from "@/core/auth/account-settings-api";
import { SettingsSection } from "./settings-section";
export function PasswordSettings({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const status = useQuery({
queryKey: ["account-password", userId],
queryFn: getAccountPasswordStatus,
});
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const hasPassword = status.data?.has_password ?? true;
const requiresCurrentPassword = status.data?.requires_current_password ?? true;
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (pending || !status.data) return;
setError("");
if (newPassword !== confirmPassword) {
setError("两次输入的新密码不一致");
return;
}
if (newPassword.length < 8) {
setError("新密码至少需要 8 位,请避免常见弱密码");
return;
}
const auth = getStoredAuth();
if (!auth || auth.user_id !== userId) {
setError("登录账号已变化,请重新打开个人信息");
return;
}
setPending(true);
try {
const session = await changeAccountPassword(requiresCurrentPassword ? currentPassword : "", newPassword);
// Another tab may have changed the active account while the request was pending.
if (getStoredAuth()?.access_token !== auth.access_token) return;
if (session.access_token) {
setStoredAuth({
...auth,
access_token: session.access_token,
token_type: session.token_type ?? auth.token_type,
expires_in: session.expires_in ?? auth.expires_in,
needs_setup: false,
});
}
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
queryClient.setQueryData(["account-password", userId], { ...status.data, has_password: true, requires_current_password: true, uses_default_password: false });
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
toast.success("密码与登录口令已同步更新,其他登录会话已失效");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "密码修改失败");
} finally {
setPending(false);
}
};
return (
<SettingsSection title={hasPassword ? "修改密码" : "设置密码"} description="密码与登录口令共用,修改后原密码和原口令链接失效。">
{status.isPending ? <p className="text-muted-foreground text-sm">正在读取密码状态…</p> : status.isError ? (
<div role="alert" className="space-y-3 text-sm">
<p className="text-destructive">{status.error.message}</p>
<Button variant="outline" onClick={() => void status.refetch()}>重新加载</Button>
</div>
) : (
<form onSubmit={(event) => void submit(event)} className="space-y-4 rounded-xl border p-5">
{!hasPassword && <p className="text-muted-foreground text-sm">此账号尚未设置个人密码,当前使用系统口令。请先输入现有口令,再设置个人密码。</p>}
{requiresCurrentPassword && (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-current-password">当前密码 / 口令</label>
<Input id="account-current-password" type="password" autoComplete="current-password" required value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} disabled={pending} />
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-new-password">新密码</label>
<Input id="account-new-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} placeholder="至少 8 位,避免常见弱密码" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} disabled={pending} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-confirm-password">确认新密码</label>
<Input id="account-confirm-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} disabled={pending} />
</div>
{error && <p role="alert" className="text-destructive text-sm">{error}</p>}
<div className="flex justify-end"><Button type="submit" disabled={pending}>{pending ? "保存中…" : "保存密码"}</Button></div>
</form>
)}
</SettingsSection>
);
}
@@ -1,6 +1,19 @@
"use client";
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import {
BellIcon,
BrainIcon,
CpuIcon,
FileOutputIcon,
PaletteIcon,
SearchIcon,
ShieldCheckIcon,
ShrinkIcon,
SparklesIcon,
UploadCloudIcon,
UserIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -12,18 +25,29 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AuthenticationSettingsPage } from "@/components/workspace/settings/authentication-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
import { KnowledgeRetrievalSettingsPage } from "@/components/workspace/settings/knowledge-retrieval-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
import { WriteFileCompatibilitySettingsPage } from "@/components/workspace/settings/write-file-compatibility-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
type SettingsSection =
| "account"
| "authentication"
| "appearance"
| "conversation-context"
| "knowledge-retrieval"
| "memory"
| "fileWriting"
| "uploads"
| "tools"
| "encoder"
| "about";
@@ -35,6 +59,7 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
export function SettingsDialog(props: SettingsDialogProps) {
const { defaultSection = "appearance", ...dialogProps } = props;
const { t } = useI18n();
const live = useLiveAccount();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [activeSection, setActiveSection] =
useState<SettingsSection>(defaultSection);
@@ -61,15 +86,19 @@ export function SettingsDialog(props: SettingsDialogProps) {
// },
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
// 记忆已移至侧边栏入口 (/page/workspace/memory)
// {
// id: "memory",
// label: t.settings.sections.memory,
// icon: BrainIcon,
// },
...(isAdmin
? [
{ id: "conversation-context", label: "上下文管理", icon: ShrinkIcon },
{ id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon },
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
{ id: "authentication", label: "登录与注册", icon: ShieldCheckIcon },
]
: []),
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
: []),
{
id: "account",
@@ -129,12 +158,36 @@ export function SettingsDialog(props: SettingsDialogProps) {
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
<div className="min-w-0 space-y-8 p-6">
{activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
{activeSection === "authentication" && isAdmin && (
<AuthenticationSettingsPage
key={live.userId}
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "appearance" && (
<AppearanceSettingsPage
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "conversation-context" && isAdmin && (
<ContextWindowSettingsPage />
)}
{activeSection === "knowledge-retrieval" && isAdmin && (
<KnowledgeRetrievalSettingsPage />
)}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "fileWriting" && isAdmin && (
<WriteFileCompatibilitySettingsPage />
)}
{activeSection === "uploads" && isAdmin && (
<UploadSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "encoder" && isAdmin && (
<LocalEncoderSettingsCard />
)}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -19,6 +19,8 @@ import {
LockIcon,
PinIcon,
PlusIcon,
Power,
PowerOff,
RefreshCwIcon,
SparklesIcon,
TagsIcon,
@@ -70,11 +72,13 @@ import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { MINIMAL_SKILL_MD_EXAMPLE } from "@/core/skill-example";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteBuiltinSkill,
useDeleteCustomSkill,
import {
useDeleteBuiltinSkill,
useDeleteCustomSkill,
useDuplicateSkill,
useEnableSkill,
useGenerateMissingSkillNameZh,
useGenerateSkillNameZh,
useInstallSkillUpload,
@@ -87,13 +91,25 @@ import {
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import {
canToggleSkillAvailability,
isSkillOffline,
type SkillAvailabilityControl,
} from "@/core/skill-availability";
import {
usePublishSquares,
useSkillCompressionSettings,
useViewMode,
} from "@/core/system-settings/hooks";
import {
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
WX_RESTRICTED_TAB_NANJING_DB,
isWxRestrictedPlaceholderTab,
wxRestrictedDefaultSquareTabValue,
wxRestrictedNanjingTabValue,
} from "@/core/system-settings/wx-type-filter";
import type { Skill } from "@/core/skills/type";
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
import { formatDateTime } from "@/core/utils/datetime";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
@@ -102,21 +118,28 @@ import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({
onClose,
embedded = false,
}: {
onClose?: () => void;
embedded?: boolean;
} = {}) {
const { t } = useI18n();
if (embedded) return <SkillSettingsList onClose={onClose} />;
return (
export function SkillSettingsPage({
onClose,
embedded = false,
restrictWxTypes = false,
}: {
onClose?: () => void;
embedded?: boolean;
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
restrictWxTypes?: boolean;
} = {}) {
const { t } = useI18n();
if (embedded) {
return (
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
);
}
return (
<SettingsSection
title={t.settings.skills.title}
description={t.settings.skills.description}
>
<SkillSettingsList onClose={onClose} />
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
</SettingsSection>
);
}
@@ -165,7 +188,13 @@ const rescanErrorToastDarkOptions = {
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
function SkillSettingsList({
onClose,
restrictWxTypes = false,
}: {
onClose?: () => void;
restrictWxTypes?: boolean;
}) {
const { t } = useI18n();
const { theme } = useTheme();
const router = useRouter();
@@ -187,6 +216,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
// Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default).
const squareTabs = publishSquares?.squares ?? [];
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
publishSquares?.default_square_id,
);
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
const reconcileMutation = useReconcileCustomSkills();
const fillNamesMutation = useGenerateMissingSkillNameZh();
// Optional model override for the admin "一键补充中文名" action (null = default).
@@ -253,6 +286,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const isSquareTab = filter !== "mine";
const scopedSkills = filter === "mine" ? activeMine : activeAll;
const filteredSkills = useMemo(() => {
if (restrictWxTypes && isWxRestrictedPlaceholderTab(filter)) {
return [];
}
let list = publisher
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
: scopedSkills;
@@ -266,7 +302,14 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
});
}
return list;
}, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
}, [
scopedSkills,
publisher,
filter,
isSquareTab,
publishSquares?.default_square_id,
restrictWxTypes,
]);
const orderedSkills = useMemo(
() =>
filteredSkills
@@ -296,6 +339,30 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the page never points at a dead tab.
useEffect(() => {
if (restrictWxTypes) {
if (
filter === WX_RESTRICTED_TAB_NANJING_DB &&
nanjingTabValue !== WX_RESTRICTED_TAB_NANJING_DB
) {
setFilter(nanjingTabValue);
return;
}
if (
filter === WX_RESTRICTED_TAB_DEFAULT_SQUARE &&
defaultSquareTabValue !== WX_RESTRICTED_TAB_DEFAULT_SQUARE
) {
setFilter(defaultSquareTabValue);
return;
}
if (
filter !== "mine" &&
filter !== defaultSquareTabValue &&
filter !== nanjingTabValue
) {
setFilter("mine");
}
return;
}
if (
filter !== "mine" &&
squareTabs.length > 0 &&
@@ -303,7 +370,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
) {
setFilter("mine");
}
}, [filter, squareTabs]);
}, [filter, squareTabs, restrictWxTypes, defaultSquareTabValue, nanjingTabValue]);
const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page],
@@ -354,7 +421,6 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
theme === "dark" ? rescanErrorToastDarkOptions : undefined,
);
}
};
return (
<div className="flex w-full flex-col gap-4">
@@ -362,12 +428,22 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line" className="flex-wrap">
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
{squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
))}
{restrictWxTypes ? (
<>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
<TabsTrigger value={defaultSquareTabValue}>默认广场</TabsTrigger>
<TabsTrigger value={nanjingTabValue}>南京-db</TabsTrigger>
</>
) : (
<>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
{squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
))}
</>
)}
</TabsList>
</Tabs>
<Button
@@ -479,11 +555,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
</Button>
</div>
</header>
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<div className="flex items-center gap-2">
<ViewModeToggle value={viewMode} onChange={setViewMode} />
@@ -506,16 +582,16 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
</div>
}
/>
{!isLoading && !error && (
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
共 {orderedSkills.length} 个技能
</div>
)}
{publisher && (
<div className="flex items-center gap-2">
</div>
}
/>
{!isLoading && !error && (
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
共 {orderedSkills.length} 个技能
</div>
)}
{publisher && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span>
<button
type="button"
@@ -547,9 +623,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
layout={viewMode === "card" ? "card" : "row"}
currentUserId={currentUserId}
isAdmin={isAdmin}
availabilityControl="owner"
layout={viewMode === "card" ? "card" : "row"}
onPublisherClick={setPublisher}
/>
))}
@@ -604,23 +681,26 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
}
export function SkillRow({
skill,
currentUserId,
isAdmin,
skill,
currentUserId,
isAdmin,
availabilityControl,
layout = "row",
onPublisherClick,
knowledgeBindings = [],
knowledgeBindings = [],
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
currentUserId: string | null;
isAdmin: boolean;
/** Which permission boundary exposes the global online/offline control. */
availabilityControl?: SkillAvailabilityControl;
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
layout?: "row" | "card";
/** Click the 发布者 line to filter the list to that publisher. Receives the
* owner_user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void;
/** Admin list can inject its single batched binding query here (never N+1). */
knowledgeBindings?: SkillKnowledgeBinding[];
/** Admin list can inject its single batched binding query here (never N+1). */
knowledgeBindings?: SkillKnowledgeBinding[];
}) {
const isCard = layout === "card";
const { t } = useI18n();
@@ -629,6 +709,7 @@ export function SkillRow({
const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured();
const setAlwaysOn = useSetSkillAlwaysOn();
const setEnabled = useEnableSkill();
const { data: compression } = useSkillCompressionSettings();
const deleteCustom = useDeleteCustomSkill();
const deleteBuiltin = useDeleteBuiltinSkill();
@@ -639,6 +720,7 @@ export function SkillRow({
const [duplicateOpen, setDuplicateOpen] = useState(false);
const [duplicateName, setDuplicateName] = useState("");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const [confirmOfflineOpen, setConfirmOfflineOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
@@ -647,12 +729,33 @@ export function SkillRow({
const isOwned =
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isFromOthers = isCustom && !isBuiltin && !isOwned;
// Admin can manage custom skills from any owner; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
const canDelete = isAdmin || isOwned;
// Admin can manage custom skills from any owner; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
const canDelete = isAdmin || isOwned;
const canTogglePublish = canManage && !isBuiltin;
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
const canSetAlwaysOn = isAdmin || isOwned;
const canToggleAvailability = canToggleSkillAvailability({
skill,
currentUserId,
isAdmin,
control: availabilityControl,
});
const isOffline = isSkillOffline(skill);
async function handleSetEnabled(enabled: boolean) {
try {
await setEnabled.mutateAsync({ skillName: skill.name, enabled });
toast.success(
enabled
? t.settings.skills.onlineSuccess
: t.settings.skills.offlineSuccess,
);
setConfirmOfflineOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleAlwaysOn(next: boolean) {
try {
@@ -700,15 +803,15 @@ export function SkillRow({
}
}
async function handleDelete() {
try {
if (isBuiltin) {
await deleteBuiltin.mutateAsync(skill.name);
} else {
await deleteCustom.mutateAsync(skill.name);
}
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
async function handleDelete() {
try {
if (isBuiltin) {
await deleteBuiltin.mutateAsync(skill.name);
} else {
await deleteCustom.mutateAsync(skill.name);
}
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
@@ -883,6 +986,16 @@ export function SkillRow({
{knowledgeBindings.length > 3 && (
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
)}
{isOffline && (
<Badge
variant="outline"
className="border-red-500/35 bg-red-500/5 text-red-700 dark:text-red-300 text-xs"
title={t.settings.skills.badgeOfflineTitle}
>
<PowerOff className="size-3" />
{t.settings.skills.badgeOffline}
</Badge>
)}
</div>
);
@@ -941,6 +1054,43 @@ export function SkillRow({
const actionButtons = (
<>
{canToggleAvailability && (
<Button
size="sm"
variant={isOffline ? "default" : "outline"}
className={cn(
"h-8 shrink-0 gap-1.5 px-2.5",
!isOffline && "text-destructive hover:text-destructive",
)}
disabled={
setEnabled.isPending ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
onClick={() => {
if (isOffline) {
void handleSetEnabled(true);
} else {
setConfirmOfflineOpen(true);
}
}}
title={
isOffline
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction
}
>
{setEnabled.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : isOffline ? (
<Power className="size-3.5" />
) : (
<PowerOff className="size-3.5" />
)}
{isOffline
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction}
</Button>
)}
<Button
size="icon"
variant="ghost"
@@ -950,7 +1100,7 @@ export function SkillRow({
>
<TagsIcon className="size-3.5" />
</Button>
{canManage && (
{canManage && (
<Button
size="icon"
variant="ghost"
@@ -971,12 +1121,12 @@ export function SkillRow({
/>
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
skill.pinned && "text-emerald-600 hover:text-emerald-700",
)}
disabled={setPinned.isPending}
@@ -1043,11 +1193,11 @@ export function SkillRow({
>
<CopyIcon className="size-3.5" />
</Button>
{canDelete && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
{canDelete && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
@@ -1060,7 +1210,12 @@ export function SkillRow({
return (
<>
{isCard ? (
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div
className={cn(
"flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
)}
>
<div
className="min-w-0 flex-1 cursor-pointer"
onClick={() => setDetailOpen(true)}
@@ -1076,7 +1231,13 @@ export function SkillRow({
</div>
</div>
) : (
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
<Item
className={cn(
"w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
)}
variant="outline"
>
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
@@ -1141,7 +1302,7 @@ export function SkillRow({
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
>
{t.common.cancel}
</Button>
@@ -1153,6 +1314,37 @@ export function SkillRow({
{deleteCustom.isPending || deleteBuiltin.isPending
? t.common.loading
: t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={confirmOfflineOpen} onOpenChange={setConfirmOfflineOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.settings.skills.offlineConfirmTitle}</DialogTitle>
<DialogDescription>
{t.settings.skills.offlineConfirmDescription.replace(
"{name}",
desensitize(skill.name),
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmOfflineOpen(false)}
disabled={setEnabled.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleSetEnabled(false)}
disabled={setEnabled.isPending}
>
{setEnabled.isPending
? t.common.loading
: t.settings.skills.offlineAction}
</Button>
</DialogFooter>
</DialogContent>
@@ -1226,24 +1418,24 @@ function InstallSkillDialog({
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
const setNameZhMutation = useSetSkillNameZh();
const generateNameZhMutation = useGenerateSkillNameZh();
const validateFile = useCallback(
async (selected: File, category: "custom" | "public") => {
setValidation(null);
setValidationError(null);
try {
const result = await validateMutation.mutateAsync({
file: selected,
targetCategory: category,
});
setValidation(result);
} catch (err) {
setValidationError(err instanceof Error ? err.message : String(err));
}
},
[validateMutation],
);
const generateNameZhMutation = useGenerateSkillNameZh();
const validateFile = useCallback(
async (selected: File, category: "custom" | "public") => {
setValidation(null);
setValidationError(null);
try {
const result = await validateMutation.mutateAsync({
file: selected,
targetCategory: category,
});
setValidation(result);
} catch (err) {
setValidationError(err instanceof Error ? err.message : String(err));
}
},
[validateMutation],
);
const reset = useCallback(() => {
setFile(null);
@@ -1282,12 +1474,12 @@ function InstallSkillDialog({
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
toast.error(dialog.rejectInvalidExt);
return;
}
setFile(selected);
await validateFile(selected, targetCategory);
},
[dialog.rejectInvalidExt, targetCategory, validateFile],
);
}
setFile(selected);
await validateFile(selected, targetCategory);
},
[dialog.rejectInvalidExt, targetCategory, validateFile],
);
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
@@ -1364,29 +1556,34 @@ function InstallSkillDialog({
}
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && validation.can_install_as_is;
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
const customConflictMessage = !validation
? ""
: otherCustomConflict
? dialog.conflictOwnedByOther
.replace("{name}", validation.skill_name)
.replace(
"{owner}",
validation.owner_username ?? validation.owner_user_id ?? "—",
)
: validation.owned_by_me
? (ownCustomCanOverwrite
? dialog.conflictOwnedByMe
: dialog.conflictOwnedByMeWrongTarget
).replace("{name}", validation.skill_name)
: dialog.conflictCustom.replace("{name}", validation.skill_name);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && validation.can_install_as_is;
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
// On-disk leftover with no ownership record: replaceable by whoever uploads it.
const unclaimedCustomConflict =
!!isCustomConflict && !validation?.owned_by_me && !validation?.owned_by_other && canOverwrite;
const customConflictMessage = !validation
? ""
: otherCustomConflict
? dialog.conflictOwnedByOther
.replace("{name}", validation.skill_name)
.replace(
"{owner}",
validation.owner_username ?? validation.owner_user_id ?? "—",
)
: validation.owned_by_me
? (ownCustomCanOverwrite
? dialog.conflictOwnedByMe
: dialog.conflictOwnedByMeWrongTarget
).replace("{name}", validation.skill_name)
: unclaimedCustomConflict
? dialog.conflictUnclaimed.replace("{name}", validation.skill_name)
: dialog.conflictCustom.replace("{name}", validation.skill_name);
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
@@ -1496,6 +1693,16 @@ function InstallSkillDialog({
/>
</div>
<div className="rounded-md border bg-muted/30 p-3">
<div className="text-sm font-medium">{dialog.exampleTitle}</div>
<p className="text-muted-foreground mt-1 text-xs">
{dialog.exampleHint}
</p>
<pre className="mt-2 overflow-x-auto rounded bg-background px-3 py-2 text-xs leading-5">
<code>{MINIMAL_SKILL_MD_EXAMPLE}</code>
</pre>
</div>
{isAdmin && (
<div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
@@ -1505,12 +1712,12 @@ function InstallSkillDialog({
<input
type="radio"
name="targetCategory"
value={cat}
checked={targetCategory === cat}
onChange={() => {
setTargetCategory(cat);
if (file) void validateFile(file, cat);
}}
value={cat}
checked={targetCategory === cat}
onChange={() => {
setTargetCategory(cat);
if (file) void validateFile(file, cat);
}}
className="accent-primary"
/>
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
@@ -1568,18 +1775,18 @@ function InstallSkillDialog({
</ul>
</Field>
)}
{isCustomConflict && (
<div
className={cn(
"mt-1 flex items-start gap-2 text-xs",
ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
)}
>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{desensitize(customConflictMessage)}
</span>
</div>
{isCustomConflict && (
<div
className={cn(
"mt-1 flex items-start gap-2 text-xs",
ownCustomCanOverwrite || unclaimedCustomConflict ? "text-amber-600" : "text-destructive",
)}
>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{desensitize(customConflictMessage)}
</span>
</div>
)}
{isPublicConflict && (
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
@@ -0,0 +1,346 @@
"use client";
import {
Loader2Icon,
RotateCcwIcon,
SaveIcon,
UploadCloudIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DEFAULT_UPLOAD_POLICY,
MEBIBYTE,
normalizeUploadExtension,
type UploadPolicy,
type UploadPolicyGroupKey,
useResetUploadPolicy,
useUpdateUploadPolicy,
useUploadPolicy,
} from "@/core/uploads";
type UploadPolicyDraft = {
maxFiles: string;
maxTotalMiB: string;
groups: Record<
UploadPolicyGroupKey,
{ maxMiB: string; extensions: string }
>;
thresholdMiB: string;
maxResultChars: string;
hardMaxResultChars: string;
};
const GROUP_META: Array<{
key: UploadPolicyGroupKey;
label: string;
description: string;
}> = [
{ key: "document", label: "文档", description: "Word、PPT、TXT、PDF 等" },
{ key: "image", label: "图片", description: "JPG、PNG 等" },
{ key: "spreadsheet", label: "表格", description: "Excel 等" },
];
function toDraft(policy: UploadPolicy): UploadPolicyDraft {
return {
maxFiles: String(policy.max_files_per_message),
maxTotalMiB: String(policy.max_total_bytes / MEBIBYTE),
groups: {
document: {
maxMiB: String(policy.groups.document.max_bytes / MEBIBYTE),
extensions: policy.groups.document.extensions.join(", "),
},
image: {
maxMiB: String(policy.groups.image.max_bytes / MEBIBYTE),
extensions: policy.groups.image.extensions.join(", "),
},
spreadsheet: {
maxMiB: String(policy.groups.spreadsheet.max_bytes / MEBIBYTE),
extensions: policy.groups.spreadsheet.extensions.join(", "),
},
},
thresholdMiB: String(policy.large_file.threshold_bytes / MEBIBYTE),
maxResultChars: String(policy.large_file.max_result_chars),
hardMaxResultChars: String(policy.large_file.hard_max_result_chars),
};
}
function positiveNumber(value: string, label: string): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${label}必须大于 0`);
}
return parsed;
}
function extensionsOf(value: string, label: string): string[] {
const extensions = Array.from(
new Set(
value
.split(/[,,\s]+/)
.map(normalizeUploadExtension)
.filter(Boolean),
),
);
if (extensions.length === 0) throw new Error(`${label}至少填写一个扩展名`);
return extensions;
}
function fromDraft(draft: UploadPolicyDraft): UploadPolicy {
const maxResultChars = Math.floor(
positiveNumber(draft.maxResultChars, "常规输出上限"),
);
const hardMaxResultChars = Math.floor(
positiveNumber(draft.hardMaxResultChars, "硬输出上限"),
);
if (maxResultChars > hardMaxResultChars) {
throw new Error("常规输出上限不能大于硬输出上限");
}
const groups = {} as UploadPolicy["groups"];
for (const { key, label } of GROUP_META) {
groups[key] = {
max_bytes: Math.floor(
positiveNumber(draft.groups[key].maxMiB, `${label}单文件上限`) *
MEBIBYTE,
),
extensions: extensionsOf(draft.groups[key].extensions, label),
};
}
return {
max_files_per_message: Math.floor(
positiveNumber(draft.maxFiles, "单条消息文件数"),
),
max_total_bytes: Math.floor(
positiveNumber(draft.maxTotalMiB, "附件总大小") * MEBIBYTE,
),
groups,
large_file: {
threshold_bytes: Math.floor(
positiveNumber(draft.thresholdMiB, "大文件阈值") * MEBIBYTE,
),
max_result_chars: maxResultChars,
hard_max_result_chars: hardMaxResultChars,
},
};
}
export function UploadSettingsPage() {
const { data, isLoading } = useUploadPolicy();
const update = useUpdateUploadPolicy();
const reset = useResetUploadPolicy();
const [draft, setDraft] = useState<UploadPolicyDraft>(() =>
toDraft(DEFAULT_UPLOAD_POLICY),
);
useEffect(() => {
if (data) setDraft(toDraft(data));
}, [data]);
const setGroup = (
key: UploadPolicyGroupKey,
patch: Partial<UploadPolicyDraft["groups"][UploadPolicyGroupKey]>,
) => {
setDraft((current) => ({
...current,
groups: {
...current.groups,
[key]: { ...current.groups[key], ...patch },
},
}));
};
const save = async () => {
try {
const saved = await update.mutateAsync(fromDraft(draft));
setDraft(toDraft(saved));
toast.success("上传设置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存上传设置失败");
}
};
const restoreDefaults = async () => {
if (!window.confirm("确定恢复系统默认上传策略吗?当前自定义设置会被覆盖。")) {
return;
}
try {
const saved = await reset.mutateAsync();
setDraft(toDraft(saved));
toast.success("已恢复默认上传设置");
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复默认上传设置失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<UploadCloudIcon className="size-5" />
上传设置
</h3>
<p className="text-muted-foreground text-sm">
控制聊天附件的数量、格式和大小,并限制大文件读取结果,防止文件内容一次性占满模型上下文。
</p>
</div>
<div className="grid gap-4 rounded-lg border p-4 sm:grid-cols-2">
<NumberField
id="upload-max-files"
label="单条消息最多文件数"
value={draft.maxFiles}
onChange={(maxFiles) => setDraft((value) => ({ ...value, maxFiles }))}
step="1"
/>
<NumberField
id="upload-max-total"
label="单条消息附件总上限(MiB)"
value={draft.maxTotalMiB}
onChange={(maxTotalMiB) =>
setDraft((value) => ({ ...value, maxTotalMiB }))
}
/>
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold">文件分组</h4>
{GROUP_META.map(({ key, label, description }) => (
<div
key={key}
className="grid gap-4 rounded-lg border p-4 sm:grid-cols-[180px_minmax(0,1fr)]"
>
<NumberField
id={`upload-${key}-size`}
label={`${label}单文件上限(MiB)`}
value={draft.groups[key].maxMiB}
onChange={(maxMiB) => setGroup(key, { maxMiB })}
/>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor={`upload-${key}-extensions`}
>
{label}扩展名
</label>
<Input
id={`upload-${key}-extensions`}
value={draft.groups[key].extensions}
onChange={(event) =>
setGroup(key, { extensions: event.currentTarget.value })
}
placeholder=".doc, .docx"
/>
<p className="text-muted-foreground text-xs">
{description};用逗号或空格分隔,保存时自动补全小数点。
</p>
</div>
</div>
))}
</div>
<div className="space-y-3 rounded-lg border p-4">
<div>
<h4 className="text-sm font-semibold">大文件读取保护</h4>
<p className="text-muted-foreground mt-1 text-xs">
达到阈值后使用头部、中部、尾部采样;输出始终受字符预算约束,并在界面中标明截断或采样。
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<NumberField
id="upload-large-threshold"
label="大文件阈值(MiB)"
value={draft.thresholdMiB}
onChange={(thresholdMiB) =>
setDraft((value) => ({ ...value, thresholdMiB }))
}
/>
<NumberField
id="upload-result-chars"
label="常规输出上限(字符)"
value={draft.maxResultChars}
onChange={(maxResultChars) =>
setDraft((value) => ({ ...value, maxResultChars }))
}
step="100"
/>
<NumberField
id="upload-hard-result-chars"
label="硬输出上限(字符)"
value={draft.hardMaxResultChars}
onChange={(hardMaxResultChars) =>
setDraft((value) => ({ ...value, hardMaxResultChars }))
}
step="100"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2">
{isLoading && (
<span className="text-muted-foreground flex items-center gap-1 text-xs">
<Loader2Icon className="size-3.5 animate-spin" /> 正在读取配置
</span>
)}
<Button
type="button"
variant="outline"
onClick={() => void restoreDefaults()}
disabled={update.isPending || reset.isPending}
>
{reset.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<RotateCcwIcon className="size-4" />
)}
恢复默认
</Button>
<Button
onClick={() => void save()}
disabled={update.isPending || reset.isPending}
>
{update.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SaveIcon className="size-4" />
)}
保存
</Button>
</div>
</section>
);
}
function NumberField({
id,
label,
value,
onChange,
step = "0.1",
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
step?: string;
}) {
return (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={id}>
{label}
</label>
<Input
id={id}
type="number"
min="0.01"
step={step}
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
/>
</div>
);
}
@@ -0,0 +1,70 @@
"use client";
import { FileOutputIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useUpdateWriteFileCompatibilitySettings,
useWriteFileCompatibilitySettings,
} from "@/core/system-settings/hooks";
export function WriteFileCompatibilitySettingsPage() {
const { data, isLoading, isError } = useWriteFileCompatibilitySettings();
const update = useUpdateWriteFileCompatibilitySettings();
const enabled = data?.enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(checked ? "文件流式兼容已开启" : "文件流式兼容已关闭");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<FileOutputIcon className="size-5" />
文件流式兼容
</h3>
<p className="text-muted-foreground text-sm">
临时兼容会缓存长工具参数的内网模型网关。开启后,所有兼容模型仍调用原有
write_file 工具,但文件正文改由普通文本流生成,再投影为标准 write_file
流式事件。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">所有模型启用兼容写入</div>
<p className="text-muted-foreground text-sm">
适用于当前配置的 OpenAI-compatible Chat Completions
模型。关闭后恢复各模型原有写入方式。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="所有模型启用文件流式兼容"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取文件流式兼容开关,系统将按关闭处理。
</p>
)}
<p className="text-muted-foreground text-xs">
保存后无需重启后台,从下一次发起的问答开始生效;正在生成中的回答不会中途切换。
</p>
</section>
);
}
@@ -2,14 +2,17 @@ import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } fr
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import {
resolveWeKnoraSourceTarget,
type WeKnoraSourceTarget,
} from "@/core/llmwiki/source-links";
import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { reasoningPlugins } from "@/core/streamdown";
@@ -56,6 +59,9 @@ export function SourceCard({
}) {
const [relatedOpen, setRelatedOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(
null,
);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const previewTarget = resolveWeKnoraSourceTarget({
@@ -65,7 +71,13 @@ export function SourceCard({
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
const isWikiIndexSource =
source.type === "llmwiki_wiki_index_intro" ||
source.raw?.type === "llmwiki_wiki_index_intro";
const isWikiSource =
Boolean(previewTarget?.wikiSlug) ||
source.type === "llmwiki_wiki" ||
source.type === "llmwiki_wiki_page";
const matchedHeading = isWikiSource
? String(
(source.raw?.matched_heading as string | undefined) ||
@@ -78,13 +90,17 @@ export function SourceCard({
const summary = isWikiSource && typeof source.raw?.summary === "string"
? source.raw.summary
: "";
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
const score =
typeof source.score === "number"
? source.score <= 1
? `${Math.round(source.score * 100)}%`
: String(source.score)
: null;
const snippet = plainTextReferencePreview(content || summary).slice(0, 280);
const scoreSemantics = typeof source.raw?.rank_score_semantics === "string"
? source.raw.rank_score_semantics
: "";
const scoreMeta = typeof source.score !== "number"
? null
: scoreSemantics === "rrf"
? `融合排序分 ${source.score.toFixed(4)}`
: scoreSemantics === "provider_relevance"
? `上游通道分 ${source.score.toFixed(4)}`
: `相关度 ${source.score <= 1 ? `${Math.round(source.score * 100)}%` : String(source.score)}`;
const meta = [
source.source,
isWikiSource ? "检索对象:Wiki 文档" : null,
@@ -92,7 +108,7 @@ export function SourceCard({
source.author ? `作者 ${source.author}` : null,
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
source.time,
score ? `相关度 ${score}` : null,
scoreMeta,
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = isCitation;
@@ -151,7 +167,7 @@ export function SourceCard({
</p>
)}
</div>
{(source.url || isWikiSource) && (
{(source.url || isWikiSource || isWikiIndexSource) && (
previewTarget ? (
<span className={cn(
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
@@ -162,30 +178,47 @@ export function SourceCard({
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
{isWikiSource ? "Wiki 文档" : "知识库文档"}
</span>
) : isWikiIndexSource ? (
<span className="mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md bg-emerald-100 px-1.5 py-1 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
<BookOpenTextIcon className="size-3.5" />
Wiki 索引
</span>
) : (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
)
)}
</div>
{previewTarget && (
{(previewTarget || isWikiIndexSource) && (
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
<span>
{isWikiIndexSource
? "点击卡片查看完整 Wiki 索引"
: isWikiSource
? "点击卡片查看完整 Wiki"
: "点击卡片查看详情"}
</span>
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</div>
)}
</div>
{previewTarget && (
{(previewTarget || linkedTarget) && (
<WeKnoraSourceDrawer
open={detailOpen}
target={previewTarget}
target={linkedTarget ?? previewTarget}
title={source.title}
quote={content}
onOpenChange={setDetailOpen}
onOpenChange={(open) => {
setDetailOpen(open);
if (!open) setLinkedTarget(null);
}}
/>
)}
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
<DialogHeader>
{!previewTarget && <Sheet open={relatedOpen} onOpenChange={setRelatedOpen}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
@@ -194,16 +227,19 @@ export function SourceCard({
{source.index}
</span>
)}
<DialogTitle className="text-base leading-relaxed">
<SheetTitle className="text-base leading-relaxed">
{source.title}
</DialogTitle>
</SheetTitle>
</div>
{meta.length > 0 && (
<DialogDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
<SheetDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
{meta.map((item, index) => (
<span key={`${item}-${index}`}>{item}</span>
))}
</DialogDescription>
</SheetDescription>
)}
{meta.length === 0 && (
<SheetDescription className="sr-only">参考文献详情</SheetDescription>
)}
</div>
{source.url && !previewTarget && (
@@ -219,33 +255,51 @@ export function SourceCard({
</Button>
)}
</div>
</DialogHeader>
{content ? (
<div className="max-h-[62vh] overflow-y-auto rounded-xl border bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
{isWikiSource && (
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<BookOpenTextIcon className="size-4" />
本次检索命中的 Wiki 文档内容
</div>
)}
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
<MarkdownContent
className="max-w-none"
content={content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</article>
</div>
) : null}
{source.url && !previewTarget && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{source.url}
</div>
)}
</DialogContent>
</Dialog>}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
{content ? (
<>
{isWikiSource && (
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<BookOpenTextIcon className="size-4" />
本次检索命中的 Wiki 文档内容
</div>
)}
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
<MarkdownContent
className="max-w-none"
content={content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
linkClassName={(href) =>
href.startsWith("/weknora-source-preview?")
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
: undefined
}
onLinkClick={(event, href) => {
const target = resolveWeKnoraSourceTarget({ url: href });
if (!target) return;
event.preventDefault();
setLinkedTarget(target);
setDetailOpen(true);
}}
/>
</article>
</>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的引用内容
</div>
)}
{source.url && !previewTarget && (
<div className="text-muted-foreground mt-4 border-t pt-3 text-xs break-all">
{source.url}
</div>
)}
</div>
</SheetContent>
</Sheet>}
</>
);
}
@@ -27,7 +27,7 @@ const PHASE_LABELS: Record<string, string> = {
writing_sources: "上传知识资料",
generating_wiki: "分析并生成分类 Wiki",
vectorizing_wiki: "Wiki 向量化",
syncing_assistant: "同步知识梳理总库",
syncing_assistant: "同步检索索引",
activating: "激活新版本",
completed: "完成",
};
@@ -205,28 +205,28 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
/>
<Database className="size-4 text-muted-foreground" />
<span className="text-sm">{target.target_name}</span>
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
<Badge variant="outline" className="ml-auto text-[10px]">普通 Wiki 库</Badge>
</label>
);
})}
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
<p className="p-3 text-xs text-destructive">
读取 WeKnora 普通知识库失败:
读取普通知识库失败:
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
</p>
)}
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
<p className="p-3 text-xs text-muted-foreground">
{runtime.data?.provider !== "weknora"
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
? "当前未配置普通知识库地址;请先配置知识服务地址,再归纳技能内容。"
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
</p>
)}
</div>
</div>
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及所需索引同步均在后台完成。
</div>
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
@@ -226,7 +226,9 @@ export function SkillDetailDialog({
style={{
width: "min(48rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(760px, calc(100dvh - 2rem))",
minHeight: "min(420px, calc(100vh - 2rem))",
height: "min(760px, calc(100vh - 2rem))",
maxHeight: "min(760px, calc(100vh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
@@ -149,7 +149,9 @@ export function SkillFilePreviewDialog({
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
minHeight: "min(420px, calc(100vh - 2rem))",
height: "min(720px, calc(100vh - 2rem))",
maxHeight: "min(720px, calc(100vh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3">
@@ -8,21 +8,31 @@ export interface TagColorDef {
chip: string;
/** A solid swatch used in the colour picker. */
swatch: string;
/** Real border hex — Tailwind `border-*-200` is often not generated, so `* { border-color: var(--border) }` greys every chip. */
border: string;
borderDark: string;
}
export const TAG_COLORS: TagColorDef[] = [
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400" },
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500" },
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500" },
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500" },
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500" },
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500" },
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500" },
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500" },
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500" },
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500" },
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400", border: "#d1d5db", borderDark: "#4b5563" },
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500", border: "#fca5a5", borderDark: "#7f1d1d" },
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500", border: "#fdba74", borderDark: "#7c2d12" },
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500", border: "#fcd34d", borderDark: "#78350f" },
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500", border: "#86efac", borderDark: "#14532d" },
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500", border: "#5eead4", borderDark: "#134e4a" },
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500", border: "#93c5fd", borderDark: "#1e3a8a" },
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500", border: "#a5b4fc", borderDark: "#312e81" },
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500", border: "#d8b4fe", borderDark: "#581c87" },
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500", border: "#f9a8d4", borderDark: "#831843" },
];
export function tagBorderStyle(color: TagColorDef) {
return {
"--lam-tag-border": color.border,
"--lam-tag-border-dark": color.borderDark,
} as Record<string, string>;
}
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
export function resolveTagColor(name: string | undefined | null): TagColorDef {
@@ -0,0 +1,214 @@
import { useEffect, useMemo, useRef, useState } from "react";
import * as maplibregl from "maplibre-gl";
import type { StyleSpecification } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
import type { MapSpec } from "./visualization-spec";
import { VisualizationCard } from "./visualization-card";
function createStyle(): StyleSpecification {
return {
version: 8,
sources: {
"google-raster": {
type: "raster",
tiles: [getMapTileUrl()],
tileSize: 256,
attribution: getMapTileAttribution(),
},
},
layers: [{ id: "google-raster", type: "raster", source: "google-raster" }],
};
}
function allCoordinates(spec: MapSpec): Array<[number, number]> {
return [
...spec.markers.map((marker) => marker.coordinate),
...spec.lines.flatMap((line) => line.coordinates),
...spec.polygons.flatMap((polygon) => polygon.coordinates),
];
}
function defaultCenter(spec: MapSpec): [number, number] {
if (spec.center) return spec.center;
const coordinates = allCoordinates(spec);
if (coordinates.length === 0) return [104.1954, 35.8617];
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
return [total[0] / coordinates.length, total[1] / coordinates.length];
}
export default function MapRenderer({ spec }: { spec: MapSpec }) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [tileFailed, setTileFailed] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
setTileFailed(false);
const map = new maplibregl.Map({
container,
style: createStyle(),
center: defaultCenter(spec),
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
minZoom: 1,
maxZoom: 20,
attributionControl: { compact: true },
});
mapRef.current = map;
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
const markers: maplibregl.Marker[] = [];
for (const marker of spec.markers) {
const element = document.createElement("button");
element.type = "button";
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
element.title = marker.name;
element.setAttribute("aria-label", marker.name);
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
);
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
}
let baseTileLoaded = false;
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
if (event.sourceId !== "google-raster" || event.sourceDataType !== "content") return;
baseTileLoaded = true;
setTileFailed(false);
};
const onError = (event: maplibregl.ErrorEvent) => {
const message = event.error?.message ?? "";
if (!baseTileLoaded && /tile|fetch|network|request|http/i.test(message)) setTileFailed(true);
};
map.on("sourcedata", onSourceData);
map.on("error", onError);
map.on("load", () => {
if (spec.lines.length > 0) {
map.addSource("visual-lines", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.lines.map((line) => ({
type: "Feature",
properties: { name: line.name ?? "" },
geometry: { type: "LineString", coordinates: line.coordinates },
})),
},
});
map.addLayer({
id: "visual-lines",
type: "line",
source: "visual-lines",
paint: { "line-color": "#2563eb", "line-width": 3, "line-opacity": 0.85 },
});
}
if (spec.polygons.length > 0) {
map.addSource("visual-polygons", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.polygons.map((polygon) => ({
type: "Feature",
properties: { name: polygon.name ?? "" },
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
})),
},
});
map.addLayer({
id: "visual-polygons-fill",
type: "fill",
source: "visual-polygons",
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
});
map.addLayer({
id: "visual-polygons-border",
type: "line",
source: "visual-polygons",
paint: { "line-color": "#2563eb", "line-width": 2 },
});
}
if (!spec.center && coordinates.length > 1) {
const bounds = coordinates.reduce(
(current, coordinate) => current.extend(coordinate),
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
);
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
}
});
return () => {
map.off("sourcedata", onSourceData);
map.off("error", onError);
markers.forEach((marker) => marker.remove());
mapRef.current = null;
map.remove();
};
}, [coordinates, reloadToken, spec]);
return (
<VisualizationCard>
<div className="flex min-h-12 items-center gap-2 border-b px-3 py-2">
<MapPinned aria-hidden="true" className="size-4 shrink-0 text-primary" />
<span className="truncate text-sm font-medium">{spec.title}</span>
<span className="ml-auto text-xs text-muted-foreground">{spec.markers.length} 个地点</span>
</div>
<div className="relative">
<div ref={containerRef} className="h-[420px] w-full" role="img" aria-label={`${spec.title},包含 ${spec.markers.length} 个地点`} />
{tileFailed ? (
<div className="absolute inset-x-3 top-3 z-10 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50/95 p-3 text-xs text-amber-900 shadow-sm" role="status">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<span className="flex-1">地图底图当前不可访问;请检查网络。地点和线路数据不会丢失。</span>
<Button
aria-label="重新加载地图"
className="shrink-0 text-amber-950 hover:bg-amber-100"
onClick={() => {
setTileFailed(false);
setReloadToken((value) => value + 1);
}}
size="sm"
variant="ghost"
>
<RefreshCw aria-hidden="true" />
重试
</Button>
</div>
) : null}
</div>
{spec.markers.length > 0 ? (
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">查看地点列表</summary>
<ul className="mt-2 space-y-1">
{spec.markers.map((marker, index) => (
<li key={`${marker.name}-${index}`}>
<span className="font-medium text-foreground">{marker.name}</span>
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
{marker.description ? <span>:{marker.description}</span> : null}
</li>
))}
</ul>
</details>
) : null}
</VisualizationCard>
);
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[character] ?? character);
}
@@ -0,0 +1,682 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Background,
BackgroundVariant,
Handle,
Panel,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Edge,
type Node,
type NodeProps,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Download,
FileJson,
ImageDown,
LocateFixed,
Maximize2,
Minus,
Plus,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { renderSvgElementToPngBlob, triggerBlobDownload } from "@/core/utils/mermaid";
import { cn } from "@/lib/utils";
import { VisualizationCard } from "./visualization-card";
import type { MindmapNodeKind, MindmapSpec } from "./visualization-spec";
const NODE_WIDTH = 184;
const NODE_HEIGHT = 68;
const LAYER_GAP = 244;
const ROW_GAP = 88;
const PREVIEW_DEPTH = 2;
const KIND_META: Record<MindmapNodeKind, { label: string; className: string }> = {
topic: { label: "主题", className: "border-primary/50 bg-primary/10" },
dimension: { label: "维度", className: "border-sky-500/45 bg-sky-500/10" },
finding: { label: "发现", className: "border-violet-500/45 bg-violet-500/10" },
evidence: { label: "依据", className: "border-emerald-500/45 bg-emerald-500/10" },
risk: { label: "风险", className: "border-amber-500/50 bg-amber-500/10" },
action: { label: "建议", className: "border-rose-500/45 bg-rose-500/10" },
};
type MindmapNode = MindmapSpec["nodes"][number];
type LayoutNode = MindmapNode & {
depth: number;
x: number;
y: number;
hiddenCount: number;
hasChildren: boolean;
collapsed: boolean;
};
type MindmapLayout = {
nodes: LayoutNode[];
edges: Array<{ id: string; from: string; to: string }>;
width: number;
height: number;
};
function childrenByParent(spec: MindmapSpec) {
const children = new Map<string, MindmapNode[]>();
for (const node of spec.nodes) {
if (node.parentId === null) continue;
const list = children.get(node.parentId) ?? [];
list.push(node);
children.set(node.parentId, list);
}
return children;
}
function getRoot(spec: MindmapSpec): MindmapNode {
return spec.nodes.find((node) => node.parentId === null)!;
}
function descendantsCount(id: string, children: Map<string, MindmapNode[]>): number {
let count = 0;
for (const child of children.get(id) ?? []) {
count += 1 + descendantsCount(child.id, children);
}
return count;
}
/**
* A rooted analysis tree uses a left-to-right layout in messages. It reads more
* naturally than a radial graph inside a narrow chat column and stays stable
* when the same persisted message is replayed.
*/
function layoutMindmap(
spec: MindmapSpec,
maxDepth?: number,
collapsedIds: ReadonlySet<string> = new Set(),
): MindmapLayout {
const children = childrenByParent(spec);
const root = getRoot(spec);
const visible = new Map<string, { node: MindmapNode; depth: number }>();
const collect = (node: MindmapNode, depth: number) => {
visible.set(node.id, { node, depth });
if ((maxDepth !== undefined && depth >= maxDepth) || collapsedIds.has(node.id)) return;
for (const child of children.get(node.id) ?? []) collect(child, depth + 1);
};
collect(root, 0);
const unitHeight = (id: string): number => {
const childIds = (children.get(id) ?? []).filter((child) => visible.has(child.id));
if (childIds.length === 0) return 1;
return childIds.reduce((sum, child) => sum + unitHeight(child.id), 0);
};
const positions = new Map<string, { x: number; y: number }>();
const assign = (id: string, depth: number, top: number): number => {
const ownUnits = unitHeight(id);
positions.set(id, {
x: depth * LAYER_GAP,
y: top + ((ownUnits - 1) * ROW_GAP) / 2,
});
let nextTop = top;
for (const child of children.get(id) ?? []) {
if (!visible.has(child.id)) continue;
nextTop = assign(child.id, depth + 1, nextTop) + ROW_GAP;
}
return top + (ownUnits - 1) * ROW_GAP;
};
assign(root.id, 0, 0);
const nodes = [...visible.values()].map(({ node, depth }) => ({
...node,
depth,
x: positions.get(node.id)?.x ?? 0,
y: positions.get(node.id)?.y ?? 0,
hiddenCount:
(maxDepth !== undefined && depth === maxDepth) || collapsedIds.has(node.id)
? descendantsCount(node.id, children)
: 0,
hasChildren: (children.get(node.id) ?? []).length > 0,
collapsed: collapsedIds.has(node.id),
}));
const nodeIds = new Set(nodes.map((node) => node.id));
const edges = nodes
.filter((node) => node.parentId && nodeIds.has(node.parentId))
.map((node) => ({ id: `${node.parentId}->${node.id}`, from: node.parentId!, to: node.id }));
const maxX = Math.max(...nodes.map((node) => node.x), 0);
const maxY = Math.max(...nodes.map((node) => node.y), 0);
return {
nodes,
edges,
width: maxX + NODE_WIDTH + 64,
height: maxY + NODE_HEIGHT + 64,
};
}
type FlowNodeData = {
node: LayoutNode;
selected: boolean;
onToggle?: (id: string) => void;
};
function MindmapFlowNode({ data }: NodeProps) {
const { node, selected, onToggle } = data as FlowNodeData;
const meta = KIND_META[node.kind];
return (
<div
className={cn(
"relative w-[184px] rounded-xl border bg-background px-3 py-2 shadow-sm transition-shadow",
"hover:shadow-md focus-within:ring-2 focus-within:ring-primary/50",
meta.className,
selected && "ring-2 ring-primary ring-offset-2 ring-offset-background",
)}
title={node.label}
>
{node.parentId ? <Handle type="target" position={Position.Left} className="!size-1.5 !border-0 !bg-border" /> : null}
<div className="mb-1 flex items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
<span className="font-medium">{meta.label}</span>
{node.hiddenCount > 0 ? (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+{node.hiddenCount}
</span>
) : null}
</div>
<div className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{node.label}</div>
{node.hasChildren ? (
<>
<Handle type="source" position={Position.Right} className="!size-6 !border-0 !bg-transparent" />
<button
type="button"
className="absolute -right-3 top-1/2 z-20 flex size-6 -translate-y-1/2 items-center justify-center rounded-full border border-slate-400 bg-background text-slate-600 shadow-sm transition-colors hover:border-primary hover:bg-primary hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={`${node.collapsed ? "展开" : "收起"}${node.label}`}
aria-expanded={!node.collapsed}
title={node.collapsed ? "展开子节点" : "收起子节点"}
onClick={(event) => {
event.stopPropagation();
onToggle?.(node.id);
}}
>
{node.collapsed ? <Plus aria-hidden="true" className="size-3.5" /> : <Minus aria-hidden="true" className="size-3.5" />}
</button>
</>
) : null}
</div>
);
}
const NODE_TYPES = { mindmap: MindmapFlowNode };
function MindmapCanvasControls({
compact,
onClose,
}: {
compact: boolean;
onClose?: () => void;
}) {
const rf = useReactFlow();
const buttonClassName = compact ? "size-7" : "size-8";
const padding = compact ? 0.16 : 0.12;
return (
<Panel position="top-right" className="flex items-center gap-1 rounded-lg border bg-background/95 p-1 shadow-sm backdrop-blur">
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="放大"
aria-label="放大思维导图"
onClick={() => void rf.zoomIn({ duration: 150 })}
>
<Plus aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="缩小"
aria-label="缩小思维导图"
onClick={() => void rf.zoomOut({ duration: 150 })}
>
<Minus aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="重置位置与缩放"
aria-label="重置思维导图位置与缩放"
onClick={() => void rf.fitView({ duration: 220, padding })}
>
<LocateFixed aria-hidden="true" className="size-4" />
</Button>
{onClose ? (
<>
<span aria-hidden="true" className="mx-0.5 h-4 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="关闭"
aria-label="关闭思维导图"
onClick={onClose}
>
<X aria-hidden="true" className="size-4" />
</Button>
</>
) : null}
</Panel>
);
}
function overviewNodeColor(kind: MindmapNodeKind): string {
return svgColors(kind).fill;
}
function MindmapOverview({ layout }: { layout: MindmapLayout }) {
const nodesById = new Map(layout.nodes.map((node) => [node.id, node]));
return (
<Panel position="bottom-right" className="overflow-hidden rounded-lg border bg-background/95 p-1 shadow-md backdrop-blur">
<div className="mb-1 px-1.5 pt-0.5 text-[10px] font-medium text-muted-foreground">全图预览</div>
<svg
className="block h-28 w-44 rounded bg-muted/30"
viewBox={`0 0 ${layout.width} ${layout.height}`}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="思维导图全图预览"
>
{layout.edges.map((edge) => {
const from = nodesById.get(edge.from);
const to = nodesById.get(edge.to);
if (!from || !to) return null;
return (
<path
key={edge.id}
d={`M ${from.x + NODE_WIDTH} ${from.y + NODE_HEIGHT / 2} L ${to.x} ${to.y + NODE_HEIGHT / 2}`}
fill="none"
stroke="#64748b"
strokeWidth="4"
strokeOpacity="0.8"
/>
);
})}
{layout.nodes.map((node) => (
<rect
key={node.id}
x={node.x}
y={node.y}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx="10"
fill={overviewNodeColor(node.kind)}
stroke="#94a3b8"
strokeWidth="2"
/>
))}
</svg>
</Panel>
);
}
function MindmapCanvas({
spec,
preview,
selectedId,
onSelect,
onClose,
}: {
spec: MindmapSpec;
preview: boolean;
selectedId?: string | null;
onSelect?: (id: string) => void;
onClose?: () => void;
}) {
const rf = useReactFlow();
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => new Set());
const toggleNode = useCallback((id: string) => {
setCollapsedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const layout = useMemo(
() => layoutMindmap(spec, preview ? PREVIEW_DEPTH : undefined, collapsedIds),
[collapsedIds, preview, spec],
);
const nodes = useMemo<Node[]>(
() =>
layout.nodes.map((node) => ({
id: node.id,
type: "mindmap",
position: { x: node.x, y: node.y },
width: NODE_WIDTH,
height: NODE_HEIGHT,
data: { node, selected: node.id === selectedId, onToggle: toggleNode } satisfies FlowNodeData,
})),
[layout.nodes, selectedId, toggleNode],
);
const edges = useMemo<Edge[]>(
() =>
layout.edges.map((edge) => ({
id: edge.id,
source: edge.from,
target: edge.to,
type: "smoothstep",
style: { stroke: "#64748b", strokeWidth: 2, strokeOpacity: 0.8 },
})),
[layout.edges],
);
const handleNodeClick = useCallback((id: string) => {
const node = layout.nodes.find((item) => item.id === id);
if (node?.hasChildren) {
toggleNode(id);
return;
}
onSelect?.(id);
}, [layout.nodes, onSelect, toggleNode]);
useEffect(() => {
const timer = window.setTimeout(() => {
void rf.fitView({ padding: preview ? 0.16 : 0.12, duration: 180 });
}, 0);
return () => window.clearTimeout(timer);
}, [collapsedIds, preview, rf, spec]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
fitView
fitViewOptions={{ padding: preview ? 0.16 : 0.12 }}
minZoom={0.12}
maxZoom={2}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={Boolean(onSelect)}
panOnDrag
panOnScroll={false}
zoomOnScroll
zoomOnDoubleClick
zoomOnPinch
preventScrolling
onNodeClick={(_, node) => handleNodeClick(node.id)}
aria-label={`${spec.title}思维导图`}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--border)" />
<MindmapCanvasControls compact={preview} onClose={onClose} />
{!preview ? <MindmapOverview layout={layout} /> : null}
</ReactFlow>
);
}
function svgEscape(value: string): string {
return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[char]!);
}
function splitSvgLabel(label: string): string[] {
const chunks = label.match(/.{1,16}/gu) ?? [label];
return chunks.slice(0, 2);
}
function buildMindmapSvg(spec: MindmapSpec): string {
const layout = layoutMindmap(spec);
const offset = 32;
const lines = layout.edges
.map((edge) => {
const from = layout.nodes.find((node) => node.id === edge.from)!;
const to = layout.nodes.find((node) => node.id === edge.to)!;
const x1 = from.x + NODE_WIDTH + offset;
const y1 = from.y + NODE_HEIGHT / 2 + offset;
const x2 = to.x + offset;
const y2 = to.y + NODE_HEIGHT / 2 + offset;
const control = Math.max(42, (x2 - x1) / 2);
return `<path d="M ${x1} ${y1} C ${x1 + control} ${y1}, ${x2 - control} ${y2}, ${x2} ${y2}" fill="none" stroke="#94a3b8" stroke-width="1.5"/>`;
})
.join("");
const nodes = layout.nodes
.map((node) => {
const meta = KIND_META[node.kind];
const { fill, stroke } = svgColors(node.kind);
const lines = splitSvgLabel(node.label)
.map((line, index) => `<tspan x="${node.x + offset + 14}" dy="${index === 0 ? 0 : 18}">${svgEscape(line)}</tspan>`)
.join("");
return [
`<g>`,
`<rect x="${node.x + offset}" y="${node.y + offset}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="12" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`,
`<text x="${node.x + offset + 14}" y="${node.y + offset + 18}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="10" fill="#64748b">${meta.label}</text>`,
`<text x="${node.x + offset + 14}" y="${node.y + offset + 42}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="13" font-weight="600" fill="#0f172a">${lines}</text>`,
`</g>`,
].join("");
})
.join("");
return `<svg xmlns="http://www.w3.org/2000/svg" width="${layout.width + offset}" height="${layout.height + offset}" viewBox="0 0 ${layout.width + offset} ${layout.height + offset}" role="img" aria-label="${svgEscape(spec.title)}"><rect width="100%" height="100%" fill="#ffffff"/><text x="32" y="26" font-family="Microsoft YaHei, Arial, sans-serif" font-size="16" font-weight="700" fill="#0f172a">${svgEscape(spec.title)}</text>${lines}${nodes}</svg>`;
}
function svgColors(kind: MindmapNodeKind): { fill: string; stroke: string } {
switch (kind) {
case "topic": return { fill: "#eff6ff", stroke: "#60a5fa" };
case "dimension": return { fill: "#f0f9ff", stroke: "#38bdf8" };
case "evidence": return { fill: "#ecfdf5", stroke: "#34d399" };
case "risk": return { fill: "#fffbeb", stroke: "#fbbf24" };
case "action": return { fill: "#fff1f2", stroke: "#fb7185" };
default: return { fill: "#f5f3ff", stroke: "#a78bfa" };
}
}
function safeDownloadName(title: string, extension: string): string {
const clean = title.replace(/[\\/:*?"<>|]/g, "-").trim().slice(0, 80) || "分析思维导图";
return `${clean}.${extension}`;
}
function MindmapDownloads({ spec }: { spec: MindmapSpec }) {
const [downloading, setDownloading] = useState<"png" | "svg" | "json" | null>(null);
const [open, setOpen] = useState(false);
const download = useCallback(async (format: "png" | "svg" | "json") => {
setDownloading(format);
try {
if (format === "json") {
triggerBlobDownload(
new Blob([JSON.stringify(spec, null, 2)], { type: "application/json;charset=utf-8" }),
safeDownloadName(spec.title, "mindmap.json"),
);
setOpen(false);
return;
}
const svgMarkup = buildMindmapSvg(spec);
if (format === "svg") {
triggerBlobDownload(
new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" }),
safeDownloadName(spec.title, "svg"),
);
setOpen(false);
return;
}
const host = document.createElement("div");
host.style.cssText = "position:fixed;left:-10000px;top:-10000px;pointer-events:none;opacity:0";
host.innerHTML = svgMarkup;
document.body.appendChild(host);
try {
const svg = host.querySelector("svg");
if (!(svg instanceof SVGSVGElement)) throw new Error("无法生成思维导图图片");
const blob = await renderSvgElementToPngBlob(svg);
triggerBlobDownload(blob, safeDownloadName(spec.title, "png"));
setOpen(false);
} finally {
host.remove();
}
} catch (error) {
console.error("Failed to download mindmap", error);
toast.error(error instanceof Error ? error.message : "下载思维导图失败");
} finally {
setDownloading(null);
}
}, [spec]);
return (
<>
<Button
type="button"
variant="outline"
size="icon"
className="size-9"
title="下载思维导图"
aria-label="下载思维导图"
onClick={() => setOpen(true)}
>
<Download aria-hidden="true" className="size-4" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm p-5">
<DialogHeader className="text-left">
<DialogTitle>下载思维导图</DialogTitle>
<DialogDescription>选择需要导出的文件格式。</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("png")}
disabled={downloading !== null}
>
<ImageDown aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">PNG 图片</span>
<span className="block text-xs font-normal text-muted-foreground">
{downloading === "png" ? "正在生成…" : "便于在文档或图片中使用"}
</span>
</span>
</Button>
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("svg")}
disabled={downloading !== null}
>
<Download aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">SVG 矢量图</span>
<span className="block text-xs font-normal text-muted-foreground">适合无损缩放和二次编辑</span>
</span>
</Button>
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("json")}
disabled={downloading !== null}
>
<FileJson aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">JSON 数据</span>
<span className="block text-xs font-normal text-muted-foreground">保留完整节点结构</span>
</span>
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
function MindmapDialog({
open,
onOpenChange,
spec,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
spec: MindmapSpec;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="flex h-[calc(100dvh-2rem)] !w-[calc(100vw-2rem)] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden rounded-xl p-0 sm:!max-w-[calc(100vw-2rem)]"
>
<DialogTitle className="sr-only">{spec.title}</DialogTitle>
<DialogDescription className="sr-only">
思维导图画布。支持鼠标滚轮缩放、拖动画布,并可使用右上角按钮调整视图。
</DialogDescription>
<div className="relative min-h-0 flex-1 bg-muted/20">
<div className="absolute inset-0">
<ReactFlowProvider>
<MindmapCanvas spec={spec} preview={false} onClose={() => onOpenChange(false)} />
</ReactFlowProvider>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default function MindmapRenderer({ spec }: { spec: MindmapSpec }) {
const [open, setOpen] = useState(false);
const root = getRoot(spec);
const ariaSummary = spec.nodes.map((node) => `${KIND_META[node.kind].label}:${node.label}`).join(";");
return (
<>
<VisualizationCard className="my-4" >
<section aria-label={`${spec.title}思维导图`}>
<div className="flex flex-wrap items-start justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-sm font-semibold">{spec.title}</h2>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">思维导图</span>
</div>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{spec.summary || `围绕“${root.label}”整理的 ${spec.nodes.length} 个分析节点。`}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<MindmapDownloads spec={spec} />
<Button type="button" size="sm" className="h-8 gap-1.5" onClick={() => setOpen(true)}>
<Maximize2 aria-hidden="true" className="size-3.5" />
展开查看
</Button>
</div>
</div>
<p className="sr-only">{ariaSummary}</p>
<div className="h-[20rem] bg-muted/15">
<ReactFlowProvider>
<MindmapCanvas
spec={spec}
preview
onSelect={() => {
setOpen(true);
}}
/>
</ReactFlowProvider>
</div>
<div className="flex items-center justify-between gap-3 border-t px-4 py-2 text-xs text-muted-foreground">
<span>预览显示前 {PREVIEW_DEPTH + 1} 层;完整导图可缩放查看。</span>
<span>{spec.nodes.length} 个节点</span>
</div>
</section>
</VisualizationCard>
<MindmapDialog open={open} onOpenChange={setOpen} spec={spec} />
</>
);
}
@@ -0,0 +1,129 @@
import { Component, lazy, Suspense, useMemo, type ErrorInfo, type ReactNode } from "react";
import { AlertTriangle, Loader2 } from "lucide-react";
import {
parseVisualizationSpec,
type MapSpec,
type MindmapSpec,
type VisualizationLanguage,
} from "./visualization-spec";
const MapRenderer = lazy(() => import("./map-renderer"));
const MindmapRenderer = lazy(() => import("./mindmap-renderer"));
type VisualizationBlockProps = {
language: VisualizationLanguage;
source: string;
complete: boolean;
isLoading: boolean;
};
function LoadingCard({ label = "正在生成可视化内容…" }: { label?: string }) {
return (
<div
className="my-4 flex min-h-32 w-full max-w-none items-center justify-center rounded-xl border bg-muted/20 text-muted-foreground"
role="status"
>
<Loader2 aria-hidden="true" className="mr-2 size-4 animate-spin" />
<span className="text-sm">{label}</span>
</div>
);
}
function ErrorCard({
message,
source,
title = "可视化内容暂时无法显示",
}: {
message: string;
source?: string;
title?: string;
}) {
return (
<div
className="my-4 w-full max-w-none rounded-xl border border-destructive/30 bg-destructive/5 p-4"
role="alert"
>
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">{title}</div>
<div className="mt-1 text-xs opacity-80">{message}</div>
</div>
</div>
{source ? (
<details className="mt-3 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">查看原始数据</summary>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-muted/50 p-3">
{source}
</pre>
</details>
) : null}
</div>
);
}
class VisualizationErrorBoundary extends Component<
{ children: ReactNode; source: string },
{ error: string | null }
> {
state = { error: null as string | null };
static getDerivedStateFromError(error: unknown) {
return { error: error instanceof Error ? error.message : "可视化渲染失败" };
}
componentDidCatch(error: unknown, info: ErrorInfo) {
console.error("Visualization renderer failed", error, info.componentStack);
}
render() {
if (this.state.error) {
return <ErrorCard message={this.state.error} source={this.props.source} />;
}
return this.props.children;
}
}
export function VisualizationBlock({
language,
source,
complete,
isLoading,
}: VisualizationBlockProps) {
const result = useMemo(
() => parseVisualizationSpec(language, source),
[language, source],
);
if (!result.ok) {
if (isLoading && !complete) {
return <LoadingCard label={language === "mindmap-json" ? "正在整理思维导图…" : undefined} />;
}
return (
<ErrorCard
title={language === "mindmap-json" ? "思维导图暂时无法显示" : "地图内容暂时无法显示"}
message={result.error}
source={source}
/>
);
}
return (
<VisualizationErrorBoundary key={`${language}-${source.length}`} source={source}>
<Suspense
fallback={
<LoadingCard
label={language === "mindmap-json" ? "正在加载思维导图组件…" : "正在加载本地地图组件…"}
/>
}
>
{language === "mindmap-json" ? (
<MindmapRenderer spec={result.spec as MindmapSpec} />
) : (
<MapRenderer spec={result.spec as MapSpec} />
)}
</Suspense>
</VisualizationErrorBoundary>
);
}
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function VisualizationCard({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<section
className={cn(
"my-4 w-full max-w-none overflow-hidden rounded-xl border bg-background shadow-sm",
className,
)}
>
{children}
</section>
);
}
@@ -0,0 +1,248 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
import { parseVisualizationSpec, splitVisualizationContent } from "./visualization-spec.ts";
test("ordinary markdown remains one unchanged segment", () => {
const content = "普通回答\n\n```ts\nconst value = 1;\n```";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("splits a dedicated map block while keeping surrounding markdown", () => {
const content = [
"下面是地图:\n",
"```map-json",
'{"title":"地点","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
"```",
"\n地图结束。",
].join("\n");
const segments = splitVisualizationContent(content);
assert.equal(segments.length, 3);
assert.equal(segments[0]?.type, "markdown");
assert.equal(segments[1]?.type, "visualization");
assert.equal(segments[2]?.type, "markdown");
});
test("accepts the map contract", () => {
const map = parseVisualizationSpec(
"map-json",
JSON.stringify({ markers: [{ name: "北京", coordinate: [116.4, 39.9] }] }),
);
assert.equal(map.ok, true);
});
test("accepts a dedicated mindmap contract", () => {
const result = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
title: "形势分析",
rootId: "root",
nodes: [
{ id: "root", parentId: null, label: "形势分析", kind: "topic" },
{ id: "risk", parentId: "root", label: "主要风险", kind: "risk" },
{ id: "action", parentId: "root", label: "处置建议", kind: "action" },
],
}),
);
assert.equal(result.ok, true);
assert.equal(result.ok && result.spec.type, "mindmap");
});
test("accepts four meaningful children under a non-root analysis node", () => {
const result = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
title: "人物分析",
rootId: "root",
nodes: [
{ id: "root", parentId: null, label: "人物分析", kind: "topic" },
{ id: "basic", parentId: "root", label: "基本信息", kind: "dimension" },
{ id: "identity", parentId: "basic", label: "身份", kind: "finding" },
{ id: "experience", parentId: "basic", label: "经历", kind: "finding" },
{ id: "relations", parentId: "basic", label: "关系", kind: "finding" },
{ id: "events", parentId: "basic", label: "事件", kind: "finding" },
],
}),
);
assert.equal(result.ok, true);
assert.equal(
result.ok && result.spec.type === "mindmap" && result.spec.nodes.length,
6,
);
});
test("renders a completed legacy mindmap fence as a mindmap", () => {
const content = [
"```mindmap",
'{"rootId":"root","nodes":[{"id":"root","parentId":null,"label":"主题"}]}',
"```",
].join("\n");
const visual = splitVisualizationContent(content).find((segment) => segment.type === "visualization");
assert.ok(visual && visual.type === "visualization");
assert.equal(visual.language, "mindmap-json");
assert.equal(visual.complete, true);
});
test("rejects mindmaps with a cycle or missing parent", () => {
const cycle = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "a", parentId: "b", label: "A" },
{ id: "b", parentId: "a", label: "B" },
],
}),
);
assert.equal(cycle.ok, false);
const missingParent = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "a", parentId: "missing", label: "A" },
],
}),
);
assert.equal(missingParent.ok, false);
});
test("rejects dense mindmaps before they can overload the canvas", () => {
const tooManyChildren = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "a", parentId: "root", label: "维度 A" },
{ id: "b", parentId: "root", label: "维度 B" },
{ id: "c", parentId: "root", label: "维度 C" },
{ id: "d", parentId: "root", label: "维度 D" },
{ id: "e", parentId: "root", label: "维度 E" },
{ id: "f", parentId: "root", label: "维度 F" },
{ id: "g", parentId: "root", label: "维度 G" },
{ id: "h", parentId: "root", label: "维度 H" },
],
}),
);
assert.equal(tooManyChildren.ok, false);
const tooManyBranchChildren = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "basic", parentId: "root", label: "基本信息" },
...Array.from({ length: 5 }, (_, index) => ({
id: `detail-${index}`,
parentId: "basic",
label: `要点 ${index + 1}`,
})),
],
}),
);
assert.equal(tooManyBranchChildren.ok, false);
assert.equal(
tooManyBranchChildren.ok ? "" : tooManyBranchChildren.error,
"节点“基本信息”最多保留 4 个子节点,当前有 5 个;请合并同类信息",
);
const tooManyNodes = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
...Array.from({ length: 36 }, (_, index) => ({
id: `node-${index}`,
parentId: "root",
label: `节点 ${index + 1}`,
})),
],
}),
);
assert.equal(tooManyNodes.ok, false);
assert.equal(
tooManyNodes.ok ? "" : tooManyNodes.error,
"思维导图节点过多(37 个);请将同类信息合并后控制在 36 个以内",
);
});
test("keeps the four-layer depth guard after branch tolerance", () => {
const tooDeep = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "level-1", parentId: "root", label: "一级" },
{ id: "level-2", parentId: "level-1", label: "二级" },
{ id: "level-3", parentId: "level-2", label: "三级" },
{ id: "level-4", parentId: "level-3", label: "四级" },
],
}),
);
assert.equal(tooDeep.ok, false);
assert.equal(
tooDeep.ok ? "" : tooDeep.error,
"思维导图最多支持 4 层层级",
);
});
test("renders a valid map contract from a generic json fence", () => {
const content = [
"下面是地图:",
"```json",
'{"title":"城市分布","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
"```",
].join("\n");
const segments = splitVisualizationContent(content);
const visual = segments.find((segment) => segment.type === "visualization");
assert.ok(visual && visual.type === "visualization");
assert.equal(visual.language, "map-json");
});
test("keeps non-map json fences on the ordinary Markdown path", () => {
const content = '```json\n{"name":"普通数据","items":[1,2,3]}\n```';
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("keeps generic mindmap-shaped JSON on the ordinary Markdown path", () => {
const content = [
"```json",
'{"type":"mindmap","nodes":[{"id":"root","parentId":null,"label":"主题"}]}',
"```",
].join("\n");
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("keeps unfinished generic json fences on the ordinary Markdown path", () => {
const content = '```json\n{"markers":[';
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("rejects invalid map coordinates", () => {
const result = parseVisualizationSpec(
"map-json",
JSON.stringify({ markers: [{ name: "错误地点", coordinate: [200, 100] }] }),
);
assert.equal(result.ok, false);
});
test("flow and chart fences stay on the ordinary Markdown path", () => {
const content = "```flow-json\n{}\n```\n```chart-json\n{}\n```";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("keeps an unfinished map fence for streaming fallback", () => {
const segments = splitVisualizationContent("说明\n```map-json\n{\"title\":\"地点\"");
const visual = segments.find((segment) => segment.type === "visualization");
assert.ok(visual && visual.type === "visualization");
assert.equal(visual.complete, false);
});
test("shows unfinished dedicated mindmap JSON before mounting the renderer", () => {
const content = "说明\n```mindmap-json\n{\"title\":\"分析导图\"";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
@@ -0,0 +1,282 @@
import { z } from "zod";
export const VISUALIZATION_LANGUAGES = ["map-json", "mindmap-json"] as const;
export type VisualizationLanguage = (typeof VISUALIZATION_LANGUAGES)[number];
const safeText = z.string().trim().min(1).max(500);
const optionalText = z.string().trim().max(2_000).optional();
const longitude = z.number().finite().min(-180).max(180);
const latitude = z.number().finite().min(-90).max(90);
const coordinateSchema = z.tuple([longitude, latitude]);
const mapSchema = z.object({
type: z.literal("map").default("map"),
title: z.string().trim().max(200).default("地图"),
center: coordinateSchema.optional(),
zoom: z.number().finite().min(1).max(20).optional(),
markers: z
.array(
z.object({
name: safeText,
coordinate: coordinateSchema,
description: optionalText,
}),
)
.max(500)
.default([]),
lines: z
.array(
z.object({
name: z.string().trim().max(120).optional(),
coordinates: z.array(coordinateSchema).min(2).max(2_000),
}),
)
.max(100)
.default([]),
polygons: z
.array(
z.object({
name: z.string().trim().max(120).optional(),
coordinates: z.array(coordinateSchema).min(3).max(2_000),
}),
)
.max(100)
.default([]),
});
export type MapSpec = z.infer<typeof mapSchema>;
export const MINDMAP_NODE_KINDS = [
"topic",
"dimension",
"finding",
"evidence",
"risk",
"action",
] as const;
export type MindmapNodeKind = (typeof MINDMAP_NODE_KINDS)[number];
const mindmapNodeSchema = z.object({
id: z.string().trim().min(1).max(80),
parentId: z.string().trim().min(1).max(80).nullable(),
label: z.string().trim().min(1).max(120),
kind: z.enum(MINDMAP_NODE_KINDS).default("finding"),
detail: z.string().trim().max(1_200).optional(),
sourceRefs: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
});
const mindmapSchema = z.object({
type: z.literal("mindmap").default("mindmap"),
title: z.string().trim().max(200).default("分析思维导图"),
summary: z.string().trim().max(800).optional(),
rootId: z.string().trim().min(1).max(80).optional(),
// A mind map is an overview, not a transcription of every paragraph in a
// report. Keeping this small is also important because every node becomes a
// rendered React Flow element in the message list and full-screen canvas.
nodes: z.array(mindmapNodeSchema).min(1).max(36),
});
export type MindmapSpec = z.infer<typeof mindmapSchema>;
type MindmapNode = MindmapSpec["nodes"][number];
export type VisualizationSpec = MapSpec | MindmapSpec;
const MINDMAP_ROOT_CHILD_LIMIT = 6;
// The generation prompt still asks the model to target three children so the
// overview stays compact. Real analysis dimensions can naturally contain four
// equally important facts, though, and rejecting the entire graph for that
// one-node overflow is too brittle. Keep a one-item rendering tolerance while
// the global node, depth and cycle guards continue to bound the canvas.
const MINDMAP_BRANCH_CHILD_LIMIT = 4;
export type VisualizationParseResult =
| { ok: true; spec: VisualizationSpec }
| { ok: false; error: string };
function normalizeLanguageType(
language: VisualizationLanguage,
value: unknown,
): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const record = { ...(value as Record<string, unknown>) };
if (language === "map-json") record.type = "map";
if (language === "mindmap-json") record.type = "mindmap";
return record;
}
function formatZodError(error: z.ZodError): string {
const issue = error.issues[0];
if (!issue) return "可视化数据格式不正确";
const path = issue.path.length > 0 ? `${issue.path.join(".")}:` : "";
return `${path}${issue.message}`;
}
function validateMindmap(spec: MindmapSpec): string | null {
const nodesById = new Map<string, MindmapSpec["nodes"][number]>();
for (const node of spec.nodes) {
if (nodesById.has(node.id)) return `节点 id 重复:${node.id}`;
nodesById.set(node.id, node);
}
const roots = spec.nodes.filter((node) => node.parentId === null);
if (roots.length !== 1) return "思维导图必须且只能有一个根节点";
const root = roots[0]!;
if (spec.rootId && spec.rootId !== root.id) return "rootId 必须指向唯一的根节点";
for (const node of spec.nodes) {
if (node.parentId === null) continue;
if (node.parentId === node.id) return `节点不能以自身为父节点:${node.id}`;
if (!nodesById.has(node.parentId)) return `节点 ${node.id} 的父节点不存在:${node.parentId}`;
}
const children = new Map<string, MindmapNode[]>();
for (const node of spec.nodes) {
if (node.parentId === null) continue;
const siblings = children.get(node.parentId) ?? [];
siblings.push(node);
children.set(node.parentId, siblings);
}
for (const [parentId, siblings] of children) {
const limit =
parentId === root.id
? MINDMAP_ROOT_CHILD_LIMIT
: MINDMAP_BRANCH_CHILD_LIMIT;
if (siblings.length > limit) {
const parentLabel = nodesById.get(parentId)?.label ?? parentId;
return parentId === root.id
? `中心主题最多保留 ${MINDMAP_ROOT_CHILD_LIMIT} 个一级维度,当前“${parentLabel}”下有 ${siblings.length} 个`
: `节点“${parentLabel}”最多保留 ${MINDMAP_BRANCH_CHILD_LIMIT} 个子节点,当前有 ${siblings.length} 个;请合并同类信息`;
}
}
for (const node of spec.nodes) {
let depth = 0;
let cursor: MindmapSpec["nodes"][number] | undefined = node;
const visited = new Set<string>();
while (cursor && cursor.parentId !== null) {
if (visited.has(cursor.id)) return "思维导图不能包含循环父子关系";
visited.add(cursor.id);
cursor = nodesById.get(cursor.parentId);
depth += 1;
if (depth > 3) return "思维导图最多支持 4 层层级";
}
}
return null;
}
function mindmapShapeError(value: unknown): string | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const nodes = (value as Record<string, unknown>).nodes;
if (Array.isArray(nodes) && nodes.length > 36) {
return `思维导图节点过多(${nodes.length} 个);请将同类信息合并后控制在 36 个以内`;
}
return null;
}
export function parseVisualizationSpec(
language: VisualizationLanguage,
source: string,
): VisualizationParseResult {
let raw: unknown;
try {
raw = JSON.parse(source);
} catch {
return { ok: false, error: "地图数据尚未完整或不是有效 JSON" };
}
const normalized = normalizeLanguageType(language, raw);
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
return { ok: false, error: "地图数据必须是 JSON 对象" };
}
if (language === "mindmap-json") {
const error = mindmapShapeError(normalized);
if (error) return { ok: false, error };
}
const schema = language === "map-json" ? mapSchema : mindmapSchema;
const parsed = schema.safeParse(normalized);
if (!parsed.success) return { ok: false, error: formatZodError(parsed.error) };
if (language === "mindmap-json") {
const error = validateMindmap(parsed.data as MindmapSpec);
if (error) return { ok: false, error };
}
return { ok: true, spec: parsed.data };
}
export type VisualizationSegment =
| { type: "markdown"; content: string }
| {
type: "visualization";
language: VisualizationLanguage;
source: string;
complete: boolean;
};
// `mindmap-json` is the contract. `mindmap` is retained as a compatibility
// alias because existing model turns have already emitted that fence name.
const FENCE_PATTERN = /(^|\n)```(map-json|mindmap-json|mindmap|json)[ \t]*\r?\n/gi;
function isMapJsonSource(source: string): boolean {
let raw: unknown;
try {
raw = JSON.parse(source);
} catch {
return false;
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
const record = raw as Record<string, unknown>;
const hasMapField = ["center", "zoom", "markers", "lines", "polygons"].some(
(field) => Object.prototype.hasOwnProperty.call(record, field),
);
return hasMapField && parseVisualizationSpec("map-json", source).ok;
}
/** Split only our dedicated fences. With no match, the caller keeps the exact legacy renderer path. */
export function splitVisualizationContent(content: string): VisualizationSegment[] {
const segments: VisualizationSegment[] = [];
let cursor = 0;
FENCE_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = FENCE_PATTERN.exec(content)) !== null) {
const blockStart = match.index + match[1].length;
if (blockStart > cursor) {
segments.push({ type: "markdown", content: content.slice(cursor, blockStart) });
}
const sourceStart = FENCE_PATTERN.lastIndex;
const closeIndex = content.indexOf("```", sourceStart);
const complete = closeIndex >= 0;
const source = content.slice(sourceStart, complete ? closeIndex : content.length).trim();
const matchedLanguage = match[2].toLowerCase();
const isMindmapFence = matchedLanguage === "mindmap-json" || matchedLanguage === "mindmap";
// Dedicated mind-map JSON is shown as an ordinary streaming code block
// until the closing fence arrives. That lets people see the model writing
// and validate the raw structure before the expensive renderer mounts.
if (isMindmapFence && !complete) {
return [{ type: "markdown", content }];
}
if (matchedLanguage === "json" && (!complete || !isMapJsonSource(source))) {
if (!complete) break;
FENCE_PATTERN.lastIndex = closeIndex + 3;
continue;
}
segments.push({
type: "visualization",
language: isMindmapFence ? "mindmap-json" : "map-json",
source,
complete,
});
cursor = complete ? closeIndex + 3 : content.length;
FENCE_PATTERN.lastIndex = cursor;
if (!complete) break;
}
if (cursor < content.length) {
segments.push({ type: "markdown", content: content.slice(cursor) });
}
return segments.length > 0 ? segments : [{ type: "markdown", content }];
}
@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Landmark, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -24,7 +24,10 @@ import {
applyCompactMenuOverrides,
type CompactMenuItem,
} from "@/core/page-layout/compact-menu-overrides";
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
import {
MARKDOWN_DOCX_MENU_ITEM,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -65,6 +68,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/chats",
match: (pathname) => pathname === "/page/workspace/chats",
},
{
id: "strategy-projects",
label: "我的项目",
icon: FolderKanban,
path: "/page/strategy/qa/projects",
match: (pathname) =>
pathname.startsWith("/page/strategy/qa/projects") ||
pathname.startsWith("/page/strategy/qa/strategy-selection"),
},
{
id: "workspace-library",
label: "资料库",
@@ -86,13 +98,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/skills",
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
},
{
id: "workspace-memory",
label: "记忆",
icon: BrainIcon,
path: "/page/workspace/memory",
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
},
{
id: "workspace-ai-writing",
label: "AI写作",
@@ -107,6 +112,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/deep-research",
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
},
{
id: "workspace-gongwen",
label: "公文助手",
icon: Landmark,
path: "/page/workspace/gongwen",
match: (pathname) => pathname.startsWith("/page/workspace/gongwen"),
},
// {
// id: "workspace-report-collaboration",
// label: "报告协作",
@@ -135,12 +147,18 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/scheduled/tasks",
match: (pathname) => pathname === "/page/workspace/scheduled/tasks",
},
// {
// id: "workspace-knowledge",
// label: "知识库管理",
// icon: BookOpen,
// path: "/page/workspace/knowledge",
// match: (pathname) =>
// pathname.startsWith("/page/workspace/knowledge") &&
// !pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
// },
{
id: "workspace-knowledge",
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
...MARKDOWN_DOCX_MENU_ITEM,
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
];
@@ -151,7 +169,8 @@ export function useWorkspaceCompactNavItems(): {
settingsItems: EffectiveWorkspaceCompactNavItem[];
toolboxItems: EffectiveWorkspaceCompactNavItem[];
} {
const { data: overrides = [] } = useCompactMenuOverrides();
const { data: overrideConfig } = useCompactMenuOverrides();
const overrides = overrideConfig?.overrides ?? [];
return useMemo(() => {
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
return {
@@ -20,9 +20,10 @@ import {
SettingsIcon,
SparklesIcon,
TagsIcon,
TimerIcon,
TrophyIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import {
@@ -92,11 +93,34 @@ export function WorkspaceNavMenu() {
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const { settingsItems } = useWorkspaceCompactNavItems();
// 整页缩放时菜单会盖住按钮。按下后松手若落在菜单项上,Radix 会当成选中并立刻关闭。
// 这次打开手势的 pointerup / click 不传给菜单项。
const openingGesture = useRef(false);
useEffect(() => {
setMounted(true);
}, []);
const markOpeningGesture = () => {
openingGesture.current = true;
window.setTimeout(() => {
openingGesture.current = false;
}, 400);
};
const swallowOpeningPointerUp = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
if (!openingGesture.current) return;
event.preventDefault();
event.stopPropagation();
};
const swallowOpeningClick = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
if (!openingGesture.current) return;
openingGesture.current = false;
event.preventDefault();
event.stopPropagation();
};
// 简洁模式界面引导:已在问答页(ChatPage 挂载中)时直接广播事件启动;否则先落
// sessionStorage 请求,导航到新对话页后由 CompactModeGuide 消费启动。
const startCompactGuide = () => {
@@ -140,19 +164,23 @@ export function WorkspaceNavMenu() {
<SidebarMenu className="w-full" data-compact-guide="settings-menu">
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
onPointerDown={markOpeningGesture}
>
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="top"
align="end"
sideOffset={4}
sideOffset={8}
onPointerUpCapture={swallowOpeningPointerUp}
onClickCapture={swallowOpeningClick}
>
<DropdownMenuGroup>
{settingsItems.map((item) => {
@@ -223,7 +251,7 @@ export function WorkspaceNavMenu() {
{isAdmin ? (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
navigate("/page/workspace/admin/user-leaderboard")
}
>
<TrophyIcon />
@@ -265,6 +293,22 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skill-timing")}
>
<TimerIcon />
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollTextIcon />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
@@ -273,16 +317,15 @@ export function WorkspaceNavMenu() {
模型配置
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTreeIcon />
菜单管理
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口:
简洁模式没有全局侧边栏,走工作区地址带上 WorkspaceSidebar;
/page/strategy/admin/menu-management 保留给全量模式侧边栏。 */}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/menu-management")}
>
<ListTreeIcon />
{isAdmin ? "默认菜单管理" : "我的菜单"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
@@ -328,7 +371,7 @@ export function WorkspaceNavMenu() {
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/fixed-questions")
navigate("/page/workspace/admin/fixed-questions")
}
>
<MessageSquareTextIcon />
@@ -18,15 +18,18 @@ import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({
compactChrome = false,
forceCollapsed = false,
className,
...props
}: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean;
forceCollapsed?: boolean;
}) {
export function WorkspaceSidebar({
compactChrome = false,
forceCollapsed = false,
wxCommonAgents = false,
className,
...props
}: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean;
forceCollapsed?: boolean;
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
}) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
@@ -35,6 +38,7 @@ export function WorkspaceSidebar({
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa =
wxCommonAgents ||
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats");
@@ -84,7 +88,7 @@ export function WorkspaceSidebar({
{isGeneralQa ? (
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar />
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
) : (
<>
<WorkspaceNavChatList showCollapseTrigger compact />
@@ -0,0 +1,28 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface ModelExchangeRecord {
id: string;
at: string;
model: string;
user_id: string;
enable_thinking: string;
request_preview: string;
response_preview: string;
request_text: string;
request_body: string;
response_text: string;
response_raw: string;
response_content: string;
response_reasoning: string;
reasoning_field: string;
}
export async function loadModelExchanges(): Promise<ModelExchangeRecord[]> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/model-exchanges`);
if (!response.ok) {
throw new Error("模型收发记录读取失败");
}
const body = (await response.json()) as { records?: ModelExchangeRecord[] };
return body.records ?? [];
}
@@ -0,0 +1,72 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
/** One completed sandbox call. Written only after that call returns. */
export interface SkillViewEvent {
at: string;
status: string;
total_ms: number;
lock_wait_ms: number;
refresh_ms: number;
source_entries: number;
target_entries: number;
enabled_count: number;
path_count: number;
tool: string;
command: string;
skill_names: string[];
tool_ms: number;
explanation: {
level: string;
headline: string;
look_at: string;
cause: string;
};
}
export interface SkillViewCheck {
id: string;
title: string;
result: string;
level: string;
look_at: string;
cause: string;
command: string;
skill_names: string[];
}
export interface SkillViewDiagnostics {
cache_enabled: boolean;
reuse_enabled: boolean;
level: string;
conclusion: string;
checks: SkillViewCheck[];
events: SkillViewEvent[];
total: number;
page: number;
page_size: number;
sort: "time" | "duration" | "check";
order: "asc" | "desc";
}
export interface SkillViewQuery {
page: number;
pageSize: number;
sort: "time" | "duration" | "check";
order: "asc" | "desc";
}
/** Polled by the admin timing page. Not an operation log. */
export async function loadSkillViewDiagnostics(query: SkillViewQuery): Promise<SkillViewDiagnostics> {
const params = new URLSearchParams({
page: String(query.page),
page_size: String(query.pageSize),
sort: query.sort,
order: query.order,
});
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/skill-view-diagnostics?${params}`);
if (!response.ok) {
throw new Error("技能耗时记录读取失败");
}
return (await response.json()) as SkillViewDiagnostics;
}
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mergeConversationAgentOptions } from "./conversation-options";
import type { Agent } from "./types";
function agent(
id: string,
options: Partial<Agent> = {},
): Agent {
return {
id,
name: options.name ?? id,
description: "",
user_id: options.user_id ?? null,
published: options.published ?? true,
is_private: options.is_private ?? false,
builtin: options.builtin ?? options.user_id == null,
...options,
};
}
test("keeps curated built-ins and appends every visible custom agent", () => {
const featured = [
agent("featured-builtin", { builtin: true }),
agent("featured-custom", { builtin: false, user_id: "owner-1" }),
];
const visible = [
agent("internal-worker", { builtin: true }),
agent("featured-custom", { builtin: false, user_id: "owner-1" }),
agent("owned-private", {
builtin: false,
user_id: "owner-1",
published: false,
}),
agent("published-custom", {
builtin: false,
user_id: "owner-2",
}),
];
assert.deepEqual(
mergeConversationAgentOptions(featured, visible).map(({ id }) => id),
[
"featured-builtin",
"featured-custom",
"owned-private",
"published-custom",
],
);
});
test("deduplicates ids and ignores malformed unavailable rows", () => {
const duplicate = agent("agent-1", {
name: "精选名称",
builtin: false,
user_id: "owner-1",
});
const result = mergeConversationAgentOptions(
[duplicate],
[
agent("agent-1", {
name: "列表名称",
builtin: false,
user_id: "owner-1",
}),
agent("", { builtin: false, user_id: "owner-1" }),
agent("missing-name", {
name: " ",
builtin: false,
user_id: "owner-1",
}),
],
);
assert.equal(result.length, 1);
assert.equal(result[0]?.name, "精选名称");
});
Loaded 100 of 1656 files, more files were not shown because too many files have changed in this diff. Show more