Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc8b0e082 |
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";
|
} from "lucide-react";
|
||||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||||
import { createContext, memo, useContext, useEffect, useState } 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> & {
|
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
from: UIMessage["role"];
|
from: UIMessage["role"];
|
||||||
@@ -302,11 +304,11 @@ export const MessageBranchPage = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
export type MessageResponseProps = StreamdownProps;
|
||||||
|
|
||||||
export const MessageResponse = memo(
|
export const MessageResponse = memo(
|
||||||
({ className, ...props }: MessageResponseProps) => (
|
({ className, ...props }: MessageResponseProps) => (
|
||||||
<Streamdown
|
<SafeStreamdown
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -34,9 +34,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import type { PromptInputFilePart } from "@/core/uploads";
|
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
|
||||||
import { splitUnsupportedUploadFiles } from "@/core/uploads";
|
import {
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
DEFAULT_UPLOAD_POLICY,
|
||||||
|
splitUnsupportedUploadFiles,
|
||||||
|
useUploadPolicy,
|
||||||
|
validateChatUploadFiles,
|
||||||
|
} from "@/core/uploads";
|
||||||
|
import { imeEnterAction } from "@/lib/ime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
@@ -56,11 +61,12 @@ import {
|
|||||||
type ChangeEventHandler,
|
type ChangeEventHandler,
|
||||||
Children,
|
Children,
|
||||||
type ClipboardEventHandler,
|
type ClipboardEventHandler,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
createContext,
|
type CompositionEventHandler,
|
||||||
type FormEvent,
|
createContext,
|
||||||
type FormEventHandler,
|
type FormEvent,
|
||||||
type HTMLAttributes,
|
type FormEventHandler,
|
||||||
|
type HTMLAttributes,
|
||||||
type KeyboardEventHandler,
|
type KeyboardEventHandler,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -123,11 +129,11 @@ export const usePromptInputController = () => {
|
|||||||
return ctx;
|
return ctx;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optional variants (do NOT throw). Useful for dual-mode components and for
|
// Optional variants (do NOT throw). Useful for dual-mode components and for
|
||||||
// Fast Refresh transitions where a page can briefly retain the previous
|
// Fast Refresh transitions where a page can briefly retain the previous
|
||||||
// provider context instance.
|
// provider context instance.
|
||||||
export const useOptionalPromptInputController = () =>
|
export const useOptionalPromptInputController = () =>
|
||||||
useContext(PromptInputController);
|
useContext(PromptInputController);
|
||||||
|
|
||||||
export const useProviderAttachments = () => {
|
export const useProviderAttachments = () => {
|
||||||
const ctx = useContext(ProviderAttachmentsContext);
|
const ctx = useContext(ProviderAttachmentsContext);
|
||||||
@@ -145,6 +151,7 @@ const usePromptInputValidation = () => useContext(PromptInputValidationContext);
|
|||||||
|
|
||||||
export type PromptInputProviderProps = PropsWithChildren<{
|
export type PromptInputProviderProps = PropsWithChildren<{
|
||||||
initialInput?: string;
|
initialInput?: string;
|
||||||
|
uploadPolicy?: UploadPolicy;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,8 +160,12 @@ export type PromptInputProviderProps = PropsWithChildren<{
|
|||||||
*/
|
*/
|
||||||
export function PromptInputProvider({
|
export function PromptInputProvider({
|
||||||
initialInput: initialTextInput = "",
|
initialInput: initialTextInput = "",
|
||||||
|
uploadPolicy,
|
||||||
children,
|
children,
|
||||||
}: PromptInputProviderProps) {
|
}: PromptInputProviderProps) {
|
||||||
|
const { data: fetchedUploadPolicy } = useUploadPolicy();
|
||||||
|
const effectiveUploadPolicy =
|
||||||
|
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
||||||
// ----- textInput state
|
// ----- textInput state
|
||||||
const [textInput, setTextInput] = useState(initialTextInput);
|
const [textInput, setTextInput] = useState(initialTextInput);
|
||||||
const clearInput = useCallback(() => setTextInput(""), []);
|
const clearInput = useCallback(() => setTextInput(""), []);
|
||||||
@@ -163,6 +174,7 @@ export function PromptInputProvider({
|
|||||||
const [attachmentFiles, setAttachmentFiles] = useState<
|
const [attachmentFiles, setAttachmentFiles] = useState<
|
||||||
(PromptInputFilePart & { id: string })[]
|
(PromptInputFilePart & { id: string })[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const attachmentsRef = useRef(attachmentFiles);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const openRef = useRef<() => void>(() => {});
|
const openRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
@@ -172,9 +184,26 @@ export function PromptInputProvider({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAttachmentFiles((prev) =>
|
const current = attachmentsRef.current;
|
||||||
prev.concat(
|
const existing = current.map((item) => ({
|
||||||
incoming.map((file) => ({
|
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(),
|
id: nanoid(),
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
url: URL.createObjectURL(file),
|
url: URL.createObjectURL(file),
|
||||||
@@ -182,33 +211,31 @@ export function PromptInputProvider({
|
|||||||
filename: file.name,
|
filename: file.name,
|
||||||
file,
|
file,
|
||||||
})),
|
})),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}, []);
|
attachmentsRef.current = next;
|
||||||
|
setAttachmentFiles(next);
|
||||||
|
}, [effectiveUploadPolicy]);
|
||||||
|
|
||||||
const remove = useCallback((id: string) => {
|
const remove = useCallback((id: string) => {
|
||||||
setAttachmentFiles((prev) => {
|
const current = attachmentsRef.current;
|
||||||
const found = prev.find((f) => f.id === id);
|
const found = current.find((f) => f.id === id);
|
||||||
if (found?.url) {
|
if (found?.url) URL.revokeObjectURL(found.url);
|
||||||
URL.revokeObjectURL(found.url);
|
const next = current.filter((f) => f.id !== id);
|
||||||
}
|
attachmentsRef.current = next;
|
||||||
return prev.filter((f) => f.id !== id);
|
setAttachmentFiles(next);
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
setAttachmentFiles((prev) => {
|
for (const f of attachmentsRef.current) {
|
||||||
for (const f of prev) {
|
if (f.url) {
|
||||||
if (f.url) {
|
URL.revokeObjectURL(f.url);
|
||||||
URL.revokeObjectURL(f.url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return [];
|
}
|
||||||
});
|
attachmentsRef.current = [];
|
||||||
|
setAttachmentFiles([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
||||||
const attachmentsRef = useRef(attachmentFiles);
|
|
||||||
attachmentsRef.current = attachmentFiles;
|
attachmentsRef.current = attachmentFiles;
|
||||||
|
|
||||||
// Cleanup blob URLs on unmount to prevent memory leaks
|
// Cleanup blob URLs on unmount to prevent memory leaks
|
||||||
@@ -404,14 +431,14 @@ export function PromptInputAttachments({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
|
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{attachments.files.map((file) => (
|
{attachments.files.map((file) => (
|
||||||
<div key={file.id} className="max-w-60">
|
<div key={file.id} className="max-w-60">
|
||||||
{children(file)}
|
{children(file)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,8 +486,15 @@ export type PromptInputProps = Omit<
|
|||||||
// Minimal constraints
|
// Minimal constraints
|
||||||
maxFiles?: number;
|
maxFiles?: number;
|
||||||
maxFileSize?: number; // bytes
|
maxFileSize?: number; // bytes
|
||||||
|
uploadPolicy?: UploadPolicy;
|
||||||
onError?: (err: {
|
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;
|
message: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
@@ -478,11 +512,22 @@ export const PromptInput = ({
|
|||||||
syncHiddenInput,
|
syncHiddenInput,
|
||||||
maxFiles,
|
maxFiles,
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
|
uploadPolicy,
|
||||||
onError,
|
onError,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: PromptInputProps) => {
|
}: 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
|
// Try to use a provider controller if present
|
||||||
const controller = useOptionalPromptInputController();
|
const controller = useOptionalPromptInputController();
|
||||||
const usingProvider = !!controller;
|
const usingProvider = !!controller;
|
||||||
@@ -517,6 +562,9 @@ export const PromptInput = ({
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
return patterns.some((pattern) => {
|
return patterns.some((pattern) => {
|
||||||
|
if (pattern.startsWith(".")) {
|
||||||
|
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
|
||||||
|
}
|
||||||
if (pattern.endsWith("/*")) {
|
if (pattern.endsWith("/*")) {
|
||||||
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
|
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
|
||||||
return f.type.startsWith(prefix);
|
return f.type.startsWith(prefix);
|
||||||
@@ -531,52 +579,67 @@ export const PromptInput = ({
|
|||||||
(fileList: File[] | FileList) => {
|
(fileList: File[] | FileList) => {
|
||||||
const incoming = Array.from(fileList);
|
const incoming = Array.from(fileList);
|
||||||
const accepted = incoming.filter((f) => matchesAccept(f));
|
const accepted = incoming.filter((f) => matchesAccept(f));
|
||||||
if (incoming.length && accepted.length === 0) {
|
if (accepted.length < incoming.length) {
|
||||||
onError?.({
|
const error = {
|
||||||
code: "accept",
|
code: "accept",
|
||||||
message: "No files match the accepted types.",
|
message: "部分文件不符合当前输入框允许的格式。",
|
||||||
});
|
} as const;
|
||||||
return;
|
onError?.(error);
|
||||||
|
if (!onError) toast.error(error.message);
|
||||||
}
|
}
|
||||||
const withinSize = (f: File) =>
|
const localPolicy: UploadPolicy = {
|
||||||
maxFileSize ? f.size <= maxFileSize : true;
|
...effectiveUploadPolicy,
|
||||||
const sized = accepted.filter(withinSize);
|
max_files_per_message:
|
||||||
if (accepted.length > 0 && sized.length === 0) {
|
|
||||||
onError?.({
|
|
||||||
code: "max_file_size",
|
|
||||||
message: "All files exceed the maximum size.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setItems((prev) => {
|
|
||||||
const capacity =
|
|
||||||
typeof maxFiles === "number"
|
typeof maxFiles === "number"
|
||||||
? Math.max(0, maxFiles - prev.length)
|
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
|
||||||
: undefined;
|
: effectiveUploadPolicy.max_files_per_message,
|
||||||
const capped =
|
groups: Object.fromEntries(
|
||||||
typeof capacity === "number" ? sized.slice(0, capacity) : sized;
|
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
|
||||||
if (typeof capacity === "number" && sized.length > capacity) {
|
key,
|
||||||
onError?.({
|
{
|
||||||
code: "max_files",
|
...group,
|
||||||
message: "Too many files. Some were not added.",
|
max_bytes:
|
||||||
});
|
typeof maxFileSize === "number"
|
||||||
}
|
? Math.min(group.max_bytes, maxFileSize)
|
||||||
const next: (PromptInputFilePart & { id: string })[] = [];
|
: group.max_bytes,
|
||||||
for (const file of capped) {
|
},
|
||||||
next.push({
|
]),
|
||||||
|
) 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(),
|
id: nanoid(),
|
||||||
type: "file",
|
type: "file" as const,
|
||||||
url: URL.createObjectURL(file),
|
url: URL.createObjectURL(file),
|
||||||
mediaType: file.type,
|
mediaType: file.type,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
file,
|
file,
|
||||||
});
|
})),
|
||||||
}
|
),
|
||||||
return prev.concat(next);
|
);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[matchesAccept, maxFiles, maxFileSize, onError],
|
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeLocal = useCallback(
|
const removeLocal = useCallback(
|
||||||
@@ -765,11 +828,17 @@ export const PromptInput = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const form = event.currentTarget;
|
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
|
const text = usingProvider
|
||||||
? controller.textInput.value
|
? (liveText ?? controller.textInput.value)
|
||||||
: (() => {
|
: (() => {
|
||||||
const formData = new FormData(form);
|
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
|
// Reset form immediately after capturing text to avoid race condition
|
||||||
@@ -832,7 +901,7 @@ export const PromptInput = ({
|
|||||||
const inner = (
|
const inner = (
|
||||||
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
|
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
|
||||||
<input
|
<input
|
||||||
accept={accept}
|
accept={accept ?? policyAccept}
|
||||||
aria-label="Upload files"
|
aria-label="Upload files"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
@@ -847,10 +916,12 @@ export const PromptInput = ({
|
|||||||
ref={formRef}
|
ref={formRef}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{/* Do not rely on InputGroup's :has(textarea) selector here: Chrome
|
{/* Do not rely on InputGroup's :has() selectors here: Chrome versions
|
||||||
versions before 105 ignore :has(), retain h-9, and clip the whole
|
before 105 ignore them, keep the horizontal flex layout, and
|
||||||
multi-line composer to a single row. */}
|
squeeze the textarea beside the footer controls. */}
|
||||||
<InputGroup className="h-auto min-h-16">{children}</InputGroup>
|
<InputGroup className="h-auto min-h-16 flex-col items-stretch">
|
||||||
|
{children}
|
||||||
|
</InputGroup>
|
||||||
</form>
|
</form>
|
||||||
</PromptInputValidationContext.Provider>
|
</PromptInputValidationContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -877,58 +948,100 @@ export type PromptInputTextareaProps = ComponentProps<
|
|||||||
typeof InputGroupTextarea
|
typeof InputGroupTextarea
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const PromptInputTextarea = ({
|
export const PromptInputTextarea = ({
|
||||||
onChange,
|
onChange,
|
||||||
onInput,
|
onInput,
|
||||||
className,
|
onKeyDown,
|
||||||
placeholder = "What would you like to know?",
|
onCompositionStart,
|
||||||
...props
|
onCompositionEnd,
|
||||||
}: PromptInputTextareaProps) => {
|
className,
|
||||||
const controller = useOptionalPromptInputController();
|
placeholder = "What would you like to know?",
|
||||||
const attachments = usePromptInputAttachments();
|
defaultValue,
|
||||||
const sanitizeIncomingFiles = usePromptInputValidation();
|
...props
|
||||||
const [isComposing, setIsComposing] = useState(false);
|
}: PromptInputTextareaProps) => {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const controller = useOptionalPromptInputController();
|
||||||
|
const attachments = usePromptInputAttachments();
|
||||||
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
|
const sanitizeIncomingFiles = usePromptInputValidation();
|
||||||
if (
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
typeof CSS !== "undefined" &&
|
const composingRef = useRef(false);
|
||||||
typeof CSS.supports === "function" &&
|
const compositionEndedAtRef = useRef(0);
|
||||||
CSS.supports("field-sizing", "content")
|
const onChangeRef = useRef(onChange);
|
||||||
) {
|
const onInputRef = useRef(onInput);
|
||||||
return;
|
const onKeyDownRef = useRef(onKeyDown);
|
||||||
}
|
const onCompositionStartRef = useRef(onCompositionStart);
|
||||||
|
const onCompositionEndRef = useRef(onCompositionEnd);
|
||||||
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
|
const setInputRef = useRef(controller?.textInput.setInput);
|
||||||
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
|
const attachmentsRef = useRef(attachments);
|
||||||
// stuck at its minimum height.
|
onChangeRef.current = onChange;
|
||||||
textarea.style.height = "auto";
|
onInputRef.current = onInput;
|
||||||
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
|
onKeyDownRef.current = onKeyDown;
|
||||||
textarea.style.height = `${height}px`;
|
onCompositionStartRef.current = onCompositionStart;
|
||||||
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
|
onCompositionEndRef.current = onCompositionEnd;
|
||||||
}, []);
|
setInputRef.current = controller?.textInput.setInput;
|
||||||
|
attachmentsRef.current = attachments;
|
||||||
// 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.
|
const publishValue = useCallback((value: string) => {
|
||||||
useEffect(() => {
|
setInputRef.current?.(value);
|
||||||
const textarea = textareaRef.current;
|
}, []);
|
||||||
if (!textarea) return;
|
|
||||||
|
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
|
||||||
const frame = window.requestAnimationFrame(() => {
|
// Firefox 102 commits an open IME composition as raw pinyin when the
|
||||||
resizeForLegacyChrome(textarea);
|
// textarea's height or overflow changes mid-composition.
|
||||||
});
|
if (composingRef.current) return;
|
||||||
return () => window.cancelAnimationFrame(frame);
|
if (
|
||||||
}, [controller?.textInput.value, resizeForLegacyChrome]);
|
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) => {
|
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
if (e.key === "Enter") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (e.shiftKey) {
|
onKeyDownRef.current?.(e);
|
||||||
|
if (e.defaultPrevented || e.shiftKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The confirming Enter already finished the candidate. Swallow it so
|
||||||
|
// Firefox does not also insert a newline or send the message.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (action === "swallow") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the submit button is disabled before submitting
|
// Check if the submit button is disabled before submitting
|
||||||
const form = e.currentTarget.form;
|
const form = e.currentTarget.form;
|
||||||
@@ -940,22 +1053,59 @@ export const PromptInputTextarea = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
form?.requestSubmit();
|
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
|
// Remove last attachment when Backspace is pressed and textarea is empty
|
||||||
if (
|
if (
|
||||||
e.key === "Backspace" &&
|
e.key === "Backspace" &&
|
||||||
e.currentTarget.value === "" &&
|
e.currentTarget.value === "" &&
|
||||||
attachments.files.length > 0
|
attachmentsRef.current.files.length > 0
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const lastAttachment = attachments.files.at(-1);
|
const lastAttachment = attachmentsRef.current.files.at(-1);
|
||||||
if (lastAttachment) {
|
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 handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
|
||||||
const items = event.clipboardData?.items;
|
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 (
|
return (
|
||||||
<InputGroupTextarea
|
<InputGroupTextarea
|
||||||
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
{...props}
|
||||||
name="message"
|
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
||||||
ref={textareaRef}
|
name="message"
|
||||||
rows={3}
|
ref={textareaRef}
|
||||||
onCompositionEnd={() => setIsComposing(false)}
|
rows={3}
|
||||||
onCompositionStart={() => setIsComposing(true)}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onCompositionEnd={handleCompositionEnd}
|
||||||
onInput={(event) => {
|
onCompositionStart={handleCompositionStart}
|
||||||
resizeForLegacyChrome(event.currentTarget);
|
onKeyDown={handleKeyDown}
|
||||||
onInput?.(event);
|
onInput={(event) => {
|
||||||
}}
|
if (!composingRef.current) {
|
||||||
|
resizeForLegacyChrome(event.currentTarget);
|
||||||
|
}
|
||||||
|
onInputRef.current?.(event);
|
||||||
|
}}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
{...props}
|
{...(controller
|
||||||
{...controlledProps}
|
? { value: controller.textInput.value }
|
||||||
|
: { defaultValue })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1131,20 +1274,24 @@ export const PromptInputSubmit = ({
|
|||||||
status,
|
status,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: PromptInputSubmitProps) => {
|
}: PromptInputSubmitProps) => {
|
||||||
let Icon = <ArrowUpIcon className="size-4" />;
|
let Icon = <ArrowUpIcon className="size-4" />;
|
||||||
|
let ariaLabel = "发送消息";
|
||||||
if (status === "submitted") {
|
|
||||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
if (status === "submitted") {
|
||||||
} else if (status === "streaming") {
|
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||||||
Icon = <SquareIcon className="size-4" />;
|
ariaLabel = "正在发送";
|
||||||
} else if (status === "error") {
|
} else if (status === "streaming") {
|
||||||
Icon = <XIcon className="size-4" />;
|
Icon = <SquareIcon className="size-4" />;
|
||||||
}
|
ariaLabel = "停止生成";
|
||||||
|
} else if (status === "error") {
|
||||||
return (
|
Icon = <XIcon className="size-4" />;
|
||||||
<InputGroupButton
|
ariaLabel = "重新发送";
|
||||||
aria-label="Submit"
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputGroupButton
|
||||||
|
aria-label={ariaLabel}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-300",
|
"transition-all duration-300",
|
||||||
status === "streaming" &&
|
status === "streaming" &&
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { createContext, memo, useContext, useEffect, useState } 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 { reasoningPlugins } from "@/core/streamdown/plugins";
|
||||||
import { Shimmer } from "./shimmer";
|
import { Shimmer } from "./shimmer";
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export const ReasoningContent = memo(
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{typeof children === "string" ? (
|
{typeof children === "string" ? (
|
||||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
<SafeStreamdown {...reasoningPlugins}>{children}</SafeStreamdown>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ import { queryClient } from "@/components/query-client-provider";
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import Empty from "./empty";
|
import Empty from "./empty";
|
||||||
import { NetworkActor } from "./network-actor";
|
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 }
|
data?: { query: string; mode: string }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
// 上传成功回调,返回上传的来源信息
|
// 上传成功回调,返回上传的来源信息
|
||||||
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
onUploadComplete?: (sources: StudioUploadSource[]) => 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[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface listDataItem {
|
interface listDataItem {
|
||||||
@@ -224,7 +214,7 @@ export const DocumentUploadDialogProvider: FC<{
|
|||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
searchSpace: { id: number | string;[key: string]: unknown };
|
searchSpace: { id: number | string;[key: string]: unknown };
|
||||||
onUploadSuccess?: () => void;
|
onUploadSuccess?: () => void;
|
||||||
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
onUploadComplete?: (sources: StudioUploadSource[]) => void;
|
||||||
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
|
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [initialSearchText, setInitialSearchText] = useState("");
|
const [initialSearchText, setInitialSearchText] = useState("");
|
||||||
@@ -353,27 +343,23 @@ const DocumentUploadPopupContent: FC<{
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
formData.append("files", file);
|
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",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json() as UploadResponse;
|
const data = await res.json() as V2UploadResponse;
|
||||||
// 调用上传完成回调,传递来源信息
|
// 调用上传完成回调,传递来源信息
|
||||||
if (data.sources && data.sources.length > 0) {
|
if (data.sources && data.sources.length > 0) {
|
||||||
onUploadComplete?.(data.sources.map(s => ({
|
onUploadComplete?.(normalizeV2UploadSources(data));
|
||||||
id: undefined, // 后端返回的数据中暂时没有 id
|
|
||||||
title: s.title,
|
|
||||||
task_id: s.task_id,
|
|
||||||
status: s.status,
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
|
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
|
||||||
// 只关闭上传弹窗
|
// 只关闭上传弹窗
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} else {
|
} else {
|
||||||
toast.error("上传失败,请重试");
|
const payload = await res.json().catch(() => null) as { detail?: string } | null;
|
||||||
|
toast.error(payload?.detail || `上传失败(HTTP ${res.status})`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("上传失败,请重试");
|
toast.error("上传失败,请重试");
|
||||||
@@ -410,7 +396,6 @@ const DocumentUploadPopupContent: FC<{
|
|||||||
selectedSearchSources.map((s) => ({
|
selectedSearchSources.map((s) => ({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
task_id: undefined,
|
|
||||||
status: "success" as const,
|
status: "success" as const,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useLayoutEffect } from "react";
|
||||||
|
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
|
import {
|
||||||
|
applyFontScaleToDocument,
|
||||||
|
normalizeFontScale,
|
||||||
|
normalizeFontScaleMode,
|
||||||
|
} from "@/core/ui/page-zoom";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
|
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
|
||||||
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
|
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
|
||||||
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
|
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变;
|
||||||
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
|
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。
|
||||||
* `fontScale === 1` 即默认 16px。
|
* `fontScale === 1` 即默认 16px。
|
||||||
*/
|
*/
|
||||||
export const BASE_FONT_PX = 16;
|
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 },
|
{ 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 变量。挂在应用根部,
|
* 把字号设置写到文档根节点。挂在应用根部,刷新后、绘制前生效。不渲染任何 DOM。
|
||||||
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
|
|
||||||
* 不渲染任何 DOM。
|
|
||||||
*/
|
*/
|
||||||
export function FontScaleApplier() {
|
export function FontScaleApplier() {
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const { fontScale } = settings.display;
|
const { fontScale, fontScaleMode } = settings.display;
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const scale =
|
applyFontScaleToDocument(
|
||||||
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
|
normalizeFontScaleMode(fontScaleMode),
|
||||||
? fontScale
|
normalizeFontScale(fontScale),
|
||||||
: 1;
|
);
|
||||||
const root = document.documentElement;
|
}, [fontScale, fontScaleMode]);
|
||||||
root.style.setProperty("--ui-font-scale", String(scale));
|
|
||||||
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
|
|
||||||
root.style.removeProperty("font-size");
|
|
||||||
}, [fontScale]);
|
|
||||||
|
|
||||||
return null;
|
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">`)
|
* 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
|
* 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 —
|
* 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
|
* The sandbox permits scripts so the preview can use the app-bundled HTML
|
||||||
* server already sanitizes script vectors and this is defense-in-depth).
|
* visualization runtimes, while still withholding same-origin privileges.
|
||||||
*
|
*
|
||||||
* The `charset=utf-8` on the blob type is required: a blob: URL whose
|
* 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
|
* 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>();
|
const [url, setUrl] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
|
const blob = new Blob([
|
||||||
|
prepareGeneratedHtml(html ?? ""),
|
||||||
|
], {
|
||||||
type: "text/html;charset=utf-8",
|
type: "text/html;charset=utf-8",
|
||||||
});
|
});
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { toCssPx } from "@/core/ui/page-zoom";
|
||||||
|
|
||||||
import type { TooltipState } from "./graphTypes";
|
import type { TooltipState } from "./graphTypes";
|
||||||
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
|
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
|
||||||
|
|
||||||
@@ -9,8 +11,8 @@ export function GraphTooltip({ tooltip }: GraphTooltipProps) {
|
|||||||
if (!tooltip) return null;
|
if (!tooltip) return null;
|
||||||
const meta = NODE_KIND_META[tooltip.kind];
|
const meta = NODE_KIND_META[tooltip.kind];
|
||||||
// Keep the tooltip inside the viewport.
|
// Keep the tooltip inside the viewport.
|
||||||
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
|
const left = Math.min(toCssPx(tooltip.x) + 14, window.innerWidth - 336);
|
||||||
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
|
const top = Math.min(toCssPx(tooltip.y) + 14, window.innerHeight - 120);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kg-tooltip" style={{ left, top }}>
|
<div className="kg-tooltip" style={{ left, top }}>
|
||||||
|
|||||||
@@ -4,19 +4,19 @@
|
|||||||
top: 4rem;
|
top: 4rem;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
width: 98px;
|
width: 98px;
|
||||||
height: calc(100vh - 4rem);
|
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
|
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
|
||||||
.sidebar-contnet--header-hidden {
|
.sidebar-contnet--header-hidden {
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 100vh;
|
height: calc(100vh / var(--df-page-zoom, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-contnet--header-hidden .item-nav-content {
|
.sidebar-contnet--header-hidden .item-nav-content {
|
||||||
height: calc(100vh - 20px);
|
height: calc(100vh / var(--df-page-zoom, 1) - 20px);
|
||||||
min-height: calc(100vh - 20px);
|
min-height: calc(100vh / var(--df-page-zoom, 1) - 20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-contents {
|
.sidebar-contents {
|
||||||
@@ -160,8 +160,8 @@
|
|||||||
|
|
||||||
.item-nav-content {
|
.item-nav-content {
|
||||||
width: 155px;
|
width: 155px;
|
||||||
height: calc(100vh - 4rem - 20px);
|
height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
|
||||||
min-height: calc(100vh - 4rem - 20px);
|
min-height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
|
||||||
background: rgba(3, 38, 80, 0.8);
|
background: rgba(3, 38, 80, 0.8);
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
border-color: rgba(43, 143, 254, 0.6);
|
border-color: rgba(43, 143, 254, 0.6);
|
||||||
@@ -634,7 +634,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
|
|||||||
top: 4rem;
|
top: 4rem;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
width: 96px;
|
width: 96px;
|
||||||
height: calc(100vh - 4rem);
|
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
background: #ffffff;
|
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. */
|
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
|
||||||
.light-sidebar--header-hidden {
|
.light-sidebar--header-hidden {
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 100vh;
|
height: calc(100vh / var(--df-page-zoom, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
|
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export function PageSidebar({
|
|||||||
onOpenChange={setNotificationOpen}
|
onOpenChange={setNotificationOpen}
|
||||||
/>
|
/>
|
||||||
<aside
|
<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
|
isDark
|
||||||
? "border-r border-blue-800/30 bg-slate-900"
|
? "border-r border-blue-800/30 bg-slate-900"
|
||||||
: "border-r border-red-100 bg-white"
|
: "border-r border-red-100 bg-white"
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
ScrollText,
|
||||||
|
Timer,
|
||||||
User,
|
User,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -222,14 +224,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
<User className="size-4" />
|
<User className="size-4" />
|
||||||
个人信息
|
个人信息
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isAdmin && (
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
||||||
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
>
|
||||||
>
|
<ListTree className="size-4" />
|
||||||
<ListTree className="size-4" />
|
{isAdmin ? "默认菜单管理" : "我的菜单"}
|
||||||
菜单管理
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/strategy/admin/task-buttons")}
|
onClick={() => navigate("/page/strategy/admin/task-buttons")}
|
||||||
@@ -270,6 +270,22 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
模型配置
|
模型配置
|
||||||
</DropdownMenuItem>
|
</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 && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||||
<Cpu className="size-4" />
|
<Cpu className="size-4" />
|
||||||
@@ -484,7 +500,9 @@ function LegacyAccordionSidebar({
|
|||||||
/>
|
/>
|
||||||
<aside
|
<aside
|
||||||
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
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
|
isDark
|
||||||
? "border-r border-blue-800/30 bg-slate-900"
|
? "border-r border-blue-800/30 bg-slate-900"
|
||||||
@@ -561,12 +579,10 @@ function LegacyAccordionSidebar({
|
|||||||
<User className="size-4" />
|
<User className="size-4" />
|
||||||
个人信息
|
个人信息
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isAdmin && (
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
|
||||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
|
<ListTree className="size-4" />
|
||||||
<ListTree className="size-4" />
|
{isAdmin ? "默认菜单管理" : "我的菜单"}
|
||||||
菜单管理
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/task-buttons")}>
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/task-buttons")}>
|
||||||
<MousePointerClick className="size-4" />
|
<MousePointerClick className="size-4" />
|
||||||
@@ -591,6 +607,22 @@ function LegacyAccordionSidebar({
|
|||||||
模型配置
|
模型配置
|
||||||
</DropdownMenuItem>
|
</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 && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||||
<Cpu className="size-4" />
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -71,37 +71,42 @@ const DialogOverlay = React.forwardRef<
|
|||||||
|
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
|
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
|
||||||
overlayClassName?: string;
|
overlayClassName?: string;
|
||||||
}
|
}
|
||||||
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
|
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay className={overlayClassName} />
|
<DialogOverlay className={overlayClassName} />
|
||||||
<DialogPrimitive.Content
|
<div
|
||||||
ref={ref}
|
data-slot="dialog-viewport"
|
||||||
data-slot="dialog-content"
|
className={cn("legacy-dialog-viewport", overlayClassName)}
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
{children}
|
<DialogPrimitive.Content
|
||||||
{showCloseButton && (
|
ref={ref}
|
||||||
<DialogPrimitive.Close
|
data-slot="dialog-content"
|
||||||
data-slot="dialog-close"
|
className={cn(
|
||||||
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"
|
"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,
|
||||||
<XIcon />
|
)}
|
||||||
<span className="sr-only">Close</span>
|
{...props}
|
||||||
</DialogPrimitive.Close>
|
>
|
||||||
)}
|
{children}
|
||||||
</DialogPrimitive.Content>
|
{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>
|
</DialogPortal>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,42 +20,42 @@ function DropdownMenuPortal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropdownMenuTrigger = React.forwardRef<
|
const DropdownMenuTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||||
>(({ ...props }, ref) => {
|
>(({ ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Trigger
|
<DropdownMenuPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="dropdown-menu-trigger"
|
data-slot="dropdown-menu-trigger"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
const DropdownMenuContent = React.forwardRef<
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => {
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
return (
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
<DropdownMenuPrimitive.Portal>
|
>(({ className, sideOffset = 4, ...props }, ref) => {
|
||||||
<DropdownMenuPrimitive.Content
|
return (
|
||||||
ref={ref}
|
<DropdownMenuPrimitive.Portal>
|
||||||
data-slot="dropdown-menu-content"
|
<DropdownMenuPrimitive.Content
|
||||||
sideOffset={sideOffset}
|
ref={ref}
|
||||||
className={cn(
|
data-slot="dropdown-menu-content"
|
||||||
"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",
|
sideOffset={sideOffset}
|
||||||
className,
|
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",
|
||||||
{...props}
|
className,
|
||||||
/>
|
)}
|
||||||
</DropdownMenuPrimitive.Portal>
|
{...props}
|
||||||
);
|
/>
|
||||||
});
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
});
|
||||||
|
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
function DropdownMenuGroup({
|
||||||
...props
|
...props
|
||||||
@@ -65,29 +65,29 @@ function DropdownMenuGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
variant?: "default" | "destructive";
|
variant?: "default" | "destructive";
|
||||||
}
|
}
|
||||||
>(({ className, inset, variant = "default", ...props }, ref) => {
|
>(({ className, inset, variant = "default", ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="dropdown-menu-item"
|
data-slot="dropdown-menu-item"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
className,
|
className,
|
||||||
@@ -205,51 +205,51 @@ function DropdownMenuSub({
|
|||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => {
|
>(({ className, inset, children, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
DropdownMenuSubTrigger.displayName =
|
|
||||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
DropdownMenuSubTrigger.displayName =
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
|
||||||
>(({ className, ...props }, ref) => {
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
return (
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
<DropdownMenuPrimitive.SubContent
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
ref={ref}
|
>(({ className, ...props }, ref) => {
|
||||||
data-slot="dropdown-menu-sub-content"
|
return (
|
||||||
className={cn(
|
<DropdownMenuPrimitive.SubContent
|
||||||
"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",
|
ref={ref}
|
||||||
className,
|
data-slot="dropdown-menu-sub-content"
|
||||||
)}
|
className={cn(
|
||||||
{...props}
|
"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;
|
});
|
||||||
|
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const SelectContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
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" &&
|
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",
|
"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,
|
className,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { Fragment } from "react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -21,12 +20,12 @@ export function Breadcrumb({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{segments.map((segment, i) => (
|
{segments.map((segment, i) => (
|
||||||
<Fragment key={`${segment}-${i}`}>
|
<div className="contents" key={`${segment}-${i}`}>
|
||||||
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
||||||
{segment}
|
{segment}
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
||||||
</Fragment>
|
</div>
|
||||||
))}
|
))}
|
||||||
<span
|
<span
|
||||||
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
||||||
import { useAllUsers } from "@/core/auth/hooks";
|
import { useAllUsers } from "@/core/auth/hooks";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -17,6 +19,11 @@ export function UserManagementPage() {
|
|||||||
title={t.adminUsers.title}
|
title={t.adminUsers.title}
|
||||||
description={t.adminUsers.description}
|
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 && (
|
{isLoading && (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<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
|
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
||||||
// both the card header and the compact list row.
|
// both the card header and the compact list row.
|
||||||
const statusBadges = (
|
const statusBadges = (
|
||||||
@@ -965,9 +960,9 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
已选 {selectedSkills.length} 个
|
已选 {selectedSkills.length} 个
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<SkillTransferSelector
|
<SkillTransferSelector
|
||||||
skills={enabledSkills}
|
skills={skills}
|
||||||
selected={selectedSkills}
|
selected={selectedSkills}
|
||||||
onChange={setSelectedSkills}
|
onChange={setSelectedSkills}
|
||||||
isLoading={isSkillsLoading}
|
isLoading={isSkillsLoading}
|
||||||
onViewDetail={setDetailSkillName}
|
onViewDetail={setDetailSkillName}
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
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 { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { useAgentsPaginated } from "@/core/agents";
|
import { useAgentsPaginated } from "@/core/agents";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
@@ -69,6 +73,8 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} 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 {
|
import {
|
||||||
bootstrapResumePath,
|
bootstrapResumePath,
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
@@ -379,6 +385,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||||
[threads],
|
[threads],
|
||||||
);
|
);
|
||||||
|
const projectThreadNames = useProjectThreadNames(visibleThreads);
|
||||||
|
|
||||||
const filteredThreads = useMemo(() => {
|
const filteredThreads = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -479,6 +486,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
) : (
|
) : (
|
||||||
filteredThreads.map((thread) => {
|
filteredThreads.map((thread) => {
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
|
const projectInfo = myProjectConversationInfoOfThread(
|
||||||
|
thread,
|
||||||
|
projectThreadNames,
|
||||||
|
);
|
||||||
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
@@ -492,6 +503,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
href={href}
|
href={href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
"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
|
isActive
|
||||||
? "border border-primary/40 bg-primary/5 font-medium text-primary"
|
? "border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||||
@@ -505,6 +517,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
)}
|
)}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
|
) : projectInfo ? (
|
||||||
|
<MyProjectThreadIcon type={projectInfo.type} />
|
||||||
) : (
|
) : (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -516,8 +530,22 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">
|
<span
|
||||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
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>
|
</span>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
<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,
|
positionName,
|
||||||
skillsIcon,
|
skillsIcon,
|
||||||
skillsTitle,
|
skillsTitle,
|
||||||
|
belowSkillsSlot,
|
||||||
|
feedbackScoreSlot,
|
||||||
showCommonQuestions = false,
|
showCommonQuestions = false,
|
||||||
jobResponsibilities,
|
jobResponsibilities,
|
||||||
panelTitle,
|
panelTitle,
|
||||||
@@ -181,6 +183,10 @@ export function AgentDetailPanel({
|
|||||||
positionName?: string;
|
positionName?: string;
|
||||||
skillsIcon?: ReactNode;
|
skillsIcon?: ReactNode;
|
||||||
skillsTitle?: string;
|
skillsTitle?: string;
|
||||||
|
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
|
||||||
|
belowSkillsSlot?: ReactNode;
|
||||||
|
/** Optional answer-feedback summary rendered near the agent identity. */
|
||||||
|
feedbackScoreSlot?: ReactNode;
|
||||||
showCommonQuestions?: boolean;
|
showCommonQuestions?: boolean;
|
||||||
/**
|
/**
|
||||||
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
||||||
@@ -744,6 +750,8 @@ export function AgentDetailPanel({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{!editing && feedbackScoreSlot}
|
||||||
|
|
||||||
|
|
||||||
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
|
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
|
||||||
{jobResponsibilities !== undefined && (
|
{jobResponsibilities !== undefined && (
|
||||||
@@ -905,7 +913,7 @@ export function AgentDetailPanel({
|
|||||||
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
|
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
|
||||||
<Section
|
<Section
|
||||||
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
|
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
|
||||||
title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
|
title={skillsTitle ?? `已配置技能 ${skillNamesShown.length} 个`}
|
||||||
className="position-roundtable-skills-section"
|
className="position-roundtable-skills-section"
|
||||||
extra={
|
extra={
|
||||||
editing ? (
|
editing ? (
|
||||||
@@ -940,6 +948,9 @@ export function AgentDetailPanel({
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
|
||||||
|
{belowSkillsSlot}
|
||||||
|
|
||||||
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
||||||
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
||||||
</div>
|
</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 { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||||
import type { Agent, AgentSort } from "@/core/agents";
|
import type { Agent, AgentSort } from "@/core/agents";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { useRouteBase } from "@/core/route-base";
|
||||||
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||||
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
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 { AgentCard } from "./agent-card";
|
||||||
import { SentimentAgentCard } from "./sentiment-agent-card";
|
import { SentimentAgentCard } from "./sentiment-agent-card";
|
||||||
@@ -34,9 +42,15 @@ const PAGE_SIZE = 12;
|
|||||||
// password never persists beyond the browsing session.
|
// password never persists beyond the browsing session.
|
||||||
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
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 { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const routeBase = useRouteBase();
|
||||||
const sentimentAgentVisible = isSentimentAgentVisible();
|
const sentimentAgentVisible = isSentimentAgentVisible();
|
||||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
@@ -52,6 +66,12 @@ export function AgentGallery() {
|
|||||||
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
|
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
|
||||||
// 其余自定义广场继续保持后端给出的顺序。
|
// 其余自定义广场继续保持后端给出的顺序。
|
||||||
const [defaultSquare, ...additionalSquares] = squareTabs;
|
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.
|
// 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.
|
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
||||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
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
|
// If the active square tab disappears (admin removed that square), fall back
|
||||||
// to "我的" so the gallery never points at a dead tab.
|
// to "我的" so the gallery never points at a dead tab.
|
||||||
useEffect(() => {
|
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) {
|
if (activeTab === "builtin" && !isAdmin) {
|
||||||
setActiveTab("mine");
|
setActiveTab("mine");
|
||||||
@@ -87,7 +131,14 @@ export function AgentGallery() {
|
|||||||
) {
|
) {
|
||||||
setActiveTab("mine");
|
setActiveTab("mine");
|
||||||
}
|
}
|
||||||
}, [activeTab, squareTabs, isAdmin]);
|
}, [
|
||||||
|
activeTab,
|
||||||
|
squareTabs,
|
||||||
|
isAdmin,
|
||||||
|
restrictWxTypes,
|
||||||
|
defaultSquareTabValue,
|
||||||
|
nanjingTabValue,
|
||||||
|
]);
|
||||||
|
|
||||||
const { data: privateStatus } = usePrivateSquareStatus();
|
const { data: privateStatus } = usePrivateSquareStatus();
|
||||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||||
@@ -128,10 +179,13 @@ export function AgentGallery() {
|
|||||||
sort,
|
sort,
|
||||||
page: mainPage,
|
page: mainPage,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
enabled: activeTab !== "private" && activeTab !== "sentiment",
|
enabled:
|
||||||
|
activeTab !== "private" &&
|
||||||
|
activeTab !== "sentiment" &&
|
||||||
|
!wxPlaceholderTab,
|
||||||
owner: publisher?.id,
|
owner: publisher?.id,
|
||||||
// Each square tab fetches only that square's agents.
|
// Each square tab fetches only that square's agents.
|
||||||
square: isSquareTab ? activeTab : undefined,
|
square: isSquareTab && !wxPlaceholderTab ? activeTab : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -169,11 +223,13 @@ export function AgentGallery() {
|
|||||||
(activeTab !== "private" || privateUnlocked);
|
(activeTab !== "private" || privateUnlocked);
|
||||||
|
|
||||||
const activeTotal =
|
const activeTotal =
|
||||||
activeTab === "sentiment"
|
wxPlaceholderTab
|
||||||
? 1
|
? 0
|
||||||
: activeTab === "private"
|
: activeTab === "sentiment"
|
||||||
? privateTotal
|
? 1
|
||||||
: mainTotal;
|
: activeTab === "private"
|
||||||
|
? privateTotal
|
||||||
|
: mainTotal;
|
||||||
const setTabPage = (tab: string, page: number) =>
|
const setTabPage = (tab: string, page: number) =>
|
||||||
setPages((prev) => ({ ...prev, [tab]: page }));
|
setPages((prev) => ({ ...prev, [tab]: page }));
|
||||||
|
|
||||||
@@ -404,6 +460,14 @@ export function AgentGallery() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
title="打开智能体广场视图"
|
||||||
|
onClick={() => router.push(`${routeBase}/agents/plaza`)}
|
||||||
|
>
|
||||||
|
广场视图
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -442,6 +506,14 @@ export function AgentGallery() {
|
|||||||
className="w-auto max-w-full flex-wrap justify-start self-start"
|
className="w-auto max-w-full flex-wrap justify-start self-start"
|
||||||
data-agent-guide="tabs"
|
data-agent-guide="tabs"
|
||||||
>
|
>
|
||||||
|
{restrictWxTypes ? (
|
||||||
|
<>
|
||||||
|
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||||
|
<TabsTrigger value={defaultSquareTabValue}>默认广场</TabsTrigger>
|
||||||
|
<TabsTrigger value={nanjingTabValue}>南京-db</TabsTrigger>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||||
{defaultSquare && (
|
{defaultSquare && (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
@@ -475,6 +547,8 @@ export function AgentGallery() {
|
|||||||
内置智能体
|
内置智能体
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||||
@@ -545,7 +619,41 @@ export function AgentGallery() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<TabsContent
|
||||||
key={sq.id}
|
key={sq.id}
|
||||||
value={sq.id}
|
value={sq.id}
|
||||||
@@ -560,7 +668,8 @@ export function AgentGallery() {
|
|||||||
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
|
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
|
||||||
})}
|
})}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||||
{renderListBody({
|
{renderListBody({
|
||||||
@@ -576,7 +685,7 @@ export function AgentGallery() {
|
|||||||
|
|
||||||
{/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑),
|
{/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑),
|
||||||
接口地址与鉴权在 runtime-config.js 配置。 */}
|
接口地址与鉴权在 runtime-config.js 配置。 */}
|
||||||
{sentimentAgentVisible && (
|
{!restrictWxTypes && sentimentAgentVisible && (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="sentiment"
|
value="sentiment"
|
||||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||||
@@ -593,7 +702,7 @@ export function AgentGallery() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{!restrictWxTypes && isAdmin && (
|
||||||
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||||
{renderListBody({
|
{renderListBody({
|
||||||
list: mainAgents,
|
list: mainAgents,
|
||||||
@@ -606,7 +715,7 @@ export function AgentGallery() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{privateFeatureAvailable && (
|
{!restrictWxTypes && privateFeatureAvailable && (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="private"
|
value="private"
|
||||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ export function AgentGuide({
|
|||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [current, setCurrent] = useState(-1);
|
const [current, setCurrent] = useState(-1);
|
||||||
|
const [appDialogOpen, setAppDialogOpen] = useState(false);
|
||||||
const stepsRef = useRef<GuideStep[]>([]);
|
const stepsRef = useRef<GuideStep[]>([]);
|
||||||
|
|
||||||
const start = useCallback(() => {
|
const start = useCallback(() => {
|
||||||
@@ -317,7 +318,32 @@ export function AgentGuide({
|
|||||||
setCurrent(-1);
|
setCurrent(-1);
|
||||||
}, [stage]);
|
}, [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 (
|
return (
|
||||||
<Guide
|
<Guide
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { MessageSquareIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MyProjectThreadBadge,
|
||||||
|
MyProjectThreadIcon,
|
||||||
|
} from "@/components/workspace/my-project-thread-indicator";
|
||||||
import { useThreadsPage } from "@/core/threads/hooks";
|
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 { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -26,6 +32,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
|||||||
const threads = (data?.threads ?? []).filter(
|
const threads = (data?.threads ?? []).filter(
|
||||||
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
|
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
|
||||||
);
|
);
|
||||||
|
const projectThreadNames = useProjectThreadNames(threads);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-card/60 rounded-xl border p-4">
|
<section className="bg-card/60 rounded-xl border p-4">
|
||||||
@@ -45,6 +52,10 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
|||||||
{threads.map((thread) => {
|
{threads.map((thread) => {
|
||||||
const href = pathOfThread(thread);
|
const href = pathOfThread(thread);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
|
const projectInfo = myProjectConversationInfoOfThread(
|
||||||
|
thread,
|
||||||
|
projectThreadNames,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={thread.thread_id}
|
key={thread.thread_id}
|
||||||
@@ -52,19 +63,37 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
|||||||
title={desensitize(titleOfThread(thread, "新对话"))}
|
title={desensitize(titleOfThread(thread, "新对话"))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors",
|
"flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors",
|
||||||
|
projectInfo && "min-h-12",
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary/5 text-primary font-medium"
|
? "bg-primary/5 text-primary font-medium"
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
: "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(
|
className={cn(
|
||||||
"size-3.5 shrink-0",
|
"min-w-0 flex-1",
|
||||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
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>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useMemo } from "react";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Skill } from "@/core/skills";
|
import type { Skill } from "@/core/skills";
|
||||||
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { isSkillOffline } from "@/core/skill-availability";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ export function EnabledSkillList({
|
|||||||
onViewDetail?: (name: string) => void;
|
onViewDetail?: (name: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const byName = useMemo(
|
const byName = useMemo(
|
||||||
() => new Map(skills.map((s) => [s.name, s])),
|
() => new Map(skills.map((s) => [s.name, s])),
|
||||||
[skills],
|
[skills],
|
||||||
@@ -79,12 +82,14 @@ export function EnabledSkillList({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{skillNames.map((name) => {
|
{skillNames.map((name) => {
|
||||||
const skill = byName.get(name);
|
const skill = byName.get(name);
|
||||||
|
const offline = isSkillOffline(skill);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={name}
|
key={name}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
|
"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",
|
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">
|
<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)}
|
{skillSourceLabel(skill)}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
|
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
|
||||||
{skill
|
{skill
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import type { Skill } from "@/core/skills";
|
import type { Skill } from "@/core/skills";
|
||||||
|
import { isSkillOffline } from "@/core/skill-availability";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -28,7 +29,11 @@ const PAGE_SIZE = 5;
|
|||||||
const ALL_CATEGORIES = "__all__";
|
const ALL_CATEGORIES = "__all__";
|
||||||
|
|
||||||
interface SkillTransferSelectorProps {
|
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[];
|
skills: Skill[];
|
||||||
/** Currently selected skill names — the persisted `skills: string[]`. */
|
/** Currently selected skill names — the persisted `skills: string[]`. */
|
||||||
selected: string[];
|
selected: string[];
|
||||||
@@ -91,7 +96,9 @@ export function SkillTransferSelector({
|
|||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const skill of skills) {
|
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));
|
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||||
}, [skills]);
|
}, [skills]);
|
||||||
@@ -102,6 +109,7 @@ export function SkillTransferSelector({
|
|||||||
const availableSkills = useMemo(() => {
|
const availableSkills = useMemo(() => {
|
||||||
return skills.filter(
|
return skills.filter(
|
||||||
(skill) =>
|
(skill) =>
|
||||||
|
!isSkillOffline(skill) &&
|
||||||
!selectedSet.has(skill.name) &&
|
!selectedSet.has(skill.name) &&
|
||||||
matchesQuery(skill, query) &&
|
matchesQuery(skill, query) &&
|
||||||
(category === ALL_CATEGORIES || skill.category?.trim() === category),
|
(category === ALL_CATEGORIES || skill.category?.trim() === category),
|
||||||
@@ -384,6 +392,7 @@ function SkillItemCard({
|
|||||||
onPick,
|
onPick,
|
||||||
onViewDetail,
|
onViewDetail,
|
||||||
}: SkillItemCardProps) {
|
}: SkillItemCardProps) {
|
||||||
|
const isOffline = isSkillOffline(skill);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -398,6 +407,7 @@ function SkillItemCard({
|
|||||||
title={isSelected ? "点击移除" : "点击添加"}
|
title={isSelected ? "点击移除" : "点击添加"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
|
"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
|
isSelected
|
||||||
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
|
? "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",
|
: "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)}
|
{skillLabel(skill)}
|
||||||
</div>
|
</div>
|
||||||
|
{isOffline && (
|
||||||
|
<div className="text-destructive truncate text-[10px]">
|
||||||
|
已下线,仅可移除
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{skill.description?.trim() && (
|
{skill.description?.trim() && (
|
||||||
<div className="text-muted-foreground truncate text-[11px]">
|
<div className="text-muted-foreground truncate text-[11px]">
|
||||||
{desensitize(skill.description)}
|
{desensitize(skill.description)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FileType2Icon,
|
FileType2Icon,
|
||||||
|
GlobeIcon,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
PackageIcon,
|
PackageIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
@@ -17,11 +18,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useParams } from "next/navigation";
|
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 type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { defaultUrlTransform } from "react-markdown";
|
import { defaultUrlTransform } from "react-markdown";
|
||||||
import { Streamdown } from "streamdown";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Artifact,
|
Artifact,
|
||||||
@@ -58,17 +59,13 @@ import { publishArtifactToKnowledge } from "@/core/agents/api";
|
|||||||
import { useAgent } from "@/core/agents/hooks";
|
import { useAgent } from "@/core/agents/hooks";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
import { copyTextToClipboard } from "@/core/clipboard";
|
import { copyTextToClipboard } from "@/core/clipboard";
|
||||||
|
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
|
||||||
import {
|
import {
|
||||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||||
DOCUMENT_REWRITE_STARTED_EVENT,
|
DOCUMENT_REWRITE_STARTED_EVENT,
|
||||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||||
useDocumentRewritePresentation,
|
useDocumentRewritePresentation,
|
||||||
} from "@/core/document-rewrite/context";
|
} from "@/core/document-rewrite/context";
|
||||||
import {
|
|
||||||
clearArtifactRewriteFallback,
|
|
||||||
completeArtifactRewriteFallback,
|
|
||||||
primeArtifactRewriteFallback,
|
|
||||||
} from "@/core/document-rewrite/local-fallback";
|
|
||||||
import {
|
import {
|
||||||
downloadArtifactFile,
|
downloadArtifactFile,
|
||||||
} from "@/core/artifacts/download";
|
} from "@/core/artifacts/download";
|
||||||
@@ -85,7 +82,11 @@ import {
|
|||||||
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
|
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||||
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
import {
|
||||||
|
SafeStreamdown,
|
||||||
|
streamdownPlugins,
|
||||||
|
useStreamdownMermaidPngDownload,
|
||||||
|
} from "@/core/streamdown";
|
||||||
import {
|
import {
|
||||||
checkCodeFile,
|
checkCodeFile,
|
||||||
getFileName,
|
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({
|
export function ArtifactFileDetail({
|
||||||
className,
|
className,
|
||||||
filepath: filepathFromProps,
|
filepath: filepathFromProps,
|
||||||
@@ -266,8 +283,16 @@ export function ArtifactFileDetail({
|
|||||||
headerLeadingActionBare?: boolean;
|
headerLeadingActionBare?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { artifacts, setOpen, select } = useArtifacts();
|
const { artifacts, setOpen, select, setGeneratingArtifact } = useArtifacts();
|
||||||
const { isMock, thread, publicShareCode } = useThread();
|
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(() => {
|
const isWriteFileVirtual = useMemo(() => {
|
||||||
return filepathFromProps.startsWith("write-file:");
|
return filepathFromProps.startsWith("write-file:");
|
||||||
}, [filepathFromProps]);
|
}, [filepathFromProps]);
|
||||||
@@ -413,16 +438,23 @@ export function ArtifactFileDetail({
|
|||||||
presentationId: null,
|
presentationId: null,
|
||||||
thinking: "",
|
thinking: "",
|
||||||
});
|
});
|
||||||
const documentRewriteActive = documentRewrite.isActive;
|
const documentRewriteActive = documentRewrite.isActive;
|
||||||
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
const generationLockId = useId();
|
||||||
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
useLayoutEffect(() => {
|
||||||
const displayContent = documentRewriteActive
|
const owner = `preview:${generationLockId}`;
|
||||||
? documentRewrite.run.draft
|
const generating = (writeFileInFlight && thread.isLoading) || documentRewriteActive;
|
||||||
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
|
setGeneratingArtifact(owner, generating ? filepathFromProps : null);
|
||||||
? draftContent
|
return () => setGeneratingArtifact(owner, null);
|
||||||
: (content ?? ""));
|
}, [documentRewriteActive, filepathFromProps, generationLockId, setGeneratingArtifact, thread.isLoading, writeFileInFlight]);
|
||||||
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
||||||
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
|
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 sandboxDisplayText = useMemo(() => {
|
||||||
const raw = displayContent ?? "";
|
const raw = displayContent ?? "";
|
||||||
if (disableDesensitize) return raw;
|
if (disableDesensitize) return raw;
|
||||||
@@ -445,8 +477,8 @@ export function ArtifactFileDetail({
|
|||||||
!isArtifactContentLoading;
|
!isArtifactContentLoading;
|
||||||
const isAutoPreviewLanguage =
|
const isAutoPreviewLanguage =
|
||||||
language === "html" || language === "markdown" || language === "mermaid";
|
language === "html" || language === "markdown" || language === "mermaid";
|
||||||
const isPreviewStreaming =
|
const isPreviewStreaming =
|
||||||
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
|
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
|
||||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||||
const fileTypeLabel = useMemo(() => {
|
const fileTypeLabel = useMemo(() => {
|
||||||
if (isImageFile(filepath)) return "IMG";
|
if (isImageFile(filepath)) return "IMG";
|
||||||
@@ -471,16 +503,16 @@ export function ArtifactFileDetail({
|
|||||||
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
|
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!documentRewriteActive) return;
|
if (!documentRewriteActive) return;
|
||||||
setAllowAutoViewSwitch(false);
|
setAllowAutoViewSwitch(false);
|
||||||
setAutoScrollEnabled(true);
|
setAutoScrollEnabled(true);
|
||||||
setShowResumeAutoScroll(false);
|
setShowResumeAutoScroll(false);
|
||||||
setViewMode("code");
|
setViewMode("code");
|
||||||
}, [documentRewriteActive]);
|
}, [documentRewriteActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewMode !== "edit" || canUseAiEditor) return;
|
if (viewMode !== "edit" || canUseAiEditor) return;
|
||||||
@@ -525,13 +557,20 @@ export function ArtifactFileDetail({
|
|||||||
presentationId: string,
|
presentationId: string,
|
||||||
event: DocumentRewriteStreamEvent,
|
event: DocumentRewriteStreamEvent,
|
||||||
) => {
|
) => {
|
||||||
if (!rewritePresentation) return;
|
if (!rewritePresentation) return;
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "snapshot_fixed":
|
case "job_started":
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
stage: "requirements",
|
jobId: event.jobId,
|
||||||
fileName: event.fileName || getFileName(filepath),
|
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||||
originalCharCount: event.originalCharCount,
|
});
|
||||||
|
break;
|
||||||
|
case "snapshot_fixed":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "requirements",
|
||||||
|
fileName: event.fileName || getFileName(filepath),
|
||||||
|
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||||
|
originalCharCount: event.originalCharCount,
|
||||||
requirementsPlan: "",
|
requirementsPlan: "",
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -592,10 +631,11 @@ export function ArtifactFileDetail({
|
|||||||
case "commit_started":
|
case "commit_started":
|
||||||
rewritePresentation.update(presentationId, { stage: "committing" });
|
rewritePresentation.update(presentationId, { stage: "committing" });
|
||||||
break;
|
break;
|
||||||
case "committed":
|
case "committed":
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
rewrittenContent: event.content,
|
rewrittenContent: event.content,
|
||||||
versionId: event.versionId ?? null,
|
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||||
|
versionId: event.versionId ?? null,
|
||||||
committedHash: event.committedHash,
|
committedHash: event.committedHash,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -611,9 +651,13 @@ export function ArtifactFileDetail({
|
|||||||
case "cancelled":
|
case "cancelled":
|
||||||
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
|
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
console.error("AI 全文改写历史恢复失败:", event.message);
|
rewritePresentation.update(presentationId, {
|
||||||
break;
|
stage: "error",
|
||||||
|
error: event.message,
|
||||||
|
diagnostic: event.diagnostic ?? null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -635,10 +679,18 @@ export function ArtifactFileDetail({
|
|||||||
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
|
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
|
||||||
? historicalResult.summary as DocumentRewriteSummary
|
? historicalResult.summary as DocumentRewriteSummary
|
||||||
: null;
|
: null;
|
||||||
const historicalPresentationId = rewritePresentation?.create({
|
const historicalPresentationId = rewritePresentation?.create({
|
||||||
threadId,
|
threadId,
|
||||||
source: { kind: "artifact", id: threadId, path: filepath },
|
source: { kind: "artifact", id: threadId, path: filepath },
|
||||||
fileName: getFileName(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,
|
instruction: historical.request.instruction,
|
||||||
style: historical.request.style,
|
style: historical.request.style,
|
||||||
modelName: historical.request.modelName,
|
modelName: historical.request.modelName,
|
||||||
@@ -659,11 +711,14 @@ export function ArtifactFileDetail({
|
|||||||
summary: historicalSummary,
|
summary: historicalSummary,
|
||||||
warnings: historicalSummary?.validationWarnings ?? [],
|
warnings: historicalSummary?.validationWarnings ?? [],
|
||||||
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
|
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
|
||||||
error: historicalResult.status === "conflict"
|
error: historicalResult.status === "conflict"
|
||||||
? "文件在改写期间已变化,未覆盖原文件。"
|
? "文件在改写期间已变化,未覆盖原文件。"
|
||||||
: historical.status === "cancelled"
|
: historical.status === "cancelled"
|
||||||
? "已停止全文改写,原文件未被修改。"
|
? "已停止全文改写,原文件未被修改。"
|
||||||
: historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null,
|
: historical.status === "failed"
|
||||||
|
? historical.errorMessage ?? "全文改写未完成,原文件未被修改。"
|
||||||
|
: null,
|
||||||
|
diagnostic: historical.diagnostic ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const result = latest.result;
|
const result = latest.result;
|
||||||
@@ -672,36 +727,25 @@ export function ArtifactFileDetail({
|
|||||||
: null;
|
: null;
|
||||||
const originalContent = latest.request.originalContent || draftContent;
|
const originalContent = latest.request.originalContent || draftContent;
|
||||||
const rewrittenContent = typeof result.content === "string" ? result.content : null;
|
const rewrittenContent = typeof result.content === "string" ? result.content : null;
|
||||||
const versionSnapshotFailed = result.versionSnapshotFailed === true;
|
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
|
||||||
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
|
const outputPath = typeof result.outputPath === "string"
|
||||||
if (!versionSnapshotFailed) {
|
? result.outputPath
|
||||||
clearArtifactRewriteFallback(threadId, filepath);
|
: latest.outputPath;
|
||||||
}
|
const presentationId = rewritePresentation?.create({
|
||||||
const localFallbackAvailable =
|
threadId,
|
||||||
versionSnapshotFailed &&
|
source: { kind: "artifact", id: threadId, path: filepath },
|
||||||
latest.status === "completed" &&
|
jobId: latest.id,
|
||||||
result.status !== "conflict" &&
|
outputPath,
|
||||||
committedHash !== null &&
|
fileName: getFileName(outputPath ?? filepath),
|
||||||
completeArtifactRewriteFallback({
|
|
||||||
threadId,
|
|
||||||
path: filepath,
|
|
||||||
originalContent,
|
|
||||||
committedHash,
|
|
||||||
});
|
|
||||||
const presentationId = rewritePresentation?.create({
|
|
||||||
threadId,
|
|
||||||
source: { kind: "artifact", id: threadId, path: filepath },
|
|
||||||
fileName: getFileName(filepath),
|
|
||||||
instruction: latest.request.instruction,
|
instruction: latest.request.instruction,
|
||||||
style: latest.request.style,
|
style: latest.request.style,
|
||||||
modelName: latest.request.modelName,
|
modelName: latest.request.modelName,
|
||||||
generateImages: latest.request.generateImages,
|
generateImages: latest.request.generateImages,
|
||||||
maxGeneratedImages: latest.request.maxGeneratedImages,
|
maxGeneratedImages: latest.request.maxGeneratedImages,
|
||||||
originalContent,
|
originalContent,
|
||||||
rewrittenContent,
|
rewrittenContent,
|
||||||
committedHash,
|
committedHash,
|
||||||
localFallbackAvailable,
|
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
||||||
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
|
||||||
});
|
});
|
||||||
if (!presentationId || !rewritePresentation) return;
|
if (!presentationId || !rewritePresentation) return;
|
||||||
setDocumentRewritePresentationId(presentationId);
|
setDocumentRewritePresentationId(presentationId);
|
||||||
@@ -710,32 +754,22 @@ export function ArtifactFileDetail({
|
|||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
stage: result.status === "conflict" ? "conflict" : "completed",
|
stage: result.status === "conflict" ? "conflict" : "completed",
|
||||||
summary,
|
summary,
|
||||||
warnings: summary?.validationWarnings ?? [],
|
warnings: summary?.validationWarnings ?? [],
|
||||||
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
||||||
committedHash,
|
committedHash,
|
||||||
localFallbackAvailable,
|
error: result.status === "conflict" ? "改写版文件未生成。" : null,
|
||||||
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
|
});
|
||||||
});
|
return;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
if (latest.status === "failed" || latest.status === "cancelled") {
|
if (latest.status === "failed" || latest.status === "cancelled") {
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
stage: latest.status === "cancelled" ? "cancelled" : "error",
|
stage: latest.status === "cancelled" ? "cancelled" : "error",
|
||||||
summary,
|
summary,
|
||||||
});
|
error: latest.status === "cancelled"
|
||||||
|
? "已停止全文改写,原文件未被修改。"
|
||||||
|
: latest.errorMessage ?? "全文改写未完成,原文件未被修改。",
|
||||||
|
diagnostic: latest.status === "failed" ? latest.diagnostic ?? null : null,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,35 +791,22 @@ export function ArtifactFileDetail({
|
|||||||
signal,
|
signal,
|
||||||
),
|
),
|
||||||
onEvent: (event) => updatePresentation(presentationId, event),
|
onEvent: (event) => updatePresentation(presentationId, event),
|
||||||
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
|
onCommitted: (rewritten, { committedHash, outputPath }) => {
|
||||||
setDraftContent(rewritten);
|
rewritePresentation.update(presentationId, {
|
||||||
setHasLocalEdits(versionSnapshotFailed);
|
rewrittenContent: rewritten,
|
||||||
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
|
committedHash,
|
||||||
threadId,
|
...(outputPath ? { outputPath } : {}),
|
||||||
path: filepath,
|
});
|
||||||
originalContent,
|
},
|
||||||
committedHash,
|
onFailure: ({ error, diagnostic }) => {
|
||||||
});
|
rewritePresentation.update(presentationId, {
|
||||||
if (!versionSnapshotFailed) {
|
stage: "error",
|
||||||
clearArtifactRewriteFallback(threadId, filepath);
|
error,
|
||||||
}
|
diagnostic: diagnostic ?? null,
|
||||||
if (versionSnapshotFailed) {
|
});
|
||||||
console.error(
|
},
|
||||||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
},
|
||||||
);
|
);
|
||||||
}
|
|
||||||
rewritePresentation.update(presentationId, {
|
|
||||||
rewrittenContent: rewritten,
|
|
||||||
committedHash,
|
|
||||||
localFallbackAvailable,
|
|
||||||
});
|
|
||||||
queryClient.setQueriesData(
|
|
||||||
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
|
||||||
{ content: rewritten, url: undefined },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Existing artifacts remain available even if history hydration fails.
|
// Existing artifacts remain available even if history hydration fails.
|
||||||
}
|
}
|
||||||
@@ -908,8 +929,8 @@ export function ArtifactFileDetail({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (language === "html") {
|
if (language === "html") {
|
||||||
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
|
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,9 +940,9 @@ export function ArtifactFileDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setViewMode("code");
|
setViewMode("code");
|
||||||
}, [
|
}, [
|
||||||
documentRewriteActive,
|
documentRewriteActive,
|
||||||
isPreviewStreaming,
|
isPreviewStreaming,
|
||||||
isSupportPreview,
|
isSupportPreview,
|
||||||
language,
|
language,
|
||||||
writeFileInFlight,
|
writeFileInFlight,
|
||||||
@@ -932,7 +953,7 @@ export function ArtifactFileDetail({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (writeFileInFlight || documentRewriteActive) {
|
if (writeFileInFlight || documentRewriteActive) {
|
||||||
setViewMode("code");
|
setViewMode("code");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -944,10 +965,10 @@ export function ArtifactFileDetail({
|
|||||||
}, 1400);
|
}, 1400);
|
||||||
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, [
|
}, [
|
||||||
allowAutoViewSwitch,
|
allowAutoViewSwitch,
|
||||||
documentRewriteActive,
|
documentRewriteActive,
|
||||||
isAutoPreviewLanguage,
|
isAutoPreviewLanguage,
|
||||||
isSupportPreview,
|
isSupportPreview,
|
||||||
writeFileInFlight,
|
writeFileInFlight,
|
||||||
]);
|
]);
|
||||||
@@ -1108,16 +1129,17 @@ export function ArtifactFileDetail({
|
|||||||
generateImages: boolean;
|
generateImages: boolean;
|
||||||
maxGeneratedImages: number;
|
maxGeneratedImages: number;
|
||||||
}) => {
|
}) => {
|
||||||
if (!canEditArtifact) {
|
if (!canEditArtifact) {
|
||||||
toast.error("当前文件暂不支持全文改写。");
|
toast.error("当前文件暂不支持全文改写。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDocumentRewriteDialogOpen(false);
|
setDocumentRewriteDialogOpen(false);
|
||||||
setAllowAutoViewSwitch(false);
|
setAllowAutoViewSwitch(false);
|
||||||
setAutoScrollEnabled(true);
|
setAutoScrollEnabled(true);
|
||||||
setViewMode("code");
|
setShowResumeAutoScroll(false);
|
||||||
|
setViewMode("code");
|
||||||
// Do not make users manually save before using AI rewrite. Persist their
|
|
||||||
|
// Do not make users manually save before using AI rewrite. Persist their
|
||||||
// current draft first so the rewrite starts from exactly what they see,
|
// current draft first so the rewrite starts from exactly what they see,
|
||||||
// and the optimistic hash remains valid on the server.
|
// and the optimistic hash remains valid on the server.
|
||||||
if (hasLocalEdits) {
|
if (hasLocalEdits) {
|
||||||
@@ -1136,12 +1158,7 @@ export function ArtifactFileDetail({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
primeArtifactRewriteFallback({
|
const expectedHash = await sha256Content(draftContent);
|
||||||
threadId,
|
|
||||||
path: filepath,
|
|
||||||
originalContent: draftContent,
|
|
||||||
});
|
|
||||||
const expectedHash = await sha256Content(draftContent);
|
|
||||||
const presentationId = rewritePresentation?.create({
|
const presentationId = rewritePresentation?.create({
|
||||||
threadId,
|
threadId,
|
||||||
source: { kind: "artifact", id: threadId, path: filepath },
|
source: { kind: "artifact", id: threadId, path: filepath },
|
||||||
@@ -1173,14 +1190,21 @@ export function ArtifactFileDetail({
|
|||||||
expectedHash,
|
expectedHash,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onEvent: (event) => {
|
onEvent: (event) => {
|
||||||
if (!presentationId || !rewritePresentation) return;
|
if (!presentationId || !rewritePresentation) return;
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "snapshot_fixed":
|
case "job_started":
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
stage: "requirements",
|
jobId: event.jobId,
|
||||||
fileName: event.fileName || getFileName(filepath),
|
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||||
originalCharCount: event.originalCharCount,
|
});
|
||||||
|
break;
|
||||||
|
case "snapshot_fixed":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "requirements",
|
||||||
|
fileName: event.fileName || getFileName(filepath),
|
||||||
|
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||||
|
originalCharCount: event.originalCharCount,
|
||||||
requirementsPlan: "",
|
requirementsPlan: "",
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -1244,9 +1268,11 @@ export function ArtifactFileDetail({
|
|||||||
case "commit_started":
|
case "commit_started":
|
||||||
rewritePresentation.update(presentationId, { stage: "committing" });
|
rewritePresentation.update(presentationId, { stage: "committing" });
|
||||||
break;
|
break;
|
||||||
case "committed":
|
case "committed":
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
versionId: event.versionId ?? null,
|
rewrittenContent: event.content,
|
||||||
|
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||||
|
versionId: event.versionId ?? null,
|
||||||
committedHash: event.committedHash,
|
committedHash: event.committedHash,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -1256,77 +1282,48 @@ export function ArtifactFileDetail({
|
|||||||
versionId: event.versionId ?? null,
|
versionId: event.versionId ?? null,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "conflict":
|
case "conflict":
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
stage: "conflict",
|
stage: "conflict",
|
||||||
error: event.message,
|
error: event.message,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "cancelled":
|
||||||
// The sandbox retains the visible draft and exposes manual
|
rewritePresentation.update(presentationId, {
|
||||||
// save instead of showing an error state to the user.
|
stage: "cancelled",
|
||||||
console.error("AI 全文改写接口失败:", event.message);
|
error: event.message,
|
||||||
break;
|
});
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "error",
|
||||||
|
error: event.message,
|
||||||
|
diagnostic: event.diagnostic ?? null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
|
onCommitted: (rewritten, { committedHash, outputPath }) => {
|
||||||
setDraftContent(rewritten);
|
if (presentationId && rewritePresentation) {
|
||||||
setHasLocalEdits(versionSnapshotFailed);
|
rewritePresentation.update(presentationId, {
|
||||||
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
|
rewrittenContent: rewritten,
|
||||||
threadId,
|
committedHash,
|
||||||
path: filepath,
|
...(outputPath ? { outputPath } : {}),
|
||||||
originalContent: draftContent,
|
});
|
||||||
committedHash,
|
}
|
||||||
});
|
toast.success("AI 改写完成,已生成独立的改写版文件。原文件保持不变。");
|
||||||
if (!versionSnapshotFailed) {
|
},
|
||||||
clearArtifactRewriteFallback(threadId, filepath);
|
onFailure: ({ error, diagnostic }) => {
|
||||||
}
|
if (presentationId && rewritePresentation) {
|
||||||
if (presentationId && rewritePresentation) {
|
rewritePresentation.update(presentationId, {
|
||||||
rewritePresentation.update(presentationId, {
|
stage: "error",
|
||||||
rewrittenContent: rewritten,
|
error,
|
||||||
committedHash,
|
diagnostic: diagnostic ?? null,
|
||||||
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);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1344,15 +1341,37 @@ export function ArtifactFileDetail({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCancelDocumentRewrite = useCallback(() => {
|
const handleCancelDocumentRewrite = useCallback(() => {
|
||||||
documentRewrite.cancel();
|
documentRewrite.cancel();
|
||||||
if (documentRewritePresentationId && rewritePresentation) {
|
if (documentRewritePresentationId && rewritePresentation) {
|
||||||
rewritePresentation.update(documentRewritePresentationId, {
|
rewritePresentation.update(documentRewritePresentationId, {
|
||||||
stage: "cancelled",
|
stage: "cancelled",
|
||||||
error: "已停止全文改写,原文件未被修改。",
|
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/…)且该智能体
|
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
|
||||||
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
|
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
|
||||||
@@ -1427,7 +1446,9 @@ export function ArtifactFileDetail({
|
|||||||
toast.error("文件内容为空,暂时无法在新窗口打开");
|
toast.error("文件内容为空,暂时无法在新窗口打开");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const blob = new Blob([prepareGeneratedHtml(source)], {
|
const blob = new Blob([
|
||||||
|
prepareGeneratedHtml(source),
|
||||||
|
], {
|
||||||
type: "text/html;charset=utf-8",
|
type: "text/html;charset=utf-8",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -1454,7 +1475,11 @@ export function ArtifactFileDetail({
|
|||||||
: language === "markdown"
|
: language === "markdown"
|
||||||
? "text/markdown;charset=utf-8"
|
? "text/markdown;charset=utf-8"
|
||||||
: "text/plain;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 url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -1585,19 +1610,25 @@ export function ArtifactFileDetail({
|
|||||||
{canEditArtifact && (
|
{canEditArtifact && (
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
|
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
|
||||||
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||||||
tooltip={
|
tooltip={
|
||||||
documentRewriteActive
|
documentRewriteActive
|
||||||
? "正在生成临时草稿,原文件尚未被覆盖"
|
? "正在生成独立改写版文件,原文件保持不变;点击停止"
|
||||||
: hasLocalEdits
|
: hasLocalEdits
|
||||||
? "将先保存当前 Markdown 修改,再开始改写"
|
? "将先保存当前 Markdown 修改,再开始改写"
|
||||||
: "使用 AI 重写当前完整 Markdown"
|
: "使用 AI 重写当前完整 Markdown"
|
||||||
}
|
}
|
||||||
// Keep the entry available after a completed rewrite and when
|
// Keep the entry available after a completed rewrite and when
|
||||||
// the user has local edits. Starting with local edits saves
|
// the user has local edits. Starting with local edits saves
|
||||||
// them automatically in handleStartDocumentRewrite.
|
// them automatically in handleStartDocumentRewrite.
|
||||||
onClick={() => setDocumentRewriteDialogOpen(true)}
|
onClick={() => {
|
||||||
/>
|
if (documentRewriteActive) {
|
||||||
|
handleCancelDocumentRewrite();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDocumentRewriteDialogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||||
@@ -1647,6 +1678,16 @@ export function ArtifactFileDetail({
|
|||||||
onClick={handleOpenInNewWindow}
|
onClick={handleOpenInNewWindow}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
|
||||||
|
<ArtifactAction
|
||||||
|
icon={GlobeIcon}
|
||||||
|
label="站点预览"
|
||||||
|
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(siteStudioRoute(threadId, siteStudioTarget))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
icon={DownloadIcon}
|
icon={DownloadIcon}
|
||||||
label={t.common.download}
|
label={t.common.download}
|
||||||
@@ -1713,27 +1754,6 @@ export function ArtifactFileDetail({
|
|||||||
正在改写 Markdown
|
正在改写 Markdown
|
||||||
</div>
|
</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 && (
|
{showResumeAutoScroll && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1744,7 +1764,7 @@ export function ArtifactFileDetail({
|
|||||||
滑动到底部并继续自动下滑
|
滑动到底部并继续自动下滑
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
|
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
|
||||||
<MarkdownAiEditor
|
<MarkdownAiEditor
|
||||||
// 编辑用原始内容(与源码模式一致,不脱敏);有 artifacts 保存能力时走头部「保存 Markdown」。
|
// 编辑用原始内容(与源码模式一致,不脱敏);有 artifacts 保存能力时走头部「保存 Markdown」。
|
||||||
// write-file 虚拟预览(如深度研究)仅本地 draft,切换预览/源码仍可见本次改动。
|
// write-file 虚拟预览(如深度研究)仅本地 draft,切换预览/源码仍可见本次改动。
|
||||||
@@ -1801,16 +1821,16 @@ export function ArtifactFileDetail({
|
|||||||
? (displayContent ?? "")
|
? (displayContent ?? "")
|
||||||
: sandboxDisplayText
|
: sandboxDisplayText
|
||||||
}
|
}
|
||||||
readonly={!canEditArtifact || documentRewriteActive}
|
readonly={!canEditArtifact || documentRewriteActive}
|
||||||
onChange={
|
onChange={
|
||||||
canEditArtifact && !documentRewriteActive
|
canEditArtifact && !documentRewriteActive
|
||||||
? (value) => {
|
? (value) => {
|
||||||
setDraftContent(value);
|
setDraftContent(value);
|
||||||
setHasLocalEdits(true);
|
setHasLocalEdits(true);
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
isStreaming={writeFileInFlight || documentRewriteActive}
|
isStreaming={writeFileInFlight || documentRewriteActive}
|
||||||
streamingReadonlyFallback={!canEditArtifact}
|
streamingReadonlyFallback={!canEditArtifact}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2094,7 +2114,9 @@ export function ArtifactFilePreview({
|
|||||||
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||||||
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
||||||
// of navigating the sandboxed blob: document (which blanks the frame).
|
// 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",
|
type: "text/html;charset=utf-8",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -2147,7 +2169,7 @@ export function ArtifactFilePreview({
|
|||||||
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Streamdown
|
<SafeStreamdown
|
||||||
className="size-full"
|
className="size-full"
|
||||||
{...streamdownPlugins}
|
{...streamdownPlugins}
|
||||||
// react-markdown's safe default strips unknown protocols before the
|
// react-markdown's safe default strips unknown protocols before the
|
||||||
@@ -2172,7 +2194,7 @@ export function ArtifactFilePreview({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{previewContent}
|
{previewContent}
|
||||||
</Streamdown>
|
</SafeStreamdown>
|
||||||
{activeCursor && (
|
{activeCursor && (
|
||||||
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
getFileExtensionDisplayName,
|
getFileExtensionDisplayName,
|
||||||
getFileIcon,
|
getFileIcon,
|
||||||
getFileName,
|
getFileName,
|
||||||
|
isWordDocumentFile,
|
||||||
} from "@/core/utils/files";
|
} from "@/core/utils/files";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -93,12 +94,12 @@ export function ArtifactFileList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={cn("flex w-full flex-col gap-4", className)}>
|
<ul className={cn("flex w-full flex-col gap-4", className)}>
|
||||||
{files.map((file) => (
|
{files.map((file) => (
|
||||||
<Card
|
<Card
|
||||||
key={file}
|
key={file}
|
||||||
className="relative cursor-pointer p-3"
|
className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")}
|
||||||
onClick={() => handleClick(file)}
|
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">
|
<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">
|
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
|
||||||
<div className="min-w-0">{getFileName(file)}</div>
|
<div className="min-w-0">{getFileName(file)}</div>
|
||||||
@@ -133,7 +134,7 @@ export function ArtifactFileList({
|
|||||||
</Button>
|
</Button>
|
||||||
</CardAction>
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
<SkillInstallDialog
|
<SkillInstallDialog
|
||||||
open={!!installFile}
|
open={!!installFile}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
|
||||||
|
|
||||||
export function artifactPreviewKey(artifact: string): string {
|
export function artifactPreviewKey(artifact: string): string {
|
||||||
if (!artifact.startsWith("write-file:")) {
|
if (!artifact.startsWith("write-file:")) {
|
||||||
@@ -38,6 +40,8 @@ export interface ArtifactsContextType {
|
|||||||
select: (artifact: string, autoSelect?: boolean) => void;
|
select: (artifact: string, autoSelect?: boolean) => void;
|
||||||
/** Open/update a streamed write without overriding a manual close of that call. */
|
/** Open/update a streamed write without overriding a manual close of that call. */
|
||||||
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
|
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;
|
deselect: () => void;
|
||||||
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -86,6 +90,26 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
const [preOpen, setPreOpen] = useState(false);
|
const [preOpen, setPreOpen] = useState(false);
|
||||||
const [autoOpen, setAutoOpen] = useState(true);
|
const [autoOpen, setAutoOpen] = useState(true);
|
||||||
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
|
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,避免每次
|
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
|
||||||
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
|
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
|
||||||
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
|
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
|
||||||
@@ -129,6 +153,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
|
|
||||||
const select = useCallback(
|
const select = useCallback(
|
||||||
(artifact: string, autoSelect = false) => {
|
(artifact: string, autoSelect = false) => {
|
||||||
|
if (!canSwitchArtifact(artifact, !autoSelect)) return;
|
||||||
const previewKey = artifactPreviewKey(artifact);
|
const previewKey = artifactPreviewKey(artifact);
|
||||||
selectedArtifactKeyRef.current = previewKey;
|
selectedArtifactKeyRef.current = previewKey;
|
||||||
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
||||||
@@ -140,11 +165,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
setAutoSelect((previous) => (previous ? false : previous));
|
setAutoSelect((previous) => (previous ? false : previous));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sidebar],
|
[canSwitchArtifact, sidebar],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openStreamedArtifact = useCallback(
|
const openStreamedArtifact = useCallback(
|
||||||
(artifact: string, previewKey: string) => {
|
(artifact: string, previewKey: string) => {
|
||||||
|
if (!canSwitchArtifact(artifact, false)) return false;
|
||||||
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -180,10 +206,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[applyOpen, cancelPendingStreamedOpen, sidebar],
|
[applyOpen, canSwitchArtifact, cancelPendingStreamedOpen, sidebar],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deselect = useCallback(() => {
|
const deselect = useCallback(() => {
|
||||||
|
generatingArtifactsRef.current.clear();
|
||||||
selectedArtifactKeyRef.current = null;
|
selectedArtifactKeyRef.current = null;
|
||||||
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
||||||
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
||||||
@@ -224,6 +251,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
selectedArtifactKey,
|
selectedArtifactKey,
|
||||||
select,
|
select,
|
||||||
openStreamedArtifact,
|
openStreamedArtifact,
|
||||||
|
setGeneratingArtifact,
|
||||||
deselect,
|
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 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 { FilesIcon, XIcon } from "lucide-react";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
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 type { GroupImperativeHandle } from "react-resizable-panels";
|
||||||
|
|
||||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
@@ -19,11 +19,22 @@ import {
|
|||||||
listThreadArtifacts,
|
listThreadArtifacts,
|
||||||
mergeArtifactPaths,
|
mergeArtifactPaths,
|
||||||
} from "@/core/artifacts/listing";
|
} 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 { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
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 { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||||
@@ -35,6 +46,7 @@ import {
|
|||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
collectRuntimeDisplaySources,
|
collectRuntimeDisplaySources,
|
||||||
} from "@/core/skill-display/runtime";
|
} from "@/core/skill-display/runtime";
|
||||||
|
import { isWordDocumentFile } from "@/core/utils/files";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -45,6 +57,7 @@ import {
|
|||||||
useArtifacts,
|
useArtifacts,
|
||||||
} from "../artifacts";
|
} from "../artifacts";
|
||||||
import { ReferenceBatchesContext, useThread } from "../messages/context";
|
import { ReferenceBatchesContext, useThread } from "../messages/context";
|
||||||
|
import { KnowledgeWikiPanel } from "../knowledge-wiki-panel";
|
||||||
import { SkillDisplaySources } from "../skill-display-sources";
|
import { SkillDisplaySources } from "../skill-display-sources";
|
||||||
|
|
||||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||||
@@ -83,15 +96,30 @@ const ChatBox: React.FC<{
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
disableArtifacts?: boolean;
|
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,
|
children,
|
||||||
threadId,
|
threadId,
|
||||||
disableArtifacts = false,
|
disableArtifacts = false,
|
||||||
|
writingMode,
|
||||||
|
preferredArtifact = null,
|
||||||
|
pollForPreferredArtifact = false,
|
||||||
|
onPreferredArtifactAvailabilityChange,
|
||||||
|
responsiveArtifactLayout = false,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
isMock,
|
isMock,
|
||||||
thread,
|
thread,
|
||||||
knowledgeReferencesEnabled = false,
|
knowledgeReferencesEnabled = false,
|
||||||
|
knowledgeReferences,
|
||||||
} = useThread();
|
} = useThread();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -101,6 +129,8 @@ const ChatBox: React.FC<{
|
|||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
const threadIdRef = useRef(threadId);
|
const threadIdRef = useRef(threadId);
|
||||||
const openedRequestedArtifactRef = useRef<string | null>(null);
|
const openedRequestedArtifactRef = useRef<string | null>(null);
|
||||||
|
const openedPreferredArtifactRef = useRef<string | null>(null);
|
||||||
|
const preferredRunWasLoadingRef = useRef(false);
|
||||||
const artifactsCountRef = useRef(0);
|
const artifactsCountRef = useRef(0);
|
||||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||||
@@ -112,9 +142,12 @@ const ChatBox: React.FC<{
|
|||||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const stackArtifactPanel = responsiveArtifactLayout && isMobile;
|
||||||
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||||
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
||||||
const writingModeEnabled = !!settings.context.writing_mode;
|
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
|
||||||
const artifactDirectoryModeEnabled =
|
const artifactDirectoryModeEnabled =
|
||||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
@@ -123,7 +156,8 @@ const ChatBox: React.FC<{
|
|||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||||
isThreadRagModeLocked(threadId) ||
|
isThreadRagModeLocked(threadId) ||
|
||||||
knowledgeReferencesEnabled;
|
knowledgeReferencesEnabled ||
|
||||||
|
!!knowledgeReferences;
|
||||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||||
// the existing reference panel, but must not request or write reference
|
// the existing reference panel, but must not request or write reference
|
||||||
// batches through DeerFlow's backend APIs.
|
// batches through DeerFlow's backend APIs.
|
||||||
@@ -140,7 +174,7 @@ const ChatBox: React.FC<{
|
|||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
|
|
||||||
const canScanArtifactDirectory =
|
const canScanArtifactDirectory =
|
||||||
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
|
(artifactDirectoryModeEnabled || !!requestedArtifact || !!preferredArtifact) &&
|
||||||
!disableArtifacts &&
|
!disableArtifacts &&
|
||||||
!isMock &&
|
!isMock &&
|
||||||
!!threadId &&
|
!!threadId &&
|
||||||
@@ -149,7 +183,8 @@ const ChatBox: React.FC<{
|
|||||||
queryKey: ["artifact-directory", threadId],
|
queryKey: ["artifact-directory", threadId],
|
||||||
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
|
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
|
||||||
enabled: canScanArtifactDirectory,
|
enabled: canScanArtifactDirectory,
|
||||||
refetchInterval: thread.isLoading ? 1_500 : false,
|
refetchInterval:
|
||||||
|
thread.isLoading || pollForPreferredArtifact ? 1_500 : false,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const checkpointArtifacts = useMemo(
|
const checkpointArtifacts = useMemo(
|
||||||
@@ -159,7 +194,7 @@ const ChatBox: React.FC<{
|
|||||||
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||||
const availableArtifacts = useMemo(
|
const availableArtifacts = useMemo(
|
||||||
() => {
|
() => {
|
||||||
if (!artifactDirectoryModeEnabled && !requestedArtifact) {
|
if (!artifactDirectoryModeEnabled && !requestedArtifact && !preferredArtifact) {
|
||||||
return checkpointArtifacts;
|
return checkpointArtifacts;
|
||||||
}
|
}
|
||||||
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
|
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
|
||||||
@@ -172,9 +207,28 @@ const ChatBox: React.FC<{
|
|||||||
artifactDirectoryModeEnabled,
|
artifactDirectoryModeEnabled,
|
||||||
artifactListingQuery.data?.files,
|
artifactListingQuery.data?.files,
|
||||||
checkpointArtifacts,
|
checkpointArtifacts,
|
||||||
|
preferredArtifact,
|
||||||
requestedArtifact,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!canScanArtifactDirectory || thread.isLoading) return;
|
if (!canScanArtifactDirectory || thread.isLoading) return;
|
||||||
@@ -199,8 +253,20 @@ const ChatBox: React.FC<{
|
|||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
selectedArtifactKey,
|
selectedArtifactKey,
|
||||||
openStreamedArtifact,
|
openStreamedArtifact,
|
||||||
|
setGeneratingArtifact,
|
||||||
} = useArtifacts();
|
} = 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 [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
const skillDisplayMap = useMemo(
|
const skillDisplayMap = useMemo(
|
||||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
@@ -239,6 +305,41 @@ const ChatBox: React.FC<{
|
|||||||
threadId,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!shouldFetchBackendReferenceBatches) {
|
if (!shouldFetchBackendReferenceBatches) {
|
||||||
setBackendReferenceBatches([]);
|
setBackendReferenceBatches([]);
|
||||||
@@ -290,7 +391,7 @@ const ChatBox: React.FC<{
|
|||||||
),
|
),
|
||||||
[backendReferenceBatches, skillDisplayMap],
|
[backendReferenceBatches, skillDisplayMap],
|
||||||
);
|
);
|
||||||
const referenceBatches = useMemo(
|
const resolvedReferenceBatches = useMemo(
|
||||||
() => {
|
() => {
|
||||||
if (
|
if (
|
||||||
shouldFetchBackendReferenceBatches &&
|
shouldFetchBackendReferenceBatches &&
|
||||||
@@ -306,6 +407,18 @@ const ChatBox: React.FC<{
|
|||||||
shouldFetchBackendReferenceBatches,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!shouldFetchBackendReferenceBatches) {
|
if (!shouldFetchBackendReferenceBatches) {
|
||||||
return;
|
return;
|
||||||
@@ -434,13 +547,50 @@ const ChatBox: React.FC<{
|
|||||||
!disableArtifacts &&
|
!disableArtifacts &&
|
||||||
nextArtifacts.length > 0 &&
|
nextArtifacts.length > 0 &&
|
||||||
((artifactDirectoryModeEnabled && threadChanged) ||
|
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
(hasNewArtifact || !hadArtifacts))
|
||||||
) {
|
) {
|
||||||
const newest = nextArtifacts[nextArtifacts.length - 1]!;
|
// The preferred deliverable wins over the generic "newest artifact"
|
||||||
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
|
// heuristic. Both effects can run in the same commit with a stale null
|
||||||
selectArtifact(newest);
|
// selectedArtifact; choosing the preferred path here prevents a sibling
|
||||||
setArtifactsOpen(true);
|
// file such as research-plan.json from overwriting report.md.
|
||||||
setSideTab("document");
|
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) {
|
if (thread?.values?.artifacts?.length > 0) {
|
||||||
setAutoSelectFirstArtifact(false);
|
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,
|
autoSelectFirstArtifact,
|
||||||
deselect,
|
deselect,
|
||||||
selectArtifact,
|
selectArtifact,
|
||||||
|
openStreamedArtifact,
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
|
selectedArtifactKey,
|
||||||
artifacts,
|
artifacts,
|
||||||
setArtifacts,
|
setArtifacts,
|
||||||
|
setArtifactsOpen,
|
||||||
availableArtifacts,
|
availableArtifacts,
|
||||||
|
availablePreferredArtifact,
|
||||||
disableArtifacts,
|
disableArtifacts,
|
||||||
thread.messages,
|
thread.messages,
|
||||||
writingModeEnabled,
|
writingModeEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Live sandbox: open as soon as any in-flight write_file has a path or
|
// Live sandbox: open when an in-flight write_file has a usable output path.
|
||||||
// the first content bytes — not only when that tool is the last CoT step.
|
// Pathless content stays inline in the CoT, including content-first streams.
|
||||||
// Agentfx (and other agents) often emit write_file + present_files/bash in
|
// Agentfx (and other agents) often emit write_file + present_files/bash in
|
||||||
// the same turn; the collapsed earlier write would otherwise never auto-open.
|
// the same turn; the collapsed earlier write would otherwise never auto-open.
|
||||||
const streamingWritePreview = useMemo(
|
const streamingWritePreview = useMemo(
|
||||||
@@ -497,6 +656,25 @@ const ChatBox: React.FC<{
|
|||||||
setSideTab("document");
|
setSideTab("document");
|
||||||
}, [openStreamedArtifact, streamingWritePreview]);
|
}, [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);
|
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wasEnabled = directoryModeWasEnabledRef.current;
|
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||||
@@ -504,12 +682,13 @@ const ChatBox: React.FC<{
|
|||||||
if (
|
if (
|
||||||
wasEnabled ||
|
wasEnabled ||
|
||||||
!artifactDirectoryModeEnabled ||
|
!artifactDirectoryModeEnabled ||
|
||||||
availableArtifacts.length === 0
|
availableArtifacts.length === 0 ||
|
||||||
|
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedArtifact) {
|
if (!selectedArtifact) {
|
||||||
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
|
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!, true);
|
||||||
}
|
}
|
||||||
setArtifactsOpen(true);
|
setArtifactsOpen(true);
|
||||||
setSideTab("document");
|
setSideTab("document");
|
||||||
@@ -664,7 +843,7 @@ const ChatBox: React.FC<{
|
|||||||
<ReferenceBatchesContext.Provider value={referenceBatches}>
|
<ReferenceBatchesContext.Provider value={referenceBatches}>
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
id={`${resizableIdBase}-panels`}
|
id={`${resizableIdBase}-panels`}
|
||||||
orientation="horizontal"
|
orientation={stackArtifactPanel ? "vertical" : "horizontal"}
|
||||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||||
groupRef={layoutRef}
|
groupRef={layoutRef}
|
||||||
>
|
>
|
||||||
@@ -675,6 +854,8 @@ const ChatBox: React.FC<{
|
|||||||
id={`${resizableIdBase}-separator`}
|
id={`${resizableIdBase}-separator`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"opacity-33 hover:opacity-100",
|
"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",
|
!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">
|
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
|
||||||
<TabsList variant="line">
|
<TabsList variant="line">
|
||||||
<TabsTrigger value="document">文档</TabsTrigger>
|
<TabsTrigger value="document">文档</TabsTrigger>
|
||||||
<TabsTrigger value="references">参考文献</TabsTrigger>
|
<TabsTrigger value="references">
|
||||||
|
{knowledgeReferences ? "Wiki" : "参考文献"}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -712,11 +895,19 @@ const ChatBox: React.FC<{
|
|||||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||||
{artifactDocumentPanel}
|
{artifactDocumentPanel}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
<TabsContent value="references" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||||
<ReferencePanel
|
{knowledgeReferences ? (
|
||||||
highlightedReferenceId={pinnedReferenceId}
|
<KnowledgeWikiPanel
|
||||||
sources={referenceSources}
|
highlightedReferenceId={pinnedReferenceId}
|
||||||
/>
|
selection={knowledgeReferences}
|
||||||
|
sources={referenceSources}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReferencePanel
|
||||||
|
highlightedReferenceId={pinnedReferenceId}
|
||||||
|
sources={referenceSources}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : artifactPanelOpen ? (
|
) : artifactPanelOpen ? (
|
||||||
@@ -735,10 +926,18 @@ const ChatBox: React.FC<{
|
|||||||
<XIcon />
|
<XIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ReferencePanel
|
{knowledgeReferences ? (
|
||||||
highlightedReferenceId={pinnedReferenceId}
|
<KnowledgeWikiPanel
|
||||||
sources={referenceSources}
|
highlightedReferenceId={pinnedReferenceId}
|
||||||
/>
|
selection={knowledgeReferences}
|
||||||
|
sources={referenceSources}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReferencePanel
|
||||||
|
highlightedReferenceId={pinnedReferenceId}
|
||||||
|
sources={referenceSources}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -304,7 +304,9 @@ export function CompactModeGuide({
|
|||||||
setCurrent(-1);
|
setCurrent(-1);
|
||||||
}, [clearPollTimer, releasePromptDialog]);
|
}, [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 (
|
return (
|
||||||
<Guide
|
<Guide
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
FileJson,
|
FileJson,
|
||||||
FileText,
|
FileText,
|
||||||
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -32,14 +33,17 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
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 { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Select } from "tdesign-react";
|
||||||
|
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { findNanjingDbSquareId } from "@/core/system-settings/wx-type-filter";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -60,6 +64,10 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
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 { useAgentsPaginated, type Agent } from "@/core/agents";
|
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -75,6 +83,8 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} 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 {
|
import {
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
isHiddenFromRecentChats,
|
isHiddenFromRecentChats,
|
||||||
@@ -87,10 +97,17 @@ import {
|
|||||||
} from "@/core/threads/utils";
|
} from "@/core/threads/utils";
|
||||||
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
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 { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
import { cn } from "@/lib/utils";
|
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 { agentColor } from "./agent-icon-color";
|
||||||
import { SidebarCollapseButton } from "./workspace-header";
|
import { SidebarCollapseButton } from "./workspace-header";
|
||||||
@@ -114,30 +131,74 @@ function SectionLabel({
|
|||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
|
||||||
type="button"
|
<button
|
||||||
onClick={onToggle}
|
type="button"
|
||||||
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"
|
onClick={onToggle}
|
||||||
>
|
className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none"
|
||||||
<span className="flex items-center gap-1.5">
|
>
|
||||||
<span>{label}</span>
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
{count != null && (
|
<span className="truncate">{label}</span>
|
||||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
{count != null && (
|
||||||
{count}
|
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
||||||
</span>
|
{count}
|
||||||
)}
|
</span>
|
||||||
</span>
|
)}
|
||||||
<ChevronDown
|
</span>
|
||||||
className={cn(
|
<ChevronDown
|
||||||
"size-3.5 shrink-0 transition-transform duration-200",
|
className={cn(
|
||||||
collapsed && "-rotate-90",
|
"size-3.5 shrink-0 transition-transform duration-200",
|
||||||
)}
|
collapsed && "-rotate-90",
|
||||||
/>
|
)}
|
||||||
</button>
|
/>
|
||||||
|
</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 { t } = useI18n();
|
||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
||||||
@@ -158,17 +219,70 @@ export function GeneralQaSidebar() {
|
|||||||
setAgentsCollapsed((v) => {
|
setAgentsCollapsed((v) => {
|
||||||
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
||||||
return !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 = () =>
|
const toggleChats = () =>
|
||||||
setChatsCollapsed((v) => {
|
setChatsCollapsed((v) => {
|
||||||
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
|
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
|
||||||
return !v;
|
return !v;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
|
// ── 常用智能体取数:wx 走类型筛选;其它页面仍是置顶 → 我的 → 广场 ──
|
||||||
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
const { agents: mine } = useAgentsPaginated({
|
||||||
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
scope: "mine",
|
||||||
const frequent = useMemo<Agent[]>(() => {
|
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 out: Agent[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const add = (agent: Agent | undefined) => {
|
const add = (agent: Agent | undefined) => {
|
||||||
@@ -180,7 +294,15 @@ export function GeneralQaSidebar() {
|
|||||||
mine.forEach(add);
|
mine.forEach(add);
|
||||||
square.forEach(add);
|
square.forEach(add);
|
||||||
return out;
|
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=):用于高亮对应卡片。
|
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -193,6 +315,12 @@ export function GeneralQaSidebar() {
|
|||||||
`${routeBase}/agents`,
|
`${routeBase}/agents`,
|
||||||
currentSearch,
|
currentSearch,
|
||||||
);
|
);
|
||||||
|
const moreSkillsHref = buildFullChatNavigationPath(
|
||||||
|
`${routeBase}/skills`,
|
||||||
|
currentSearch,
|
||||||
|
);
|
||||||
|
const wxAgentsPageActive = pathname === `${routeBase}/agents`;
|
||||||
|
const wxSkillsPageActive = pathname === `${routeBase}/skills`;
|
||||||
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
|
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
|
||||||
|
|
||||||
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||||||
@@ -200,21 +328,31 @@ export function GeneralQaSidebar() {
|
|||||||
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
||||||
const handleSelectAgent = useCallback(
|
const handleSelectAgent = useCallback(
|
||||||
(agentId: string) => {
|
(agentId: string) => {
|
||||||
|
if (agentId === SENTIMENT_AGENT_ID) {
|
||||||
|
navigate(
|
||||||
|
buildFullChatNavigationPath(
|
||||||
|
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`,
|
||||||
|
currentSearch,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigate(
|
navigate(
|
||||||
buildFullChatNavigationPath(
|
buildFullChatNavigationPath(
|
||||||
routeBase === "/page/workspace"
|
routeBase === "/page/workspace" || wxCommonAgents
|
||||||
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
||||||
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
||||||
currentSearch,
|
currentSearch,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[currentSearch, navigate, newChatPath, routeBase],
|
[currentSearch, navigate, newChatPath, routeBase, wxCommonAgents],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 最近的对话取数 + 搜索 ──
|
// ── 最近的对话取数 + 搜索 ──
|
||||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||||
const { data: threads = [] } = useThreads();
|
const { data: threads = [] } = useThreads();
|
||||||
|
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
|
||||||
const { mutate: deleteThread } = useDeleteThread();
|
const { mutate: deleteThread } = useDeleteThread();
|
||||||
const { mutate: renameThread } = useRenameThread();
|
const { mutate: renameThread } = useRenameThread();
|
||||||
const { toggle: togglePin } = useToggleThreadPin();
|
const { toggle: togglePin } = useToggleThreadPin();
|
||||||
@@ -241,17 +379,28 @@ export function GeneralQaSidebar() {
|
|||||||
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||||
[threads],
|
[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 q = query.trim().toLowerCase();
|
||||||
const list = q
|
const list = q
|
||||||
? visibleThreads.filter((thread) =>
|
? recentItems.filter((item) =>
|
||||||
titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q),
|
(item.kind === "thread"
|
||||||
|
? titleOfThread(item.thread, t.pages.newChat)
|
||||||
|
: item.project.title
|
||||||
|
)
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q),
|
||||||
)
|
)
|
||||||
: visibleThreads;
|
: recentItems;
|
||||||
// 未搜索时与旧版一致:最多展示 10 条
|
// 未搜索时与旧版一致:最多展示 10 条
|
||||||
return q ? list : list.slice(0, 10);
|
return q ? list : list.slice(0, 10);
|
||||||
}, [visibleThreads, query, t.pages.newChat]);
|
}, [query, recentItems, t.pages.newChat]);
|
||||||
|
|
||||||
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
|
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">
|
<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">
|
<div className="pt-2">
|
||||||
<SectionLabel
|
<SectionLabel
|
||||||
label="常用智能体"
|
label="常用智能体"
|
||||||
|
count={wxCommonAgents ? frequent.length : undefined}
|
||||||
collapsed={agentsCollapsed}
|
collapsed={agentsCollapsed}
|
||||||
onToggle={toggleAgents}
|
onToggle={toggleAgents}
|
||||||
/>
|
/>
|
||||||
{!agentsCollapsed && (
|
{!agentsCollapsed && (
|
||||||
<div className="mt-1 flex flex-col gap-1">
|
<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) => {
|
{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);
|
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -437,13 +657,50 @@ export function GeneralQaSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Link
|
</div>
|
||||||
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"
|
{wxCommonAgents ? (
|
||||||
>
|
<div className="grid grid-cols-2 gap-1.5 px-1 pt-0.5">
|
||||||
<span>{t.common.more}</span>
|
<Link
|
||||||
<ChevronRight className="size-3.5 shrink-0" />
|
href={moreAgentsHref}
|
||||||
</Link>
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -453,7 +710,7 @@ export function GeneralQaSidebar() {
|
|||||||
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
|
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
|
||||||
<SectionLabel
|
<SectionLabel
|
||||||
label={t.sidebar.recentChats}
|
label={t.sidebar.recentChats}
|
||||||
count={visibleThreads.length}
|
count={recentItems.length}
|
||||||
collapsed={chatsCollapsed}
|
collapsed={chatsCollapsed}
|
||||||
onToggle={toggleChats}
|
onToggle={toggleChats}
|
||||||
/>
|
/>
|
||||||
@@ -471,29 +728,75 @@ export function GeneralQaSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-0.5">
|
<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">
|
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
|
||||||
{query ? "没有匹配的会话" : t.chats.noChats}
|
{query ? "没有匹配的会话" : t.chats.noChats}
|
||||||
</div>
|
</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(
|
const href = buildFullChatNavigationPath(
|
||||||
pathForThread(thread),
|
pathForThread(thread),
|
||||||
currentSearch,
|
currentSearch,
|
||||||
);
|
);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
|
const projectInfo = myProjectConversationInfoOfThread(
|
||||||
|
thread,
|
||||||
|
projectThreadNames,
|
||||||
|
);
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
const favorited = isThreadFavorite(thread);
|
const favorited = isThreadFavorite(thread);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={thread.thread_id}
|
key={`thread:${thread.thread_id}`}
|
||||||
className="group/item relative flex items-center"
|
className="group/item relative flex items-center"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className={cn(
|
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",
|
"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
|
isActive
|
||||||
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
|
? "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",
|
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||||
@@ -507,6 +810,8 @@ export function GeneralQaSidebar() {
|
|||||||
)}
|
)}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
|
) : projectInfo ? (
|
||||||
|
<MyProjectThreadIcon type={projectInfo.type} />
|
||||||
) : (
|
) : (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -518,8 +823,22 @@ export function GeneralQaSidebar() {
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">
|
<span
|
||||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
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>
|
</span>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
<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";
|
"use client";
|
||||||
|
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import { toast } from "sonner";
|
||||||
BrainCircuitIcon,
|
import {
|
||||||
BookMarkedIcon,
|
BookMarkedIcon,
|
||||||
|
BrainCircuitIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
FolderTreeIcon,
|
FolderTreeIcon,
|
||||||
@@ -62,12 +63,23 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import {
|
||||||
|
isDocumentRewriteActiveStage,
|
||||||
|
useDocumentRewritePresentation,
|
||||||
|
} from "@/core/document-rewrite/context";
|
||||||
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||||
import { useModels } from "@/core/models/hooks";
|
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 { Model } from "@/core/models/types";
|
||||||
import type { AgentThreadContext } from "@/core/threads";
|
import type { AgentThreadContext } from "@/core/threads";
|
||||||
|
import type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -101,25 +113,11 @@ import {
|
|||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
|
|
||||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||||
import { useThread } from "./messages/context";
|
import { resolveInputMode, type InputMode } from "./input-mode";
|
||||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
import { hasPromptInputPayload } from "./input-box-submit";
|
||||||
import { Tooltip } from "./tooltip";
|
import { useThread } from "./messages/context";
|
||||||
|
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
|
|
||||||
function getResolvedMode(
|
|
||||||
mode: InputMode | undefined,
|
|
||||||
supportsThinking: boolean,
|
|
||||||
): InputMode {
|
|
||||||
if (!supportsThinking && mode !== "flash") {
|
|
||||||
return "flash";
|
|
||||||
}
|
|
||||||
if (mode) {
|
|
||||||
return mode;
|
|
||||||
}
|
|
||||||
return supportsThinking ? "pro" : "flash";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InputBox({
|
export function InputBox({
|
||||||
className,
|
className,
|
||||||
@@ -130,20 +128,22 @@ export function InputBox({
|
|||||||
extraHeader,
|
extraHeader,
|
||||||
isNewThread,
|
isNewThread,
|
||||||
threadId,
|
threadId,
|
||||||
initialValue,
|
initialValue,
|
||||||
onContextChange,
|
onContextChange,
|
||||||
onFollowupsVisibilityChange,
|
onFollowupsPresenceChange,
|
||||||
onSubmit,
|
followupsDisplayAllowed = true,
|
||||||
|
onSubmit,
|
||||||
onStop,
|
onStop,
|
||||||
promptPrefix,
|
promptPrefix,
|
||||||
agentSelector,
|
agentSelector,
|
||||||
knowledgeSelector,
|
knowledgeSelector,
|
||||||
|
knowledgeRetrieval,
|
||||||
|
knowledgeSuggestedQuestions = [],
|
||||||
extraModels = [],
|
extraModels = [],
|
||||||
assistantId,
|
assistantId,
|
||||||
hideAdminRecommendedQuestions,
|
hideAdminRecommendedQuestions,
|
||||||
compact,
|
compact,
|
||||||
hideContextTools,
|
hideContextTools,
|
||||||
hideMemoryTool = true,
|
|
||||||
...props
|
...props
|
||||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||||
assistantId?: string | null;
|
assistantId?: string | null;
|
||||||
@@ -151,23 +151,17 @@ export function InputBox({
|
|||||||
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
||||||
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
|
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||||
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
||||||
hideContextTools?: boolean;
|
hideContextTools?: boolean;
|
||||||
/**
|
|
||||||
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
|
|
||||||
* 写作和参考文献开关不受影响。
|
|
||||||
*/
|
|
||||||
hideMemoryTool?: boolean;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
context: Omit<
|
context: Omit<
|
||||||
AgentThreadContext,
|
AgentThreadContext,
|
||||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||||
> & {
|
> & {
|
||||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: StoredReasoningEffort;
|
||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
memory_injection_enabled?: boolean;
|
|
||||||
};
|
};
|
||||||
extraHeader?: React.ReactNode;
|
extraHeader?: React.ReactNode;
|
||||||
isNewThread?: boolean;
|
isNewThread?: boolean;
|
||||||
@@ -182,12 +176,18 @@ export function InputBox({
|
|||||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||||
> & {
|
> & {
|
||||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: StoredReasoningEffort;
|
||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
memory_injection_enabled?: boolean;
|
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
onFollowupsVisibilityChange?: (visible: boolean) => 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;
|
onSubmit?: (message: PromptInputMessage) => void;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
/**
|
/**
|
||||||
@@ -209,8 +209,19 @@ export function InputBox({
|
|||||||
selectedAgentId: string | null;
|
selectedAgentId: string | null;
|
||||||
onAgentChange: (agentId: string | null) => void;
|
onAgentChange: (agentId: string | null) => void;
|
||||||
};
|
};
|
||||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||||
knowledgeSelector?: ReactNode;
|
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. */
|
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||||
extraModels?: Model[];
|
extraModels?: Model[];
|
||||||
}) {
|
}) {
|
||||||
@@ -228,11 +239,25 @@ export function InputBox({
|
|||||||
return singleRoundRagActive ? extraModels : models;
|
return singleRoundRagActive ? extraModels : models;
|
||||||
}, [extraModels, models, singleRoundRagActive]);
|
}, [extraModels, models, singleRoundRagActive]);
|
||||||
const { thread, isMock } = useThread();
|
const { thread, isMock } = useThread();
|
||||||
|
const rewritePresentation = useDocumentRewritePresentation();
|
||||||
const { textInput } = usePromptInputController();
|
const { textInput } = usePromptInputController();
|
||||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const referenceModeEntryEnabled =
|
const referenceModeEntryEnabled =
|
||||||
citationDisplay?.reference_mode_enabled ?? false;
|
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 [followups, setFollowups] = useState<string[]>([]);
|
||||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||||
@@ -247,8 +272,10 @@ export function InputBox({
|
|||||||
|
|
||||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||||
const userPreferences = useUserPreferences();
|
const userPreferences = useUserPreferences();
|
||||||
const showAdminRecommendedQuestions =
|
const followupsEnabled = userPreferences.followup_suggestions_enabled;
|
||||||
!hideAdminRecommendedQuestions &&
|
const showAdminRecommendedQuestions =
|
||||||
|
!hideAdminRecommendedQuestions &&
|
||||||
|
knowledgeSuggestedQuestions.length === 0 &&
|
||||||
!userPreferences.hide_admin_prompt_prefix &&
|
!userPreferences.hide_admin_prompt_prefix &&
|
||||||
recommendedQuestions.length > 0;
|
recommendedQuestions.length > 0;
|
||||||
|
|
||||||
@@ -264,12 +291,20 @@ export function InputBox({
|
|||||||
const currentModel = modelOptions.find((m) => m.name === context.model_name);
|
const currentModel = modelOptions.find((m) => m.name === context.model_name);
|
||||||
const fallbackModel = currentModel ?? modelOptions[0]!;
|
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||||
|
const supportsReasoningEffort =
|
||||||
|
fallbackModel.supports_reasoning_effort ?? false;
|
||||||
const nextModelName = fallbackModel.name;
|
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 (
|
if (
|
||||||
context.model_name === nextModelName &&
|
context.model_name === nextModelName &&
|
||||||
context.mode === nextMode &&
|
context.mode === nextMode &&
|
||||||
|
context.reasoning_effort === nextReasoningEffort &&
|
||||||
context.execution_profile === fallbackModel.execution_profile
|
context.execution_profile === fallbackModel.execution_profile
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -279,6 +314,7 @@ export function InputBox({
|
|||||||
...context,
|
...context,
|
||||||
model_name: nextModelName,
|
model_name: nextModelName,
|
||||||
mode: nextMode,
|
mode: nextMode,
|
||||||
|
reasoning_effort: nextReasoningEffort,
|
||||||
execution_profile: fallbackModel.execution_profile,
|
execution_profile: fallbackModel.execution_profile,
|
||||||
});
|
});
|
||||||
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
||||||
@@ -303,6 +339,31 @@ export function InputBox({
|
|||||||
[selectedModel],
|
[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(
|
const handleModelSelect = useCallback(
|
||||||
(model_name: string) => {
|
(model_name: string) => {
|
||||||
const model = modelOptions.find((m) => m.name === model_name);
|
const model = modelOptions.find((m) => m.name === model_name);
|
||||||
@@ -312,8 +373,12 @@ export function InputBox({
|
|||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name,
|
model_name,
|
||||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
|
||||||
reasoning_effort: context.reasoning_effort,
|
reasoning_effort: resolveModelReasoningEffort(
|
||||||
|
model.supports_reasoning_effort ?? false,
|
||||||
|
context.reasoning_effort,
|
||||||
|
model_name !== context.model_name,
|
||||||
|
),
|
||||||
execution_profile: model.execution_profile,
|
execution_profile: model.execution_profile,
|
||||||
});
|
});
|
||||||
setModelDialogOpen(false);
|
setModelDialogOpen(false);
|
||||||
@@ -328,22 +393,24 @@ export function InputBox({
|
|||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name: ordinaryModel?.name ?? context.model_name,
|
model_name: ordinaryModel?.name ?? context.model_name,
|
||||||
mode: getResolvedMode(
|
mode: resolveInputMode(
|
||||||
mode,
|
mode,
|
||||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||||
),
|
),
|
||||||
reasoning_effort:
|
reasoning_effort: resolveModelReasoningEffort(
|
||||||
mode === "ultra"
|
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort,
|
||||||
? "high"
|
context.reasoning_effort,
|
||||||
: mode === "pro"
|
),
|
||||||
? "medium"
|
|
||||||
: mode === "thinking"
|
|
||||||
? "low"
|
|
||||||
: "minimal",
|
|
||||||
execution_profile: undefined,
|
execution_profile: undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[onContextChange, context, models, supportThinking],
|
[
|
||||||
|
onContextChange,
|
||||||
|
context,
|
||||||
|
models,
|
||||||
|
supportThinking,
|
||||||
|
supportReasoningEffort,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSingleRoundRagSelect = useCallback(() => {
|
const handleSingleRoundRagSelect = useCallback(() => {
|
||||||
@@ -357,7 +424,7 @@ export function InputBox({
|
|||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name: ragModel.name,
|
model_name: ragModel.name,
|
||||||
mode: getResolvedMode(
|
mode: resolveInputMode(
|
||||||
context.mode,
|
context.mode,
|
||||||
ragModel.supports_thinking ?? false,
|
ragModel.supports_thinking ?? false,
|
||||||
),
|
),
|
||||||
@@ -373,7 +440,7 @@ export function InputBox({
|
|||||||
}, [context, onContextChange]);
|
}, [context, onContextChange]);
|
||||||
|
|
||||||
const handleReasoningEffortSelect = useCallback(
|
const handleReasoningEffortSelect = useCallback(
|
||||||
(effort: "minimal" | "low" | "medium" | "high") => {
|
(effort: ReasoningEffort) => {
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
reasoning_effort: effort,
|
reasoning_effort: effort,
|
||||||
@@ -397,16 +464,13 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||||
|
|
||||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
const smartKnowledgeRetrievalActive =
|
||||||
const handleMemoryInjectionToggle = useCallback(() => {
|
knowledgeRetrieval?.mode === "smart_reasoning";
|
||||||
onContextChange?.({
|
const ragModeLocked =
|
||||||
...context,
|
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId);
|
||||||
memory_injection_enabled: !memoryInjectionEnabled,
|
const ragModeEnabled =
|
||||||
});
|
!smartKnowledgeRetrievalActive &&
|
||||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
(localSettings.display.ragModeEnabled || ragModeLocked);
|
||||||
|
|
||||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
|
||||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
|
||||||
const handleRagModeToggle = useCallback(() => {
|
const handleRagModeToggle = useCallback(() => {
|
||||||
if (ragModeLocked) {
|
if (ragModeLocked) {
|
||||||
return;
|
return;
|
||||||
@@ -422,15 +486,19 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
}, [contextCompressionEnabled, setLocalSettings]);
|
}, [contextCompressionEnabled, setLocalSettings]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (message: PromptInputMessage) => {
|
async (message: PromptInputMessage) => {
|
||||||
if (status === "streaming") {
|
if (composerStatus === "streaming") {
|
||||||
onStop?.();
|
toast.info("正在生成中,请等待当前回复完成后再发送。", {
|
||||||
return;
|
description: "已保留您输入的内容。",
|
||||||
}
|
});
|
||||||
if (!message.text) {
|
// Reject rather than resolve: PromptInput treats a resolved submit as
|
||||||
return;
|
// 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([]);
|
setFollowups([]);
|
||||||
setFollowupsHidden(false);
|
setFollowupsHidden(false);
|
||||||
setFollowupsLoading(false);
|
setFollowupsLoading(false);
|
||||||
@@ -440,14 +508,19 @@ export function InputBox({
|
|||||||
|
|
||||||
// Guard against submitting before the initial model auto-selection
|
// Guard against submitting before the initial model auto-selection
|
||||||
// effect has flushed thread settings to storage/state.
|
// 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?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name: resolvedModelName,
|
model_name: resolvedModelName,
|
||||||
mode: getResolvedMode(
|
mode: resolveInputMode(
|
||||||
context.mode,
|
context.mode,
|
||||||
selectedModel?.supports_thinking ?? false,
|
selectedModel?.supports_thinking ?? false,
|
||||||
),
|
),
|
||||||
|
reasoning_effort: selectedReasoningEffort,
|
||||||
execution_profile: selectedModel?.execution_profile,
|
execution_profile: selectedModel?.execution_profile,
|
||||||
});
|
});
|
||||||
setTimeout(() => onSubmit?.(message), 0);
|
setTimeout(() => onSubmit?.(message), 0);
|
||||||
@@ -458,26 +531,40 @@ export function InputBox({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
context,
|
context,
|
||||||
onContextChange,
|
onContextChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onStop,
|
composerStatus,
|
||||||
resolvedModelName,
|
resolvedModelName,
|
||||||
ragModeEnabled,
|
ragModeEnabled,
|
||||||
selectedModel?.supports_thinking,
|
selectedModel?.supports_thinking,
|
||||||
selectedModel?.execution_profile,
|
selectedModel?.execution_profile,
|
||||||
|
selectedReasoningEffort,
|
||||||
status,
|
status,
|
||||||
threadId,
|
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 requestFormSubmit = useCallback(() => {
|
||||||
const form = promptRootRef.current?.querySelector("form");
|
const form = promptRootRef.current?.querySelector("form");
|
||||||
form?.requestSubmit();
|
form?.requestSubmit();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFollowupClick = useCallback(
|
const handleFollowupClick = useCallback(
|
||||||
(suggestion: string) => {
|
(suggestion: string) => {
|
||||||
if (status === "streaming") {
|
if (composerStatus === "streaming") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const current = (textInput.value ?? "").trim();
|
const current = (textInput.value ?? "").trim();
|
||||||
@@ -490,7 +577,7 @@ export function InputBox({
|
|||||||
setFollowupsHidden(true);
|
setFollowupsHidden(true);
|
||||||
setTimeout(() => requestFormSubmit(), 0);
|
setTimeout(() => requestFormSubmit(), 0);
|
||||||
},
|
},
|
||||||
[requestFormSubmit, status, textInput],
|
[composerStatus, requestFormSubmit, textInput],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmReplaceAndSend = useCallback(() => {
|
const confirmReplaceAndSend = useCallback(() => {
|
||||||
@@ -521,32 +608,43 @@ export function InputBox({
|
|||||||
setTimeout(() => requestFormSubmit(), 0);
|
setTimeout(() => requestFormSubmit(), 0);
|
||||||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||||||
|
|
||||||
const showFollowups =
|
const followupsPresent =
|
||||||
!disabled &&
|
followupsEnabled &&
|
||||||
!isNewThread &&
|
!disabled &&
|
||||||
!selectedSingleRoundRag &&
|
!isNewThread &&
|
||||||
!followupsHidden &&
|
!selectedSingleRoundRag &&
|
||||||
(followupsLoading || followups.length > 0);
|
!followupsHidden &&
|
||||||
|
(followupsLoading || followups.length > 0);
|
||||||
const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange);
|
const showFollowups = followupsDisplayAllowed && followupsPresent;
|
||||||
|
|
||||||
|
const followupsPresenceChangeRef = useRef(onFollowupsPresenceChange);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
followupsPresenceChangeRef.current = onFollowupsPresenceChange;
|
||||||
|
}, [onFollowupsPresenceChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
followupsPresenceChangeRef.current?.(followupsPresent);
|
||||||
|
}, [followupsPresent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => followupsPresenceChangeRef.current?.(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
followupsVisibilityChangeRef.current = onFollowupsVisibilityChange;
|
if (followupsEnabled) return;
|
||||||
}, [onFollowupsVisibilityChange]);
|
setFollowups([]);
|
||||||
|
setFollowupsLoading(false);
|
||||||
useEffect(() => {
|
setFollowupsHidden(true);
|
||||||
followupsVisibilityChangeRef.current?.(showFollowups);
|
setConfirmOpen(false);
|
||||||
}, [showFollowups]);
|
setPendingSuggestion(null);
|
||||||
|
}, [followupsEnabled]);
|
||||||
useEffect(() => {
|
|
||||||
return () => followupsVisibilityChangeRef.current?.(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const streaming = status === "streaming";
|
const streaming = status === "streaming";
|
||||||
const wasStreaming = wasStreamingRef.current;
|
const wasStreaming = wasStreamingRef.current;
|
||||||
wasStreamingRef.current = streaming;
|
wasStreamingRef.current = streaming;
|
||||||
if (!wasStreaming || streaming) {
|
if (!followupsEnabled || !wasStreaming || streaming) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +696,7 @@ export function InputBox({
|
|||||||
return (await res.json()) as { suggestions?: string[] };
|
return (await res.json()) as { suggestions?: string[] };
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
const suggestions = (data.suggestions ?? [])
|
const suggestions = (data.suggestions ?? [])
|
||||||
.map((s) => (typeof s === "string" ? s.trim() : ""))
|
.map((s) => (typeof s === "string" ? s.trim() : ""))
|
||||||
.filter((s) => s.length > 0)
|
.filter((s) => s.length > 0)
|
||||||
@@ -605,24 +704,51 @@ export function InputBox({
|
|||||||
setFollowups(suggestions);
|
setFollowups(suggestions);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
setFollowups([]);
|
setFollowups([]);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
setFollowupsLoading(false);
|
setFollowupsLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => {
|
||||||
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
controller.abort();
|
||||||
|
setFollowupsLoading(false);
|
||||||
|
};
|
||||||
|
}, [context.model_name, disabled, followupsEnabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||||
{extraHeader && (
|
{extraHeader && (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
{extraHeader}
|
{extraHeader}
|
||||||
</div>
|
</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]">
|
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||||
{recommendedQuestions.map((q) => (
|
{recommendedQuestions.map((q) => (
|
||||||
<button
|
<button
|
||||||
@@ -733,12 +859,12 @@ export function InputBox({
|
|||||||
</PromptInputBody>
|
</PromptInputBody>
|
||||||
<PromptInputFooter
|
<PromptInputFooter
|
||||||
className={cn(
|
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",
|
compact && "gap-2 px-3 pt-1.5 pb-3",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PromptInputTools
|
<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
|
{/* TODO: Add more connectors here
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
@@ -813,62 +939,42 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">文件</span>
|
<span className="text-xs font-normal">文件</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
{referenceModeEntryEnabled &&
|
||||||
content={
|
!frontendRagActive &&
|
||||||
memoryInjectionEnabled
|
!smartKnowledgeRetrievalActive &&
|
||||||
? "记忆注入已开启:对话前会注入历史记忆"
|
!hideContextTools && (
|
||||||
: "记忆注入已关闭:本次对话不注入记忆"
|
<Tooltip
|
||||||
}
|
content={
|
||||||
>
|
ragModeEnabled
|
||||||
<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
|
<PromptInputButton
|
||||||
? "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"
|
type="button"
|
||||||
: compact
|
aria-pressed={ragModeEnabled}
|
||||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
disabled={ragModeLocked || disabled}
|
||||||
: "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",
|
className={cn(
|
||||||
)}
|
"gap-1.5! rounded-full px-3! shadow-none",
|
||||||
onClick={handleMemoryInjectionToggle}
|
compact &&
|
||||||
>
|
"rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||||
{memoryInjectionEnabled && (
|
ragModeLocked && "cursor-not-allowed opacity-80",
|
||||||
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
|
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"
|
||||||
<BrainCircuitIcon className="size-3" />
|
: compact
|
||||||
<span className="text-xs font-normal">记忆</span>
|
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
</PromptInputButton>
|
: "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",
|
||||||
</Tooltip>}
|
)}
|
||||||
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
onClick={handleRagModeToggle}
|
||||||
content={
|
>
|
||||||
ragModeEnabled
|
{ragModeEnabled && (
|
||||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
|
||||||
: "参考文献模式已关闭:点击开启"
|
)}
|
||||||
}
|
<BookMarkedIcon className="size-3" />
|
||||||
>
|
<span className="text-xs font-normal">参考文献</span>
|
||||||
<PromptInputButton
|
</PromptInputButton>
|
||||||
type="button"
|
</Tooltip>
|
||||||
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
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
contextCompressionEnabled
|
contextCompressionEnabled
|
||||||
@@ -1161,124 +1267,63 @@ export function InputBox({
|
|||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu>
|
</PromptInputActionMenu>
|
||||||
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
{!frontendRagActive &&
|
||||||
<PromptInputActionMenu>
|
supportReasoningEffort &&
|
||||||
<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]")}>
|
context.mode !== "flash" && (
|
||||||
<div className="text-xs font-normal">
|
<PromptInputActionMenu>
|
||||||
{t.inputBox.reasoningEffort}:
|
<PromptInputActionMenuTrigger
|
||||||
{context.reasoning_effort === "minimal" &&
|
aria-label={`${t.inputBox.reasoningEffortSelect}: ${reasoningEffortLabel}`}
|
||||||
" " + t.inputBox.reasoningEffortMinimal}
|
className={cn(
|
||||||
{context.reasoning_effort === "low" &&
|
"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",
|
||||||
" " + t.inputBox.reasoningEffortLow}
|
compact &&
|
||||||
{context.reasoning_effort === "medium" &&
|
"rounded-lg border border-border px-2.5! py-1! text-[13px]",
|
||||||
" " + t.inputBox.reasoningEffortMedium}
|
)}
|
||||||
{context.reasoning_effort === "high" &&
|
>
|
||||||
" " + t.inputBox.reasoningEffortHigh}
|
<div className="flex items-center gap-1.5 text-xs font-medium">
|
||||||
</div>
|
<SparklesIcon className="size-3" />
|
||||||
</PromptInputActionMenuTrigger>
|
<span className="text-foreground">
|
||||||
<PromptInputActionMenuContent className="w-70">
|
{reasoningEffortLabel}
|
||||||
<DropdownMenuGroup>
|
</span>
|
||||||
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
</div>
|
||||||
{t.inputBox.reasoningEffort}
|
</PromptInputActionMenuTrigger>
|
||||||
</DropdownMenuLabel>
|
<PromptInputActionMenuContent className="w-56 p-3">
|
||||||
<PromptInputActionMenu>
|
<div className="flex items-center justify-between gap-3 px-1">
|
||||||
<PromptInputActionMenuItem
|
<span className="text-xs text-muted-foreground">
|
||||||
className={cn(
|
{t.inputBox.reasoningEffortSelect}
|
||||||
context.reasoning_effort === "minimal"
|
</span>
|
||||||
? "text-accent-foreground"
|
<span className="text-sm font-semibold text-violet-600 dark:text-violet-300">
|
||||||
: "text-muted-foreground/65",
|
{reasoningEffortLabel}
|
||||||
)}
|
</span>
|
||||||
onSelect={() => handleReasoningEffortSelect("minimal")}
|
</div>
|
||||||
>
|
<div className="mt-2 px-1">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="relative h-9 w-full">
|
||||||
<div className="flex items-center gap-1 font-bold">
|
<input
|
||||||
{t.inputBox.reasoningEffortMinimal}
|
type="range"
|
||||||
</div>
|
aria-label={`${t.inputBox.reasoningEffortSelect}: ${selectedModel?.display_name || selectedModel?.name || ""}`}
|
||||||
<div className="pl-2 text-xs">
|
aria-valuetext={reasoningEffortLabel}
|
||||||
{t.inputBox.reasoningEffortMinimalDescription}
|
className="peer absolute inset-x-0 -inset-y-1 z-10 h-auto w-full cursor-grab touch-none opacity-0 active:cursor-grabbing"
|
||||||
</div>
|
value={reasoningEffortIndex}
|
||||||
</div>
|
min={0}
|
||||||
{context.reasoning_effort === "minimal" ? (
|
max={2}
|
||||||
<CheckIcon className="ml-auto size-4" />
|
step={1}
|
||||||
) : (
|
onChange={(event) => {
|
||||||
<div className="ml-auto size-4" />
|
const effort =
|
||||||
)}
|
REASONING_EFFORTS[Number(event.currentTarget.value)];
|
||||||
</PromptInputActionMenuItem>
|
if (effort) handleReasoningEffortSelect(effort);
|
||||||
<PromptInputActionMenuItem
|
}}
|
||||||
className={cn(
|
/>
|
||||||
context.reasoning_effort === "low"
|
<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" />
|
||||||
? "text-accent-foreground"
|
<div
|
||||||
: "text-muted-foreground/65",
|
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 }}
|
||||||
onSelect={() => handleReasoningEffortSelect("low")}
|
/>
|
||||||
>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
</div>
|
||||||
<div className="flex items-center gap-1 font-bold">
|
</PromptInputActionMenuContent>
|
||||||
{t.inputBox.reasoningEffortLow}
|
</PromptInputActionMenu>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</PromptInputTools>
|
</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 && (
|
{!frontendRagActive && agentSelector && (
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
selectedAgentId={agentSelector.selectedAgentId}
|
selectedAgentId={agentSelector.selectedAgentId}
|
||||||
@@ -1288,6 +1333,100 @@ export function InputBox({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!frontendRagActive && knowledgeSelector}
|
{!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 && (
|
{!frontendRagActive && promptPrefix && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
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",
|
"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 &&
|
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",
|
"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}
|
disabled={disabled}
|
||||||
variant="outline"
|
onClick={(event) => {
|
||||||
status={status}
|
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>
|
</PromptInputTools>
|
||||||
</PromptInputFooter>
|
</PromptInputFooter>
|
||||||
{!isNewThread && (
|
{!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,40 +1,44 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
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 { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||||
import {
|
import {
|
||||||
loadKnowledgeBaseProductVersion,
|
|
||||||
loadOrdinaryKnowledgeLanding,
|
loadOrdinaryKnowledgeLanding,
|
||||||
saveOrdinaryKnowledgeLanding,
|
saveOrdinaryKnowledgeLanding,
|
||||||
type OrdinaryKnowledgeLanding,
|
type OrdinaryKnowledgeLanding,
|
||||||
type KnowledgeBaseProductVersion,
|
|
||||||
updateKnowledgeBaseProductVersion,
|
|
||||||
} from "@/core/system-settings/api";
|
} 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;
|
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||||
|
|
||||||
export function useOrdinaryKnowledgeLanding() {
|
export function useOrdinaryKnowledgeLanding(enabled = true) {
|
||||||
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
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_VALUE = "normal";
|
||||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
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() {
|
export function KnowledgeProductNav() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const version = useKnowledgeBaseProductVersion();
|
// const assistantVisibility = useAssistantKnowledgeVisibility();
|
||||||
const landing = useOrdinaryKnowledgeLanding();
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
|
const landing = useOrdinaryKnowledgeLanding(isAdmin);
|
||||||
const saveLanding = useMutation({
|
const saveLanding = useMutation({
|
||||||
mutationFn: saveOrdinaryKnowledgeLanding,
|
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||||
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||||
@@ -42,24 +46,32 @@ export function KnowledgeProductNav() {
|
|||||||
});
|
});
|
||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
// const assistantAllowed = canAccessAssistantKnowledge(
|
||||||
const save = useMutation({
|
// isAdmin,
|
||||||
mutationFn: updateKnowledgeBaseProductVersion,
|
// assistantVisibility.isError ? undefined : assistantVisibility.data,
|
||||||
onSuccess: (value) => {
|
// );
|
||||||
client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
|
// const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
|
||||||
toast.success(`默认知识库体验已切换为 ${value}`);
|
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
|
||||||
},
|
|
||||||
});
|
|
||||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||||
|
const ordinary = !assistant && !retrievalDebug;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
<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={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||||
<BookOpen />普通知识库
|
<BookOpen />普通知识库
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
{/* 助手知识库、检索调试暂时隐藏,后续放开时去掉这段注释。
|
||||||
<Database />助手知识库
|
{assistantAllowed && (
|
||||||
</Button>
|
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||||
|
<Database />助手知识库
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<Button size="sm" variant={retrievalDebug ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/retrieval-debug")}>
|
||||||
|
<SearchCheck />检索调试
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
*/}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
普通知识库默认页面
|
普通知识库默认页面
|
||||||
@@ -67,11 +79,22 @@ export function KnowledgeProductNav() {
|
|||||||
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
默认产品版本
|
{/* 助手知识库日常展示暂时隐藏,和上面两个入口一起放开。
|
||||||
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
助手知识库日常展示
|
||||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
<Select
|
||||||
<SelectContent><SelectItem value="v1">v1 普通知识库</SelectItem><SelectItem value="v2">v2 助手知识库</SelectItem></SelectContent>
|
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>
|
</Select>
|
||||||
|
*/}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
type AssistantKnowledgeBase,
|
type AssistantKnowledgeBase,
|
||||||
@@ -35,6 +38,28 @@ import {
|
|||||||
|
|
||||||
type SelectorSource = "all" | "agent";
|
type SelectorSource = "all" | "agent";
|
||||||
type SelectorVariant = "popover" | "panel";
|
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[]) {
|
function groupRows(rows: LlmWikiKnowledgeBase[]) {
|
||||||
return [
|
return [
|
||||||
@@ -323,9 +348,14 @@ function SelectionContent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
<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">
|
<div>
|
||||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
<div className="text-[11px] text-muted-foreground">
|
||||||
</span>
|
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||||
|
</div>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -363,9 +393,10 @@ function SelectionContent({
|
|||||||
|
|
||||||
export function LlmWikiKnowledgeSelector({
|
export function LlmWikiKnowledgeSelector({
|
||||||
value,
|
value,
|
||||||
assistantValue = [],
|
assistantValue = EMPTY_IDS,
|
||||||
onChange,
|
onChange,
|
||||||
onAssistantChange = () => undefined,
|
onAssistantChange,
|
||||||
|
onSuggestedQuestionsChange,
|
||||||
source = "all",
|
source = "all",
|
||||||
agentId,
|
agentId,
|
||||||
autoSelectAll = false,
|
autoSelectAll = false,
|
||||||
@@ -373,12 +404,15 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
className,
|
className,
|
||||||
label = "知识空间",
|
label = "知识空间",
|
||||||
|
panelTitle = "绑定知识空间",
|
||||||
|
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
|
||||||
variant = "popover",
|
variant = "popover",
|
||||||
}: {
|
}: {
|
||||||
value: string[];
|
value: string[];
|
||||||
assistantValue?: string[];
|
assistantValue?: string[];
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
onAssistantChange?: (value: string[]) => void;
|
onAssistantChange?: (value: string[]) => void;
|
||||||
|
onSuggestedQuestionsChange?: (questions: string[]) => void;
|
||||||
source?: SelectorSource;
|
source?: SelectorSource;
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
autoSelectAll?: boolean;
|
autoSelectAll?: boolean;
|
||||||
@@ -387,31 +421,59 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
panelTitle?: string;
|
||||||
|
panelDescription?: string;
|
||||||
variant?: SelectorVariant;
|
variant?: SelectorVariant;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
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 runtime = useLlmWikiRuntime();
|
||||||
const weknoraEnabled = runtime.data?.provider === "weknora";
|
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||||
|
const shouldLoadOptions =
|
||||||
|
variant === "panel" ||
|
||||||
|
open ||
|
||||||
|
autoSelectAll ||
|
||||||
|
value.length > 0 ||
|
||||||
|
assistantValue.length > 0;
|
||||||
const all = useLlmWikiKnowledgeBases(
|
const all = useLlmWikiKnowledgeBases(
|
||||||
"all",
|
"all",
|
||||||
weknoraEnabled,
|
weknoraEnabled && shouldLoadOptions,
|
||||||
);
|
);
|
||||||
const agent = useAgentLlmWikiKnowledgeBases(
|
const agent = useAgentLlmWikiKnowledgeBases(
|
||||||
agentId,
|
agentId,
|
||||||
weknoraEnabled && source === "agent" && !!agentId,
|
weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId,
|
||||||
);
|
);
|
||||||
const assistantBases = useAssistantKnowledgeBases();
|
const assistantBases = useAssistantKnowledgeBases(
|
||||||
const rows = weknoraEnabled ? (all.data ?? []) : [];
|
assistantAllowed &&
|
||||||
const assistantRows = assistantBases.data ?? [];
|
(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(() => {
|
const defaultIds = useMemo(() => {
|
||||||
if (source !== "agent") {
|
if (source !== "agent") {
|
||||||
return rows.map((item) => item.id);
|
return rows.map((item) => item.id);
|
||||||
}
|
}
|
||||||
return (agent.data ?? []).map((item) => item.id);
|
return (agent.data ?? []).map((item) => item.id);
|
||||||
}, [agent.data, rows, source]);
|
}, [agent.data, rows, source]);
|
||||||
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading);
|
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) ?? assistantBases.error;
|
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 availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
|
||||||
const assistantAvailableIds = useMemo(
|
const assistantAvailableIds = useMemo(
|
||||||
() => new Set(assistantRows.map((item) => item.id)),
|
() => new Set(assistantRows.map((item) => item.id)),
|
||||||
@@ -419,6 +481,41 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
);
|
);
|
||||||
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
||||||
const initialized = useRef<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!runtime.isLoading && !weknoraEnabled) {
|
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]);
|
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!assistantBases.data) return;
|
if (!assistantAllowed || !assistantBases.data || !onAssistantChange) return;
|
||||||
const valid = assistantValue.filter((id) => assistantAvailableIds.has(id));
|
const valid = effectiveAssistantValue.filter((id) => assistantAvailableIds.has(id));
|
||||||
if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) {
|
if (valid.length !== effectiveAssistantValue.length || valid.some((id, index) => id !== effectiveAssistantValue[index])) {
|
||||||
onAssistantChange(valid);
|
onAssistantChange(valid);
|
||||||
}
|
}
|
||||||
}, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]);
|
}, [assistantAllowed, assistantAvailableIds, assistantBases.data, effectiveAssistantValue, onAssistantChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) setSearch("");
|
if (!open) setSearch("");
|
||||||
}, [open]);
|
}, [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 = (
|
const content = (
|
||||||
<SelectionContent
|
<SelectionContent
|
||||||
rows={rows}
|
rows={rows}
|
||||||
assistantRows={assistantRows}
|
assistantRows={assistantRows}
|
||||||
value={value}
|
value={value}
|
||||||
assistantValue={assistantValue}
|
assistantValue={effectiveAssistantValue}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onAssistantChange={onAssistantChange}
|
onAssistantChange={
|
||||||
|
assistantAllowed
|
||||||
|
? (onAssistantChange ?? IGNORE_ASSISTANT_CHANGE)
|
||||||
|
: IGNORE_ASSISTANT_CHANGE
|
||||||
|
}
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
loading={loading}
|
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 className="flex items-start justify-between gap-4 border-b px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
<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>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
{panelDescription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
|
<Badge variant="secondary" className="shrink-0">{value.length + effectiveAssistantValue.length} 个</Badge>
|
||||||
</div>
|
</div>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedNames = value
|
const selectedCount = value.length + effectiveAssistantValue.length;
|
||||||
.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 selectedLabel = allSelectedNames.length > 0
|
const selectedLabel = allSelectedNames.length > 0
|
||||||
? allSelectedNames.join("、")
|
? allSelectedNames.join("、")
|
||||||
: selectedCount > 0
|
: 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 type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
|
||||||
|
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||||
import type { AgentThreadState } from "@/core/threads";
|
import type { AgentThreadState } from "@/core/threads";
|
||||||
|
|
||||||
export interface ThreadContextType {
|
export interface ThreadContextType {
|
||||||
@@ -13,6 +14,11 @@ export interface ThreadContextType {
|
|||||||
* from the admin-controlled manual reference-mode button.
|
* from the admin-controlled manual reference-mode button.
|
||||||
*/
|
*/
|
||||||
knowledgeReferencesEnabled?: boolean;
|
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.
|
* When set, the conversation is rendered from a public read-only share.
|
||||||
* Artifact/image URLs then resolve to the unauthenticated
|
* Artifact/image URLs then resolve to the unauthenticated
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CheckCircle2Icon,
|
CheckCircle2Icon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronsDownIcon,
|
ChevronsDownIcon,
|
||||||
|
CopyIcon,
|
||||||
FilePenLineIcon,
|
FilePenLineIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
@@ -14,6 +15,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArtifactFileList } from "@/components/workspace/artifacts/artifact-file-list";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { updateArtifactContent } from "@/core/artifacts/api";
|
import { updateArtifactContent } from "@/core/artifacts/api";
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
|
import { copyTextToClipboard } from "@/core/clipboard";
|
||||||
import {
|
import {
|
||||||
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
|
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
|
||||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||||
@@ -42,7 +45,7 @@ import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
|
|||||||
const REWRITE_STEPS = [
|
const REWRITE_STEPS = [
|
||||||
{ key: "snapshot", label: "固定原文版本" },
|
{ key: "snapshot", label: "固定原文版本" },
|
||||||
{ key: "requirements", label: "理解改写要求" },
|
{ key: "requirements", label: "理解改写要求" },
|
||||||
{ key: "writing", label: "流式重写 Markdown" },
|
{ key: "writing", label: "生成改写版 Markdown" },
|
||||||
{ key: "illustrations", label: "生成文档配图" },
|
{ key: "illustrations", label: "生成文档配图" },
|
||||||
{ key: "validation", label: "校验文档" },
|
{ key: "validation", label: "校验文档" },
|
||||||
{ key: "comparison", label: "对比原文并生成说明" },
|
{ key: "comparison", label: "对比原文并生成说明" },
|
||||||
@@ -221,6 +224,9 @@ export function DocumentRewriteTimeline({
|
|||||||
const presentation = useDocumentRewritePresentation();
|
const presentation = useDocumentRewritePresentation();
|
||||||
const [undoing, setUndoing] = useState(false);
|
const [undoing, setUndoing] = useState(false);
|
||||||
const [diffOpen, setDiffOpen] = useState(false);
|
const [diffOpen, setDiffOpen] = useState(false);
|
||||||
|
const diagnosticText = item.diagnostic
|
||||||
|
? JSON.stringify(item.diagnostic, null, 2)
|
||||||
|
: "";
|
||||||
|
|
||||||
const reapplying = Boolean(item.revertedAt);
|
const reapplying = Boolean(item.revertedAt);
|
||||||
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
|
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
|
||||||
@@ -370,7 +376,7 @@ export function DocumentRewriteTimeline({
|
|||||||
: "bg-primary/10 text-primary",
|
: "bg-primary/10 text-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
|
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未生成改写版") : "进行中"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
@@ -440,13 +446,13 @@ export function DocumentRewriteTimeline({
|
|||||||
{step.key === "writing" && (
|
{step.key === "writing" && (
|
||||||
item.writtenCharCount > 0 ? (
|
item.writtenCharCount > 0 ? (
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
|
正在{isNewReportGeneration ? "生成新报告" : "生成改写版"},已输出 {item.writtenCharCount.toLocaleString()} 字。
|
||||||
</p>
|
</p>
|
||||||
) : active ? (
|
) : active ? (
|
||||||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||||||
{isNewReportGeneration
|
{isNewReportGeneration
|
||||||
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
|
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
|
||||||
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
|
: "正在等待模型开始输出;完成后将生成一份独立的改写版文件。"}
|
||||||
</p>
|
</p>
|
||||||
) : null
|
) : null
|
||||||
)}
|
)}
|
||||||
@@ -583,9 +589,42 @@ export function DocumentRewriteTimeline({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{item.error && (
|
{item.error && (
|
||||||
<div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
|
<div className="border-t px-5 py-3 text-sm text-destructive">
|
||||||
<XCircleIcon className="size-4 shrink-0" />
|
<div className="flex items-start gap-2">
|
||||||
{item.error}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DocumentRewriteDiffDialog
|
<DocumentRewriteDiffDialog
|
||||||
@@ -598,3 +637,36 @@ export function DocumentRewriteTimeline({
|
|||||||
</article>
|
</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,
|
MessageResponse,
|
||||||
type MessageResponseProps,
|
type MessageResponseProps,
|
||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
import {
|
||||||
|
looksLikeHtmlDocument,
|
||||||
|
MarkdownFallback,
|
||||||
|
MarkdownRenderBoundary,
|
||||||
|
streamdownPlugins,
|
||||||
|
useStreamdownMermaidPngDownload,
|
||||||
|
} from "@/core/streamdown";
|
||||||
import {
|
import {
|
||||||
createMarkdownAnchorResolver,
|
createMarkdownAnchorResolver,
|
||||||
isInPageAnchor,
|
isInPageAnchor,
|
||||||
@@ -20,6 +26,9 @@ import {
|
|||||||
} from "@/core/utils/markdown";
|
} from "@/core/utils/markdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { VisualizationBlock } from "../visualizations/visualization-block";
|
||||||
|
import { splitVisualizationContent } from "../visualizations/visualization-spec";
|
||||||
|
|
||||||
import { CitationLink } from "../citations/citation-link";
|
import { CitationLink } from "../citations/citation-link";
|
||||||
|
|
||||||
function isExternalUrl(href: string | undefined): boolean {
|
function isExternalUrl(href: string | undefined): boolean {
|
||||||
@@ -50,6 +59,7 @@ export type MarkdownContentProps = {
|
|||||||
/** Renders markdown content. */
|
/** Renders markdown content. */
|
||||||
export function MarkdownContent({
|
export function MarkdownContent({
|
||||||
content,
|
content,
|
||||||
|
isLoading,
|
||||||
rehypePlugins,
|
rehypePlugins,
|
||||||
className,
|
className,
|
||||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||||
@@ -59,6 +69,7 @@ export function MarkdownContent({
|
|||||||
}: MarkdownContentProps) {
|
}: MarkdownContentProps) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||||
|
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
|
||||||
useStreamdownMermaidPngDownload(rootRef);
|
useStreamdownMermaidPngDownload(rootRef);
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||||
@@ -139,17 +150,72 @@ export function MarkdownContent({
|
|||||||
|
|
||||||
if (!content) return null;
|
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 (
|
return (
|
||||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||||
<MessageResponse
|
{visualizationSegments.map((segment, index) =>
|
||||||
className={className}
|
segment.type === "visualization" ? (
|
||||||
remarkPlugins={remarkPlugins}
|
<VisualizationBlock
|
||||||
rehypePlugins={rehypePlugins}
|
key={`visualization-${index}`}
|
||||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
language={segment.language}
|
||||||
components={components}
|
source={segment.source}
|
||||||
>
|
complete={segment.complete}
|
||||||
{content}
|
isLoading={isLoading}
|
||||||
</MessageResponse>
|
/>
|
||||||
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -32,21 +32,21 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Sheet,
|
||||||
DialogContent,
|
SheetContent,
|
||||||
DialogDescription,
|
SheetDescription,
|
||||||
DialogHeader,
|
SheetHeader,
|
||||||
DialogTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/sheet";
|
||||||
import {
|
import {
|
||||||
buildWriteFileVirtualUrl,
|
buildWriteFileVirtualUrl,
|
||||||
hasCompleteArtifactExtension,
|
|
||||||
isAutoPreviewableArtifactPath,
|
isAutoPreviewableArtifactPath,
|
||||||
languageOfStreamingArtifact,
|
languageOfStreamingArtifact,
|
||||||
normalizeStreamingArtifactPath,
|
normalizeStreamingArtifactPath,
|
||||||
provisionalWriteFilePath,
|
namedStreamingWriteFilePath,
|
||||||
} from "@/core/artifacts/streaming-write-preview";
|
} from "@/core/artifacts/streaming-write-preview";
|
||||||
|
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
normalizeSearchResults,
|
normalizeSearchResults,
|
||||||
@@ -57,9 +57,12 @@ import {
|
|||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
findToolCallResult,
|
findToolCallResult,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
|
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
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 {
|
import {
|
||||||
applyInlineCitations,
|
applyInlineCitations,
|
||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
@@ -82,7 +85,8 @@ import { Tooltip } from "../tooltip";
|
|||||||
import { SourceCard } from "../skill-display-sources";
|
import { SourceCard } from "../skill-display-sources";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
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({
|
export function MessageGroup({
|
||||||
className,
|
className,
|
||||||
@@ -90,14 +94,17 @@ export function MessageGroup({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
displaySources = [],
|
displaySources = [],
|
||||||
ragModeEnabled = false,
|
ragModeEnabled = false,
|
||||||
|
threadId,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
displaySources?: RuntimeDisplaySource[];
|
displaySources?: RuntimeDisplaySource[];
|
||||||
ragModeEnabled?: boolean;
|
ragModeEnabled?: boolean;
|
||||||
|
threadId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const [showAbove, setShowAbove] = useState(
|
const [showAbove, setShowAbove] = useState(
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||||
);
|
);
|
||||||
@@ -112,7 +119,13 @@ export function MessageGroup({
|
|||||||
() => convertToSteps(messages, writingModeEnabled),
|
() => convertToSteps(messages, writingModeEnabled),
|
||||||
[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(
|
const skillDisplayMap = useMemo(
|
||||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
[ragModeEnabled, skills],
|
[ragModeEnabled, skills],
|
||||||
@@ -210,6 +223,7 @@ export function MessageGroup({
|
|||||||
{...step}
|
{...step}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skillDisplayMap={skillDisplayMap}
|
skillDisplayMap={skillDisplayMap}
|
||||||
|
displaySources={displaySources}
|
||||||
ragModeEnabled={ragModeEnabled}
|
ragModeEnabled={ragModeEnabled}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -222,6 +236,7 @@ export function MessageGroup({
|
|||||||
isLast={true}
|
isLast={true}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skillDisplayMap={skillDisplayMap}
|
skillDisplayMap={skillDisplayMap}
|
||||||
|
displaySources={displaySources}
|
||||||
ragModeEnabled={ragModeEnabled}
|
ragModeEnabled={ragModeEnabled}
|
||||||
/>
|
/>
|
||||||
</FlipDisplay>
|
</FlipDisplay>
|
||||||
@@ -271,7 +286,8 @@ export function MessageGroup({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ChainOfThought>
|
</ChainOfThought>
|
||||||
<SingleRoundRagCitationDialog
|
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
|
||||||
|
<SingleRoundRagCitationDrawer
|
||||||
source={selectedCitation}
|
source={selectedCitation}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setSelectedCitation(null);
|
if (!open) setSelectedCitation(null);
|
||||||
@@ -336,6 +352,7 @@ function ToolCall({
|
|||||||
isLast = false,
|
isLast = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
skillDisplayMap = new Map(),
|
skillDisplayMap = new Map(),
|
||||||
|
displaySources = [],
|
||||||
ragModeEnabled = false,
|
ragModeEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -346,6 +363,7 @@ function ToolCall({
|
|||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||||
|
displaySources?: RuntimeDisplaySource[];
|
||||||
ragModeEnabled?: boolean;
|
ragModeEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -356,19 +374,42 @@ function ToolCall({
|
|||||||
null);
|
null);
|
||||||
const displayItems =
|
const displayItems =
|
||||||
displayMatch && result !== undefined
|
displayMatch && result !== undefined
|
||||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
? syncDisplaySourceIndexes(
|
||||||
...item,
|
rankDisplaySources(
|
||||||
index: index + 1,
|
normalizeDisplaySources(result, displayMatch.display).map(
|
||||||
skillName: displayMatch.skillName,
|
(item, index) => ({
|
||||||
mode: item.displayMode ?? displayMatch.display.mode,
|
...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"
|
const wikiSearchLabel = name === "llmwiki_search"
|
||||||
? wikiQuery
|
? wikiQuery
|
||||||
? `Wiki 文档检索 "${wikiQuery}"`
|
? `Wiki 文档检索 "${wikiQuery}"`
|
||||||
: "Wiki 文档检索"
|
: "Wiki 文档检索"
|
||||||
: null;
|
: name === "llmwiki_read_wiki_page"
|
||||||
|
? wikiQuery
|
||||||
|
? `读取 Wiki 页面「${wikiQuery}」`
|
||||||
|
: "读取 Wiki 页面"
|
||||||
|
: name === "llmwiki_browse_wiki_index"
|
||||||
|
? "读取 Wiki 索引与知识库摘要"
|
||||||
|
: null;
|
||||||
if (displayItems.length > 0) {
|
if (displayItems.length > 0) {
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
@@ -394,12 +435,17 @@ function ToolCall({
|
|||||||
|
|
||||||
const searchItems = normalizeSearchResults(result);
|
const searchItems = normalizeSearchResults(result);
|
||||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
const defaultItems = syncDisplaySourceIndexes(
|
||||||
...item,
|
rankDisplaySources(
|
||||||
index: index + 1,
|
normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||||
skillName: name,
|
...item,
|
||||||
mode: "citation" as const,
|
index: index + 1,
|
||||||
}));
|
skillName: name,
|
||||||
|
mode: "citation" as const,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
displaySources,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||||
<DisplayResultsList results={defaultItems} />
|
<DisplayResultsList results={defaultItems} />
|
||||||
@@ -781,17 +827,10 @@ function ArtifactWriteToolCall({
|
|||||||
const rawPath = typeof args.path === "string" ? args.path : "";
|
const rawPath = typeof args.path === "string" ? args.path : "";
|
||||||
const content = typeof args.content === "string" ? args.content : "";
|
const content = typeof args.content === "string" ? args.content : "";
|
||||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||||
const hasUsablePath =
|
const sourcePath = namedStreamingWriteFilePath(rawPath);
|
||||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
const showInlineContent = name === "write_file" && !sourcePath;
|
||||||
const sourcePath = hasUsablePath
|
|
||||||
? normalizedPath
|
|
||||||
: name === "write_file" && content.trim()
|
|
||||||
? provisionalWriteFilePath(content)
|
|
||||||
: null;
|
|
||||||
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
||||||
// Never select a bare provisional name like `未命名文件.md` — the artifacts
|
// Until a real path arrives, both auto-open and manual clicks stay inline.
|
||||||
// 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.
|
|
||||||
const artifactPath =
|
const artifactPath =
|
||||||
sourcePath && name === "write_file" && messageId && id
|
sourcePath && name === "write_file" && messageId && id
|
||||||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||||||
@@ -832,7 +871,9 @@ function ArtifactWriteToolCall({
|
|||||||
artifacts.setOpen(true);
|
artifacts.setOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(normalizedPath || sourcePath) && (
|
{showInlineContent ? (
|
||||||
|
content && <InlineWritePreview content={content} />
|
||||||
|
) : (normalizedPath || sourcePath) && (
|
||||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||||
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
||||||
</ChainOfThoughtSearchResult>
|
</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;
|
id?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
type: T;
|
type: T;
|
||||||
@@ -861,6 +924,10 @@ type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
|||||||
|
|
||||||
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||||
const steps: 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) {
|
for (const message of messages) {
|
||||||
if (message.type === "ai") {
|
if (message.type === "ai") {
|
||||||
const reasoning = extractReasoningContentFromMessage(message);
|
const reasoning = extractReasoningContentFromMessage(message);
|
||||||
@@ -873,14 +940,37 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
|||||||
};
|
};
|
||||||
steps.push(step);
|
steps.push(step);
|
||||||
}
|
}
|
||||||
for (const tool_call of message.tool_calls ?? []) {
|
const writes = new Map(
|
||||||
if (tool_call.name === "task") {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const writePath =
|
const write = tool_call.name === "write_file" && tool_call.id
|
||||||
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
|
? writes.get(tool_call.id)
|
||||||
? (tool_call.args as { path: string }).path
|
: 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 (
|
if (
|
||||||
(
|
(
|
||||||
tool_call.name === "write_file" ||
|
tool_call.name === "write_file" ||
|
||||||
@@ -898,15 +988,27 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
|||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
type: "toolCall",
|
type: "toolCall",
|
||||||
name: tool_call.name,
|
name: tool_call.name,
|
||||||
args: tool_call.args,
|
args,
|
||||||
};
|
};
|
||||||
const toolCallId = tool_call.id;
|
const toolCallId = tool_call.id;
|
||||||
if (toolCallId) {
|
if (toolCallId) {
|
||||||
const toolCallResult = findToolCallResult(
|
let toolCallResult = findToolCallResult(
|
||||||
toolCallId,
|
toolCallId,
|
||||||
messages,
|
messages,
|
||||||
tool_call.name,
|
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 (toolCallResult !== undefined) {
|
||||||
if (typeof toolCallResult === "string") {
|
if (typeof toolCallResult === "string") {
|
||||||
try {
|
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 }) {
|
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const content = item.snippet ?? item.content;
|
const content = item.snippet ?? item.content;
|
||||||
@@ -1010,32 +1154,46 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-2xl">
|
<SheetContent
|
||||||
<DialogHeader>
|
side="right"
|
||||||
<DialogTitle className="pr-6 text-base leading-relaxed">
|
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||||
{item.title ?? "检索结果"}
|
>
|
||||||
</DialogTitle>
|
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
|
||||||
{(sourceLabel || date) && (
|
<SheetTitle className="text-base leading-relaxed">
|
||||||
<DialogDescription className="flex items-center gap-2 text-xs">
|
{item.title ?? "检索结果"}
|
||||||
{sourceLabel && <span>{sourceLabel}</span>}
|
</SheetTitle>
|
||||||
{sourceLabel && date && <span>·</span>}
|
{(sourceLabel || date) && (
|
||||||
{date && <span>{date}</span>}
|
<SheetDescription className="flex items-center gap-2 text-xs">
|
||||||
</DialogDescription>
|
{sourceLabel && <span>{sourceLabel}</span>}
|
||||||
)}
|
{sourceLabel && date && <span>·</span>}
|
||||||
</DialogHeader>
|
{date && <span>{date}</span>}
|
||||||
{content && (
|
</SheetDescription>
|
||||||
<div className="text-foreground max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
)}
|
||||||
{content}
|
{!sourceLabel && !date && (
|
||||||
</div>
|
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
|
||||||
)}
|
)}
|
||||||
{item.url && (
|
</SheetHeader>
|
||||||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
|
||||||
{item.url}
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
</div>
|
{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">
|
||||||
</DialogContent>
|
{content}
|
||||||
</Dialog>
|
</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 type { Message } from '@langchain/langgraph-sdk';
|
||||||
import {
|
import {
|
||||||
BookMarkedIcon,
|
ArrowLeftIcon,
|
||||||
ChevronDownIcon,
|
BookMarkedIcon,
|
||||||
ClockIcon,
|
ChevronRightIcon,
|
||||||
ExternalLinkIcon,
|
ClockIcon,
|
||||||
|
ExternalLinkIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
@@ -25,7 +26,6 @@ import { Loader } from '@/components/ai-elements/loader';
|
|||||||
import {
|
import {
|
||||||
Message as AIElementMessage,
|
Message as AIElementMessage,
|
||||||
MessageContent as AIElementMessageContent,
|
MessageContent as AIElementMessageContent,
|
||||||
MessageResponse as AIElementMessageResponse,
|
|
||||||
MessageToolbar,
|
MessageToolbar,
|
||||||
} from '@/components/ai-elements/message';
|
} from '@/components/ai-elements/message';
|
||||||
import {
|
import {
|
||||||
@@ -34,20 +34,15 @@ import {
|
|||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from '@/components/ai-elements/reasoning';
|
} from '@/components/ai-elements/reasoning';
|
||||||
import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Sheet,
|
||||||
DialogContent,
|
SheetContent,
|
||||||
DialogDescription,
|
SheetDescription,
|
||||||
DialogHeader,
|
SheetHeader,
|
||||||
DialogTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/sheet';
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
UploadedFilePreviewDialog,
|
UploadedFilePreviewDialog,
|
||||||
@@ -55,10 +50,11 @@ import {
|
|||||||
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
|
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
|
||||||
import { resolveArtifactURL } from '@/core/artifacts/utils';
|
import { resolveArtifactURL } from '@/core/artifacts/utils';
|
||||||
import { useChatAskTimes } from '@/core/chat-timestamps';
|
import { useChatAskTimes } from '@/core/chat-timestamps';
|
||||||
import { getBackendBaseURL } from '@/core/config';
|
import { getBackendBaseURL } from '@/core/config';
|
||||||
import { readRuntimeEnv } from '@/env';
|
import { readRuntimeEnv } from '@/env';
|
||||||
import { useI18n } from '@/core/i18n/hooks';
|
import { useI18n } from '@/core/i18n/hooks';
|
||||||
import {
|
import { stripKnowledgeRetrievalDiagnostics } from '@/core/llmwiki/answer-display';
|
||||||
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
parseUploadedFiles,
|
parseUploadedFiles,
|
||||||
@@ -71,18 +67,20 @@ import {
|
|||||||
applyInlineCitations,
|
applyInlineCitations,
|
||||||
type RuntimeDisplaySource,
|
type RuntimeDisplaySource,
|
||||||
} from '@/core/skill-display/runtime';
|
} from '@/core/skill-display/runtime';
|
||||||
import { humanMessagePlugins } from '@/core/streamdown';
|
|
||||||
import {
|
import {
|
||||||
getMultiRoundRagTrace,
|
getMultiRoundRagTrace,
|
||||||
isMultiRoundRagMessage,
|
isMultiRoundRagMessage,
|
||||||
} from '@/core/threads/use-multi-round-rag';
|
} from '@/core/threads/use-multi-round-rag';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { AnswerFeedbackContext } from '@/strategy-components/api/answer-feedback';
|
||||||
|
|
||||||
import { CitationLink } from '../citations/citation-link';
|
import { CitationLink } from '../citations/citation-link';
|
||||||
import { CopyButton } from '../copy-button';
|
import { CopyButton } from '../copy-button';
|
||||||
import { Tooltip } from '../tooltip';
|
import { Tooltip } from '../tooltip';
|
||||||
import { SkillDisplayBlock } from '../skill-display-block';
|
import { SkillDisplayBlock } from '../skill-display-block';
|
||||||
import { ThreadContext } from './context';
|
import { ThreadContext } from './context';
|
||||||
|
import { AnswerFeedbackControl } from './answer-feedback-control';
|
||||||
|
import { isEligibleAnswerFeedbackMessage } from './answer-feedback-eligibility';
|
||||||
import { DeleteMessageButton } from './delete-message-button';
|
import { DeleteMessageButton } from './delete-message-button';
|
||||||
import { DownloadWordButton } from './download-word-button';
|
import { DownloadWordButton } from './download-word-button';
|
||||||
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
||||||
@@ -168,6 +166,8 @@ export function MessageListItem({
|
|||||||
onEditSave,
|
onEditSave,
|
||||||
onMessageDeleted,
|
onMessageDeleted,
|
||||||
turnTime,
|
turnTime,
|
||||||
|
answerFeedbackEnabled = false,
|
||||||
|
answerFeedbackContext,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
fitRowToContent?: boolean;
|
fitRowToContent?: boolean;
|
||||||
@@ -194,6 +194,10 @@ export function MessageListItem({
|
|||||||
* wall clock at render time and appear to "drift" to now on every reload.
|
* wall clock at render time and appear to "drift" to now on every reload.
|
||||||
*/
|
*/
|
||||||
turnTime?: string;
|
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 { t } = useI18n();
|
||||||
const isHuman = message.type === 'human';
|
const isHuman = message.type === 'human';
|
||||||
@@ -218,6 +222,9 @@ export function MessageListItem({
|
|||||||
!!message.id &&
|
!!message.id &&
|
||||||
!message.id.startsWith('opt-') &&
|
!message.id.startsWith('opt-') &&
|
||||||
!!messageMarkdown;
|
!!messageMarkdown;
|
||||||
|
const canEvaluateAnswer =
|
||||||
|
answerFeedbackEnabled &&
|
||||||
|
isEligibleAnswerFeedbackMessage(message, Boolean(isLoading));
|
||||||
const confirmAssistantEdit = useCallback(async () => {
|
const confirmAssistantEdit = useCallback(async () => {
|
||||||
const value = assistantDraft.trim();
|
const value = assistantDraft.trim();
|
||||||
if (!value || !message.id || !onEditSave || assistantSaving) {
|
if (!value || !message.id || !onEditSave || assistantSaving) {
|
||||||
@@ -313,20 +320,27 @@ export function MessageListItem({
|
|||||||
onMessageDeleted={onMessageDeleted}
|
onMessageDeleted={onMessageDeleted}
|
||||||
turnTime={turnTime}
|
turnTime={turnTime}
|
||||||
/>
|
/>
|
||||||
{/*
|
{/*
|
||||||
Human messages carry their own inline copy/edit toolbar inside
|
Human messages carry their own inline copy/edit toolbar inside
|
||||||
MessageContent (above the timestamp). Only assistant answers use the
|
MessageContent (above the timestamp). Only assistant answers use the
|
||||||
floating bottom toolbar here.
|
always-visible floating bottom toolbar here.
|
||||||
*/}
|
*/}
|
||||||
{!isLoading && !isHuman && (
|
{!isLoading && !isHuman && (
|
||||||
<MessageToolbar
|
<MessageToolbar
|
||||||
className={cn(
|
className={cn(
|
||||||
'-bottom-8',
|
'-bottom-8',
|
||||||
'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100',
|
'absolute right-0 left-0 z-20',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<CopyButton clipboardData={messageMarkdown} />
|
<CopyButton clipboardData={messageMarkdown} />
|
||||||
|
{canEvaluateAnswer && message.id && answerFeedbackContext && (
|
||||||
|
<AnswerFeedbackControl
|
||||||
|
threadId={threadId}
|
||||||
|
messageId={message.id}
|
||||||
|
context={answerFeedbackContext}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{messageMarkdown && (
|
{messageMarkdown && (
|
||||||
<DownloadWordButton
|
<DownloadWordButton
|
||||||
markdown={messageMarkdown}
|
markdown={messageMarkdown}
|
||||||
@@ -358,7 +372,7 @@ export function MessageListItem({
|
|||||||
/**
|
/**
|
||||||
* Inline copy/edit affordances + editor for the user's own question bubble.
|
* 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
|
* 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
|
* 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
|
* the question; confirming sends the edited text as a brand-new turn (the
|
||||||
@@ -370,7 +384,6 @@ function HumanMessageBlock({
|
|||||||
pageContext,
|
pageContext,
|
||||||
filesList,
|
filesList,
|
||||||
messageTime,
|
messageTime,
|
||||||
components,
|
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onEditSave,
|
onEditSave,
|
||||||
deleteButton,
|
deleteButton,
|
||||||
@@ -380,7 +393,7 @@ function HumanMessageBlock({
|
|||||||
pageContext?: EmbeddedPageContext | null;
|
pageContext?: EmbeddedPageContext | null;
|
||||||
filesList: ReactNode;
|
filesList: ReactNode;
|
||||||
messageTime: { label: string; full: string } | null;
|
messageTime: { label: string; full: string } | null;
|
||||||
components: MarkdownContentProps["components"];
|
components?: MarkdownContentProps["components"];
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||||
deleteButton?: ReactNode;
|
deleteButton?: ReactNode;
|
||||||
@@ -460,14 +473,9 @@ function HumanMessageBlock({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageResponse = text ? (
|
const messageResponse = text ? (
|
||||||
<AIElementMessageResponse
|
<div className="whitespace-pre-wrap break-words text-[15px] leading-7">
|
||||||
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
|
||||||
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
|
||||||
components={components}
|
|
||||||
parseIncompleteMarkdown={false}
|
|
||||||
>
|
|
||||||
{text}
|
{text}
|
||||||
</AIElementMessageResponse>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -479,9 +487,9 @@ function HumanMessageBlock({
|
|||||||
</AIElementMessageContent>
|
</AIElementMessageContent>
|
||||||
)}
|
)}
|
||||||
<MessageTimeStamp time={messageTime} align="right" />
|
<MessageTimeStamp time={messageTime} align="right" />
|
||||||
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
|
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
|
||||||
{(text || deleteButton) && (
|
{(text || deleteButton) && (
|
||||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
|
<div className="flex items-center gap-0.5">
|
||||||
{text && <CopyButton clipboardData={text} className="size-6" />}
|
{text && <CopyButton clipboardData={text} className="size-6" />}
|
||||||
{text && (onEditResubmit || onEditSave) && (
|
{text && (onEditResubmit || onEditSave) && (
|
||||||
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
||||||
@@ -686,22 +694,25 @@ function MessageContent_({
|
|||||||
return files as FileInMessage[];
|
return files as FileInMessage[];
|
||||||
}, [message.additional_kwargs?.files, rawContent]);
|
}, [message.additional_kwargs?.files, rawContent]);
|
||||||
|
|
||||||
const contentToDisplay = useMemo(() => {
|
const contentToDisplay = useMemo(() => {
|
||||||
if (isHuman) {
|
if (isHuman) {
|
||||||
return rawContent
|
return rawContent
|
||||||
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
if (isMultiRoundRag) {
|
const sanitizedContent = stripKnowledgeRetrievalDiagnostics(rawContent ?? '');
|
||||||
return stripIncompleteCitationMarker(rawContent ?? '');
|
if (isMultiRoundRag) {
|
||||||
}
|
return stripIncompleteCitationMarker(sanitizedContent);
|
||||||
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
}
|
||||||
return stripIncompleteCitationMarker(withCitations);
|
const withCitations = applyInlineCitations(sanitizedContent, displaySources);
|
||||||
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
|
return stripIncompleteCitationMarker(withCitations);
|
||||||
const markdownRehypePlugins = useMemo(
|
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
|
||||||
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
const markdownRehypePlugins = useMemo<
|
||||||
[rehypePlugins],
|
NonNullable<MarkdownContentProps['rehypePlugins']>
|
||||||
);
|
>(
|
||||||
|
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
||||||
|
[rehypePlugins],
|
||||||
|
);
|
||||||
const parsedHumanContent = useMemo(
|
const parsedHumanContent = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isHuman
|
isHuman
|
||||||
@@ -807,11 +818,8 @@ function MessageContent_({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isSingleRoundRag && displaySources.length > 0 && (
|
{isSingleRoundRag && displaySources.length > 0 && (
|
||||||
<FrontendRagSourcesPanel
|
<FrontendRagSourcesPanel sources={displaySources} />
|
||||||
sources={displaySources}
|
|
||||||
onSelect={setSelectedCitation}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<MessageTokenUsage
|
<MessageTokenUsage
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
@@ -823,7 +831,7 @@ function MessageContent_({
|
|||||||
) : null}
|
) : null}
|
||||||
</AIElementMessageContent>
|
</AIElementMessageContent>
|
||||||
{isSingleRoundRag && (
|
{isSingleRoundRag && (
|
||||||
<SingleRoundRagCitationDialog
|
<SingleRoundRagCitationDrawer
|
||||||
source={selectedCitation}
|
source={selectedCitation}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setSelectedCitation(null);
|
if (!open) setSelectedCitation(null);
|
||||||
@@ -834,112 +842,176 @@ function MessageContent_({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单轮 RAG 专属的「引用来源」折叠面板。
|
* 单轮 RAG 的参考文献入口。先打开右侧抽屉查看列表,
|
||||||
*
|
* 选中条目后在同一抽屉中阅读详情,避免跳出居中弹框打断问答。
|
||||||
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
|
*/
|
||||||
*/
|
function FrontendRagSourcesPanel({ sources }: { sources: RuntimeDisplaySource[] }) {
|
||||||
function FrontendRagSourcesPanel({
|
const [open, setOpen] = useState(false);
|
||||||
sources,
|
const [selectedSource, setSelectedSource] = useState<RuntimeDisplaySource | null>(null);
|
||||||
onSelect,
|
if (sources.length === 0) return null;
|
||||||
}: {
|
|
||||||
sources: RuntimeDisplaySource[];
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
onSelect: (source: RuntimeDisplaySource) => void;
|
setOpen(nextOpen);
|
||||||
}) {
|
if (!nextOpen) setSelectedSource(null);
|
||||||
const [open, setOpen] = useState(false);
|
};
|
||||||
if (sources.length === 0) return null;
|
|
||||||
return (
|
return (
|
||||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
|
<>
|
||||||
<CollapsibleTrigger asChild>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => setOpen(true)}
|
||||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-xs transition-colors"
|
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>
|
<BookMarkedIcon className="size-3.5" />
|
||||||
<span className="text-muted-foreground/70">({sources.length})</span>
|
<span className="font-medium">参考文献</span>
|
||||||
<ChevronDownIcon
|
<span className="text-muted-foreground/70">({sources.length})</span>
|
||||||
className={cn(
|
<span>查看</span>
|
||||||
'size-3.5 transition-transform duration-200',
|
<ChevronRightIcon className="size-3.5" />
|
||||||
!open && '-rotate-90',
|
</button>
|
||||||
)}
|
|
||||||
/>
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||||
</button>
|
<SheetContent
|
||||||
</CollapsibleTrigger>
|
side="right"
|
||||||
<CollapsibleContent className="mt-2 grid gap-2">
|
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||||
{sources.map((source, idx) => (
|
>
|
||||||
<button
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
key={source.id ?? `${source.title}-${idx}`}
|
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
|
||||||
type="button"
|
<div className="flex items-start gap-3">
|
||||||
onClick={() => onSelect(source)}
|
{selectedSource && (
|
||||||
className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
|
<Button
|
||||||
>
|
type="button"
|
||||||
<div className="flex items-center gap-2">
|
size="icon-sm"
|
||||||
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
|
variant="ghost"
|
||||||
{source.index}
|
className="mt-0.5 shrink-0"
|
||||||
</span>
|
onClick={() => setSelectedSource(null)}
|
||||||
<span
|
aria-label="返回参考文献列表"
|
||||||
className="text-foreground line-clamp-1 min-w-0 flex-1 text-sm font-medium"
|
>
|
||||||
title={source.title}
|
<ArrowLeftIcon className="size-4" />
|
||||||
>
|
</Button>
|
||||||
{source.title}
|
)}
|
||||||
</span>
|
<div className="min-w-0">
|
||||||
{source.type && (
|
<SheetTitle className="truncate text-base">
|
||||||
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
|
{selectedSource?.title || "参考文献"}
|
||||||
{source.type}
|
</SheetTitle>
|
||||||
</span>
|
<SheetDescription className="mt-1 text-xs">
|
||||||
)}
|
{selectedSource
|
||||||
</div>
|
? "引用详情;可返回列表继续查看其他文献。"
|
||||||
{source.snippet && (
|
: `本次回答共引用 ${sources.length} 条资料,点击条目查看具体内容。`}
|
||||||
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
</SheetDescription>
|
||||||
{source.snippet}
|
</div>
|
||||||
</p>
|
</div>
|
||||||
)}
|
</SheetHeader>
|
||||||
</button>
|
|
||||||
))}
|
{selectedSource ? (
|
||||||
</CollapsibleContent>
|
<CitationSourceDetail source={selectedSource} />
|
||||||
</Collapsible>
|
) : (
|
||||||
);
|
<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) => (
|
||||||
export function SingleRoundRagCitationDialog({
|
<button
|
||||||
source,
|
key={source.id ?? `${source.title}-${idx}`}
|
||||||
onOpenChange,
|
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;
|
source: RuntimeDisplaySource | null;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
return (
|
||||||
return (
|
<Sheet open={!!source} onOpenChange={onOpenChange}>
|
||||||
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
<SheetContent
|
||||||
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
side="right"
|
||||||
<DialogHeader className="pr-8">
|
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||||
<DialogTitle className="leading-relaxed">
|
>
|
||||||
{source?.title || "引用资料"}
|
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
|
||||||
</DialogTitle>
|
<SheetTitle className="leading-relaxed">
|
||||||
<DialogDescription className="space-y-1 text-xs">
|
{source?.title || "引用资料"}
|
||||||
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
|
</SheetTitle>
|
||||||
{source?.time && <span className="block">时间:{source.time}</span>}
|
<SheetDescription className="space-y-1 text-xs">
|
||||||
</DialogDescription>
|
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
|
||||||
</DialogHeader>
|
{source?.time && <span className="block">时间:{source.time}</span>}
|
||||||
{sourceUrl && (
|
</SheetDescription>
|
||||||
<a
|
</SheetHeader>
|
||||||
href={sourceUrl}
|
{source && <CitationSourceDetail source={source} />}
|
||||||
target="_blank"
|
</SheetContent>
|
||||||
rel="noopener noreferrer"
|
</Sheet>
|
||||||
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" />
|
|
||||||
查看原文链接
|
function CitationSourceDetail({ source }: { source: RuntimeDisplaySource }) {
|
||||||
</a>
|
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
||||||
)}
|
return (
|
||||||
<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">
|
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
|
||||||
{source?.content || source?.snippet || "暂无引用内容"}
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
</div>
|
{sourceUrl && (
|
||||||
</DialogContent>
|
<a
|
||||||
</Dialog>
|
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(
|
function resolveSingleRoundRagSourceUrl(
|
||||||
source: RuntimeDisplaySource | null,
|
source: RuntimeDisplaySource | null,
|
||||||
@@ -1160,10 +1232,12 @@ function RichFileCard({
|
|||||||
if (!fileUrl) return null;
|
if (!fileUrl) return null;
|
||||||
|
|
||||||
const preview = (
|
const preview = (
|
||||||
<UploadedFilePreviewDialog
|
<UploadedFilePreviewDialog
|
||||||
filename={file.filename}
|
filename={file.filename}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileUrl}
|
||||||
open={previewOpen}
|
threadId={threadId}
|
||||||
|
virtualPath={file.path}
|
||||||
|
open={previewOpen}
|
||||||
onOpenChange={setPreviewOpen}
|
onOpenChange={setPreviewOpen}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
|||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
|
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||||
import {
|
import {
|
||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
collectRuntimeDisplaySources,
|
collectRuntimeDisplaySources,
|
||||||
@@ -40,20 +41,35 @@ import {
|
|||||||
import type { Subtask } from "@/core/tasks";
|
import type { Subtask } from "@/core/tasks";
|
||||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||||
import type { AgentThreadState } from "@/core/threads";
|
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 { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||||
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
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 { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||||
import { getQuestionAnchorId } from "../question-navigator";
|
import { getQuestionAnchorId } from "../question-navigator";
|
||||||
import { StreamingIndicator } from "../streaming-indicator";
|
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 { MarkdownContent } from "./markdown-content";
|
||||||
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
import { DocumentRewriteMessage, DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||||
import { MessageGroup } from "./message-group";
|
import { MessageGroup } from "./message-group";
|
||||||
import { MessageListItem } from "./message-list-item";
|
import { MessageListItem } from "./message-list-item";
|
||||||
|
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
|
||||||
import {
|
import {
|
||||||
MessageMetricsProvider,
|
MessageMetricsProvider,
|
||||||
MessageTokenUsageList,
|
MessageTokenUsageList,
|
||||||
@@ -65,7 +81,7 @@ export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
|
|||||||
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
|
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
|
||||||
|
|
||||||
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
|
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
|
||||||
function ScrollToBottomOnUserMessage({
|
function ScrollToBottomOnUserMessage({
|
||||||
threadId,
|
threadId,
|
||||||
messages,
|
messages,
|
||||||
}: {
|
}: {
|
||||||
@@ -109,10 +125,54 @@ function ScrollToBottomOnUserMessage({
|
|||||||
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
|
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
|
||||||
}, [scrollToBottom, threadId]);
|
}, [scrollToBottom, threadId]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageList({
|
/**
|
||||||
|
* 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,
|
className,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
fitMessageRowsToContent = false,
|
fitMessageRowsToContent = false,
|
||||||
@@ -120,9 +180,10 @@ export function MessageList({
|
|||||||
thread,
|
thread,
|
||||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
scrollButtonClassName,
|
scrollButtonClassName,
|
||||||
onClarificationSubmit,
|
onAtBottomChange,
|
||||||
|
onClarificationSubmit,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onEditSave,
|
onEditSave,
|
||||||
messageContentOverrides,
|
messageContentOverrides,
|
||||||
@@ -139,9 +200,10 @@ export function MessageList({
|
|||||||
businessCardSlot,
|
businessCardSlot,
|
||||||
speakerShell,
|
speakerShell,
|
||||||
onOpenArtifact,
|
onOpenArtifact,
|
||||||
onDownloadArtifact,
|
onDownloadArtifact,
|
||||||
afterDocumentRewriteItems,
|
afterDocumentRewriteItems,
|
||||||
}: {
|
answerFeedbackEnabled = false,
|
||||||
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
fitMessageRowsToContent?: boolean;
|
fitMessageRowsToContent?: boolean;
|
||||||
@@ -149,8 +211,10 @@ export function MessageList({
|
|||||||
thread: BaseStream<AgentThreadState>;
|
thread: BaseStream<AgentThreadState>;
|
||||||
paddingBottom?: number;
|
paddingBottom?: number;
|
||||||
tokenUsageEnabled?: boolean;
|
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>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
@@ -194,9 +258,15 @@ export function MessageList({
|
|||||||
onOpenArtifact?: (filepath: string) => void;
|
onOpenArtifact?: (filepath: string) => void;
|
||||||
/** Host override for downloading virtual/session-owned files in a present-files card. */
|
/** Host override for downloading virtual/session-owned files in a present-files card. */
|
||||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||||
/** Optional host content placed immediately after document-rewrite timelines. */
|
/** Optional host content placed immediately after document-rewrite timelines. */
|
||||||
afterDocumentRewriteItems?: React.ReactNode;
|
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 baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
const rehypePlugins = useMemo(
|
const rehypePlugins = useMemo(
|
||||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||||
@@ -217,33 +287,65 @@ export function MessageList({
|
|||||||
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
|
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
);
|
);
|
||||||
|
const [selectedRetrievalResult, setSelectedRetrievalResult] = useState<{
|
||||||
|
item: SmartKnowledgeRetrievalResultItem;
|
||||||
|
target: WeKnoraSourceTarget;
|
||||||
|
} | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHiddenMessageIds(new Set());
|
setHiddenMessageIds(new Set());
|
||||||
|
setSelectedRetrievalResult(null);
|
||||||
}, [threadId]);
|
}, [threadId]);
|
||||||
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
|
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
|
||||||
if (deletedIds.length === 0) return;
|
if (deletedIds.length === 0) return;
|
||||||
setHiddenMessageIds((prev) => {
|
setHiddenMessageIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
for (const id of deletedIds) next.add(id);
|
for (const id of deletedIds) next.add(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const allMessages = useMemo(() => {
|
const documentRewriteItems = useMemo(
|
||||||
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
|
() => documentRewritePresentation?.forThread(threadId) ?? [],
|
||||||
return thread.messages
|
[documentRewritePresentation, threadId],
|
||||||
.filter((m) => !isHiddenFromUIMessage(m))
|
);
|
||||||
|
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) => {
|
.map((message) => {
|
||||||
const override =
|
const override =
|
||||||
hasOverrides && message.id
|
hasOverrides && message.id
|
||||||
? messageContentOverrides?.[message.id]
|
? messageContentOverrides?.[message.id]
|
||||||
: undefined;
|
: undefined;
|
||||||
return override === undefined
|
return override === undefined
|
||||||
? message
|
? message
|
||||||
: ({ ...message, content: override } as Message);
|
: ({ ...message, content: override } as Message);
|
||||||
});
|
});
|
||||||
}, [messageContentOverrides, thread.messages]);
|
const rewriteMessages = artifactRewriteItems.flatMap((item) => [
|
||||||
const messages = useMemo(
|
{
|
||||||
|
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>();
|
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
||||||
return hiddenMessageIds.size === 0 && externalHidden.size === 0
|
return hiddenMessageIds.size === 0 && externalHidden.size === 0
|
||||||
@@ -255,10 +357,58 @@ export function MessageList({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||||
);
|
);
|
||||||
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
|
const knowledgeRetrievalProgress = useKnowledgeRetrievalProgress(threadId);
|
||||||
|
const hasHumanMessage = messages.some((message) => message.type === "human");
|
||||||
const askTimes = useChatAskTimes();
|
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
|
// 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
|
// a single batched commit. Questions sent this session already carry an
|
||||||
@@ -320,8 +470,9 @@ export function MessageList({
|
|||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const referenceModeEntryEnabled =
|
const referenceModeEntryEnabled =
|
||||||
citationDisplay?.reference_mode_enabled ?? false;
|
citationDisplay?.reference_mode_enabled ?? false;
|
||||||
const knowledgeReferencesEnabled =
|
const knowledgeReferencesEnabled =
|
||||||
threadContext?.knowledgeReferencesEnabled === true;
|
threadContext?.knowledgeReferencesEnabled === true ||
|
||||||
|
!!threadContext?.knowledgeReferences;
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||||
isThreadRagModeLocked(threadId) ||
|
isThreadRagModeLocked(threadId) ||
|
||||||
@@ -383,10 +534,29 @@ export function MessageList({
|
|||||||
return map;
|
return map;
|
||||||
}, [thread.messages]);
|
}, [thread.messages]);
|
||||||
|
|
||||||
|
const smartRetrievalAssistantMessageIds = useMemo(() => {
|
||||||
|
return collectSmartKnowledgeRetrievalTurnAssistantMessageIds(messages);
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const resolveDisplaySources = useCallback(
|
const resolveDisplaySources = useCallback(
|
||||||
(msg: Message | undefined): RuntimeDisplaySource[] => {
|
(msg: Message | undefined): RuntimeDisplaySource[] => {
|
||||||
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
|
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
|
||||||
if (isMultiRoundRagMessage(msg)) 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 =
|
const batchSources =
|
||||||
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
|
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
|
||||||
?.sources ?? [];
|
?.sources ?? [];
|
||||||
@@ -396,7 +566,7 @@ export function MessageList({
|
|||||||
if (batchSources.length > 0) return batchSources;
|
if (batchSources.length > 0) return batchSources;
|
||||||
if (frontendRagSources.length > 0) return frontendRagSources;
|
if (frontendRagSources.length > 0) return frontendRagSources;
|
||||||
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
afterMessageId: previousHumanId,
|
||||||
beforeMessageId: msg.id,
|
beforeMessageId: msg.id,
|
||||||
includeBeforeMessage: true,
|
includeBeforeMessage: true,
|
||||||
});
|
});
|
||||||
@@ -407,6 +577,7 @@ export function MessageList({
|
|||||||
referenceBatches,
|
referenceBatches,
|
||||||
referencesEnabled,
|
referencesEnabled,
|
||||||
skillDisplayMap,
|
skillDisplayMap,
|
||||||
|
smartRetrievalAssistantMessageIds,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -522,24 +693,19 @@ export function MessageList({
|
|||||||
return (
|
return (
|
||||||
<Conversation
|
<Conversation
|
||||||
className={cn("flex size-full flex-col justify-center", className)}
|
className={cn("flex size-full flex-col justify-center", className)}
|
||||||
>
|
>
|
||||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||||
<ConversationContent
|
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
|
||||||
|
<ConversationContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto w-full max-w-(--container-width-lg)",
|
"mx-auto w-full max-w-(--container-width-lg)",
|
||||||
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
|
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
|
||||||
contentClassName,
|
contentClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const rewriteItemsAboveFile = documentRewriteItems.filter(
|
let didRenderLiveKnowledgeRetrieval = false;
|
||||||
(item) => item.operation !== "generate",
|
|
||||||
);
|
|
||||||
const rewriteItemsBelowFile = documentRewriteItems.filter(
|
|
||||||
(item) => item.operation === "generate",
|
|
||||||
);
|
|
||||||
let didRenderRewriteAboveFile = false;
|
|
||||||
const grouped = groupMessages(messages, (group) => {
|
const grouped = groupMessages(messages, (group) => {
|
||||||
if (group.type === "human" || group.type === "assistant") {
|
if (group.type === "human" || group.type === "assistant") {
|
||||||
const items = group.messages.map((msg) => {
|
const items = group.messages.map((msg) => {
|
||||||
@@ -584,8 +750,14 @@ export function MessageList({
|
|||||||
onMessageDeleted={
|
onMessageDeleted={
|
||||||
allowDelete ? handleMessageDeleted : undefined
|
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") {
|
if (speakerShell && group.type === "assistant") {
|
||||||
@@ -711,15 +883,30 @@ export function MessageList({
|
|||||||
{taskReportImportSlot(message)}
|
{taskReportImportSlot(message)}
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
} else if (group.type === "assistant:business-card") {
|
} else if (group.type === "assistant:business-card") {
|
||||||
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
|
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
|
||||||
const message = group.messages[0];
|
const message = group.messages[0];
|
||||||
return businessCardSlot && message ? (
|
return businessCardSlot && message ? (
|
||||||
<div key={group.id} className="w-full pb-2">
|
<div key={group.id} className="w-full pb-2">
|
||||||
{businessCardSlot(message)}
|
{businessCardSlot(message)}
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
} else if (group.type === "assistant:present-files") {
|
} 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[] = [];
|
const files: string[] = [];
|
||||||
for (const message of group.messages) {
|
for (const message of group.messages) {
|
||||||
if (hasPresentFiles(message)) {
|
if (hasPresentFiles(message)) {
|
||||||
@@ -727,16 +914,9 @@ export function MessageList({
|
|||||||
files.push(...presentFiles);
|
files.push(...presentFiles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rewriteAbove = !didRenderRewriteAboveFile
|
return (
|
||||||
? rewriteItemsAboveFile
|
<div className="w-full" key={group.id}>
|
||||||
: [];
|
{group.messages[0] && hasContent(group.messages[0]) && (
|
||||||
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]) && (
|
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
content={extractContentFromMessage(group.messages[0])}
|
content={extractContentFromMessage(group.messages[0])}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
@@ -815,6 +995,7 @@ export function MessageList({
|
|||||||
results.push(
|
results.push(
|
||||||
<MessageGroup
|
<MessageGroup
|
||||||
key={"thinking-group-" + message.id}
|
key={"thinking-group-" + message.id}
|
||||||
|
threadId={threadId}
|
||||||
messages={[message]}
|
messages={[message]}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
displaySources={resolveDisplaySources(message)}
|
displaySources={resolveDisplaySources(message)}
|
||||||
@@ -865,14 +1046,95 @@ export function MessageList({
|
|||||||
const processingAi = [...group.messages]
|
const processingAi = [...group.messages]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message.type === "ai");
|
.find((message) => message.type === "ai");
|
||||||
const processingBody = (
|
const hasSmartRetrievalCalls = hasSmartKnowledgeRetrievalCalls(
|
||||||
<div className="w-full">
|
group.messages,
|
||||||
<MessageGroup
|
);
|
||||||
messages={group.messages}
|
const isCurrentTurnRetrievalGroup = group.messages.some(
|
||||||
isLoading={thread.isLoading}
|
(message) =>
|
||||||
displaySources={resolveDisplaySources(processingAi)}
|
message.id ===
|
||||||
ragModeEnabled={ragModeEnabled}
|
(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
|
<MessageTokenUsageList
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
@@ -886,12 +1148,27 @@ export function MessageList({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const leftoverRewriteItems = didRenderRewriteAboveFile
|
const leftoverRewriteItems = documentRewriteItems.filter(
|
||||||
? rewriteItemsBelowFile
|
(item) => item.source.kind !== "artifact" || item.operation !== "rewrite",
|
||||||
: documentRewriteItems;
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{grouped}
|
{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) => (
|
{leftoverRewriteItems.map((item) => (
|
||||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
<DocumentRewriteTimeline key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
@@ -913,6 +1190,16 @@ export function MessageList({
|
|||||||
aria-label="回到最新"
|
aria-label="回到最新"
|
||||||
title="回到最新"
|
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>
|
</Conversation>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -956,8 +1243,9 @@ export type SetupWritingApprovalPayload = {
|
|||||||
outline?: string;
|
outline?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetupReportStructureApprovalPayload = {
|
export type SetupReportStructureApprovalPayload = {
|
||||||
title?: string;
|
kind?: "report_structure" | "gongwen_template";
|
||||||
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
structure_mode?: string;
|
structure_mode?: string;
|
||||||
@@ -1060,8 +1348,8 @@ function SetupAgentApprovalCard({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const approvalPayload = payload;
|
const approvalPayload = payload;
|
||||||
const busy = disabled || submitting !== null;
|
const busy = disabled || submitting !== null;
|
||||||
const skills = approvalPayload.skills ?? [];
|
const skills = approvalPayload.skills ?? [];
|
||||||
|
|
||||||
async function run(action: "approve" | "reject") {
|
async function run(action: "approve" | "reject") {
|
||||||
@@ -1357,7 +1645,7 @@ const STRUCTURE_MODE_LABELS: Record<string, string> = {
|
|||||||
adaptive: "弹性大纲",
|
adaptive: "弹性大纲",
|
||||||
};
|
};
|
||||||
|
|
||||||
function SetupReportStructureApprovalCard({
|
function SetupReportStructureApprovalCard({
|
||||||
disabled,
|
disabled,
|
||||||
message,
|
message,
|
||||||
onApprove,
|
onApprove,
|
||||||
@@ -1375,7 +1663,8 @@ function SetupReportStructureApprovalCard({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const approvalPayload = payload;
|
const approvalPayload = payload;
|
||||||
|
const isGongwenTemplate = approvalPayload.kind === "gongwen_template";
|
||||||
const busy = disabled || submitting !== null;
|
const busy = disabled || submitting !== null;
|
||||||
const directions = approvalPayload.retrieval_directions ?? [];
|
const directions = approvalPayload.retrieval_directions ?? [];
|
||||||
const skills = approvalPayload.retrieval_skills ?? [];
|
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="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
|
<div className="text-muted-foreground text-xs font-medium">{isGongwenTemplate ? "公文模板结构建议" : "报告结构建议"}</div>
|
||||||
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
|
<div className="text-sm font-medium">{isGongwenTemplate ? "确认后将应用到右侧模板表单,再保存到模板中心。" : "确认后将应用到右侧表单,再点保存入库。"}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{approvalPayload.title ? (
|
{approvalPayload.title ? (
|
||||||
@@ -1443,7 +1732,7 @@ function SetupReportStructureApprovalCard({
|
|||||||
|
|
||||||
{approvalPayload.content ? (
|
{approvalPayload.content ? (
|
||||||
<div className="space-y-1">
|
<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">
|
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
||||||
{approvalPayload.content}
|
{approvalPayload.content}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ export function SpeedLegend({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SpeedPill({ metric }: { metric: MessageMetric }) {
|
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 fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
|
||||||
const { color, label } = classifySpeed(metric.tokensPerSec);
|
const { color, label } = classifySpeed(metric.tokensPerSec);
|
||||||
const display =
|
const display =
|
||||||
@@ -85,7 +97,7 @@ export function SpeedPill({ metric }: { metric: MessageMetric }) {
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1.5"
|
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 }} />
|
<GaugeIcon className="size-3" style={{ color }} />
|
||||||
<span className="font-mono tabular-nums" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+406
@@ -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,
|
XCircleIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Streamdown } from "streamdown";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChainOfThought,
|
ChainOfThought,
|
||||||
ChainOfThoughtContent,
|
ChainOfThoughtContent,
|
||||||
@@ -19,7 +17,10 @@ import { ShineBorder } from "@/components/ui/shine-border";
|
|||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { hasToolCalls } from "@/core/messages/utils";
|
import { hasToolCalls } from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import { streamdownPluginsWithWordAnimation } from "@/core/streamdown";
|
import {
|
||||||
|
SafeStreamdown,
|
||||||
|
streamdownPluginsWithWordAnimation,
|
||||||
|
} from "@/core/streamdown";
|
||||||
import { useSubtask } from "@/core/tasks/context";
|
import { useSubtask } from "@/core/tasks/context";
|
||||||
import { explainLastToolCall } from "@/core/tools/utils";
|
import { explainLastToolCall } from "@/core/tools/utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -126,12 +127,12 @@ export function SubtaskCard({
|
|||||||
{task.prompt && (
|
{task.prompt && (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
label={
|
label={
|
||||||
<Streamdown
|
<SafeStreamdown
|
||||||
{...streamdownPluginsWithWordAnimation}
|
{...streamdownPluginsWithWordAnimation}
|
||||||
components={{ a: CitationLink }}
|
components={{ a: CitationLink }}
|
||||||
>
|
>
|
||||||
{task.prompt}
|
{task.prompt}
|
||||||
</Streamdown>
|
</SafeStreamdown>
|
||||||
}
|
}
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DownloadIcon, Loader2 } from "lucide-react";
|
import { DownloadIcon, InfoIcon, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,8 +13,10 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { reasoningPlugins } from "@/core/streamdown";
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
|
import { DEFAULT_UPLOAD_POLICY, useUploadPolicy } from "@/core/uploads";
|
||||||
|
|
||||||
const markdownClassName =
|
const markdownClassName =
|
||||||
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
|
"!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";
|
return "binary";
|
||||||
}
|
}
|
||||||
|
|
||||||
function withReplacedExtension(url: string): string {
|
function withAppendedMarkdownExtension(url: string): string {
|
||||||
// Replace the path extension with .md, keeping any query string intact.
|
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 [base, query] = url.split("?");
|
||||||
const i = base.lastIndexOf(".");
|
const i = base.lastIndexOf(".");
|
||||||
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
|
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
|
||||||
return query ? `${swapped}?${query}` : swapped;
|
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
|
* Fetch an auth-gated artifact (the artifact route requires an
|
||||||
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
|
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
|
||||||
* hence the 401s) and expose it as an object URL for inline display.
|
* 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 [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -81,9 +174,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let created: string | null = null;
|
let created: string | null = null;
|
||||||
|
const controller = new AbortController();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
apiFetch(url)
|
requestFile(url, { signal: controller.signal })
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
return res.blob();
|
return res.blob();
|
||||||
@@ -102,9 +196,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
|||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
if (created) URL.revokeObjectURL(created);
|
if (created) URL.revokeObjectURL(created);
|
||||||
};
|
};
|
||||||
}, [url, enabled]);
|
}, [url, enabled, requestFile]);
|
||||||
|
|
||||||
return { objectUrl, loading, error };
|
return { objectUrl, loading, error };
|
||||||
}
|
}
|
||||||
@@ -122,47 +217,152 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
|||||||
export function UploadedFilePreviewDialog({
|
export function UploadedFilePreviewDialog({
|
||||||
filename,
|
filename,
|
||||||
fileUrl,
|
fileUrl,
|
||||||
|
threadId,
|
||||||
|
virtualPath,
|
||||||
|
requestFile = apiFetch,
|
||||||
|
onDownload,
|
||||||
|
downloading = false,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: {
|
}: {
|
||||||
filename: string;
|
filename: string;
|
||||||
/** Absolute artifact URL for the original uploaded file. */
|
/** Absolute artifact URL for the original uploaded file. */
|
||||||
fileUrl: string;
|
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;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
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 kind = useMemo(() => kindOf(filename), [filename]);
|
||||||
const ext = useMemo(() => extOf(filename), [filename]);
|
const ext = useMemo(() => extOf(filename), [filename]);
|
||||||
|
|
||||||
// image / pdf → fetch as authed blob and display via object URL.
|
// image / pdf → fetch as authed blob and display via object URL.
|
||||||
const blobEnabled = open && (kind === "image" || kind === "pdf");
|
const blobEnabled = open && (kind === "image" || kind === "pdf");
|
||||||
const { objectUrl, loading: blobLoading, error: blobError } =
|
const { objectUrl, loading: blobLoading, error: blobError } =
|
||||||
useAuthedObjectUrl(fileUrl, blobEnabled);
|
useAuthedObjectUrl(fileUrl, blobEnabled, requestFile);
|
||||||
|
|
||||||
// text / office → fetch text (office reads the converted sibling .md).
|
// text / office → fetch text (office reads the converted sibling .md).
|
||||||
const textUrl = useMemo(
|
const textUrls = useMemo(
|
||||||
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
|
() =>
|
||||||
|
kind === "office"
|
||||||
|
? Array.from(
|
||||||
|
new Set([
|
||||||
|
withAppendedMarkdownExtension(fileUrl),
|
||||||
|
withLegacyReplacedExtension(fileUrl),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
: [fileUrl],
|
||||||
[kind, 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 [text, setText] = useState<string | null>(null);
|
||||||
|
const [previewMeta, setPreviewMeta] = useState<PreviewMeta | null>(null);
|
||||||
const [textLoading, setTextLoading] = useState(false);
|
const [textLoading, setTextLoading] = useState(false);
|
||||||
const [textError, setTextError] = useState<string | null>(null);
|
const [textError, setTextError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || (kind !== "text" && kind !== "office")) return;
|
if (!open || (kind !== "text" && kind !== "office")) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
setTextLoading(true);
|
setTextLoading(true);
|
||||||
setTextError(null);
|
setTextError(null);
|
||||||
setText(null);
|
setText(null);
|
||||||
apiFetch(textUrl)
|
setPreviewMeta(null);
|
||||||
.then(async (res) => {
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
const load = async () => {
|
||||||
return res.text();
|
if (threadId && previewPaths.length > 0) {
|
||||||
})
|
for (const previewPath of previewPaths) {
|
||||||
.then((body) => {
|
try {
|
||||||
if (!cancelled) setText(body);
|
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) => {
|
.catch((err: unknown) => {
|
||||||
if (!cancelled)
|
if (!cancelled)
|
||||||
setTextError(err instanceof Error ? err.message : String(err));
|
setTextError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -172,8 +372,9 @@ export function UploadedFilePreviewDialog({
|
|||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [open, kind, textUrl]);
|
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId, requestFile]);
|
||||||
|
|
||||||
const renderAsMarkdown =
|
const renderAsMarkdown =
|
||||||
kind === "office" || ext === "md" || ext === "markdown";
|
kind === "office" || ext === "md" || ext === "markdown";
|
||||||
@@ -192,21 +393,36 @@ export function UploadedFilePreviewDialog({
|
|||||||
style={{
|
style={{
|
||||||
width: "min(56rem, calc(100vw - 2rem))",
|
width: "min(56rem, calc(100vw - 2rem))",
|
||||||
maxWidth: "none",
|
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">
|
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
|
||||||
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
|
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
|
||||||
<DialogDescription className="mt-0.5 truncate text-xs">
|
<DialogDescription className="mt-0.5 truncate text-xs">
|
||||||
<a
|
{onDownload ? (
|
||||||
href={fileUrl}
|
<Button
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noopener noreferrer"
|
size="sm"
|
||||||
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
variant="ghost"
|
||||||
>
|
disabled={downloading}
|
||||||
<DownloadIcon className="size-3" />
|
onClick={() => void onDownload()}
|
||||||
{t.common.download}
|
>
|
||||||
</a>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -215,7 +431,7 @@ export function UploadedFilePreviewDialog({
|
|||||||
blobLoading ? (
|
blobLoading ? (
|
||||||
loadingView
|
loadingView
|
||||||
) : blobError || !objectUrl ? (
|
) : 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">
|
<div className="flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
@@ -229,7 +445,7 @@ export function UploadedFilePreviewDialog({
|
|||||||
blobLoading ? (
|
blobLoading ? (
|
||||||
loadingView
|
loadingView
|
||||||
) : blobError || !objectUrl ? (
|
) : blobError || !objectUrl ? (
|
||||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||||
) : (
|
) : (
|
||||||
<iframe
|
<iframe
|
||||||
src={objectUrl}
|
src={objectUrl}
|
||||||
@@ -241,22 +457,45 @@ export function UploadedFilePreviewDialog({
|
|||||||
textLoading ? (
|
textLoading ? (
|
||||||
loadingView
|
loadingView
|
||||||
) : textError ? (
|
) : textError ? (
|
||||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||||
) : 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">
|
<div className="space-y-3">
|
||||||
{text ?? ""}
|
{previewMeta &&
|
||||||
</pre>
|
(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>
|
</div>
|
||||||
</DialogContent>
|
</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 (
|
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">
|
<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">
|
<p>暂不支持在线预览,请下载原文件查看。</p>
|
||||||
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
{onDownload ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={downloading}
|
||||||
|
onClick={() => void onDownload()}
|
||||||
|
>
|
||||||
<DownloadIcon className="size-3.5" />
|
<DownloadIcon className="size-3.5" />
|
||||||
{label}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,29 +7,37 @@ import { extractReasoningContentFromMessage } from "@/core/messages/utils";
|
|||||||
import { getUsageMetadata } from "@/core/messages/usage";
|
import { getUsageMetadata } from "@/core/messages/usage";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
|
|
||||||
/**
|
import {
|
||||||
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
|
GENERATION_IDLE_MS,
|
||||||
* speed indicator for mixed Chinese/English replies without dragging in a
|
SAMPLE_THROTTLE_MS,
|
||||||
* real tokeniser.
|
charsToTokensPerSec,
|
||||||
*/
|
messageHasToolCalls,
|
||||||
const CHARS_PER_TOKEN = 2;
|
pushSpeedSample,
|
||||||
|
rollingTokensPerSec,
|
||||||
/** Sample throttle while a message is streaming. */
|
shouldFreezeGeneration,
|
||||||
const SAMPLE_THROTTLE_MS = 60;
|
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 {
|
export interface MessageMetric {
|
||||||
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
|
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
|
||||||
tokensPerSec: number;
|
tokensPerSec: number;
|
||||||
/**
|
/**
|
||||||
* Wall-clock duration from when the message first appeared with text to
|
* Wall-clock duration of this model-call segment: first visible character
|
||||||
* either *now* (active) or to when the stream ended (frozen).
|
* → last visible character (tool-wait after the call is excluded).
|
||||||
*/
|
*/
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
/** True while this is the currently streaming message. */
|
/** True while this message is still generating visible tokens. */
|
||||||
isActive: boolean;
|
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
|
* Whether the speed value was computed from real usage_metadata output
|
||||||
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
|
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
|
||||||
@@ -45,15 +53,12 @@ export interface MessageMetric {
|
|||||||
isTurnFinal: boolean;
|
isTurnFinal: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Sample = { at: number; chars: number };
|
|
||||||
|
|
||||||
type TimingEntry = {
|
type TimingEntry = {
|
||||||
firstSeenAt: number;
|
firstSeenAt: number;
|
||||||
endedAt: number | null;
|
endedAt: number | null;
|
||||||
samples: Sample[];
|
lastGrewAt: number;
|
||||||
|
samples: SpeedSample[];
|
||||||
lastChars: number;
|
lastChars: number;
|
||||||
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
|
|
||||||
hadUsage: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function isAiMessage(message: Message): boolean {
|
function isAiMessage(message: Message): boolean {
|
||||||
@@ -82,11 +87,12 @@ function messageText(message: Message): string {
|
|||||||
*
|
*
|
||||||
* Recording strategy:
|
* Recording strategy:
|
||||||
* - Whenever a message renders with text, we anchor `firstSeenAt`.
|
* - Whenever a message renders with text, we anchor `firstSeenAt`.
|
||||||
* - On every text-length change we push a sample and recompute the rate.
|
* - On every text-length change we push a sample.
|
||||||
* - When `isLoading` flips to false we mark *all* still-active messages as
|
* - The segment freezes when this model call ends (next AI message, tool
|
||||||
* ended so subsequent renders return a frozen duration.
|
* calls, usage + idle, or the run stops) so tool-wait is not in the
|
||||||
* - Token totals come from usage_metadata when present (most accurate);
|
* denominator.
|
||||||
* otherwise we fall back to char-count / 2.
|
* - 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(
|
export function useMessageMetrics(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
@@ -104,7 +110,8 @@ export function useMessageMetrics(
|
|||||||
// synchronously inside an effect so React isn't tracking the map identity.
|
// synchronously inside an effect so React isn't tracking the map identity.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = performance.now();
|
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++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const message = messages[i];
|
const message = messages[i];
|
||||||
@@ -118,50 +125,62 @@ export function useMessageMetrics(
|
|||||||
entry = {
|
entry = {
|
||||||
firstSeenAt: now,
|
firstSeenAt: now,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
|
lastGrewAt: now,
|
||||||
samples: [{ at: now, chars: text.length }],
|
samples: [{ at: now, chars: text.length }],
|
||||||
lastChars: text.length,
|
lastChars: text.length,
|
||||||
hadUsage: false,
|
|
||||||
};
|
};
|
||||||
timingRef.current.set(id, entry);
|
timingRef.current.set(id, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track the most recent AI message as the active one (while streaming).
|
latestId = id;
|
||||||
activeId = id;
|
|
||||||
|
|
||||||
// Sample only when actively streaming OR when content actually grew —
|
|
||||||
// throttle to keep arrays bounded.
|
|
||||||
const grew = text.length !== entry.lastChars;
|
const grew = text.length !== entry.lastChars;
|
||||||
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
if (grew) {
|
||||||
entry.samples.push({ at: now, chars: text.length });
|
grewIds.add(id);
|
||||||
entry.lastChars = text.length;
|
entry.lastGrewAt = now;
|
||||||
if (entry.samples.length > 256) {
|
if (now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
||||||
entry.samples.splice(0, entry.samples.length - 256);
|
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;
|
entry.lastChars = text.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getUsageMetadata(message)) {
|
|
||||||
entry.hadUsage = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// When streaming stops, freeze any non-ended entry to the current time.
|
for (let i = 0; i < messages.length; i++) {
|
||||||
if (!isLoading) {
|
const message = messages[i];
|
||||||
for (const entry of timingRef.current.values()) {
|
if (!message || !isAiMessage(message)) continue;
|
||||||
if (entry.endedAt === null) {
|
const id = getMessageId(message, i);
|
||||||
entry.endedAt = now;
|
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);
|
setTick((v) => v + 1);
|
||||||
}, [messages, isLoading]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isLoading) return;
|
if (!isLoading) return;
|
||||||
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
|
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
|
||||||
@@ -198,45 +217,36 @@ export function useMessageMetrics(
|
|||||||
const entry = timingRef.current.get(id);
|
const entry = timingRef.current.get(id);
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
|
|
||||||
|
const isActive =
|
||||||
|
isLoading && id === lastActiveIdRef.current && entry.endedAt === null;
|
||||||
const endedAt = entry.endedAt ?? now;
|
const endedAt = entry.endedAt ?? now;
|
||||||
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
|
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
|
||||||
const isActive = isLoading && id === lastActiveIdRef.current;
|
|
||||||
|
|
||||||
let tokensPerSec = 0;
|
let tokensPerSec = 0;
|
||||||
let fromUsage = false;
|
let fromUsage = false;
|
||||||
|
let isPaused = false;
|
||||||
const usage = getUsageMetadata(message);
|
const usage = getUsageMetadata(message);
|
||||||
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
|
||||||
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
|
if (entry.endedAt != null) {
|
||||||
fromUsage = true;
|
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
||||||
} else {
|
tokensPerSec = usageTokensPerSec(usage.outputTokens, durationMs);
|
||||||
// Char-count fallback. Uses the rolling window during streaming so a
|
fromUsage = true;
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (durationMs > 0) {
|
} else if (durationMs > 0) {
|
||||||
// Finished message without usage data: average over full duration.
|
tokensPerSec = charsToTokensPerSec(entry.lastChars, durationMs);
|
||||||
const chars = entry.lastChars;
|
|
||||||
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
tokensPerSec = rollingTokensPerSec(entry.samples, entry.lastChars, now);
|
||||||
|
isPaused =
|
||||||
|
isActive &&
|
||||||
|
tokensPerSec <= 0 &&
|
||||||
|
now - entry.lastGrewAt >= GENERATION_IDLE_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
out.set(id, {
|
out.set(id, {
|
||||||
tokensPerSec,
|
tokensPerSec,
|
||||||
durationMs,
|
durationMs,
|
||||||
isActive,
|
isActive,
|
||||||
|
isPaused,
|
||||||
fromUsage,
|
fromUsage,
|
||||||
isTurnFinal: turnFinalIds.has(id),
|
isTurnFinal: turnFinalIds.has(id),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function ModelNameSelect({
|
|||||||
className,
|
className,
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
defaultLabel = "默认模型",
|
defaultLabel = "默认模型",
|
||||||
|
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
|
||||||
}: {
|
}: {
|
||||||
value: string | null;
|
value: string | null;
|
||||||
onChange: (modelName: string | null) => void;
|
onChange: (modelName: string | null) => void;
|
||||||
@@ -32,6 +33,7 @@ export function ModelNameSelect({
|
|||||||
className?: string;
|
className?: string;
|
||||||
triggerClassName?: string;
|
triggerClassName?: string;
|
||||||
defaultLabel?: string;
|
defaultLabel?: string;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
const { models } = useModels();
|
const { models } = useModels();
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +44,7 @@ export function ModelNameSelect({
|
|||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
||||||
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
|
title={title}
|
||||||
>
|
>
|
||||||
<SelectValue placeholder={defaultLabel} />
|
<SelectValue placeholder={defaultLabel} />
|
||||||
</SelectTrigger>
|
</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,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import {
|
|
||||||
extractContentFromMessage,
|
|
||||||
stripArtifactTag,
|
|
||||||
stripUploadedFilesTag,
|
|
||||||
} from "@/core/messages/utils";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { buildQuestionItems, type QuestionItem } from "./question-navigator-data";
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
|
export { getQuestionAnchorId } from "./question-navigator-data";
|
||||||
|
|
||||||
/** Width of the expanded floating panel, in px. */
|
/** Width of the expanded floating panel, in px. */
|
||||||
const PANEL_WIDTH = 300;
|
const PANEL_WIDTH = 300;
|
||||||
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
|
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
|
||||||
const PANEL_GUTTER_MARGIN = 24;
|
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. */
|
/** Questions needed before the navigator shows up at all. */
|
||||||
const MIN_QUESTIONS = 2;
|
const MIN_QUESTIONS = 2;
|
||||||
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
|
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. */
|
/** Walk up from a message anchor to the element that actually scrolls. */
|
||||||
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
|
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
|
||||||
let node = el?.parentElement ?? null;
|
let node = el?.parentElement ?? null;
|
||||||
@@ -128,16 +81,7 @@ export function QuestionNavigator({
|
|||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
const questions = useMemo(() => {
|
const questions = useMemo(() => buildQuestionItems(messages), [messages]);
|
||||||
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 visible = questions.length >= MIN_QUESTIONS;
|
const visible = questions.length >= MIN_QUESTIONS;
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,14 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuAction,
|
SidebarMenuAction,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} 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 { getAPIClient } from "@/core/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -63,7 +68,9 @@ import {
|
|||||||
useToggleThreadFavorite,
|
useToggleThreadFavorite,
|
||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} 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 {
|
import {
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
isHiddenFromRecentChats,
|
isHiddenFromRecentChats,
|
||||||
@@ -75,9 +82,14 @@ import {
|
|||||||
titleOfThread,
|
titleOfThread,
|
||||||
} from "@/core/threads/utils";
|
} from "@/core/threads/utils";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
|
||||||
|
import {
|
||||||
|
mergeRecentWorkspaceItems,
|
||||||
|
useRecentStrategyProjects,
|
||||||
|
} from "@/strategy-selection/recent-projects";
|
||||||
|
|
||||||
export function RecentChatList({
|
export function RecentChatList({
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -92,7 +104,8 @@ export function RecentChatList({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
const [internalCollapsed, setInternalCollapsed] = useState(
|
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||||
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
||||||
);
|
);
|
||||||
@@ -109,9 +122,10 @@ export function RecentChatList({
|
|||||||
useParams<{
|
useParams<{
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
}>();
|
}>();
|
||||||
const { data: threads = [] } = useThreads();
|
const { data: threads = [] } = useThreads();
|
||||||
const { mutate: deleteThread } = useDeleteThread();
|
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
|
||||||
|
const { mutate: deleteThread } = useDeleteThread();
|
||||||
const { mutate: renameThread } = useRenameThread();
|
const { mutate: renameThread } = useRenameThread();
|
||||||
const { toggle: togglePin } = useToggleThreadPin();
|
const { toggle: togglePin } = useToggleThreadPin();
|
||||||
const { toggle: toggleFavorite } = useToggleThreadFavorite();
|
const { toggle: toggleFavorite } = useToggleThreadFavorite();
|
||||||
@@ -134,15 +148,15 @@ export function RecentChatList({
|
|||||||
});
|
});
|
||||||
if (threadIndex > -1) {
|
if (threadIndex > -1) {
|
||||||
if (threads[threadIndex + 1]) {
|
if (threads[threadIndex + 1]) {
|
||||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname);
|
||||||
} else if (threads[threadIndex - 1]) {
|
} else if (threads[threadIndex - 1]) {
|
||||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void router.push(nextThreadPath);
|
void router.push(nextThreadPath);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
|
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRenameClick = useCallback(
|
const handleRenameClick = useCallback(
|
||||||
@@ -195,15 +209,19 @@ export function RecentChatList({
|
|||||||
|
|
||||||
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
|
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
|
||||||
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
|
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
|
||||||
const visibleThreads = sortThreadsPinnedFirst(
|
const visibleThreads = sortThreadsPinnedFirst(
|
||||||
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
|
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
|
||||||
);
|
);
|
||||||
|
const projectThreadNames = useProjectThreadNames(visibleThreads);
|
||||||
if (visibleThreads.length === 0) {
|
const recentItems = mergeRecentWorkspaceItems(
|
||||||
return null;
|
visibleThreads,
|
||||||
}
|
recentStrategyProjects,
|
||||||
const shownThreads =
|
);
|
||||||
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
|
|
||||||
|
if (recentItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const shownItems = limit != null ? recentItems.slice(0, limit) : recentItems;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarGroup
|
<SidebarGroup
|
||||||
@@ -224,9 +242,9 @@ export function RecentChatList({
|
|||||||
: t.sidebar.demoChats}
|
: t.sidebar.demoChats}
|
||||||
</span>
|
</span>
|
||||||
{compact && (
|
{compact && (
|
||||||
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
|
<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}
|
{recentItems.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!compact && (
|
{!compact && (
|
||||||
@@ -242,32 +260,84 @@ export function RecentChatList({
|
|||||||
>
|
>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<div className="flex w-full flex-col gap-0.5">
|
<div className="flex w-full flex-col gap-0.5">
|
||||||
{shownThreads.map((thread) => {
|
{shownItems.map((item) => {
|
||||||
const href = listPathOfThread(thread);
|
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 isActive = href === pathname;
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
|
const projectInfo = myProjectConversationInfoOfThread(
|
||||||
|
thread,
|
||||||
|
projectThreadNames,
|
||||||
|
);
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
const favorited = isThreadFavorite(thread);
|
const favorited = isThreadFavorite(thread);
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem
|
<SidebarMenuItem
|
||||||
key={thread.thread_id}
|
key={`thread:${thread.thread_id}`}
|
||||||
className="group/side-menu-item"
|
className="group/side-menu-item"
|
||||||
>
|
>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
asChild
|
asChild
|
||||||
className={
|
className={cn(
|
||||||
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
|
"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)] " +
|
"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" +
|
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none",
|
||||||
(compact
|
compact &&
|
||||||
? " h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border"
|
"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
|
<Link
|
||||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
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 ? (
|
{isBoot ? (
|
||||||
<Bot
|
<Bot
|
||||||
@@ -279,8 +349,10 @@ export function RecentChatList({
|
|||||||
}
|
}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : projectInfo ? (
|
||||||
compact && (
|
<MyProjectThreadIcon type={projectInfo.type} />
|
||||||
|
) : (
|
||||||
|
compact && (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={
|
className={
|
||||||
"size-3.5 shrink-0 " +
|
"size-3.5 shrink-0 " +
|
||||||
@@ -294,9 +366,23 @@ export function RecentChatList({
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">
|
<span
|
||||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
className={cn(
|
||||||
</span>
|
"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>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||||
import { clearYLogToken } from "@/core/log/y-log-token";
|
import { clearYLogToken } from "@/core/log/y-log-token";
|
||||||
|
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
|
import { PasswordSettings } from "./password-settings";
|
||||||
|
|
||||||
function roleLabel(role?: string) {
|
function roleLabel(role?: string) {
|
||||||
if (role === "admin") return "管理员";
|
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
|
// 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
|
// by hitting the gateway logout endpoint and then forcing a full reload
|
||||||
// so any cached query state goes too.
|
// 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)
|
.catch(() => undefined)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
window.location.assign("/");
|
window.location.assign(`${window.location.pathname}#/login`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
|
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
|
||||||
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
|
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -106,6 +110,8 @@ export function AccountSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</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";
|
"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 { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -12,18 +25,29 @@ import {
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-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 { 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 { 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 { getStoredAuth } from "@/core/auth";
|
||||||
|
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
|
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
|
||||||
|
|
||||||
type SettingsSection =
|
type SettingsSection =
|
||||||
| "account"
|
| "account"
|
||||||
|
| "authentication"
|
||||||
| "appearance"
|
| "appearance"
|
||||||
|
| "conversation-context"
|
||||||
|
| "knowledge-retrieval"
|
||||||
| "memory"
|
| "memory"
|
||||||
|
| "fileWriting"
|
||||||
|
| "uploads"
|
||||||
| "tools"
|
| "tools"
|
||||||
| "encoder"
|
| "encoder"
|
||||||
| "about";
|
| "about";
|
||||||
@@ -35,6 +59,7 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
|||||||
export function SettingsDialog(props: SettingsDialogProps) {
|
export function SettingsDialog(props: SettingsDialogProps) {
|
||||||
const { defaultSection = "appearance", ...dialogProps } = props;
|
const { defaultSection = "appearance", ...dialogProps } = props;
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const live = useLiveAccount();
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const [activeSection, setActiveSection] =
|
const [activeSection, setActiveSection] =
|
||||||
useState<SettingsSection>(defaultSection);
|
useState<SettingsSection>(defaultSection);
|
||||||
@@ -61,15 +86,19 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
// },
|
// },
|
||||||
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||||
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||||
// 记忆已移至侧边栏入口 (/page/workspace/memory)
|
...(isAdmin
|
||||||
// {
|
? [
|
||||||
// id: "memory",
|
{ id: "conversation-context", label: "上下文管理", icon: ShrinkIcon },
|
||||||
// label: t.settings.sections.memory,
|
{ id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon },
|
||||||
// icon: BrainIcon,
|
{ 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 },
|
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||||
...(isAdmin
|
...(isAdmin
|
||||||
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
|
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
id: "account",
|
id: "account",
|
||||||
@@ -129,12 +158,36 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
|
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
|
||||||
<div className="min-w-0 space-y-8 p-6">
|
<div className="min-w-0 space-y-8 p-6">
|
||||||
{activeSection === "account" && <AccountSettingsPage />}
|
{activeSection === "account" && <AccountSettingsPage />}
|
||||||
{activeSection === "appearance" && (
|
{activeSection === "authentication" && isAdmin && (
|
||||||
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
<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 === "tools" && <ToolSettingsPage />}
|
||||||
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
{activeSection === "encoder" && isAdmin && (
|
||||||
|
<LocalEncoderSettingsCard />
|
||||||
|
)}
|
||||||
{activeSection === "about" && <AboutSettingsPage />}
|
{activeSection === "about" && <AboutSettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
LockIcon,
|
LockIcon,
|
||||||
PinIcon,
|
PinIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
@@ -70,11 +72,13 @@ import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
|||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { MINIMAL_SKILL_MD_EXAMPLE } from "@/core/skill-example";
|
||||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||||
import {
|
import {
|
||||||
useDeleteBuiltinSkill,
|
useDeleteBuiltinSkill,
|
||||||
useDeleteCustomSkill,
|
useDeleteCustomSkill,
|
||||||
useDuplicateSkill,
|
useDuplicateSkill,
|
||||||
|
useEnableSkill,
|
||||||
useGenerateMissingSkillNameZh,
|
useGenerateMissingSkillNameZh,
|
||||||
useGenerateSkillNameZh,
|
useGenerateSkillNameZh,
|
||||||
useInstallSkillUpload,
|
useInstallSkillUpload,
|
||||||
@@ -87,13 +91,25 @@ import {
|
|||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
|
import {
|
||||||
|
canToggleSkillAvailability,
|
||||||
|
isSkillOffline,
|
||||||
|
type SkillAvailabilityControl,
|
||||||
|
} from "@/core/skill-availability";
|
||||||
import {
|
import {
|
||||||
usePublishSquares,
|
usePublishSquares,
|
||||||
useSkillCompressionSettings,
|
useSkillCompressionSettings,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
} from "@/core/system-settings/hooks";
|
} 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 { 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 { formatDateTime } from "@/core/utils/datetime";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
@@ -102,21 +118,28 @@ import { cn } from "@/lib/utils";
|
|||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
import { SkillStateSheet } from "./skill-state-sheet";
|
import { SkillStateSheet } from "./skill-state-sheet";
|
||||||
|
|
||||||
export function SkillSettingsPage({
|
export function SkillSettingsPage({
|
||||||
onClose,
|
onClose,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
}: {
|
restrictWxTypes = false,
|
||||||
onClose?: () => void;
|
}: {
|
||||||
embedded?: boolean;
|
onClose?: () => void;
|
||||||
} = {}) {
|
embedded?: boolean;
|
||||||
const { t } = useI18n();
|
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
|
||||||
if (embedded) return <SkillSettingsList onClose={onClose} />;
|
restrictWxTypes?: boolean;
|
||||||
return (
|
} = {}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t.settings.skills.title}
|
title={t.settings.skills.title}
|
||||||
description={t.settings.skills.description}
|
description={t.settings.skills.description}
|
||||||
>
|
>
|
||||||
<SkillSettingsList onClose={onClose} />
|
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -165,7 +188,13 @@ const rescanErrorToastDarkOptions = {
|
|||||||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
function SkillSettingsList({
|
||||||
|
onClose,
|
||||||
|
restrictWxTypes = false,
|
||||||
|
}: {
|
||||||
|
onClose?: () => void;
|
||||||
|
restrictWxTypes?: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -187,6 +216,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||||||
// backend always returns at least one (the built-in default).
|
// backend always returns at least one (the built-in default).
|
||||||
const squareTabs = publishSquares?.squares ?? [];
|
const squareTabs = publishSquares?.squares ?? [];
|
||||||
|
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
|
||||||
|
publishSquares?.default_square_id,
|
||||||
|
);
|
||||||
|
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
|
||||||
const reconcileMutation = useReconcileCustomSkills();
|
const reconcileMutation = useReconcileCustomSkills();
|
||||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||||
@@ -253,6 +286,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
const isSquareTab = filter !== "mine";
|
const isSquareTab = filter !== "mine";
|
||||||
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
||||||
const filteredSkills = useMemo(() => {
|
const filteredSkills = useMemo(() => {
|
||||||
|
if (restrictWxTypes && isWxRestrictedPlaceholderTab(filter)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
let list = publisher
|
let list = publisher
|
||||||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||||||
: scopedSkills;
|
: scopedSkills;
|
||||||
@@ -266,7 +302,14 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
|
}, [
|
||||||
|
scopedSkills,
|
||||||
|
publisher,
|
||||||
|
filter,
|
||||||
|
isSquareTab,
|
||||||
|
publishSquares?.default_square_id,
|
||||||
|
restrictWxTypes,
|
||||||
|
]);
|
||||||
const orderedSkills = useMemo(
|
const orderedSkills = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filteredSkills
|
filteredSkills
|
||||||
@@ -296,6 +339,30 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
// If the active square tab disappears (admin removed that square), fall back
|
// If the active square tab disappears (admin removed that square), fall back
|
||||||
// to "我的" so the page never points at a dead tab.
|
// to "我的" so the page never points at a dead tab.
|
||||||
useEffect(() => {
|
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 (
|
if (
|
||||||
filter !== "mine" &&
|
filter !== "mine" &&
|
||||||
squareTabs.length > 0 &&
|
squareTabs.length > 0 &&
|
||||||
@@ -303,7 +370,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
) {
|
) {
|
||||||
setFilter("mine");
|
setFilter("mine");
|
||||||
}
|
}
|
||||||
}, [filter, squareTabs]);
|
}, [filter, squareTabs, restrictWxTypes, defaultSquareTabValue, nanjingTabValue]);
|
||||||
const pagedSkills = useMemo(
|
const pagedSkills = useMemo(
|
||||||
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||||
[orderedSkills, page],
|
[orderedSkills, page],
|
||||||
@@ -354,7 +421,6 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
theme === "dark" ? rescanErrorToastDarkOptions : undefined,
|
theme === "dark" ? rescanErrorToastDarkOptions : undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Tabs value={filter} onValueChange={setFilter}>
|
<Tabs value={filter} onValueChange={setFilter}>
|
||||||
<TabsList variant="line" className="flex-wrap">
|
<TabsList variant="line" className="flex-wrap">
|
||||||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
{restrictWxTypes ? (
|
||||||
{squareTabs.map((sq) => (
|
<>
|
||||||
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||||
{sq.name}
|
<TabsTrigger value={defaultSquareTabValue}>默认广场</TabsTrigger>
|
||||||
</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>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Button
|
<Button
|
||||||
@@ -479,11 +555,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<TagSearchBar
|
<TagSearchBar
|
||||||
targetType="skill"
|
targetType="skill"
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
tagIds={tagIds}
|
tagIds={tagIds}
|
||||||
onTagIdsChange={setTagIds}
|
onTagIdsChange={setTagIds}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||||
@@ -506,16 +582,16 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!isLoading && !error && (
|
{!isLoading && !error && (
|
||||||
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
|
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
|
||||||
共 {orderedSkills.length} 个技能
|
共 {orderedSkills.length} 个技能
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{publisher && (
|
{publisher && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -547,9 +623,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
<SkillRow
|
<SkillRow
|
||||||
key={skill.name}
|
key={skill.name}
|
||||||
skill={skill}
|
skill={skill}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
layout={viewMode === "card" ? "card" : "row"}
|
availabilityControl="owner"
|
||||||
|
layout={viewMode === "card" ? "card" : "row"}
|
||||||
onPublisherClick={setPublisher}
|
onPublisherClick={setPublisher}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -604,23 +681,26 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SkillRow({
|
export function SkillRow({
|
||||||
skill,
|
skill,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
availabilityControl,
|
||||||
layout = "row",
|
layout = "row",
|
||||||
onPublisherClick,
|
onPublisherClick,
|
||||||
knowledgeBindings = [],
|
knowledgeBindings = [],
|
||||||
}: {
|
}: {
|
||||||
skill: Skill;
|
skill: Skill;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
/** Which permission boundary exposes the global online/offline control. */
|
||||||
|
availabilityControl?: SkillAvailabilityControl;
|
||||||
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
||||||
layout?: "row" | "card";
|
layout?: "row" | "card";
|
||||||
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
||||||
* owner_user_id + its desensitized display label. */
|
* owner_user_id + its desensitized display label. */
|
||||||
onPublisherClick?: (publisher: { id: string; label: string }) => void;
|
onPublisherClick?: (publisher: { id: string; label: string }) => void;
|
||||||
/** Admin list can inject its single batched binding query here (never N+1). */
|
/** Admin list can inject its single batched binding query here (never N+1). */
|
||||||
knowledgeBindings?: SkillKnowledgeBinding[];
|
knowledgeBindings?: SkillKnowledgeBinding[];
|
||||||
}) {
|
}) {
|
||||||
const isCard = layout === "card";
|
const isCard = layout === "card";
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -629,6 +709,7 @@ export function SkillRow({
|
|||||||
const setPinned = useSetSkillPinned();
|
const setPinned = useSetSkillPinned();
|
||||||
const setFeatured = useSetSkillFeatured();
|
const setFeatured = useSetSkillFeatured();
|
||||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||||
|
const setEnabled = useEnableSkill();
|
||||||
const { data: compression } = useSkillCompressionSettings();
|
const { data: compression } = useSkillCompressionSettings();
|
||||||
const deleteCustom = useDeleteCustomSkill();
|
const deleteCustom = useDeleteCustomSkill();
|
||||||
const deleteBuiltin = useDeleteBuiltinSkill();
|
const deleteBuiltin = useDeleteBuiltinSkill();
|
||||||
@@ -639,6 +720,7 @@ export function SkillRow({
|
|||||||
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||||||
const [duplicateName, setDuplicateName] = useState("");
|
const [duplicateName, setDuplicateName] = useState("");
|
||||||
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
||||||
|
const [confirmOfflineOpen, setConfirmOfflineOpen] = useState(false);
|
||||||
|
|
||||||
const isCustom = skill.category === "custom";
|
const isCustom = skill.category === "custom";
|
||||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||||
@@ -647,12 +729,33 @@ export function SkillRow({
|
|||||||
const isOwned =
|
const isOwned =
|
||||||
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||||
const isFromOthers = isCustom && !isBuiltin && !isOwned;
|
const isFromOthers = isCustom && !isBuiltin && !isOwned;
|
||||||
// Admin can manage custom skills from any owner; non-admin only their own.
|
// Admin can manage custom skills from any owner; non-admin only their own.
|
||||||
const canManage = isCustom && (isOwned || isAdmin);
|
const canManage = isCustom && (isOwned || isAdmin);
|
||||||
const canDelete = isAdmin || isOwned;
|
const canDelete = isAdmin || isOwned;
|
||||||
const canTogglePublish = canManage && !isBuiltin;
|
const canTogglePublish = canManage && !isBuiltin;
|
||||||
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
||||||
const canSetAlwaysOn = isAdmin || isOwned;
|
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) {
|
async function handleToggleAlwaysOn(next: boolean) {
|
||||||
try {
|
try {
|
||||||
@@ -700,15 +803,15 @@ export function SkillRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
try {
|
try {
|
||||||
if (isBuiltin) {
|
if (isBuiltin) {
|
||||||
await deleteBuiltin.mutateAsync(skill.name);
|
await deleteBuiltin.mutateAsync(skill.name);
|
||||||
} else {
|
} else {
|
||||||
await deleteCustom.mutateAsync(skill.name);
|
await deleteCustom.mutateAsync(skill.name);
|
||||||
}
|
}
|
||||||
toast.success(t.settings.skills.deleteSuccess);
|
toast.success(t.settings.skills.deleteSuccess);
|
||||||
setConfirmDeleteOpen(false);
|
setConfirmDeleteOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
@@ -883,6 +986,16 @@ export function SkillRow({
|
|||||||
{knowledgeBindings.length > 3 && (
|
{knowledgeBindings.length > 3 && (
|
||||||
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -941,6 +1054,43 @@ export function SkillRow({
|
|||||||
|
|
||||||
const actionButtons = (
|
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
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -950,7 +1100,7 @@ export function SkillRow({
|
|||||||
>
|
>
|
||||||
<TagsIcon className="size-3.5" />
|
<TagsIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -971,12 +1121,12 @@ export function SkillRow({
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 shrink-0",
|
"h-8 w-8 shrink-0",
|
||||||
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
||||||
)}
|
)}
|
||||||
disabled={setPinned.isPending}
|
disabled={setPinned.isPending}
|
||||||
@@ -1043,11 +1193,11 @@ export function SkillRow({
|
|||||||
>
|
>
|
||||||
<CopyIcon className="size-3.5" />
|
<CopyIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||||
onClick={() => setConfirmDeleteOpen(true)}
|
onClick={() => setConfirmDeleteOpen(true)}
|
||||||
title={t.settings.skills.deleteAction}
|
title={t.settings.skills.deleteAction}
|
||||||
>
|
>
|
||||||
@@ -1060,7 +1210,12 @@ export function SkillRow({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isCard ? (
|
{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
|
<div
|
||||||
className="min-w-0 flex-1 cursor-pointer"
|
className="min-w-0 flex-1 cursor-pointer"
|
||||||
onClick={() => setDetailOpen(true)}
|
onClick={() => setDetailOpen(true)}
|
||||||
@@ -1076,7 +1231,13 @@ export function SkillRow({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<ItemContent
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => setDetailOpen(true)}
|
onClick={() => setDetailOpen(true)}
|
||||||
@@ -1141,7 +1302,7 @@ export function SkillRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setConfirmDeleteOpen(false)}
|
onClick={() => setConfirmDeleteOpen(false)}
|
||||||
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
|
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
|
||||||
>
|
>
|
||||||
{t.common.cancel}
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1153,6 +1314,37 @@ export function SkillRow({
|
|||||||
{deleteCustom.isPending || deleteBuiltin.isPending
|
{deleteCustom.isPending || deleteBuiltin.isPending
|
||||||
? t.common.loading
|
? t.common.loading
|
||||||
: t.common.delete}
|
: 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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -1226,24 +1418,24 @@ function InstallSkillDialog({
|
|||||||
const validateMutation = useValidateSkillUpload();
|
const validateMutation = useValidateSkillUpload();
|
||||||
const installMutation = useInstallSkillUpload();
|
const installMutation = useInstallSkillUpload();
|
||||||
const setNameZhMutation = useSetSkillNameZh();
|
const setNameZhMutation = useSetSkillNameZh();
|
||||||
const generateNameZhMutation = useGenerateSkillNameZh();
|
const generateNameZhMutation = useGenerateSkillNameZh();
|
||||||
|
|
||||||
const validateFile = useCallback(
|
const validateFile = useCallback(
|
||||||
async (selected: File, category: "custom" | "public") => {
|
async (selected: File, category: "custom" | "public") => {
|
||||||
setValidation(null);
|
setValidation(null);
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
try {
|
try {
|
||||||
const result = await validateMutation.mutateAsync({
|
const result = await validateMutation.mutateAsync({
|
||||||
file: selected,
|
file: selected,
|
||||||
targetCategory: category,
|
targetCategory: category,
|
||||||
});
|
});
|
||||||
setValidation(result);
|
setValidation(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setValidationError(err instanceof Error ? err.message : String(err));
|
setValidationError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[validateMutation],
|
[validateMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
@@ -1282,12 +1474,12 @@ function InstallSkillDialog({
|
|||||||
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
|
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
|
||||||
toast.error(dialog.rejectInvalidExt);
|
toast.error(dialog.rejectInvalidExt);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFile(selected);
|
setFile(selected);
|
||||||
await validateFile(selected, targetCategory);
|
await validateFile(selected, targetCategory);
|
||||||
},
|
},
|
||||||
[dialog.rejectInvalidExt, targetCategory, validateFile],
|
[dialog.rejectInvalidExt, targetCategory, validateFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(e: React.DragEvent<HTMLDivElement>) => {
|
(e: React.DragEvent<HTMLDivElement>) => {
|
||||||
@@ -1364,29 +1556,34 @@ function InstallSkillDialog({
|
|||||||
}
|
}
|
||||||
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
|
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
|
||||||
|
|
||||||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||||
// Admin targeting public directory can overwrite an existing public skill.
|
// Admin targeting public directory can overwrite an existing public skill.
|
||||||
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||||||
const canInstall = !!validation && !validationError && validation.can_install_as_is;
|
const canInstall = !!validation && !validationError && validation.can_install_as_is;
|
||||||
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
|
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
|
||||||
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
|
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
|
||||||
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
|
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
|
||||||
const customConflictMessage = !validation
|
// On-disk leftover with no ownership record: replaceable by whoever uploads it.
|
||||||
? ""
|
const unclaimedCustomConflict =
|
||||||
: otherCustomConflict
|
!!isCustomConflict && !validation?.owned_by_me && !validation?.owned_by_other && canOverwrite;
|
||||||
? dialog.conflictOwnedByOther
|
const customConflictMessage = !validation
|
||||||
.replace("{name}", validation.skill_name)
|
? ""
|
||||||
.replace(
|
: otherCustomConflict
|
||||||
"{owner}",
|
? dialog.conflictOwnedByOther
|
||||||
validation.owner_username ?? validation.owner_user_id ?? "—",
|
.replace("{name}", validation.skill_name)
|
||||||
)
|
.replace(
|
||||||
: validation.owned_by_me
|
"{owner}",
|
||||||
? (ownCustomCanOverwrite
|
validation.owner_username ?? validation.owner_user_id ?? "—",
|
||||||
? dialog.conflictOwnedByMe
|
)
|
||||||
: dialog.conflictOwnedByMeWrongTarget
|
: validation.owned_by_me
|
||||||
).replace("{name}", validation.skill_name)
|
? (ownCustomCanOverwrite
|
||||||
: dialog.conflictCustom.replace("{name}", validation.skill_name);
|
? 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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||||
@@ -1496,6 +1693,16 @@ function InstallSkillDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{isAdmin && (
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
|
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
|
||||||
@@ -1505,12 +1712,12 @@ function InstallSkillDialog({
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="targetCategory"
|
name="targetCategory"
|
||||||
value={cat}
|
value={cat}
|
||||||
checked={targetCategory === cat}
|
checked={targetCategory === cat}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
setTargetCategory(cat);
|
setTargetCategory(cat);
|
||||||
if (file) void validateFile(file, cat);
|
if (file) void validateFile(file, cat);
|
||||||
}}
|
}}
|
||||||
className="accent-primary"
|
className="accent-primary"
|
||||||
/>
|
/>
|
||||||
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
|
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
|
||||||
@@ -1568,18 +1775,18 @@ function InstallSkillDialog({
|
|||||||
</ul>
|
</ul>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{isCustomConflict && (
|
{isCustomConflict && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1 flex items-start gap-2 text-xs",
|
"mt-1 flex items-start gap-2 text-xs",
|
||||||
ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
|
ownCustomCanOverwrite || unclaimedCustomConflict ? "text-amber-600" : "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{desensitize(customConflictMessage)}
|
{desensitize(customConflictMessage)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isPublicConflict && (
|
{isPublicConflict && (
|
||||||
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+70
@@ -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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Sheet,
|
||||||
DialogContent,
|
SheetContent,
|
||||||
DialogDescription,
|
SheetDescription,
|
||||||
DialogHeader,
|
SheetHeader,
|
||||||
DialogTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/sheet";
|
||||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
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 { useLocalSettings } from "@/core/settings";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
import { reasoningPlugins } from "@/core/streamdown";
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
@@ -56,6 +59,9 @@ export function SourceCard({
|
|||||||
}) {
|
}) {
|
||||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const previewTarget = resolveWeKnoraSourceTarget({
|
const previewTarget = resolveWeKnoraSourceTarget({
|
||||||
@@ -65,7 +71,13 @@ export function SourceCard({
|
|||||||
const cleanupWords =
|
const cleanupWords =
|
||||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
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
|
const matchedHeading = isWikiSource
|
||||||
? String(
|
? String(
|
||||||
(source.raw?.matched_heading as string | undefined) ||
|
(source.raw?.matched_heading as string | undefined) ||
|
||||||
@@ -78,13 +90,17 @@ export function SourceCard({
|
|||||||
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
||||||
? source.raw.summary
|
? source.raw.summary
|
||||||
: "";
|
: "";
|
||||||
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
const snippet = plainTextReferencePreview(content || summary).slice(0, 280);
|
||||||
const score =
|
const scoreSemantics = typeof source.raw?.rank_score_semantics === "string"
|
||||||
typeof source.score === "number"
|
? source.raw.rank_score_semantics
|
||||||
? source.score <= 1
|
: "";
|
||||||
? `${Math.round(source.score * 100)}%`
|
const scoreMeta = typeof source.score !== "number"
|
||||||
: String(source.score)
|
? null
|
||||||
: 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 = [
|
const meta = [
|
||||||
source.source,
|
source.source,
|
||||||
isWikiSource ? "检索对象:Wiki 文档" : null,
|
isWikiSource ? "检索对象:Wiki 文档" : null,
|
||||||
@@ -92,7 +108,7 @@ export function SourceCard({
|
|||||||
source.author ? `作者 ${source.author}` : null,
|
source.author ? `作者 ${source.author}` : null,
|
||||||
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
|
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
|
||||||
source.time,
|
source.time,
|
||||||
score ? `相关度 ${score}` : null,
|
scoreMeta,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const isCitation = source.mode === "citation";
|
const isCitation = source.mode === "citation";
|
||||||
const showReferenceNumber = isCitation;
|
const showReferenceNumber = isCitation;
|
||||||
@@ -151,7 +167,7 @@ export function SourceCard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(source.url || isWikiSource) && (
|
{(source.url || isWikiSource || isWikiIndexSource) && (
|
||||||
previewTarget ? (
|
previewTarget ? (
|
||||||
<span className={cn(
|
<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",
|
"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 ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
|
||||||
{isWikiSource ? "Wiki 文档" : "知识库文档"}
|
{isWikiSource ? "Wiki 文档" : "知识库文档"}
|
||||||
</span>
|
</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" />
|
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{previewTarget && (
|
{(previewTarget || isWikiIndexSource) && (
|
||||||
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
|
<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" />
|
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{previewTarget && (
|
{(previewTarget || linkedTarget) && (
|
||||||
<WeKnoraSourceDrawer
|
<WeKnoraSourceDrawer
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
target={previewTarget}
|
target={linkedTarget ?? previewTarget}
|
||||||
title={source.title}
|
title={source.title}
|
||||||
quote={content}
|
quote={content}
|
||||||
onOpenChange={setDetailOpen}
|
onOpenChange={(open) => {
|
||||||
|
setDetailOpen(open);
|
||||||
|
if (!open) setLinkedTarget(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
{!previewTarget && <Sheet open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||||
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
<SheetContent
|
||||||
<DialogHeader>
|
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="flex items-start justify-between gap-3 pr-8">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -194,16 +227,19 @@ export function SourceCard({
|
|||||||
{source.index}
|
{source.index}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<DialogTitle className="text-base leading-relaxed">
|
<SheetTitle className="text-base leading-relaxed">
|
||||||
{source.title}
|
{source.title}
|
||||||
</DialogTitle>
|
</SheetTitle>
|
||||||
</div>
|
</div>
|
||||||
{meta.length > 0 && (
|
{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) => (
|
{meta.map((item, index) => (
|
||||||
<span key={`${item}-${index}`}>{item}</span>
|
<span key={`${item}-${index}`}>{item}</span>
|
||||||
))}
|
))}
|
||||||
</DialogDescription>
|
</SheetDescription>
|
||||||
|
)}
|
||||||
|
{meta.length === 0 && (
|
||||||
|
<SheetDescription className="sr-only">参考文献详情</SheetDescription>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{source.url && !previewTarget && (
|
{source.url && !previewTarget && (
|
||||||
@@ -219,33 +255,51 @@ export function SourceCard({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</SheetHeader>
|
||||||
{content ? (
|
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
|
||||||
<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">
|
{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">
|
{isWikiSource && (
|
||||||
<BookOpenTextIcon className="size-4" />
|
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||||
本次检索命中的 Wiki 文档内容
|
<BookOpenTextIcon className="size-4" />
|
||||||
</div>
|
本次检索命中的 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
|
<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">
|
||||||
className="max-w-none"
|
<MarkdownContent
|
||||||
content={content}
|
className="max-w-none"
|
||||||
isLoading={false}
|
content={content}
|
||||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
isLoading={false}
|
||||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||||
/>
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||||
</article>
|
linkClassName={(href) =>
|
||||||
</div>
|
href.startsWith("/weknora-source-preview?")
|
||||||
) : null}
|
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
|
||||||
{source.url && !previewTarget && (
|
: undefined
|
||||||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
}
|
||||||
{source.url}
|
onLinkClick={(event, href) => {
|
||||||
</div>
|
const target = resolveWeKnoraSourceTarget({ url: href });
|
||||||
)}
|
if (!target) return;
|
||||||
</DialogContent>
|
event.preventDefault();
|
||||||
</Dialog>}
|
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: "上传知识资料",
|
writing_sources: "上传知识资料",
|
||||||
generating_wiki: "分析并生成分类 Wiki",
|
generating_wiki: "分析并生成分类 Wiki",
|
||||||
vectorizing_wiki: "Wiki 向量化",
|
vectorizing_wiki: "Wiki 向量化",
|
||||||
syncing_assistant: "同步知识梳理总库",
|
syncing_assistant: "同步检索索引",
|
||||||
activating: "激活新版本",
|
activating: "激活新版本",
|
||||||
completed: "完成",
|
completed: "完成",
|
||||||
};
|
};
|
||||||
@@ -219,14 +219,14 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
|
|||||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||||
<p className="p-3 text-xs text-muted-foreground">
|
<p className="p-3 text-xs text-muted-foreground">
|
||||||
{runtime.data?.provider !== "weknora"
|
{runtime.data?.provider !== "weknora"
|
||||||
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
? "当前未配置普通知识库地址;请先配置知识服务地址,再归纳技能内容。"
|
||||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
|
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
|
||||||
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
|
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及所需索引同步均在后台完成。
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
|
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
|
||||||
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
|
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
|
||||||
|
|||||||
@@ -226,7 +226,9 @@ export function SkillDetailDialog({
|
|||||||
style={{
|
style={{
|
||||||
width: "min(48rem, calc(100vw - 2rem))",
|
width: "min(48rem, calc(100vw - 2rem))",
|
||||||
maxWidth: "none",
|
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">
|
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
||||||
|
|||||||
@@ -149,7 +149,9 @@ export function SkillFilePreviewDialog({
|
|||||||
style={{
|
style={{
|
||||||
width: "min(56rem, calc(100vw - 2rem))",
|
width: "min(56rem, calc(100vw - 2rem))",
|
||||||
maxWidth: "none",
|
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">
|
<DialogHeader className="shrink-0 border-b px-5 py-3">
|
||||||
|
|||||||
@@ -8,21 +8,31 @@ export interface TagColorDef {
|
|||||||
chip: string;
|
chip: string;
|
||||||
/** A solid swatch used in the colour picker. */
|
/** A solid swatch used in the colour picker. */
|
||||||
swatch: string;
|
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[] = [
|
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: "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" },
|
{ 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" },
|
{ 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" },
|
{ 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" },
|
{ 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" },
|
{ 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" },
|
{ 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" },
|
{ 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" },
|
{ 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" },
|
{ 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]));
|
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
|
||||||
|
|
||||||
export function resolveTagColor(name: string | undefined | null): TagColorDef {
|
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) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"'": "'",
|
||||||
|
'"': """,
|
||||||
|
})[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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
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 { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||||
import { SidebarCollapseButton } from "./workspace-header";
|
import { SidebarCollapseButton } from "./workspace-header";
|
||||||
@@ -24,7 +24,10 @@ import {
|
|||||||
applyCompactMenuOverrides,
|
applyCompactMenuOverrides,
|
||||||
type CompactMenuItem,
|
type CompactMenuItem,
|
||||||
} from "@/core/page-layout/compact-menu-overrides";
|
} 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";
|
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
|
||||||
|
|
||||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||||
@@ -65,6 +68,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
|||||||
path: "/page/workspace/chats",
|
path: "/page/workspace/chats",
|
||||||
match: (pathname) => pathname === "/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",
|
id: "workspace-library",
|
||||||
label: "资料库",
|
label: "资料库",
|
||||||
@@ -86,13 +98,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
|||||||
path: "/page/workspace/skills",
|
path: "/page/workspace/skills",
|
||||||
match: (pathname) => pathname.startsWith("/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",
|
id: "workspace-ai-writing",
|
||||||
label: "AI写作",
|
label: "AI写作",
|
||||||
@@ -107,6 +112,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
|||||||
path: "/page/workspace/deep-research",
|
path: "/page/workspace/deep-research",
|
||||||
match: (pathname) => pathname.startsWith("/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",
|
// id: "workspace-report-collaboration",
|
||||||
// label: "报告协作",
|
// label: "报告协作",
|
||||||
@@ -135,12 +147,18 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
|||||||
path: "/page/workspace/scheduled/tasks",
|
path: "/page/workspace/scheduled/tasks",
|
||||||
match: (pathname) => pathname === "/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",
|
...MARKDOWN_DOCX_MENU_ITEM,
|
||||||
label: "知识库管理",
|
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||||
icon: BookOpen,
|
|
||||||
path: "/page/workspace/knowledge",
|
|
||||||
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -151,7 +169,8 @@ export function useWorkspaceCompactNavItems(): {
|
|||||||
settingsItems: EffectiveWorkspaceCompactNavItem[];
|
settingsItems: EffectiveWorkspaceCompactNavItem[];
|
||||||
toolboxItems: EffectiveWorkspaceCompactNavItem[];
|
toolboxItems: EffectiveWorkspaceCompactNavItem[];
|
||||||
} {
|
} {
|
||||||
const { data: overrides = [] } = useCompactMenuOverrides();
|
const { data: overrideConfig } = useCompactMenuOverrides();
|
||||||
|
const overrides = overrideConfig?.overrides ?? [];
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
|
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ import {
|
|||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
|
TimerIcon,
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -92,11 +93,34 @@ export function WorkspaceNavMenu() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { data: unreadCount = 0 } = useUnreadCount();
|
const { data: unreadCount = 0 } = useUnreadCount();
|
||||||
const { settingsItems } = useWorkspaceCompactNavItems();
|
const { settingsItems } = useWorkspaceCompactNavItems();
|
||||||
|
// 整页缩放时菜单会盖住按钮。按下后松手若落在菜单项上,Radix 会当成选中并立刻关闭。
|
||||||
|
// 这次打开手势的 pointerup / click 不传给菜单项。
|
||||||
|
const openingGesture = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
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 挂载中)时直接广播事件启动;否则先落
|
// 简洁模式界面引导:已在问答页(ChatPage 挂载中)时直接广播事件启动;否则先落
|
||||||
// sessionStorage 请求,导航到新对话页后由 CompactModeGuide 消费启动。
|
// sessionStorage 请求,导航到新对话页后由 CompactModeGuide 消费启动。
|
||||||
const startCompactGuide = () => {
|
const startCompactGuide = () => {
|
||||||
@@ -140,19 +164,23 @@ export function WorkspaceNavMenu() {
|
|||||||
<SidebarMenu className="w-full" data-compact-guide="settings-menu">
|
<SidebarMenu className="w-full" data-compact-guide="settings-menu">
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
size="lg"
|
size="lg"
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
onPointerDown={markOpeningGesture}
|
||||||
>
|
>
|
||||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||||
|
side="top"
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={4}
|
sideOffset={8}
|
||||||
|
onPointerUpCapture={swallowOpeningPointerUp}
|
||||||
|
onClickCapture={swallowOpeningClick}
|
||||||
>
|
>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
{settingsItems.map((item) => {
|
{settingsItems.map((item) => {
|
||||||
@@ -223,7 +251,7 @@ export function WorkspaceNavMenu() {
|
|||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate("/page/strategy/admin/user-leaderboard")
|
navigate("/page/workspace/admin/user-leaderboard")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TrophyIcon />
|
<TrophyIcon />
|
||||||
@@ -265,6 +293,22 @@ export function WorkspaceNavMenu() {
|
|||||||
技能管理
|
技能管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/workspace/admin/skill-timing")}
|
||||||
|
>
|
||||||
|
<TimerIcon />
|
||||||
|
技能耗时排查
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
|
||||||
|
>
|
||||||
|
<ScrollTextIcon />
|
||||||
|
模型收发记录
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin/models")}
|
onClick={() => navigate("/page/workspace/admin/models")}
|
||||||
@@ -273,16 +317,15 @@ export function WorkspaceNavMenu() {
|
|||||||
模型配置
|
模型配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
|
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口:
|
||||||
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
|
简洁模式没有全局侧边栏,走工作区地址带上 WorkspaceSidebar;
|
||||||
{isAdmin && (
|
/page/strategy/admin/menu-management 保留给全量模式侧边栏。 */}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
onClick={() => navigate("/page/workspace/admin/menu-management")}
|
||||||
>
|
>
|
||||||
<ListTreeIcon />
|
<ListTreeIcon />
|
||||||
菜单管理
|
{isAdmin ? "默认菜单管理" : "我的菜单"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||||
>
|
>
|
||||||
@@ -328,7 +371,7 @@ export function WorkspaceNavMenu() {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate("/page/strategy/admin/fixed-questions")
|
navigate("/page/workspace/admin/fixed-questions")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MessageSquareTextIcon />
|
<MessageSquareTextIcon />
|
||||||
|
|||||||
@@ -18,15 +18,18 @@ import { WorkspaceHeader } from "./workspace-header";
|
|||||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||||
|
|
||||||
export function WorkspaceSidebar({
|
export function WorkspaceSidebar({
|
||||||
compactChrome = false,
|
compactChrome = false,
|
||||||
forceCollapsed = false,
|
forceCollapsed = false,
|
||||||
className,
|
wxCommonAgents = false,
|
||||||
...props
|
className,
|
||||||
}: React.ComponentProps<typeof Sidebar> & {
|
...props
|
||||||
compactChrome?: boolean;
|
}: React.ComponentProps<typeof Sidebar> & {
|
||||||
forceCollapsed?: boolean;
|
compactChrome?: boolean;
|
||||||
}) {
|
forceCollapsed?: boolean;
|
||||||
|
/** Enables wx's shared Agent Gallery category selection. */
|
||||||
|
wxCommonAgents?: boolean;
|
||||||
|
}) {
|
||||||
const { open: isSidebarOpen } = useSidebar();
|
const { open: isSidebarOpen } = useSidebar();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -35,6 +38,7 @@ export function WorkspaceSidebar({
|
|||||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||||
const isGeneralQa =
|
const isGeneralQa =
|
||||||
|
wxCommonAgents ||
|
||||||
pathname.startsWith("/page/workspace/chats") ||
|
pathname.startsWith("/page/workspace/chats") ||
|
||||||
pathname.startsWith("/page/strategy/chats");
|
pathname.startsWith("/page/strategy/chats");
|
||||||
|
|
||||||
@@ -84,7 +88,7 @@ export function WorkspaceSidebar({
|
|||||||
{isGeneralQa ? (
|
{isGeneralQa ? (
|
||||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||||
<GeneralQaSidebar />
|
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
<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 1651 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user