feat(provider): 完成阶段E协议适配、国内预设与模型路由
@@ -0,0 +1,40 @@
|
||||
Lobe Icons — Copyright (c) 2023 LobeHub. MIT license; see LICENSE.
|
||||
Source: https://github.com/lobehub/lobe-icons
|
||||
Revision: 4aaf4ee1fb2678a7f989ea570f0f6ce14a9abf75
|
||||
Source directory: packages/static-svg/icons/
|
||||
Assets are bundled locally. Brand trademarks belong to their respective owners.
|
||||
File mapping (local: upstream):
|
||||
anthropic.svg: anthropic.svg
|
||||
baidu.svg: baidu-color.svg
|
||||
deepseek.svg: deepseek-color.svg
|
||||
hunyuan.svg: hunyuan-color.svg
|
||||
kimi.svg: kimi-color.svg
|
||||
minimax.svg: minimax-color.svg
|
||||
ollama.svg: ollama.svg
|
||||
openai.svg: openai.svg
|
||||
qwen.svg: qwen-color.svg
|
||||
siliconflow.svg: siliconcloud-color.svg
|
||||
stepfun.svg: stepfun-color.svg
|
||||
volcengine.svg: volcengine-color.svg
|
||||
zhipu.svg: zhipu-color.svg
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,19 @@
|
||||
Lobe Icons — Copyright (c) 2023 LobeHub. MIT license; see LICENSE.
|
||||
Source: https://github.com/lobehub/lobe-icons
|
||||
Revision: 4aaf4ee1fb2678a7f989ea570f0f6ce14a9abf75
|
||||
Source directory: packages/static-svg/icons/
|
||||
Assets are bundled locally. Brand trademarks belong to their respective owners.
|
||||
File mapping (local: upstream):
|
||||
anthropic.svg: anthropic.svg
|
||||
baidu.svg: baidu-color.svg
|
||||
deepseek.svg: deepseek-color.svg
|
||||
hunyuan.svg: hunyuan-color.svg
|
||||
kimi.svg: kimi-color.svg
|
||||
minimax.svg: minimax-color.svg
|
||||
ollama.svg: ollama.svg
|
||||
openai.svg: openai.svg
|
||||
qwen.svg: qwen-color.svg
|
||||
siliconflow.svg: siliconcloud-color.svg
|
||||
stepfun.svg: stepfun-color.svg
|
||||
volcengine.svg: volcengine-color.svg
|
||||
zhipu.svg: zhipu-color.svg
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>
|
||||
|
After Width: | Height: | Size: 368 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Baidu</title><path d="M8.859 11.735c1.017-1.71 4.059-3.083 6.202.286 1.579 2.284 4.284 4.397 4.284 4.397s2.027 1.601.73 4.684c-1.24 2.956-5.64 1.607-6.005 1.49l-.024-.009s-1.746-.568-3.776-.112c-2.026.458-3.773.286-3.773.286l-.045-.001c-.328-.01-2.38-.187-3.001-2.968-.675-3.028 2.365-4.687 2.592-4.968.226-.288 1.802-1.37 2.816-3.085zm.986 1.738v2.032h-1.64s-1.64.138-2.213 2.014c-.2 1.252.177 1.99.242 2.148.067.157.596 1.073 1.927 1.342h3.078v-7.514l-1.394-.022zm3.588 2.191l-1.44.024v3.956s.064.985 1.44 1.344h3.541v-5.3h-1.528v3.979h-1.46s-.466-.068-.553-.447v-3.556zM9.82 16.715v3.06H8.58s-.863-.045-1.126-1.049c-.136-.445.02-.959.088-1.16.063-.203.353-.671.951-.85H9.82zm9.525-9.036c2.086 0 2.646 2.06 2.646 2.742 0 .688.284 3.597-2.309 3.655-2.595.057-2.704-1.77-2.704-3.08 0-1.374.277-3.317 2.367-3.317zM4.24 6.08c1.523-.135 2.645 1.55 2.762 2.513.07.625.393 3.486-1.975 4-2.364.515-3.244-2.249-2.984-3.544 0 0 .28-2.797 2.197-2.969zm8.847-1.483c.14-1.31 1.69-3.316 2.931-3.028 1.236.285 2.367 1.944 2.137 3.37-.224 1.428-1.345 3.313-3.095 3.082-1.748-.226-2.143-1.823-1.973-3.424zM9.425 1c1.307 0 2.364 1.519 2.364 3.398 0 1.879-1.057 3.4-2.364 3.4s-2.367-1.521-2.367-3.4C7.058 2.518 8.118 1 9.425 1z" fill="#2932E1" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Hunyuan</title><circle cx="12" cy="12" fill="#0055E9" r="12"></circle><path d="M12 0c.518 0 1.028.033 1.528.096A6.188 6.188 0 0112.12 12.28l-.12.001c-2.99 0-5.242 2.179-5.554 5.11-.223 2.086.353 4.412 2.242 6.146C3.672 22.1 0 17.479 0 12 0 5.373 5.373 0 12 0z" fill="#A8DFF5"></path><path d="M5.286 5a2.438 2.438 0 01.682 3.38c-3.962 5.966-3.215 10.743 2.648 15.136C3.636 22.056 0 17.452 0 12c0-1.787.39-3.482 1.09-5.006.253-.435.525-.872.817-1.311A2.438 2.438 0 015.286 5z" fill="#0055E9"></path><path d="M12.98.04c.272.021.543.053.81.093.583.106 1.117.254 1.538.44 6.638 2.927 8.07 10.052 1.748 15.642a4.125 4.125 0 01-5.822-.358c-1.51-1.706-1.3-4.184.357-5.822.858-.848 3.108-1.223 4.045-2.441 1.257-1.634 2.122-6.009-2.523-7.506L12.98.039z" fill="#00BCFF"></path><path d="M13.528.096A6.187 6.187 0 0112 12.281a5.75 5.75 0 00-1.71.255c.147-.905.595-1.784 1.321-2.501.858-.848 3.108-1.223 4.045-2.441 1.27-1.651 2.14-6.104-2.676-7.554.184.014.367.033.548.056z" fill="#ECECEE"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="#fff"></path></svg>
|
||||
|
After Width: | Height: | Size: 773 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-_R_0_" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-_R_0_)" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-_R_0_)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-_R_0_" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z" fill="#6E29F6" fill-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 520 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Stepfun</title><path d="M22.012 0h1.032v.927H24v.968h-.956V3.78h-1.032V1.896h-1.878v-.97h1.878V0zM2.6 12.371V1.87h.969v10.502h-.97zm10.423.66h10.95v.918h-6.208v9.579h-4.742V13.03zM5.629 3.333v12.356H0v4.51h10.386V8L20.859 8l-.003-4.668-15.227.001z" fill="url(#lobe-icons-stepfun-_R_0_)" fill-rule="evenodd"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-stepfun-_R_0_" x1="1.646" x2="18.342" y1="1.916" y2="22.091"><stop stop-color="#01A9FF"></stop><stop offset="1" stop-color="#0160FF"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 676 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Volcengine</title><path d="M19.44 10.153l-2.936 11.586a.215.215 0 00.214.261h5.87a.215.215 0 00.214-.261l-2.95-11.586a.214.214 0 00-.412 0zM3.28 12.778l-2.275 8.96A.214.214 0 001.22 22h4.532a.212.212 0 00.214-.165.214.214 0 000-.097l-2.276-8.96a.214.214 0 00-.41 0z" fill="#00E5E5"></path><path d="M7.29 5.359L3.148 21.738a.215.215 0 00.203.261h8.29a.214.214 0 00.215-.261L7.7 5.358a.214.214 0 00-.41 0z" fill="#006EFF"></path><path d="M14.44.15a.214.214 0 00-.41 0L8.366 21.739a.214.214 0 00.214.261H19.9a.216.216 0 00.171-.078.214.214 0 00.044-.183L14.439.15z" fill="#006EFF"></path><path d="M10.278 7.741L6.685 21.736a.214.214 0 00.214.264h7.17a.215.215 0 00.214-.264L10.688 7.741a.214.214 0 00-.41 0z" fill="#00E5E5"></path></svg>
|
||||
|
After Width: | Height: | Size: 858 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -416,6 +416,39 @@ export interface ProviderPreset {
|
||||
base_url: string
|
||||
default_credential_id?: string | null
|
||||
requires_credential: boolean
|
||||
logo_id?: string
|
||||
description?: string
|
||||
capabilities?: string[]
|
||||
}
|
||||
|
||||
export type ProviderUpdateRequest = Partial<Omit<ProviderConfig, 'provider_id' | 'credential_id' | 'base_url'>> & {
|
||||
credential_id?: string | null
|
||||
base_url?: string | null
|
||||
}
|
||||
|
||||
export type RoutingCapability = 'embedding' | 'transcription' | 'speaker_matching'
|
||||
|
||||
export interface ModelBinding {
|
||||
provider_id: string
|
||||
model: string
|
||||
endpoint: string
|
||||
dimensions?: number | null
|
||||
}
|
||||
|
||||
export interface ModelRoutingConfig {
|
||||
version: number
|
||||
embedding: ModelBinding | null
|
||||
transcription: ModelBinding | null
|
||||
speaker_matching: ModelBinding | null
|
||||
}
|
||||
|
||||
export interface ModelRoutingResponse {
|
||||
config: ModelRoutingConfig
|
||||
local_backends: Array<{
|
||||
capability: RoutingCapability
|
||||
status: 'placeholder' | 'not_installed' | 'ready'
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
|
||||
// ============ Tasks ============
|
||||
@@ -721,6 +754,9 @@ export interface ApiProviderPreset {
|
||||
base_url: string
|
||||
default_credential_id?: string | null
|
||||
requires_credential: boolean
|
||||
logo_id?: string
|
||||
description?: string
|
||||
capabilities?: string[]
|
||||
}
|
||||
|
||||
export interface ApiModelInfo {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ModelRoutingResponse, ProviderConfig } from '@/contracts'
|
||||
import { ApiErrorClass } from '@/services/apiClient'
|
||||
import * as service from '@/services/modelRoutingService'
|
||||
import { listProviders } from '@/services/providerService'
|
||||
import ModelRoutingSettings from './ModelRoutingSettings.vue'
|
||||
|
||||
vi.mock('@/services/modelRoutingService', () => ({ getModelRouting: vi.fn(), saveModelRouting: vi.fn() }))
|
||||
vi.mock('@/services/providerService', () => ({ listProviders: vi.fn() }))
|
||||
const providers: ProviderConfig[] = [
|
||||
{ provider_id: 'p1', provider_type: 'openai_compatible', name: 'Custom API', enabled: true, default_model: 'chat-model', capabilities: {}, has_credential: true },
|
||||
{ provider_id: 'p2', provider_type: 'openai_chat', name: 'OpenAI', enabled: true, default_model: '', capabilities: {}, has_credential: true },
|
||||
{ provider_id: 'responses', provider_type: 'openai_responses', name: 'Responses', enabled: true, default_model: '', capabilities: {}, has_credential: true },
|
||||
{ provider_id: 'anthropic', provider_type: 'anthropic_messages', name: 'Anthropic', enabled: true, default_model: '', capabilities: {}, has_credential: true },
|
||||
{ provider_id: 'ollama', provider_type: 'ollama', name: 'Ollama', enabled: true, default_model: '', capabilities: {}, has_credential: false },
|
||||
{ provider_id: 'disabled', provider_type: 'openai_chat', name: 'Disabled', enabled: false, default_model: '', capabilities: {}, has_credential: true },
|
||||
]
|
||||
const initial: ModelRoutingResponse = { config: { version: 3, embedding: null, transcription: null, speaker_matching: null }, local_backends: [
|
||||
{ capability: 'embedding', status: 'placeholder', message: 'hash fallback' },
|
||||
{ capability: 'transcription', status: 'not_installed', message: 'ASR not installed' },
|
||||
{ capability: 'speaker_matching', status: 'not_installed', message: 'speaker not installed' },
|
||||
] }
|
||||
const wrappers: ReturnType<typeof mount>[] = []
|
||||
async function render() {
|
||||
const wrapper = mount(ModelRoutingSettings)
|
||||
wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(service.getModelRouting).mockResolvedValue(structuredClone(initial))
|
||||
vi.mocked(listProviders).mockResolvedValue(providers)
|
||||
vi.mocked(service.saveModelRouting).mockImplementation(async config => ({ ...initial, config: { ...config, version: config.version + 1 } }))
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
|
||||
|
||||
describe('ModelRoutingSettings', () => {
|
||||
it('loads local selections honestly, explains index rebuilds, and disables incompatible providers', async () => {
|
||||
const wrapper = await render()
|
||||
expect(wrapper.findAll('select').map(select => (select.element as HTMLSelectElement).value)).toEqual(['', '', ''])
|
||||
expect(wrapper.text()).toContain('当前为占位实现')
|
||||
expect(wrapper.text()).toContain('真实本地 ASR 尚未接入')
|
||||
expect(wrapper.text()).toContain('真实本地说话人匹配尚未接入')
|
||||
expect(wrapper.text()).toContain('重建全部')
|
||||
expect(wrapper.text()).toContain('重建完成前继续使用本地检索')
|
||||
expect(wrapper.text()).toContain('不是 OpenAI 标准接口')
|
||||
for (const id of ['responses', 'anthropic', 'ollama', 'disabled']) expect(wrapper.get(`option[value="${id}"]`).attributes()).toHaveProperty('disabled')
|
||||
expect(wrapper.get('option[value="p1"]').attributes()).not.toHaveProperty('disabled')
|
||||
})
|
||||
|
||||
it('saves all three independent bindings with expected version and remote dimensions', async () => {
|
||||
const wrapper = await render()
|
||||
for (const capability of ['embedding', 'transcription', 'speaker_matching']) {
|
||||
const card = wrapper.get(`[data-capability="${capability}"]`)
|
||||
await card.get('select').setValue('p1')
|
||||
await card.get('[data-field="model"]').setValue(`${capability}-model`)
|
||||
}
|
||||
await wrapper.get('[data-field="dimensions"]').setValue('3072')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.saveModelRouting).toHaveBeenCalledWith({ version: 3,
|
||||
embedding: { provider_id: 'p1', model: 'embedding-model', endpoint: '/embeddings', dimensions: 3072 },
|
||||
transcription: { provider_id: 'p1', model: 'transcription-model', endpoint: '/audio/transcriptions' },
|
||||
speaker_matching: { provider_id: 'p1', model: 'speaker_matching-model', endpoint: '/audio/speaker-matches' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('配置版本 4')
|
||||
expect(wrapper.text()).toContain('模型路由已保存')
|
||||
await wrapper.get('[data-capability="embedding"] select').setValue('')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 4, embedding: null }))
|
||||
})
|
||||
|
||||
it('supports omitted dimensions and clears stale models/endpoints when switching providers', async () => {
|
||||
const wrapper = await render()
|
||||
const card = wrapper.get('[data-capability="embedding"]')
|
||||
await card.get('select').setValue('p1')
|
||||
await card.get('[data-field="model"]').setValue('custom-embedding')
|
||||
await card.get('[data-field="endpoint"]').setValue('/custom/embeddings')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.saveModelRouting).toHaveBeenCalledWith(expect.objectContaining({ embedding: { provider_id: 'p1', model: 'custom-embedding', endpoint: '/custom/embeddings', dimensions: null } }))
|
||||
await card.get('select').setValue('p2')
|
||||
expect((card.get('[data-field="model"]').element as HTMLInputElement).value).toBe('')
|
||||
expect((card.get('[data-field="endpoint"]').element as HTMLInputElement).value).toBe('/embeddings')
|
||||
})
|
||||
|
||||
it('shows a loading state and does not offer a default local configuration after load failure', async () => {
|
||||
let fail!: (error: Error) => void
|
||||
vi.mocked(service.getModelRouting).mockReturnValueOnce(new Promise((_, reject) => { fail = reject }))
|
||||
const wrapper = await render()
|
||||
expect(wrapper.text()).toContain('正在加载模型路由')
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
fail(new Error('offline'))
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('加载失败:offline')
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('form').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps unsaved input on save failure and retries without inventing a new version', async () => {
|
||||
const wrapper = await render()
|
||||
const card = wrapper.get('[data-capability="transcription"]')
|
||||
await card.get('select').setValue('p1')
|
||||
await card.get('[data-field="model"]').setValue('asr-model')
|
||||
vi.mocked(service.saveModelRouting).mockRejectedValueOnce(new Error('disk full'))
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('保存失败:disk full')
|
||||
expect((card.get('[data-field="model"]').element as HTMLInputElement).value).toBe('asr-model')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 3 }))
|
||||
})
|
||||
|
||||
it('blocks overwrite after a conflict until explicitly reloading the latest configuration', async () => {
|
||||
const wrapper = await render()
|
||||
vi.mocked(service.saveModelRouting).mockRejectedValueOnce(new ApiErrorClass('MODEL_ROUTING_VERSION_CONFLICT', 'stale'))
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('配置版本冲突')
|
||||
expect(wrapper.get('button[type="submit"]').attributes()).toHaveProperty('disabled')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(service.saveModelRouting).toHaveBeenCalledTimes(1)
|
||||
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, config: { ...initial.config, version: 8 } })
|
||||
await wrapper.findAll('button').find(button => button.text().includes('放弃当前输入'))!.trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 8 }))
|
||||
})
|
||||
|
||||
it('prevents invalid dimensions, endpoints, and missing provider bindings from being saved', async () => {
|
||||
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, config: { ...initial.config, embedding: { provider_id: 'missing', model: 'old-model', endpoint: '/embeddings' } } })
|
||||
const wrapper = await render()
|
||||
expect(wrapper.text()).toContain('原提供商已不可用')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(service.saveModelRouting).not.toHaveBeenCalled()
|
||||
const card = wrapper.get('[data-capability="embedding"]')
|
||||
await card.get('select').setValue('p1')
|
||||
await card.get('[data-field="model"]').setValue('embedding-model')
|
||||
await card.get('[data-field="dimensions"]').setValue('1.5')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(wrapper.text()).toContain('嵌入维度必须为 1–16384 的整数')
|
||||
await card.get('[data-field="dimensions"]').setValue('16385')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(service.saveModelRouting).not.toHaveBeenCalled()
|
||||
expect(card.get('[data-field="dimensions"]').attributes('max')).toBe('16384')
|
||||
await card.get('[data-field="dimensions"]').setValue('')
|
||||
await card.get('[data-field="endpoint"]').setValue('https://example.test/embeddings')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(wrapper.text()).toContain('Endpoint 必须是以 / 开头的相对路径')
|
||||
expect(service.saveModelRouting).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('labels injected ready local backends accurately', async () => {
|
||||
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, local_backends: [{ capability: 'transcription', status: 'ready', message: 'Local ASR ready' }] })
|
||||
const wrapper = await render()
|
||||
const card = wrapper.get('[data-capability="transcription"]')
|
||||
expect(card.get('option[value=""]').text()).toBe('本地 · 已就绪')
|
||||
expect(card.text()).toContain('本地后端已就绪')
|
||||
expect(card.text()).toContain('Local ASR ready')
|
||||
expect(card.text()).not.toContain('真实本地 ASR 尚未接入')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import type { ModelBinding, ModelRoutingConfig, ModelRoutingResponse, ProviderConfig, RoutingCapability } from '@/contracts'
|
||||
import { getModelRouting, saveModelRouting } from '@/services/modelRoutingService'
|
||||
import { listProviders } from '@/services/providerService'
|
||||
import { ApiErrorClass } from '@/services/apiClient'
|
||||
|
||||
const capabilities: Array<{ id: RoutingCapability; name: string; endpoint: string; placeholder: string; local: string }> = [
|
||||
{ id: 'embedding', name: '向量嵌入 · Embedding', endpoint: '/embeddings', placeholder: '例如 text-embedding-3-small', local: '当前为占位实现,尚未接入真实本地嵌入模型。' },
|
||||
{ id: 'transcription', name: '语音转写 · Transcription', endpoint: '/audio/transcriptions', placeholder: '输入转写模型 ID', local: '真实本地 ASR 尚未接入,等待阶段 F;当前无法进行本地语音识别。' },
|
||||
{ id: 'speaker_matching', name: '说话人匹配 · Speaker matching', endpoint: '/audio/speaker-matches', placeholder: '输入说话人匹配模型 ID', local: '真实本地说话人匹配尚未接入,等待阶段 F;当前无法进行本地声纹匹配。' },
|
||||
]
|
||||
type Draft = { provider_id: string; model: string; endpoint: string; dimensions: string | number }
|
||||
const drafts = reactive(Object.fromEntries(capabilities.map(item => [item.id, { provider_id: '', model: '', endpoint: item.endpoint, dimensions: '' }])) as Record<RoutingCapability, Draft>)
|
||||
const providers = ref<ProviderConfig[]>([])
|
||||
const response = ref<ModelRoutingResponse | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const saved = ref(false)
|
||||
const conflict = ref(false)
|
||||
let active = true
|
||||
const eligible = (provider: ProviderConfig) => provider.enabled && ['openai_chat', 'openai_compatible'].includes(provider.provider_type)
|
||||
const available = computed(() => providers.value.filter(eligible))
|
||||
const unavailable = computed(() => providers.value.filter(provider => !eligible(provider)))
|
||||
const localBackend = (capability: RoutingCapability) => response.value?.local_backends.find(item => item.capability === capability)
|
||||
const localLabel = (capability: RoutingCapability) => {
|
||||
const status = localBackend(capability)?.status
|
||||
return status === 'ready' ? '已就绪' : status === 'placeholder' ? '占位实现' : '尚未接入'
|
||||
}
|
||||
const protocols = [
|
||||
{ id: 'openai_chat', label: 'OpenAI Chat' }, { id: 'openai_compatible', label: 'OpenAI Compatible' },
|
||||
{ id: 'openai_responses', label: 'Responses' }, { id: 'anthropic_messages', label: 'Anthropic' }, { id: 'ollama', label: 'Ollama' },
|
||||
]
|
||||
|
||||
function applyResponse(result: ModelRoutingResponse) {
|
||||
response.value = result
|
||||
for (const item of capabilities) {
|
||||
const binding = result.config[item.id]
|
||||
Object.assign(drafts[item.id], { provider_id: binding?.provider_id ?? '', model: binding?.model ?? '', endpoint: binding?.endpoint ?? item.endpoint, dimensions: binding?.dimensions?.toString() ?? '' })
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (loading.value || saving.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
saved.value = false
|
||||
try {
|
||||
const [routing, items] = await Promise.all([getModelRouting(), listProviders()])
|
||||
if (!active) return
|
||||
providers.value = items
|
||||
applyResponse(routing)
|
||||
conflict.value = false
|
||||
} catch (reason) {
|
||||
if (active) error.value = `加载失败:${reason instanceof Error ? reason.message : '无法读取模型路由或提供商'}`
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { active = false })
|
||||
|
||||
function changeProvider(capability: RoutingCapability) {
|
||||
const draft = drafts[capability]
|
||||
draft.model = ''
|
||||
draft.dimensions = ''
|
||||
draft.endpoint = capabilities.find(item => item.id === capability)!.endpoint
|
||||
saved.value = false
|
||||
}
|
||||
|
||||
function bindingFor(capability: RoutingCapability): ModelBinding | null {
|
||||
const draft = drafts[capability]
|
||||
if (!draft.provider_id) return null
|
||||
if (!available.value.some(provider => provider.provider_id === draft.provider_id)) throw new Error('请选择已启用且协议可用的提供商,或切换到本地。')
|
||||
if (!draft.model.trim()) throw new Error('请填写所选 API 的模型 ID。')
|
||||
if (!/^\/[A-Za-z0-9_/-]+$/.test(draft.endpoint) || draft.endpoint.startsWith('//')) throw new Error('Endpoint 必须是以 / 开头的相对路径,只能包含字母、数字、下划线、连字符和 /。')
|
||||
const binding: ModelBinding = { provider_id: draft.provider_id, model: draft.model.trim(), endpoint: draft.endpoint }
|
||||
if (capability === 'embedding') {
|
||||
const dimension = String(draft.dimensions).trim()
|
||||
if (dimension && (!/^\d+$/.test(dimension) || !Number.isSafeInteger(Number(dimension)) || Number(dimension) < 1 || Number(dimension) > 16384)) throw new Error('嵌入维度必须为 1–16384 的整数,或留空使用 API 默认值。')
|
||||
binding.dimensions = dimension ? Number(dimension) : null
|
||||
}
|
||||
return binding
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!response.value || loading.value || saving.value || conflict.value) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
saved.value = false
|
||||
try {
|
||||
const config: ModelRoutingConfig = {
|
||||
version: response.value.config.version,
|
||||
embedding: bindingFor('embedding'), transcription: bindingFor('transcription'), speaker_matching: bindingFor('speaker_matching'),
|
||||
}
|
||||
const result = await saveModelRouting(config)
|
||||
if (active) { applyResponse(result); saved.value = true }
|
||||
} catch (reason) {
|
||||
if (!active) return
|
||||
conflict.value = reason instanceof ApiErrorClass && /CONFLICT|VERSION|HTTP_409/i.test(reason.code)
|
||||
error.value = conflict.value
|
||||
? '配置版本冲突:其他窗口已修改路由。当前输入尚未保存,请重新加载最新配置后再编辑。'
|
||||
: `保存失败:${reason instanceof Error ? reason.message : '请重试'}`
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="routing-settings" aria-labelledby="routing-title" :aria-busy="loading || saving">
|
||||
<div><h2 id="routing-title">能力模型路由</h2><p class="subtle">向量嵌入、语音转写和说话人匹配分别选择提供商与模型,独立于默认聊天模型。API Key 在「模型提供商」中管理。</p></div>
|
||||
<p class="subtle">未选择提供商即使用本地路径。API 请求失败、配置不可用或响应无效时,服务端会回退到当前本地处理;本地占位不代表真实模型已接入。</p>
|
||||
<p v-if="loading" role="status">正在加载模型路由…</p>
|
||||
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
|
||||
<div class="inline-actions"><button type="button" class="button-secondary" :disabled="loading || saving" @click="load">{{ conflict ? '放弃当前输入并加载最新配置' : response ? '重新加载(放弃未保存更改)' : '重试加载' }}</button><span v-if="response" class="subtle">配置版本 {{ response.config.version }}</span></div>
|
||||
<form v-if="response" @submit.prevent="save" @input="saved = false" @change="saved = false">
|
||||
<fieldset :disabled="loading || saving || conflict">
|
||||
<article v-for="capability in capabilities" :key="capability.id" class="routing-card" :data-capability="capability.id">
|
||||
<h3>{{ capability.name }}</h3>
|
||||
<p v-if="capability.id === 'embedding'" class="embedding-notice">更换模型或接口后,请重建全部索引。重建完成前继续使用本地检索。</p>
|
||||
<div class="protocols" aria-label="协议可用性">
|
||||
<span v-for="protocol in protocols" :key="protocol.id" class="badge" :class="{ 'protocol-unavailable': !['openai_chat', 'openai_compatible'].includes(protocol.id) }">{{ protocol.label }}{{ ['openai_chat', 'openai_compatible'].includes(protocol.id) ? ' · 可用' : ' · 不可用' }}</span>
|
||||
</div>
|
||||
<label class="field"><span>处理方式 / 提供商</span><select v-model="drafts[capability.id].provider_id" class="select" data-field="provider" @change="changeProvider(capability.id)">
|
||||
<option value="">本地 · {{ localLabel(capability.id) }}</option>
|
||||
<option v-for="provider in available" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }} · {{ provider.provider_type }}</option>
|
||||
<option v-for="provider in unavailable" :key="provider.provider_id" :value="provider.provider_id" disabled>{{ provider.name }} · {{ provider.enabled ? '协议不可用' : '未启用' }}</option>
|
||||
<option v-if="drafts[capability.id].provider_id && !providers.some(provider => provider.provider_id === drafts[capability.id].provider_id)" :value="drafts[capability.id].provider_id" disabled>原提供商已不可用 · {{ drafts[capability.id].provider_id }}</option>
|
||||
</select></label>
|
||||
<div v-if="drafts[capability.id].provider_id" class="routing-fields">
|
||||
<label class="field"><span>模型 ID</span><input v-model="drafts[capability.id].model" class="input" data-field="model" :placeholder="capability.placeholder" maxlength="256" required /></label>
|
||||
<label class="field"><span>Endpoint(相对 Base URL)</span><input v-model="drafts[capability.id].endpoint" class="input" data-field="endpoint" :placeholder="capability.endpoint" maxlength="256" required /></label>
|
||||
<label v-if="capability.id === 'embedding'" class="field"><span>向量维度(可选)</span><input v-model="drafts.embedding.dimensions" class="input" data-field="dimensions" type="number" min="1" max="16384" step="1" placeholder="留空使用 API 默认维度" /><small class="subtle">填写模型支持的 1–16384 整数维度,或留空使用 API 默认值。</small></label>
|
||||
</div>
|
||||
<p v-if="capability.id === 'speaker_matching'" class="subtle">说话人匹配使用本应用自定义 HTTP multipart 契约。该端点不是 OpenAI 标准接口;服务需实现对应的说话人匹配请求和响应。</p>
|
||||
<div class="local-status" :class="{ selected: !drafts[capability.id].provider_id }">
|
||||
<strong>{{ drafts[capability.id].provider_id ? '本地回退状态' : '当前本地状态' }}</strong>
|
||||
<p>{{ localBackend(capability.id)?.status === 'ready' ? '本地后端已就绪。' : capability.local }}</p>
|
||||
<p v-for="backend in response.local_backends.filter(item => item.capability === capability.id)" :key="backend.capability" class="subtle"><span class="badge">{{ backend.status === 'ready' ? '已就绪' : backend.status === 'placeholder' ? '占位实现' : '未安装 / 未接入' }}</span> {{ backend.message }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</fieldset>
|
||||
<div class="inline-actions"><button type="submit" class="button-primary" :disabled="loading || saving || conflict">{{ saving ? '保存中…' : '保存模型路由' }}</button><span v-if="saved" role="status">模型路由已保存。</span></div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.routing-settings, form, fieldset { display: grid; gap: var(--space-lg); }
|
||||
.routing-settings { border-top: 1px solid var(--color-border-default); padding-top: var(--space-xl); margin-top: var(--space-md); }
|
||||
fieldset { min-width: 0; padding: 0; margin: 0; border: 0; }
|
||||
.routing-card { display: grid; gap: var(--space-md); padding: var(--space-lg); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-primary); }
|
||||
.routing-card h3 { margin: 0; }
|
||||
.protocols { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
|
||||
.protocol-unavailable { opacity: .65; }
|
||||
.embedding-notice { padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-accent-soft); color: var(--color-text-primary); }
|
||||
.routing-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-md); }
|
||||
.local-status { display: grid; gap: var(--space-xs); padding: var(--space-md); background: var(--color-background-secondary); border-radius: var(--radius-md); }
|
||||
.local-status.selected { border-left: 3px solid var(--color-accent-primary); }
|
||||
@media (max-width: 700px) { .routing-fields { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProviderConfig, ProviderPreset } from '@/contracts'
|
||||
import * as service from '@/services/providerService'
|
||||
import ProviderForm from './ProviderForm.vue'
|
||||
import ProviderPresetSelector from './ProviderPresetSelector.vue'
|
||||
|
||||
vi.mock('@/services/providerService', () => ({ listProviderPresets: vi.fn(), getCredentialStatus: vi.fn(), putCredential: vi.fn(), createProvider: vi.fn(), updateProvider: vi.fn() }))
|
||||
const presets: ProviderPreset[] = [
|
||||
{ preset_id: 'deepseek', name: 'DeepSeek', provider_type: 'openai_compatible', base_url: 'https://deepseek.example.test', default_credential_id: 'shared-deepseek', requires_credential: true, logo_id: 'deepseek' },
|
||||
{ preset_id: 'qwen', name: '通义千问', provider_type: 'openai_compatible', base_url: 'https://qwen.example.test', default_credential_id: 'shared-qwen', requires_credential: true, logo_id: 'qwen' },
|
||||
]
|
||||
const existing: ProviderConfig = { provider_id: 'p1', provider_type: 'openai_compatible', name: 'DeepSeek', base_url: presets[0].base_url, default_model: 'old-model', enabled: true, credential_id: 'old-shared-key', has_credential: true, capabilities: {} }
|
||||
const wrappers: ReturnType<typeof mount>[] = []
|
||||
async function render(provider?: ProviderConfig) {
|
||||
const wrapper = mount(ProviderForm, { props: { provider, models: [{ model_id: 'old-model', name: 'Old', capabilities: {} }] } })
|
||||
wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(service.listProviderPresets).mockResolvedValue(presets)
|
||||
vi.mocked(service.getCredentialStatus).mockResolvedValue(true)
|
||||
vi.mocked(service.putCredential).mockResolvedValue()
|
||||
vi.mocked(service.createProvider).mockResolvedValue(existing)
|
||||
vi.mocked(service.updateProvider).mockResolvedValue(existing)
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
|
||||
|
||||
describe('ProviderForm', () => {
|
||||
it('filters compact preset chips and resolves bundled logos', async () => {
|
||||
const wrapper = await render()
|
||||
await wrapper.get('#provider-search').setValue('通义')
|
||||
expect(wrapper.find('[data-preset="deepseek"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-preset="qwen"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-preset="qwen"] img').attributes('src')).not.toMatch(/^https?:/)
|
||||
})
|
||||
|
||||
it('clears the secret, old model and credential on preset and custom selection', async () => {
|
||||
const wrapper = await render(existing)
|
||||
await wrapper.get('input[type="password"]').setValue('draft-secret')
|
||||
await wrapper.get('[data-preset="qwen"]').trigger('click')
|
||||
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||
expect((wrapper.get('[data-field="model"]').element as HTMLInputElement).value).toBe('')
|
||||
expect(wrapper.findAll('datalist option')).toHaveLength(0)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(service.updateProvider).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('请输入 API Key')
|
||||
await wrapper.get('input[type="password"]').setValue('new-secret')
|
||||
wrapper.getComponent(ProviderPresetSelector).vm.$emit('update:modelValue', '')
|
||||
await flushPromises()
|
||||
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('allocates different credential IDs for two new providers using the same preset', async () => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const wrapper = await render()
|
||||
await wrapper.get('[data-preset="deepseek"]').trigger('click')
|
||||
await wrapper.get('input[type="password"]').setValue(`test-key-${i}`)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
}
|
||||
const ids = vi.mocked(service.putCredential).mock.calls.map(call => call[0])
|
||||
expect(ids).toHaveLength(2)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
ids.forEach(id => expect(id).toMatch(/^provider-key-[0-9a-f-]{36}$/))
|
||||
vi.mocked(service.createProvider).mock.calls.forEach(([data], index) => {
|
||||
expect(data.credential_id).toBe(ids[index])
|
||||
expect(JSON.stringify(data)).not.toContain('test-key')
|
||||
expect(JSON.stringify(data)).not.toContain('shared-deepseek')
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a custom API key and clears it after a failed credential save', async () => {
|
||||
const wrapper = await render()
|
||||
await wrapper.get('[data-field="name"]').setValue('Custom')
|
||||
await wrapper.get('[data-field="base-url"]').setValue('https://custom.example.test/v1')
|
||||
await wrapper.get('input[type="password"]').setValue('custom-test-key')
|
||||
vi.mocked(service.putCredential).mockRejectedValueOnce(new Error('credential store unavailable'))
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||
expect(wrapper.text()).toContain('credential store unavailable')
|
||||
expect(service.createProvider).not.toHaveBeenCalled()
|
||||
await wrapper.get('input[type="password"]').setValue('custom-test-key')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.createProvider).toHaveBeenCalledWith(expect.objectContaining({ name: 'Custom', credential_id: expect.stringMatching(/^provider-key-/) }))
|
||||
})
|
||||
|
||||
it('preserves its own existing key when untouched and rotates shared legacy references when replacing a key', async () => {
|
||||
const untouched = await render(existing)
|
||||
await untouched.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.updateProvider).toHaveBeenLastCalledWith('p1', expect.objectContaining({ credential_id: 'old-shared-key', default_model: 'old-model' }))
|
||||
const rotated = await render(existing)
|
||||
await rotated.get('input[type="password"]').setValue('replacement-test-key')
|
||||
await rotated.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.putCredential).toHaveBeenCalledWith(expect.stringMatching(/^provider-key-/), 'replacement-test-key')
|
||||
expect(service.updateProvider).toHaveBeenLastCalledWith('p1', expect.objectContaining({ credential_id: vi.mocked(service.putCredential).mock.calls[0][0] }))
|
||||
})
|
||||
|
||||
it('persists edited protocols and unlinks the previous credential and model', async () => {
|
||||
const wrapper = await render(existing)
|
||||
await wrapper.get('[data-field="protocol"]').setValue('openai_responses')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.updateProvider).toHaveBeenCalledWith('p1', expect.objectContaining({ provider_type: 'openai_responses', default_model: '', credential_id: null }))
|
||||
})
|
||||
|
||||
it('does not let a late credential status reuse a key after switching presets', async () => {
|
||||
let resolveStatus!: (configured: boolean) => void
|
||||
vi.mocked(service.getCredentialStatus).mockReturnValue(new Promise(resolve => { resolveStatus = resolve }))
|
||||
const wrapper = await render(existing)
|
||||
await wrapper.get('[data-preset="qwen"]').trigger('click')
|
||||
resolveStatus(true)
|
||||
await flushPromises()
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(service.updateProvider).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('请输入 API Key')
|
||||
})
|
||||
|
||||
it('clears secrets on close and unmount, and stops a pending credential save from creating a provider', async () => {
|
||||
let finish!: () => void
|
||||
vi.mocked(service.putCredential).mockReturnValue(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = await render()
|
||||
await wrapper.get('[data-preset="deepseek"]').trigger('click')
|
||||
await wrapper.get('input[type="password"]').setValue('pending-test-key')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await wrapper.get('[aria-label="关闭提供商表单"]').trigger('click')
|
||||
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||
wrapper.unmount()
|
||||
finish()
|
||||
await flushPromises()
|
||||
expect(service.createProvider).not.toHaveBeenCalled()
|
||||
const reopened = await render()
|
||||
expect((reopened.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('clears a secret after provider save failure and keeps only the successfully saved reference for retry', async () => {
|
||||
const wrapper = await render()
|
||||
await wrapper.get('[data-preset="deepseek"]').trigger('click')
|
||||
await wrapper.get('input[type="password"]').setValue('retry-test-key')
|
||||
vi.mocked(service.createProvider).mockRejectedValueOnce(new Error('provider save failed'))
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('provider save failed')
|
||||
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.putCredential).toHaveBeenCalledTimes(1)
|
||||
expect(service.createProvider).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import type { ModelInfo, ProviderConfig, ProviderPreset, ProviderType } from '@/contracts'
|
||||
import * as service from '@/services/providerService'
|
||||
import ProviderPresetSelector from './ProviderPresetSelector.vue'
|
||||
|
||||
const props = defineProps<{ provider?: ProviderConfig; models?: ModelInfo[] }>()
|
||||
const emit = defineEmits<{ close: []; saved: [provider: ProviderConfig] }>()
|
||||
const newCredentialId = () => `provider-key-${crypto.randomUUID()}`
|
||||
const form = reactive({
|
||||
preset_id: '', provider_type: props.provider?.provider_type ?? 'openai_compatible' as ProviderType,
|
||||
name: props.provider?.name ?? '', base_url: props.provider?.base_url ?? '',
|
||||
default_model: props.provider?.default_model ?? '', enabled: props.provider?.enabled ?? true,
|
||||
})
|
||||
const credentialId = ref(props.provider?.credential_id || newCredentialId())
|
||||
const apiKey = ref('')
|
||||
const configured = ref(false)
|
||||
const credentialLoading = ref(false)
|
||||
const credentialError = ref('')
|
||||
const presets = ref<ProviderPreset[]>([])
|
||||
const presetsLoading = ref(false)
|
||||
const presetsError = ref('')
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const contextChanged = ref(false)
|
||||
const dialog = ref<HTMLElement>()
|
||||
const previousFocus = document.activeElement as HTMLElement | null
|
||||
let active = true
|
||||
let credentialGeneration = 0
|
||||
const selectedPreset = computed(() => presets.value.find(preset => preset.preset_id === form.preset_id))
|
||||
const modelOptions = computed(() => contextChanged.value ? [] : props.models ?? [])
|
||||
|
||||
async function loadPresets() {
|
||||
presetsLoading.value = true
|
||||
presetsError.value = ''
|
||||
try {
|
||||
presets.value = await service.listProviderPresets()
|
||||
if (!contextChanged.value) form.preset_id = presets.value.find(preset => preset.provider_type === props.provider?.provider_type && preset.base_url === props.provider?.base_url)?.preset_id ?? ''
|
||||
} catch { presetsError.value = '预设加载失败,请重试,或填写自定义服务。' }
|
||||
finally { presetsLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
void loadPresets()
|
||||
if (props.provider?.credential_id) {
|
||||
const generation = credentialGeneration
|
||||
credentialLoading.value = true
|
||||
try {
|
||||
const result = await service.getCredentialStatus(credentialId.value)
|
||||
if (active && generation === credentialGeneration) configured.value = result
|
||||
} catch {
|
||||
if (active && generation === credentialGeneration) credentialError.value = '无法检查已保存的凭据。可输入新密钥,或关闭后重试。'
|
||||
} finally {
|
||||
if (generation === credentialGeneration) credentialLoading.value = false
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
if (active) dialog.value?.querySelector<HTMLInputElement>('input')?.focus()
|
||||
})
|
||||
|
||||
function detachCredential() {
|
||||
credentialGeneration++
|
||||
apiKey.value = ''
|
||||
credentialId.value = newCredentialId()
|
||||
configured.value = false
|
||||
credentialLoading.value = false
|
||||
credentialError.value = ''
|
||||
form.default_model = ''
|
||||
contextChanged.value = true
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function applyPreset(id: string) {
|
||||
if (form.preset_id === id) return
|
||||
detachCredential()
|
||||
form.preset_id = id
|
||||
const preset = presets.value.find(item => item.preset_id === id)
|
||||
Object.assign(form, { provider_type: preset?.provider_type ?? 'openai_compatible', name: preset?.name ?? '', base_url: preset?.base_url ?? '' })
|
||||
}
|
||||
|
||||
function changeConnection() {
|
||||
form.preset_id = ''
|
||||
detachCredential()
|
||||
}
|
||||
|
||||
function close() {
|
||||
active = false
|
||||
apiKey.value = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
apiKey.value = ''
|
||||
previousFocus?.focus()
|
||||
})
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') { event.preventDefault(); close() }
|
||||
if (event.key !== 'Tab') return
|
||||
const elements = Array.from(dialog.value?.querySelectorAll<HTMLElement>('button, input, select, [tabindex="0"]') ?? []).filter(element => !element.matches(':disabled'))
|
||||
const first = elements[0], last = elements[elements.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving.value || credentialLoading.value || !active) return
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (!form.name.trim() || (form.provider_type !== 'mock' && !form.base_url.trim())) throw new Error('请填写名称和 Base URL。')
|
||||
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error('请输入 API Key。密钥将由后端加密保存。')
|
||||
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
|
||||
const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false }
|
||||
if (apiKey.value.trim()) {
|
||||
// Rotate even an existing reference: older installations may share preset credential IDs.
|
||||
const nextId = newCredentialId()
|
||||
const request = service.putCredential(nextId, apiKey.value.trim())
|
||||
apiKey.value = ''
|
||||
await request
|
||||
if (!active) return
|
||||
credentialId.value = nextId
|
||||
configured.value = true
|
||||
}
|
||||
const reference = configured.value ? credentialId.value : undefined
|
||||
// A failed status check must not silently unlink the provider's existing credential.
|
||||
if (credentialError.value && !reference) throw new Error(credentialError.value)
|
||||
const saved = props.provider
|
||||
? await service.updateProvider(props.provider.provider_id, { ...data, credential_id: reference ?? null })
|
||||
: await service.createProvider({ ...data, credential_id: reference })
|
||||
if (active) { emit('saved', saved); close() }
|
||||
} catch (reason) {
|
||||
if (active) error.value = reason instanceof Error ? reason.message : 'Provider 保存失败,请重试。'
|
||||
} finally { apiKey.value = ''; saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-backdrop provider-backdrop" @click.self="close" @keydown="handleKeydown">
|
||||
<div ref="dialog" class="modal provider-modal" role="dialog" aria-modal="true" aria-labelledby="provider-form-title" :aria-busy="saving">
|
||||
<div class="form-heading"><h2 id="provider-form-title">{{ provider ? '编辑 Provider' : '新增 Provider' }}</h2><button type="button" class="button-secondary" aria-label="关闭提供商表单" @click="close">关闭</button></div>
|
||||
<p v-if="presetsLoading" class="subtle" role="status">正在加载提供商预设…</p>
|
||||
<div v-if="presetsError" class="error-banner" role="alert">{{ presetsError }} <button type="button" class="button-secondary" :disabled="presetsLoading || saving" @click="loadPresets">重试</button></div>
|
||||
<form @submit.prevent="save">
|
||||
<fieldset :disabled="saving">
|
||||
<ProviderPresetSelector :presets="presets" :model-value="form.preset_id" @update:model-value="applyPreset" />
|
||||
<p v-if="selectedPreset?.description" class="subtle">{{ selectedPreset.description }}</p>
|
||||
<div class="form-grid">
|
||||
<label class="field"><span>接入协议</span><select v-model="form.provider_type" class="select" data-field="protocol" @change="changeConnection"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option><option v-if="provider?.provider_type === 'mock'" value="mock">Mock</option></select></label>
|
||||
<label class="field"><span>名称</span><input v-model="form.name" class="input" data-field="name" required /></label>
|
||||
<label class="field wide"><span>Base URL</span><input v-model="form.base_url" class="input" data-field="base-url" placeholder="https://api.example.com/v1" :required="form.provider_type !== 'mock'" @change="changeConnection" /></label>
|
||||
<label class="field wide"><span>API Key</span><input v-model="apiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="configured ? '已配置,留空表示不修改' : '请输入 API Key(无鉴权服务可留空)'" /><small class="subtle">密钥由本地 AI Core 加密保存;提供商配置仅保存独立的凭据引用。</small></label>
|
||||
<p v-if="credentialLoading" class="subtle wide" role="status">正在检查凭据状态…</p>
|
||||
<p v-if="credentialError" class="error-text wide" role="alert">{{ credentialError }}</p>
|
||||
<label class="field wide"><span>默认聊天模型</span><input v-model="form.default_model" class="input" data-field="model" list="provider-model-options" placeholder="输入模型 ID,或保存后获取模型列表" /><datalist id="provider-model-options"><option v-for="model in modelOptions" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></label>
|
||||
</div>
|
||||
<label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> 启用</label>
|
||||
</fieldset>
|
||||
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
|
||||
<div class="inline-actions form-footer"><button class="button-primary" type="submit" :disabled="saving || credentialLoading">{{ saving ? '保存中…' : '保存提供商' }}</button><button type="button" class="button-secondary" @click="close">取消</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.provider-modal { width: min(820px, 100%); max-height: 90dvh; }
|
||||
.form-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); margin-bottom: var(--space-md); }
|
||||
.form-heading h2 { margin: 0; }
|
||||
fieldset { display: grid; gap: var(--space-md); border: 0; padding: 0; margin: 0; min-width: 0; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md); }
|
||||
.wide { grid-column: 1 / -1; }
|
||||
.error-text { color: var(--color-error); }
|
||||
.form-footer { padding-top: var(--space-sm); }
|
||||
@media (max-width: 600px) { .provider-backdrop { padding: 12px; }.provider-modal { padding: var(--space-lg); max-height: 94dvh; }.form-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ logoId?: string }>()
|
||||
const assets = import.meta.glob<string>('../../assets/providers/*.svg', { eager: true, query: '?url', import: 'default' })
|
||||
const source = computed(() => {
|
||||
const id = props.logoId === 'openai-responses' ? 'openai' : props.logoId
|
||||
return assets[`../../assets/providers/${id}.svg`]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="provider-logo" :class="{ 'dark-logo': logoId === 'kimi' }" aria-hidden="true">
|
||||
<img v-if="source" :src="source" alt="" width="22" height="22" />
|
||||
<span v-else class="custom-logo">+</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.provider-logo { display: inline-flex; flex: 0 0 28px; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 7px; background: #fff; color: #252b36; }
|
||||
img { display: block; object-fit: contain; }
|
||||
.dark-logo { background: #111; }
|
||||
.custom-logo { font-size: 23px; line-height: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ProviderPreset } from '@/contracts'
|
||||
import ProviderLogo from './ProviderLogo.vue'
|
||||
|
||||
const props = defineProps<{ presets: ProviderPreset[]; modelValue: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
const search = ref('')
|
||||
const filtered = computed(() => {
|
||||
const query = search.value.trim().toLocaleLowerCase()
|
||||
return props.presets.filter(preset => [preset.name, preset.preset_id, preset.description, preset.base_url]
|
||||
.some(value => value?.toLocaleLowerCase().includes(query)))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="preset-selector">
|
||||
<label class="field" for="provider-search"><span>提供商预设</span><input id="provider-search" v-model="search" class="input" type="search" placeholder="搜索提供商,例如 通义千问 / DeepSeek" /></label>
|
||||
<div class="preset-grid" role="group" aria-label="提供商预设">
|
||||
<button type="button" class="preset-chip" :class="{ selected: !modelValue }" :aria-pressed="!modelValue" @click="emit('update:modelValue', '')"><ProviderLogo /><span>自定义</span></button>
|
||||
<button v-for="preset in filtered" :key="preset.preset_id" type="button" class="preset-chip" :class="{ selected: modelValue === preset.preset_id }" :aria-pressed="modelValue === preset.preset_id" :title="preset.description || preset.name" :data-preset="preset.preset_id" @click="emit('update:modelValue', preset.preset_id)">
|
||||
<ProviderLogo :logo-id="preset.logo_id || preset.preset_id" /><span>{{ preset.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="search && !filtered.length" class="subtle" role="status">没有匹配的预设,可以使用自定义服务。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preset-selector { display: grid; gap: var(--space-sm); }
|
||||
.preset-grid { display: flex; flex-wrap: wrap; gap: 8px; max-height: 220px; overflow-y: auto; padding: 3px; }
|
||||
.preset-chip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; border: 1px solid var(--color-border-default); border-radius: 11px; background: var(--color-surface-primary); color: var(--color-text-primary); cursor: pointer; font: inherit; font-size: 13px; }
|
||||
.preset-chip:hover { background: var(--color-background-hover); }
|
||||
.preset-chip.selected { border-color: #377cf6; background: color-mix(in srgb, #377cf6 12%, var(--color-surface-primary)); color: #377cf6; box-shadow: 0 0 0 1px #377cf6; }
|
||||
.preset-chip:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
|
||||
</style>
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import type { ProviderConfig, ProviderType } from '@/contracts'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import type { ProviderConfig } from '@/contracts'
|
||||
import ProviderForm from './ProviderForm.vue'
|
||||
import ProviderLogo from './ProviderLogo.vue'
|
||||
import ModelRoutingSettings from './ModelRoutingSettings.vue'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
@@ -15,14 +18,9 @@ const settingsStore = useSettingsStore()
|
||||
const providerStore = useProviderStore()
|
||||
const themeStore = useThemeStore()
|
||||
const showProviderForm = ref(false)
|
||||
const editingProviderId = ref<string | null>(null)
|
||||
const editingProvider = ref<ProviderConfig>()
|
||||
const providerAction = ref('')
|
||||
const testResults = ref<Record<string, string>>({})
|
||||
const providerApiKey = ref('')
|
||||
const providerForm = reactive({ preset_id: '', provider_type: 'openai_compatible' as ProviderType, name: '', base_url: '', default_model: '', credential_id: '', enabled: true })
|
||||
const formModels = computed(() => editingProviderId.value ? providerStore.modelsByProvider[editingProviderId.value] ?? [] : [])
|
||||
const selectedPreset = computed(() => providerStore.presets.find((item) => item.preset_id === providerForm.preset_id) ?? null)
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
|
||||
await providerStore.refreshEnabledModels()
|
||||
@@ -36,54 +34,15 @@ function presetIdFor(provider?: ProviderConfig) {
|
||||
}
|
||||
|
||||
function openProvider(provider?: ProviderConfig) {
|
||||
editingProviderId.value = provider?.provider_id ?? null
|
||||
const presetId = presetIdFor(provider)
|
||||
const preset = providerStore.presets.find((item) => item.preset_id === presetId)
|
||||
Object.assign(providerForm, { preset_id: presetId, provider_type: provider?.provider_type ?? 'openai_compatible', name: provider?.name ?? '', base_url: provider?.base_url ?? '', default_model: provider?.default_model ?? '', credential_id: provider?.credential_id ?? preset?.default_credential_id ?? '', enabled: provider?.enabled ?? true })
|
||||
providerApiKey.value = ''
|
||||
editingProvider.value = provider
|
||||
providerAction.value = ''
|
||||
showProviderForm.value = true
|
||||
if (providerForm.credential_id) void providerStore.loadCredentialStatus(providerForm.credential_id).catch(() => undefined)
|
||||
if (provider) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
|
||||
}
|
||||
|
||||
function applyProviderPreset() {
|
||||
const preset = providerStore.presets.find((item) => item.preset_id === providerForm.preset_id)
|
||||
if (!preset) return
|
||||
Object.assign(providerForm, {
|
||||
provider_type: preset.provider_type,
|
||||
name: preset.name,
|
||||
base_url: preset.base_url,
|
||||
credential_id: preset.default_credential_id ?? '',
|
||||
})
|
||||
providerApiKey.value = ''
|
||||
if (providerForm.credential_id) void providerStore.loadCredentialStatus(providerForm.credential_id).catch(() => undefined)
|
||||
}
|
||||
|
||||
function closeProvider() {
|
||||
providerApiKey.value = ''
|
||||
showProviderForm.value = false
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
providerAction.value = ''
|
||||
const credentialId = providerForm.credential_id.trim()
|
||||
const requiresApiKey = Boolean(selectedPreset.value?.requires_credential)
|
||||
if (requiresApiKey && !providerApiKey.value && !providerStore.credentialConfiguredById[credentialId]) {
|
||||
providerAction.value = '请输入 API Key。密钥将由后端加密保存。'
|
||||
return
|
||||
}
|
||||
const data = { ...providerForm, base_url: providerForm.base_url || undefined, credential_id: providerForm.credential_id || undefined, capabilities: {}, has_credential: Boolean(providerForm.credential_id) }
|
||||
try {
|
||||
if (providerApiKey.value) await providerStore.saveCredential(credentialId, providerApiKey.value)
|
||||
const saved = editingProviderId.value
|
||||
? await providerStore.updateProvider(editingProviderId.value, data)
|
||||
: await providerStore.addProvider(data)
|
||||
closeProvider()
|
||||
if (saved.enabled) void providerStore.loadModels(saved.provider_id).catch(() => undefined)
|
||||
} catch (error) {
|
||||
providerApiKey.value = ''
|
||||
providerAction.value = error instanceof Error ? error.message : 'Provider 保存失败'
|
||||
}
|
||||
async function providerSaved(provider: ProviderConfig) {
|
||||
await providerStore.loadProviders()
|
||||
if (provider.enabled) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function removeProvider(provider: ProviderConfig) { if (!confirm(`确定删除 Provider“${provider.name}”吗?`)) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : '删除失败' } }
|
||||
@@ -107,14 +66,14 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
||||
|
||||
<div v-else-if="activeSection === 'providers'" class="settings-section">
|
||||
<div class="section-head">
|
||||
<div><h2>模型提供商</h2><p class="subtle">支持 OpenAI、DeepSeek、Ollama 和自定义兼容服务。</p></div>
|
||||
<div><h2>模型提供商</h2><p class="subtle">选择国内外提供商预设,或配置自定义 API 与独立密钥。</p></div>
|
||||
<button class="button-primary" @click="openProvider()">新增 Provider</button>
|
||||
</div>
|
||||
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
|
||||
<div class="provider-list">
|
||||
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
|
||||
<div class="provider-main">
|
||||
<div class="inline-actions"><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
|
||||
<div class="inline-actions"><ProviderLogo :logo-id="providerStore.presets.find(preset => preset.preset_id === presetIdFor(provider))?.logo_id || presetIdFor(provider)" /><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
|
||||
<p class="subtle">{{ provider.base_url || '本地内置' }} · 默认模型 {{ provider.default_model || '未设置' }}</p>
|
||||
<div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div>
|
||||
<div v-if="providerStore.modelsByProvider[provider.provider_id]?.length" class="model-picker">
|
||||
@@ -138,42 +97,13 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><button class="button-secondary" @click="settingsStore.rebuildIndex('fts')">重建文本索引</button><button class="button-secondary" @click="settingsStore.rebuildIndex('vector')">重建向量索引</button></div></div>
|
||||
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><button class="button-secondary" @click="settingsStore.rebuildIndex('fts')">重建文本索引</button><button class="button-secondary" @click="settingsStore.rebuildIndex('vector')">重建向量索引</button></div><ModelRoutingSettings /></div>
|
||||
|
||||
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>权限策略</h2><p class="muted section-description">高影响能力默认需要确认。未知权限由后端拒绝。</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><select :value="policy" class="select short" @change="settingsStore.setPermission(String(permission), ($event.target as HTMLSelectElement).value as 'allow' | 'confirm' | 'deny')"><option value="allow">允许</option><option value="confirm">每次确认</option><option value="deny">拒绝</option></select></div></div></div>
|
||||
|
||||
<div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>Sidecar 状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><button class="button-secondary" @click="settingsStore.restartAiCore">重启 AI Core</button></div></div>
|
||||
|
||||
<div v-if="showProviderForm" class="modal-backdrop" @click.self="closeProvider">
|
||||
<div class="modal">
|
||||
<h2>{{ editingProviderId ? '编辑 Provider' : '新增 Provider' }}</h2>
|
||||
<form @submit.prevent="saveProvider">
|
||||
<div class="field">
|
||||
<label>提供商预设</label>
|
||||
<select v-model="providerForm.preset_id" class="select" @change="applyProviderPreset">
|
||||
<option value="">自定义</option>
|
||||
<option v-for="preset in providerStore.presets" :key="preset.preset_id" :value="preset.preset_id">{{ preset.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>接入协议</label><select v-model="providerForm.provider_type" class="select"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option></select></div>
|
||||
<div class="field"><label>名称</label><input v-model="providerForm.name" class="input" required /></div>
|
||||
<div class="field"><label>Base URL</label><input v-model="providerForm.base_url" class="input" placeholder="https://api.example.com/v1" required /></div>
|
||||
<div class="field">
|
||||
<label>默认模型</label>
|
||||
<input v-model="providerForm.default_model" class="input" :list="editingProviderId ? 'provider-model-options' : undefined" placeholder="保存后自动获取,也可以手动输入" />
|
||||
<datalist id="provider-model-options"><option v-for="model in formModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist>
|
||||
</div>
|
||||
<div v-if="selectedPreset?.requires_credential" class="field">
|
||||
<label>API Key</label>
|
||||
<input v-model="providerApiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="providerStore.credentialConfiguredById[providerForm.credential_id] ? '已配置,留空表示不修改' : '请输入 API Key'" />
|
||||
<small class="subtle">提交后由本地 AI Core 加密保存,页面不会回显已保存的密钥。</small>
|
||||
</div>
|
||||
<div v-else-if="!selectedPreset" class="field"><label>Credential ID</label><input v-model="providerForm.credential_id" class="input" placeholder="自定义凭据标识" /><small class="subtle">自定义服务可以引用 Host 注入或后端已保存的凭据。</small></div>
|
||||
<label class="inline-actions"><input v-model="providerForm.enabled" type="checkbox" /> 启用</label>
|
||||
<div class="inline-actions"><button class="button-primary">保存并获取模型</button><button type="button" class="button-secondary" @click="closeProvider">取消</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<ProviderForm v-if="showProviderForm" :provider="editingProvider" :models="editingProvider ? providerStore.modelsByProvider[editingProvider.provider_id] : []" @close="showProviderForm = false" @saved="providerSaved" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export * as skillService from './skillService'
|
||||
export * as pluginService from './pluginService'
|
||||
export * as mcpServerService from './mcpServerService'
|
||||
export * as providerService from './providerService'
|
||||
export * as modelRoutingService from './modelRoutingService'
|
||||
export * as taskService from './taskService'
|
||||
export * as indexService from './indexService'
|
||||
export * as systemService from './systemService'
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getModelRouting, saveModelRouting } from './modelRoutingService'
|
||||
|
||||
const config = { version: 7, embedding: { provider_id: 'p1', model: 'embedding', endpoint: '/embeddings', dimensions: 1024 }, transcription: null, speaker_matching: null }
|
||||
const result = { config, local_backends: [{ capability: 'embedding', status: 'placeholder', message: 'hash' }] }
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
describe('model routing service', () => {
|
||||
it('round-trips versioned routing without an extra config wrapper', async () => {
|
||||
vi.mocked(fetch).mockImplementation(async () => json(result))
|
||||
expect(await getModelRouting()).toEqual(result)
|
||||
expect(await saveModelRouting(config)).toEqual(result)
|
||||
expect(fetch).toHaveBeenNthCalledWith(1, '/api/model-routing', expect.objectContaining({ method: 'GET' }))
|
||||
expect(fetch).toHaveBeenNthCalledWith(2, '/api/model-routing', expect.objectContaining({ method: 'PUT', body: JSON.stringify(config) }))
|
||||
})
|
||||
|
||||
it('surfaces load, save and version conflict errors instead of returning local defaults', async () => {
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(json({ error: { code: 'MODEL_ROUTING_VERSION_CONFLICT', message: 'conflict' } }, 409))
|
||||
.mockResolvedValueOnce(json({ error: { code: 'SAVE_FAILED', message: 'disk full' } }, 500))
|
||||
await expect(getModelRouting()).rejects.toMatchObject({ code: 'NETWORK_ERROR' })
|
||||
await expect(saveModelRouting(config)).rejects.toMatchObject({ code: 'MODEL_ROUTING_VERSION_CONFLICT' })
|
||||
await expect(saveModelRouting(config)).rejects.toMatchObject({ code: 'SAVE_FAILED' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ModelRoutingConfig, ModelRoutingResponse } from '@/contracts'
|
||||
import apiClient from './apiClient'
|
||||
|
||||
export function getModelRouting(): Promise<ModelRoutingResponse> {
|
||||
return apiClient.get('/api/model-routing')
|
||||
}
|
||||
|
||||
// version is the last version read from the server (optimistic concurrency).
|
||||
export function saveModelRouting(config: ModelRoutingConfig): Promise<ModelRoutingResponse> {
|
||||
return apiClient.put('/api/model-routing', config)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createProvider, listProviderPresets, putCredential, updateProvider } from './providerService'
|
||||
|
||||
const provider = { provider_id: 'provider-1', provider_type: 'openai_chat', name: 'Custom', capabilities: [], enabled: true }
|
||||
const json = (body: unknown) => new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } })
|
||||
beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
describe('provider wire contracts', () => {
|
||||
it('retains preset logos, descriptions and capabilities', async () => {
|
||||
const preset = { preset_id: 'qwen', logo_id: 'qwen', description: '通义千问', capabilities: ['chat', 'embedding'] }
|
||||
vi.mocked(fetch).mockResolvedValue(json({ items: [preset] }))
|
||||
expect(await listProviderPresets()).toEqual([preset])
|
||||
})
|
||||
|
||||
it('persists protocol edits and explicit credential unlinking', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(json(provider))
|
||||
await updateProvider('provider-1', { provider_type: 'openai_responses', default_model: '', credential_id: null })
|
||||
expect(fetch).toHaveBeenCalledWith('/api/providers/provider-1', expect.objectContaining({ method: 'PATCH' }))
|
||||
expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body))).toEqual({ provider_type: 'openai_responses', default_model: '', credential_id: null })
|
||||
})
|
||||
|
||||
it('sends secrets only to credentials and a reference to provider configuration', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(json({ configured: true })).mockResolvedValueOnce(json(provider))
|
||||
await putCredential('provider-key-test', 'test-only-key')
|
||||
await createProvider({ name: 'Custom', provider_type: 'openai_compatible', default_model: '', enabled: true, credential_id: 'provider-key-test', has_credential: true, capabilities: {} })
|
||||
const calls = vi.mocked(fetch).mock.calls
|
||||
expect(calls[0][0]).toBe('/api/credentials/provider-key-test')
|
||||
expect(JSON.parse(String(calls[0][1]?.body))).toEqual({ api_key: 'test-only-key' })
|
||||
expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ credential_id: 'provider-key-test' })
|
||||
expect(calls[1][1]?.body).not.toContain('test-only-key')
|
||||
expect(calls[1][1]?.body).not.toContain('has_credential')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset } from '@/contracts'
|
||||
import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset, ProviderUpdateRequest } from '@/contracts'
|
||||
|
||||
function capabilityMap(capabilities: string[]): Partial<ModelCapability> {
|
||||
return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial<ModelCapability>
|
||||
@@ -61,8 +61,9 @@ export async function putCredential(credentialId: string, apiKey: string): Promi
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateProvider(providerId: string, data: Partial<ProviderConfig>): Promise<ProviderConfig> {
|
||||
export async function updateProvider(providerId: string, data: ProviderUpdateRequest): Promise<ProviderConfig> {
|
||||
const response = await apiClient.patch<ApiProviderConfig>(`/api/providers/${providerId}`, {
|
||||
provider_type: data.provider_type,
|
||||
name: data.name,
|
||||
base_url: data.base_url,
|
||||
default_model: data.default_model,
|
||||
|
||||