From c5cfb6b4b498d9e9ae1c4a9d1e3cbe4b39de8d4a Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sat, 7 Feb 2026 18:49:53 +0800 Subject: [PATCH 01/19] feat: add Octopus managed site integration - Add Octopus as a new managed site type - Support Octopus channel CRUD operations - Support model sync for Octopus channels - Add Octopus settings page with username/password auth - Hide group/priority/weight fields for Octopus (different concepts) - Enable auto_sync by default when importing to Octopus - Add /v1 suffix to base URLs for Octopus imports - Use official Octopus logo for icon - Display correct Octopus channel types in channel list - Add Octopus channel management documentation --- assets/OctopusLogo.png | Bin 0 -> 3961 bytes .../components/ChannelDialog.tsx | 150 ++--- .../components/OctopusTypeSelector.tsx | 69 +++ .../ChannelDialog/hooks/useChannelForm.ts | 17 +- components/KiloCodeExportDialog.tsx | 4 +- components/icons/ManagedSiteIcon.tsx | 7 +- components/icons/OctopusIcon.tsx | 26 + constants/octopus.ts | 68 +++ constants/siteType.ts | 3 +- contexts/UserPreferencesContext.tsx | 63 +++ docs/docs/.vuepress/config.js | 3 + docs/docs/octopus-channel-management.md | 105 ++++ entrypoints/options/App.tsx | 3 +- entrypoints/options/components/Sidebar.tsx | 3 +- .../components/ManagedSiteSelector.tsx | 10 +- .../components/OctopusSettings.tsx | 192 +++++++ .../components/managedSiteTab.tsx | 19 +- .../pages/ManagedSiteChannels/index.tsx | 25 +- .../AccountList/hooks/useAccountListItem.ts | 6 +- .../TempWindowFallbackReminderGate.tsx | 6 +- locales/en/messages.json | 12 + locales/en/settings.json | 30 +- locales/zh_CN/messages.json | 12 + locales/zh_CN/settings.json | 30 +- openspec/config.yaml | 1 - services/apiService/octopus/auth.ts | 167 ++++++ services/apiService/octopus/index.ts | 240 ++++++++ services/apiService/octopus/utils.ts | 40 ++ services/managedSiteService.ts | 27 +- .../modelRedirect/ModelRedirectService.ts | 21 +- services/modelSync/octopusModelSync.ts | 236 ++++++++ services/modelSync/scheduler.ts | 135 ++++- services/octopusService/octopusService.ts | 528 ++++++++++++++++++ services/userPreferences.ts | 45 +- .../managedSiteService.test.ts | 20 +- tests/utils/cookieHelper.test.ts | 26 +- tests/utils/protectionBypass.test.ts | 10 +- types/octopus.ts | 270 +++++++++ types/octopusConfig.ts | 22 + utils/managedSite.ts | 61 +- utils/selectOptions.ts | 4 +- 41 files changed, 2571 insertions(+), 145 deletions(-) create mode 100644 assets/OctopusLogo.png create mode 100644 components/ChannelDialog/components/OctopusTypeSelector.tsx create mode 100644 components/icons/OctopusIcon.tsx create mode 100644 constants/octopus.ts create mode 100644 docs/docs/octopus-channel-management.md create mode 100644 entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx create mode 100644 services/apiService/octopus/auth.ts create mode 100644 services/apiService/octopus/index.ts create mode 100644 services/apiService/octopus/utils.ts create mode 100644 services/modelSync/octopusModelSync.ts create mode 100644 services/octopusService/octopusService.ts create mode 100644 types/octopus.ts create mode 100644 types/octopusConfig.ts diff --git a/assets/OctopusLogo.png b/assets/OctopusLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..7bbb3540a19e3c6576178ae121fb6be82d5fbe83 GIT binary patch literal 3961 zcmb_f`8N~}_a0*~GiYpMCrgnfgpXy$R@p-gGsZsBkbN1_g2q;s6v>p3kbN-rHH;;p zM8has){jQE>?*w8_b>SV@crT5d(M5HbM86!hvz;w(b~#{6DkY^005k3riQ4~O8Rer zSx>VLQ}Fz0VIrVRt^g{BL>T}8OwG*jvfUlmwH#ePk$yPwyEH@L*?cHERh?A+R&9*M z+hXB~d5~NkndfaaN4f!G&;aFB$veBa4nR>xL1w+^wAjnPC7K~$GFP4rgr-Z3^&6oQ z_}o+m3ynq=hq9JFt_Eob_{G+4Z_hLa&a~ZCOP)Qs0})IG2$#_88Rr4wim+&I$Q{b# zgfaUS>K2D!8j)=<0UKE;uK1bw{{;BL=*m$dey%Qa{s4JtDl3y+QMEyj$Vu*BV?(d> z2hv?ETsx+JIln6KGo}Ztx{nbH^%xxbc5Dg?vH>T`$5~(QtT9{SwT3~4jEP>|lJE48 zP-TmTWz=*tTloYSr1SJrjbT$}#b)VsK-U-D4UKNshR92k_mrqSDw0%Y18~hF?=)a3 z3?wNjy3AeU99@4@vIl_HZ75klQd5FD2w(Lyd`(uz+P83!4n{hU4r|=>7_$#%Bn^&i z5$)PW@AqN&$q;K0STKA9;jUz(>ht4q<>mTPo)|@HH##Y$f&)9bJ1Zx#{u4zH)dg(|n}IY-HG#i0 zBfuMn%g#jRnTGqlE1`FyGJ&-rTFua~((HjN zA3Qflq1Xk{Fuz*98e{5pl8#`!oim4^%_n2wv|!5nCo*kwq7ht^>eHV0*IghShm~Pz zJ_Ry2oF6~Gwc>ULH@G7|qJ^DmG&?}YGWov}4{~MLhWvZ+Bh;9RN_5~J?ha7KJ0`A5E)Wk~^(WQTrY~N~F8fEe+vlwPL2TLTUg_(PE>RjX=>wj_0WK}D?2@R{dn?D4oMQ*`wR7mSIW@z#7y3){^W5HmOlErFA@YJVY1NB^o{fTvXgrx=?d z2Dyx>;xQ<{7gv<WyjmgF!pLgW6$f#2O{?! z(*rOq!d9xpb!V*#oWG{eIU4agl4NFjvN5%@WK~;{!sZVTUa&_XtK3))E7G>V{G5V} zu<|cg4EqOcCr2Nr1?O)fym)hPYjCa(bH2pzsa@V~avFrx&?;UR-5bnB4WdigzV~(b z*br40jsKH^y@ntO?g;F5t~|aWLbeFdbGUN0`R-@~Y?V5|iRfqkg{w%+_lFVi)cGE> z2#!GwtMRp9UQj5*bkgnzb>NI}z@SFhB6Z&9Jlm2ER@*v1`6||oqNT0hlHTuZ_qsfC z{s4<0EY(@Ye*~01^}rj>3{*mOu|nM4e)QO0zURtCGSP6>Yf;?spT*I!m%Y4)Wa@@? zZu;x-7cc0wUY3>j3HwHWil_XozW0ylPR75YCBujFKbez*)oPmZDU(ornyiu=uRU<= z-e%6KnwphoYRLS#D`3QSS@RW_{gA0Fuh92G^$v=8Z9eblCUKKyN$Pi#r&^fnZvFi@ z3IB}zblc$tM}16hv#i1Lz||LATduGTiRzhan{_e0W)I+Jd)76&E7}xjg};K^n=9_i zj6nJK{qpu}SKa$|t3to06~YxIm4?o=z@ASqCu=7E=%}*L$3AAiU@_Hq^E$eG422oL z?ZcMb3}l)v3a=maFK)~}x;(-=eVkEZi^PuX69U}bZtF@OI2}Ev^*R-19cqzoQ>65h zgZr9NU@Su8+1;EJ{Z#x97EK_AB=gnX#c54={QO9Bikhy0G%mPcb0$%(O!J&8?_vFV7l{h3KaMi{BO;6Le_KdkW zfCnx(=Ku^0tgm~=?(B=dhYsLVgqNcgsr1uJ+pYnrayN3m%RcgQ2BQWg<&SOUzI58c z&&J)I7wT#vr^NQ2c?l$z7H&NI?U>kiC@HSSG~ysB#HP^Tcq;vNm6HDx4&+vdndJ*X zuZ=E|q5Vex!&*!ys@f62cl(vQrDfeumwT<;bxhX3*Gz)rqEy~kiYQmYDRC3p3=v)( z3%{g);kp5Fx{eXdx~m%9(jqk!R(^e9<$PEu6L`P%6UFN)xP0QFDH#-F`;C!xC&H01 zfmf|%$4Fn0o)vK@o^LGI6zq%?@yw!~9Upi+&8}r+KxYC^dYfaYD8iz2TU$px@m8f9 z{E+}TFw22WA*S4h*LKQ<1mCndeA?6Vr1EKQ9fHLQkp;m{T@{$gJUJM%CD7+|0qB}d zv>KB_$f5{U3D}NvxOi44rKjgGX01-gzY^%R`%(y7-4O;#fN2FWT;-A&aa_0KwiSjD zQkTDa$A8v}bg+AGn3LYXXHUH0?H@5IV}{9$DO?F;#Q3H?ZT#r&$QxGlP+q#jN4V}I zl?SO#5^snzd6+$+@fM zZAx%I=B+oGl!%CT9`!(f)Bb67h^6JsX>FgI={taN9}%>Aa?4n)wPowk`hVQ>t(efI zh7U}7&blejrLP%~hFWTI=PURUZ$$uj0->aYnUnnoFTzjuiUVhYa{_p|epGIIbsTQ( z?RV_=YfDAl^Ke#=st+i_r?&Y?<`4w{w;GR%Sig zYuQ>!4|kQ~k9z&JeY2h`<4>t-5q=QiTiw$+FF2&n^^47nSuDl)K}6V{t^1iO7SV;< z1Z6j!iNmXT)(0;0cBVVIRIaG}7AaD{$?}F(?0HtlNWQ;t_5-{W){y*EKe!W!>PU7~ z!0x_58R%God=0;c!n)m7K$pW^hGGi|uhNV^#D`iM&cIieI&)l81m^%_`42$xu_DR3M4=b{kEBJhT2)9ScCJ!}Z>=6~^ruu{nxF1OR%x7st&Wi8k$i+LMdncmo5C zxVv3xO0Cae75%10?@UszyhCmFFrYI`aS$hExk=QFboQF;@=q_#+`P>Y08GGW*ny^C zg0;z9Z1_5+!tThNT>Hg+PpGGm94nb2Aa~Bv2|?PBZ=c)63Y;Af3-orszE`SOIprq~ zXaHgyYxS<7OYvh%Bk(3!6MfcpG%?<$OF^LG%-#r}g53#{q*-49WXK>quOiVS~_ zG1o6Ca-|bD9x~nR;_D#9wAAi?;OES@0_e=XhM2AtlP?YlYU44iikFUx%INcBl-_|oOcdCFIs<36?YE9A|*)LB1JC`SHr33Rj$#XEtJ zeZ%aeIhm*~2-10ljOWgFKI5%f(aT5Ve8j~EP>8cO+o6*DuEAA&ah3>~B6+D2K7fWX z7t2Pzfw+|OhrhC8)K7I`p81>sxi`Q);lB9>7p1a=gg0(h)#{{GCpO$%XgEV6Up?NB zFzt-ZfaxMMp)e9bzTf1L4)$=kfKafB(=L7%5&Gf(04AL - handleTypeChange(Number(value) as ChannelType) + handleTypeChange( + Number(value) as ChannelType | OctopusOutboundType, + ) } disabled={isSaving || mode === DIALOG_MODES.EDIT} required @@ -193,11 +201,17 @@ export function ChannelDialog({ /> - {ChannelTypeOptions.map((option) => ( - - {option.label} - - ))} + {isOctopus + ? OctopusOutboundTypeOptions.map((option) => ( + + {option.label} + + )) + : ChannelTypeOptions.map((option) => ( + + {option.label} + + ))}

@@ -316,25 +330,27 @@ export function ChannelDialog({

- {/* Groups */} -
- updateField("groups", groups)} - placeholder={ - isLoadingGroups - ? t("channelDialog:fields.groups.loading") - : t("channelDialog:fields.groups.placeholder") - } - disabled={isSaving || isLoadingGroups} - allowCustom - /> -

- {t("channelDialog:fields.groups.hint")} -

-
+ {/* Groups - Octopus 没有分组概念,隐藏此字段 */} + {!isOctopus && ( +
+ updateField("groups", groups)} + placeholder={ + isLoadingGroups + ? t("channelDialog:fields.groups.loading") + : t("channelDialog:fields.groups.placeholder") + } + disabled={isSaving || isLoadingGroups} + allowCustom + /> +

+ {t("channelDialog:fields.groups.hint")} +

+
+ )} {/* Advanced Settings */}
@@ -342,47 +358,51 @@ export function ChannelDialog({ {t("channelDialog:sections.advanced")}
- {/* Priority */} -
- - - updateField("priority", parseInt(e.target.value) || 0) - } - placeholder="0" - disabled={isSaving} - min="0" - /> -

- {t("channelDialog:fields.priority.hint")} -

-
+ {/* Priority - Octopus 不支持优先级 */} + {!isOctopus && ( +
+ + + updateField("priority", parseInt(e.target.value) || 0) + } + placeholder="0" + disabled={isSaving} + min="0" + /> +

+ {t("channelDialog:fields.priority.hint")} +

+
+ )} - {/* Weight */} -
- - - updateField("weight", parseInt(e.target.value) || 0) - } - placeholder="0" - disabled={isSaving} - min="0" - /> -

- {t("channelDialog:fields.weight.hint")} -

-
+ {/* Weight - Octopus 不支持权重 */} + {!isOctopus && ( +
+ + + updateField("weight", parseInt(e.target.value) || 0) + } + placeholder="0" + disabled={isSaving} + min="0" + /> +

+ {t("channelDialog:fields.weight.hint")} +

+
+ )} {/* Status */}
diff --git a/components/ChannelDialog/components/OctopusTypeSelector.tsx b/components/ChannelDialog/components/OctopusTypeSelector.tsx new file mode 100644 index 000000000..004fe2945 --- /dev/null +++ b/components/ChannelDialog/components/OctopusTypeSelector.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from "react-i18next" + +import { + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui" +import { OctopusOutboundTypeOptions } from "~/constants/octopus" +import { OctopusOutboundType } from "~/types/octopus" + +export interface OctopusTypeSelectorProps { + value: OctopusOutboundType | undefined | null + onChange: (value: OctopusOutboundType) => void + disabled?: boolean + required?: boolean +} + +/** + * Type selector specifically for Octopus channels. + * Displays only the 6 Octopus outbound types instead of 55+ New API types. + * @param props Component props + * @param props.value Current selected type value + * @param props.onChange Callback when type is changed + * @param props.disabled Whether the selector is disabled + * @param props.required Whether the field is required + */ +export function OctopusTypeSelector({ + value, + onChange, + disabled = false, + required = false, +}: OctopusTypeSelectorProps) { + const { t } = useTranslation(["channelDialog"]) + + return ( +
+ + +

+ {t("channelDialog:fields.type.hint")} +

+
+ ) +} + +export default OctopusTypeSelector diff --git a/components/ChannelDialog/hooks/useChannelForm.ts b/components/ChannelDialog/hooks/useChannelForm.ts index f60aaeeaf..acc6838d1 100644 --- a/components/ChannelDialog/hooks/useChannelForm.ts +++ b/components/ChannelDialog/hooks/useChannelForm.ts @@ -13,6 +13,7 @@ import type { ManagedSiteChannel, UpdateChannelPayload, } from "~/types/managedSite" +import type { OctopusOutboundType } from "~/types/octopus" import { createLogger } from "~/utils/logger" import { mergeUniqueOptions } from "~/utils/selectOptions" @@ -85,14 +86,12 @@ export function useChannelForm({ const [isSaving, setIsSaving] = useState(false) const [isLoadingGroups, setIsLoadingGroups] = useState(false) const [isLoadingModels, setIsLoadingModels] = useState(false) - const [availableGroups, setAvailableGroups] = - useState( - [], - ) - const [availableModels, setAvailableModels] = - useState( - [], - ) + const [availableGroups, setAvailableGroups] = useState< + CompactMultiSelectOption[] + >([]) + const [availableModels, setAvailableModels] = useState< + CompactMultiSelectOption[] + >([]) // Load groups and model suggestions on mount useEffect(() => { @@ -231,7 +230,7 @@ export function useChannelForm({ setFormData((prev) => ({ ...prev, [field]: value })) } - const handleTypeChange = (newType: ChannelType) => { + const handleTypeChange = (newType: ChannelType | OctopusOutboundType) => { setFormData((prev) => ({ ...prev, type: newType, diff --git a/components/KiloCodeExportDialog.tsx b/components/KiloCodeExportDialog.tsx index 8fd039836..b08d41db4 100644 --- a/components/KiloCodeExportDialog.tsx +++ b/components/KiloCodeExportDialog.tsx @@ -572,8 +572,8 @@ export function KiloCodeExportDialog({ const selectedTokenIds = selectedTokenIdsBySite[siteId] ?? [] const tokenOptions: CompactMultiSelectOption[] = inventory.tokens.map( (token) => ({ - value: `${token.id}`, - label: getTokenLabel(token, t("common:labels.token")), + value: `${token.id}`, + label: getTokenLabel(token, t("common:labels.token")), }), ) diff --git a/components/icons/ManagedSiteIcon.tsx b/components/icons/ManagedSiteIcon.tsx index be5843ea6..7823cb0cf 100644 --- a/components/icons/ManagedSiteIcon.tsx +++ b/components/icons/ManagedSiteIcon.tsx @@ -1,9 +1,10 @@ import { NewAPI } from "@lobehub/icons" import { ICON_SIZE_CLASSNAME, IconSize } from "~/components/icons/iconSizes" +import { OctopusIcon } from "~/components/icons/OctopusIcon" import { VeloeraIcon } from "~/components/icons/VeloeraIcon" import type { ManagedSiteType } from "~/constants/siteType" -import { VELOERA } from "~/constants/siteType" +import { OCTOPUS, VELOERA } from "~/constants/siteType" import { cn } from "~/lib/utils" interface ManagedSiteIconProps { @@ -18,6 +19,10 @@ export function ManagedSiteIcon({ siteType, size = "sm", }: ManagedSiteIconProps) { + if (siteType === OCTOPUS) { + return + } + if (siteType === VELOERA) { return } diff --git a/components/icons/OctopusIcon.tsx b/components/icons/OctopusIcon.tsx new file mode 100644 index 000000000..e350fafca --- /dev/null +++ b/components/icons/OctopusIcon.tsx @@ -0,0 +1,26 @@ +import OctopusLogo from "~/assets/OctopusLogo.png" +import { ICON_SIZE_CLASSNAME, IconSize } from "~/components/icons/iconSizes" +import { cn } from "~/lib/utils" + +interface OctopusIconProps { + size?: IconSize + className?: string +} + +/** + * Octopus icon for the Octopus managed site type. + * Uses the official Octopus logo. + */ +export function OctopusIcon({ size = "sm", className }: OctopusIconProps) { + return ( + Octopus + ) +} diff --git a/constants/octopus.ts b/constants/octopus.ts new file mode 100644 index 000000000..88bb90f55 --- /dev/null +++ b/constants/octopus.ts @@ -0,0 +1,68 @@ +import { OctopusOutboundType } from "~/types/octopus" + +/** + * Octopus 渠道类型选项 + * 用于 UI 下拉选择器 + */ +export const OctopusOutboundTypeOptions = [ + { + value: OctopusOutboundType.OpenAIChat, + label: "OpenAI Chat", + description: "OpenAI 聊天补全 API", + }, + { + value: OctopusOutboundType.OpenAIResponse, + label: "OpenAI Response", + description: "OpenAI 响应模式", + }, + { + value: OctopusOutboundType.Anthropic, + label: "Anthropic", + description: "Claude API", + }, + { + value: OctopusOutboundType.Gemini, + label: "Gemini", + description: "Google Gemini API", + }, + { + value: OctopusOutboundType.Volcengine, + label: "Volcengine", + description: "火山引擎 API", + }, + { + value: OctopusOutboundType.OpenAIEmbedding, + label: "OpenAI Embedding", + description: "OpenAI 嵌入 API", + }, +] as const + +/** + * Octopus 渠道类型名称映射 + */ +export const OctopusOutboundTypeNames: Record = { + [OctopusOutboundType.OpenAIChat]: "OpenAI Chat", + [OctopusOutboundType.OpenAIResponse]: "OpenAI Response", + [OctopusOutboundType.Anthropic]: "Anthropic", + [OctopusOutboundType.Gemini]: "Gemini", + [OctopusOutboundType.Volcengine]: "Volcengine", + [OctopusOutboundType.OpenAIEmbedding]: "OpenAI Embedding", +} + +/** + * 获取渠道类型的显示名称 + */ +export function getOctopusTypeName(type: OctopusOutboundType): string { + return OctopusOutboundTypeNames[type] || `Unknown (${type})` +} + +/** + * Octopus 默认渠道字段值 + */ +export const DEFAULT_OCTOPUS_CHANNEL_FIELDS = { + type: OctopusOutboundType.OpenAIChat, + enabled: true, + proxy: false, + auto_sync: true, // 默认启用自动同步 + auto_group: 0, +} as const diff --git a/constants/siteType.ts b/constants/siteType.ts index 976808b2a..a429dfdab 100644 --- a/constants/siteType.ts +++ b/constants/siteType.ts @@ -13,9 +13,10 @@ export const RIX_API = "Rix-Api" export const NEO_API = "neo-Api" export const WONG_GONGYI = "wong-gongyi" export const SUB2API = "sub2api" +export const OCTOPUS = "octopus" export const UNKNOWN_SITE = "unknown" -export type ManagedSiteType = typeof NEW_API | typeof VELOERA +export type ManagedSiteType = typeof NEW_API | typeof VELOERA | typeof OCTOPUS export type SiteType = (typeof SITE_TITLE_RULES)[number]["name"] diff --git a/contexts/UserPreferencesContext.tsx b/contexts/UserPreferencesContext.tsx index aed4ba2bf..6b516c918 100644 --- a/contexts/UserPreferencesContext.tsx +++ b/contexts/UserPreferencesContext.tsx @@ -70,6 +70,9 @@ interface UserPreferencesContextType { veloeraBaseUrl: string veloeraAdminToken: string veloeraUserId: string + octopusBaseUrl: string + octopusUsername: string + octopusPassword: string managedSiteType: ManagedSiteType cliProxyBaseUrl: string cliProxyManagementKey: string @@ -106,6 +109,9 @@ interface UserPreferencesContextType { updateVeloeraBaseUrl: (url: string) => Promise updateVeloeraAdminToken: (token: string) => Promise updateVeloeraUserId: (userId: string) => Promise + updateOctopusBaseUrl: (url: string) => Promise + updateOctopusUsername: (username: string) => Promise + updateOctopusPassword: (password: string) => Promise updateManagedSiteType: (siteType: ManagedSiteType) => Promise updateCliProxyBaseUrl: (url: string) => Promise updateCliProxyManagementKey: (key: string) => Promise @@ -140,6 +146,7 @@ interface UserPreferencesContextType { resetAutoRefreshConfig: () => Promise resetNewApiConfig: () => Promise resetVeloeraConfig: () => Promise + resetOctopusConfig: () => Promise resetNewApiModelSyncConfig: () => Promise resetCliProxyConfig: () => Promise resetClaudeCodeRouterConfig: () => Promise @@ -560,6 +567,39 @@ export const UserPreferencesProvider = ({ return success }, []) + const updateOctopusBaseUrl = useCallback(async (baseUrl: string) => { + const updates = { + octopus: { baseUrl }, + } + const success = await userPreferences.savePreferences(updates) + if (success) { + setPreferences((prev) => (prev ? deepOverride(prev, updates) : null)) + } + return success + }, []) + + const updateOctopusUsername = useCallback(async (username: string) => { + const updates = { + octopus: { username }, + } + const success = await userPreferences.savePreferences(updates) + if (success) { + setPreferences((prev) => (prev ? deepOverride(prev, updates) : null)) + } + return success + }, []) + + const updateOctopusPassword = useCallback(async (password: string) => { + const updates = { + octopus: { password }, + } + const success = await userPreferences.savePreferences(updates) + if (success) { + setPreferences((prev) => (prev ? deepOverride(prev, updates) : null)) + } + return success + }, []) + const updateManagedSiteType = useCallback( async (siteType: ManagedSiteType) => { const success = await userPreferences.updateManagedSiteType(siteType) @@ -912,6 +952,22 @@ export const UserPreferencesProvider = ({ return success }, []) + const resetOctopusConfig = useCallback(async () => { + const success = await userPreferences.resetOctopusConfig() + if (success) { + const defaults = DEFAULT_PREFERENCES.octopus + setPreferences((prev) => + prev + ? deepOverride(prev, { + octopus: defaults, + lastUpdated: Date.now(), + }) + : prev, + ) + } + return success + }, []) + const resetNewApiModelSyncConfig = useCallback(async () => { const success = await userPreferences.resetNewApiModelSyncConfig() if (success) { @@ -1152,6 +1208,9 @@ export const UserPreferencesProvider = ({ veloeraBaseUrl: preferences?.veloera?.baseUrl || "", veloeraAdminToken: preferences?.veloera?.adminToken || "", veloeraUserId: preferences?.veloera?.userId || "", + octopusBaseUrl: preferences?.octopus?.baseUrl || "", + octopusUsername: preferences?.octopus?.username || "", + octopusPassword: preferences?.octopus?.password || "", managedSiteType: preferences?.managedSiteType || NEW_API, cliProxyBaseUrl: preferences?.cliProxy?.baseUrl || "", cliProxyManagementKey: preferences?.cliProxy?.managementKey || "", @@ -1187,6 +1246,9 @@ export const UserPreferencesProvider = ({ updateVeloeraBaseUrl, updateVeloeraAdminToken, updateVeloeraUserId, + updateOctopusBaseUrl, + updateOctopusUsername, + updateOctopusPassword, updateManagedSiteType, updateCliProxyBaseUrl, updateCliProxyManagementKey, @@ -1207,6 +1269,7 @@ export const UserPreferencesProvider = ({ resetAutoRefreshConfig, resetNewApiConfig, resetVeloeraConfig, + resetOctopusConfig, resetNewApiModelSyncConfig, resetCliProxyConfig, resetClaudeCodeRouterConfig, diff --git a/docs/docs/.vuepress/config.js b/docs/docs/.vuepress/config.js index 4d8b06b0c..0e9351658 100644 --- a/docs/docs/.vuepress/config.js +++ b/docs/docs/.vuepress/config.js @@ -55,6 +55,7 @@ export default defineUserConfig({ { text: '数据导入导出', link: '/data-management' }, { text: 'New API 模型同步', link: '/new-api-model-sync' }, { text: 'New API 渠道管理', link: '/new-api-channel-management' }, + { text: 'Octopus 渠道管理', link: '/octopus-channel-management' }, { text: 'CLIProxyAPI 集成', link: '/cliproxyapi-integration' }, { text: '模型重定向', link: '/model-redirect' }, { text: '排序优先级设置', link: '/sorting-priority' }, @@ -84,6 +85,7 @@ export default defineUserConfig({ { text: 'Data Management', link: '/en/data-management' }, { text: 'New API Model Sync', link: '/en/new-api-model-sync' }, { text: 'New API Channel Mgmt', link: '/en/new-api-channel-management' }, + { text: 'Octopus Channel Mgmt', link: '/en/octopus-channel-management' }, { text: 'CLIProxyAPI Integration', link: '/en/cliproxyapi-integration' }, { text: 'Model Redirect', link: '/en/model-redirect' }, { text: 'Sorting Priority', link: '/en/sorting-priority' }, @@ -113,6 +115,7 @@ export default defineUserConfig({ { text: 'データ管理', link: '/ja/data-management' }, { text: 'New API モデル同期', link: '/ja/new-api-model-sync' }, { text: 'New API チャネル管理', link: '/ja/new-api-channel-management' }, + { text: 'Octopus チャネル管理', link: '/ja/octopus-channel-management' }, { text: 'CLIProxyAPI 連携', link: '/ja/cliproxyapi-integration' }, { text: 'モデルリダイレクト', link: '/ja/model-redirect' }, { text: '並び順優先度設定', link: '/ja/sorting-priority' }, diff --git a/docs/docs/octopus-channel-management.md b/docs/docs/octopus-channel-management.md new file mode 100644 index 000000000..9a759fdce --- /dev/null +++ b/docs/docs/octopus-channel-management.md @@ -0,0 +1,105 @@ +# Octopus 渠道管理 + +> 🧪 该功能目前处于 Beta 阶段,支持在插件内直接管理 Octopus 站点的渠道。 + +## 功能概述 + +- 📋 **渠道总览与过滤**:一眼查看所有渠道的名称、类型、模型数量与状态,支持关键字搜索与状态过滤。 +- ✏️ **快速创建 / 编辑**:弹出式表单适配 Octopus 字段定义,可配置模型列表与状态。 +- 🔄 **模型同步**:支持从上游自动拉取最新模型列表。 +- 🗑️ **安全删除**:批量勾选后触发删除前会再次确认,避免误删生产渠道。 +- 📦 **密钥导入**:从账号管理中直接导入 API Key 到 Octopus 渠道。 + +## 与 New API 的差异 + +Octopus 与 New API 在架构上有一些关键差异: + +| 对比项 | New API | Octopus | +|--------|---------|---------| +| 认证方式 | Admin Token + User ID | 用户名 + 密码(JWT) | +| 渠道类型 | 55+ 种类型 | 6 种类型 | +| 分组概念 | 用户分组 | 外部模型 ID(不同概念) | +| 优先级/权重 | 支持 | 不支持 | + +因此,在 Octopus 模式下: +- **隐藏分组字段**:Octopus 的"分组"是外部模型 ID,与 New API 完全不同 +- **隐藏优先级/权重**:Octopus 不支持这些概念 +- **渠道类型选项**:仅显示 6 种 Octopus 支持的类型 + +## 前置要求 + +| 配置项 | 说明 | +|--------|------| +| **Octopus 基础 URL** | Octopus 站点地址,例如 `https://octopus.example.com` | +| **用户名** | Octopus 登录用户名 | +| **密码** | Octopus 登录密码 | + +> 在插件中打开 **设置 → 基础设置 → Octopus 站点配置**,填写以上信息并保存。系统会自动处理登录和 JWT Token 管理。 + +## 如何进入功能页面 + +1. 打开扩展弹窗,点击左侧的 **"设置"**。 +2. 在 **基础设置 → 自建站点管理** 中选择 **"Octopus"**。 +3. 填写 Octopus 站点配置后,点击 **"验证配置"** 确保连接正常。 +4. 在设置页面选择 **"自建站点渠道管理"**,将自动加载远端渠道列表。 + +## 渠道列表视图 + +- **搜索框**:支持以名称 / Base URL 关键字模糊搜索。 +- **状态筛选**:可快速查看启用或禁用的渠道。 +- **自定义列**:通过列选择器控制显示的列(分组列在 Octopus 模式下默认隐藏)。 +- **批量操作栏**:在行前打勾即可开启批量删除或批量同步。 + +## 创建或编辑渠道 + +1. 点击右上角 **"新增渠道"** 或在行尾菜单选择 **"编辑"**。 +2. 在弹窗中填写: + - **基础信息**:名称、类型、API Key、Base URL。 + - **模型列表**:支持全选、反选、清空,并可手动输入自定义模型。 + - **状态**:启用/停用。 +3. 点击 **"保存"** 后,系统会调用 Octopus 的渠道接口,成功后自动刷新列表。 + +### Octopus 渠道类型 + +| 类型值 | 名称 | 说明 | +|--------|------|------| +| 0 | OpenAI Chat | OpenAI 聊天补全 API | +| 1 | OpenAI Response | OpenAI 响应模式 | +| 2 | Anthropic | Claude API | +| 3 | Gemini | Google Gemini API | +| 4 | Volcengine | 火山引擎 API | +| 5 | OpenAI Embedding | OpenAI 嵌入 API | + +## 从密钥管理导入 + +1. 在 **密钥管理** 页面选择一个账号的 API Key。 +2. 点击 **"导入到 Octopus"** 按钮。 +3. 系统会自动: + - 获取上游可用模型列表 + - 构建渠道名称(格式:`站点名 | 密钥名 (auto)`) + - 添加 `/v1` 后缀到 Base URL(Octopus 规则) + - 默认启用自动同步功能 +4. 导入成功后可在渠道管理页面查看。 + +## 模型同步 + +Octopus 模型同步会调用 Octopus 的 `/api/v1/channel/fetch-model` 接口,从上游自动拉取最新模型列表。 + +1. 在渠道列表中点击行尾的 **"同步"** 按钮触发单渠道同步。 +2. 或勾选多个渠道后点击 **"批量同步"** 进行批量操作。 +3. 也可以在 **模型同步** 页面进行全局同步操作。 + +## 常见问题 + +| 问题 | 解决方案 | +|------|----------| +| 渠道列表为空 | 检查 Octopus 配置是否填写完整,点击"验证配置"确认连接正常。 | +| 登录失败 | 确保用户名和密码正确,Octopus 站点可访问。 | +| 导入时报错 | 检查源账号是否有有效的 API Key,上游站点是否可访问。 | +| 类型显示 Unknown | 可能是 Octopus 返回了未知的类型值,请检查渠道配置。 | + +## 关联文档 + +- [New API 渠道管理](./new-api-channel-management.md):New API 站点的渠道管理。 +- [New API 模型列表同步](./new-api-model-sync.md):自动批量同步渠道模型。 +- [快速导出与集成](./get-started.md#quick-export-sites):了解如何把渠道推送到下游应用。 diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index 36d98ad96..0f26927a0 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -1,11 +1,10 @@ import { useState } from "react" import { AppLayout } from "~/components/AppLayout" +import { MENU_ITEM_IDS } from "~/constants/optionsMenuIds" import Header from "./components/Header" import Sidebar from "./components/Sidebar" -import { MENU_ITEM_IDS } from "~/constants/optionsMenuIds" - import { menuItems } from "./constants" import { useHashNavigation } from "./hooks/useHashNavigation" import BasicSettings from "./pages/BasicSettings" diff --git a/entrypoints/options/components/Sidebar.tsx b/entrypoints/options/components/Sidebar.tsx index 14c9482e0..ca6aa9c2d 100644 --- a/entrypoints/options/components/Sidebar.tsx +++ b/entrypoints/options/components/Sidebar.tsx @@ -7,12 +7,11 @@ import { useEffect } from "react" import { useTranslation } from "react-i18next" import { Button, Heading3, IconButton, Separator } from "~/components/ui" +import { MENU_ITEM_IDS } from "~/constants/optionsMenuIds" import { useUserPreferencesContext } from "~/contexts/UserPreferencesContext" import { cn } from "~/lib/utils" import { hasValidManagedSiteConfig } from "~/services/managedSiteService" -import { MENU_ITEM_IDS } from "~/constants/optionsMenuIds" - import { menuItems } from "../constants" interface SidebarProps { diff --git a/entrypoints/options/pages/BasicSettings/components/ManagedSiteSelector.tsx b/entrypoints/options/pages/BasicSettings/components/ManagedSiteSelector.tsx index d7ae7aa01..3d59b0319 100644 --- a/entrypoints/options/pages/BasicSettings/components/ManagedSiteSelector.tsx +++ b/entrypoints/options/pages/BasicSettings/components/ManagedSiteSelector.tsx @@ -11,7 +11,12 @@ import { SelectTrigger, SelectValue, } from "~/components/ui" -import { NEW_API, VELOERA, type ManagedSiteType } from "~/constants/siteType" +import { + NEW_API, + OCTOPUS, + VELOERA, + type ManagedSiteType, +} from "~/constants/siteType" import { useUserPreferencesContext } from "~/contexts/UserPreferencesContext" import { showUpdateToast } from "~/utils/toastHelpers" @@ -60,6 +65,9 @@ export default function ManagedSiteSelector() { {t("managedSite.veloera")} + + {t("managedSite.octopus")} + } diff --git a/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx b/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx new file mode 100644 index 000000000..887dd81aa --- /dev/null +++ b/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx @@ -0,0 +1,192 @@ +import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline" +import { useEffect, useState } from "react" +import toast from "react-hot-toast" +import { useTranslation } from "react-i18next" + +import { SettingSection } from "~/components/SettingSection" +import { + Button, + Card, + CardItem, + CardList, + IconButton, + Input, +} from "~/components/ui" +import { useUserPreferencesContext } from "~/contexts/UserPreferencesContext" +import { octopusAuthManager } from "~/services/apiService/octopus/auth" +import { showUpdateToast } from "~/utils/toastHelpers" + +/** + * Settings panel for configuring Octopus connection credentials (base URL, username, password). + * @returns Section containing inputs and reset handling for the Octopus config. + */ +export default function OctopusSettings() { + const { t } = useTranslation("settings") + const { + octopusBaseUrl, + octopusUsername, + octopusPassword, + updateOctopusBaseUrl, + updateOctopusUsername, + updateOctopusPassword, + resetOctopusConfig, + } = useUserPreferencesContext() + + const [localBaseUrl, setLocalBaseUrl] = useState(octopusBaseUrl) + const [localUsername, setLocalUsername] = useState(octopusUsername) + const [localPassword, setLocalPassword] = useState(octopusPassword) + const [showPassword, setShowPassword] = useState(false) + const [isValidating, setIsValidating] = useState(false) + + useEffect(() => { + setLocalBaseUrl(octopusBaseUrl) + }, [octopusBaseUrl]) + + useEffect(() => { + setLocalUsername(octopusUsername) + }, [octopusUsername]) + + useEffect(() => { + setLocalPassword(octopusPassword) + }, [octopusPassword]) + + const handleBaseUrlChange = async (url: string) => { + if (url === octopusBaseUrl) return + const success = await updateOctopusBaseUrl(url) + showUpdateToast(success, t("octopus.fields.baseUrlLabel")) + } + + const handleUsernameChange = async (username: string) => { + if (username === octopusUsername) return + const success = await updateOctopusUsername(username) + showUpdateToast(success, t("octopus.fields.usernameLabel")) + } + + const handlePasswordChange = async (password: string) => { + if (password === octopusPassword) return + const success = await updateOctopusPassword(password) + showUpdateToast(success, t("octopus.fields.passwordLabel")) + } + + const handleValidateConfig = async () => { + const trimmedUrl = localBaseUrl.trim() + const trimmedUsername = localUsername.trim() + const trimmedPassword = localPassword.trim() + + if (!trimmedUrl || !trimmedUsername || !trimmedPassword) { + toast.error(t("octopus.validation.missingFields")) + return + } + + setIsValidating(true) + try { + const isValid = await octopusAuthManager.validateConfig({ + baseUrl: trimmedUrl, + username: trimmedUsername, + password: trimmedPassword, + }) + + if (isValid) { + toast.success(t("octopus.validation.success")) + } else { + toast.error(t("octopus.validation.failed")) + } + } catch { + toast.error(t("octopus.validation.error")) + } finally { + setIsValidating(false) + } + } + + return ( + + + + setLocalBaseUrl(e.target.value)} + onBlur={(e) => handleBaseUrlChange(e.target.value)} + placeholder={t("octopus.fields.baseUrlPlaceholder")} + /> + } + /> + + setLocalUsername(e.target.value)} + onBlur={(e) => handleUsernameChange(e.target.value)} + placeholder={t("octopus.fields.usernamePlaceholder")} + /> + } + /> + + + setLocalPassword(e.target.value)} + onBlur={(e) => handlePasswordChange(e.target.value)} + placeholder={t("octopus.fields.passwordPlaceholder")} + rightIcon={ + setShowPassword(!showPassword)} + aria-label={ + showPassword + ? t("octopus.fields.hidePassword") + : t("octopus.fields.showPassword") + } + > + {showPassword ? ( + + ) : ( + + )} + + } + /> +
+ } + /> + + + {isValidating + ? t("octopus.validation.validating") + : t("octopus.validation.validate")} + + } + /> + + + + ) +} diff --git a/entrypoints/options/pages/BasicSettings/components/managedSiteTab.tsx b/entrypoints/options/pages/BasicSettings/components/managedSiteTab.tsx index ac44a6ce1..1a768eab8 100644 --- a/entrypoints/options/pages/BasicSettings/components/managedSiteTab.tsx +++ b/entrypoints/options/pages/BasicSettings/components/managedSiteTab.tsx @@ -1,24 +1,37 @@ -import { NEW_API } from "~/constants/siteType" +import { NEW_API, OCTOPUS, VELOERA } from "~/constants/siteType" import { useUserPreferencesContext } from "~/contexts/UserPreferencesContext" import ManagedSiteModelSyncSettings from "./managedSiteModelSyncSettings" import ManagedSiteSelector from "./ManagedSiteSelector" import ModelRedirectSettings from "./ModelRedirectSettings" import NewApiSettings from "./NewApiSettings" +import OctopusSettings from "./OctopusSettings" import VeloeraSettings from "./VeloeraSettings" /** - * Basic Settings tab aggregating managed site selector, New API/Veloera settings, + * Basic Settings tab aggregating managed site selector, New API/Veloera/Octopus settings, * model sync, and model redirect. */ export default function ManagedSiteTab() { const { managedSiteType } = useUserPreferencesContext() + const renderSiteSettings = () => { + switch (managedSiteType) { + case OCTOPUS: + return + case VELOERA: + return + case NEW_API: + default: + return + } + } + return (
- {managedSiteType === NEW_API ? : } + {renderSiteSettings()} diff --git a/entrypoints/options/pages/ManagedSiteChannels/index.tsx b/entrypoints/options/pages/ManagedSiteChannels/index.tsx index 14ba47398..a990a3aff 100644 --- a/entrypoints/options/pages/ManagedSiteChannels/index.tsx +++ b/entrypoints/options/pages/ManagedSiteChannels/index.tsx @@ -70,7 +70,10 @@ import { TableRow, } from "~/components/ui/table" import { ChannelTypeNames } from "~/constants/managedSite" +import { OctopusOutboundTypeNames } from "~/constants/octopus" import { RuntimeActionIds } from "~/constants/runtimeActions" +import { OCTOPUS } from "~/constants/siteType" +import { useUserPreferencesContext } from "~/contexts/UserPreferencesContext" import { PageHeader } from "~/entrypoints/options/components/PageHeader" import { cn } from "~/lib/utils" import { getManagedSiteService } from "~/services/managedSiteService" @@ -105,6 +108,9 @@ export default function ManagedSiteChannels({ routeParams, }: ManagedSiteChannelsProps) { const { t } = useTranslation(["managedSiteChannels", "messages"]) + const { managedSiteType } = useUserPreferencesContext() + const isOctopus = managedSiteType === OCTOPUS + const [channels, setChannels] = useState([]) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) @@ -115,7 +121,7 @@ export default function ManagedSiteChannels({ const [columnFilters, setColumnFilters] = useState([]) const [columnVisibility, setColumnVisibility] = useState({ base_url: false, - group: true, + group: !isOctopus, // Octopus 没有分组概念,隐藏分组列 }) const [pagination, setPagination] = useState({ pageIndex: 0, @@ -171,6 +177,14 @@ export default function ManagedSiteChannels({ void refreshChannels() }, [refreshChannels]) + // 当站点类型变化时,更新分组列的可见性 + useEffect(() => { + setColumnVisibility((prev) => ({ + ...prev, + group: !isOctopus, + })) + }, [isOctopus]) + useEffect(() => { if (refreshKey) { void refreshChannels() @@ -403,8 +417,12 @@ export default function ManagedSiteChannels({ { accessorKey: "type", header: t("table.columns.type"), - cell: ({ row }: { row: Row }) => - ChannelTypeNames[row.original.type] ?? "Unknown", + cell: ({ row }: { row: Row }) => { + const typeNames = isOctopus + ? OctopusOutboundTypeNames + : ChannelTypeNames + return typeNames[row.original.type] ?? "Unknown" + }, size: 90, }, { @@ -491,6 +509,7 @@ export default function ManagedSiteChannels({ handleOpenEditDialog, handleOpenFilterDialog, handleSyncChannels, + isOctopus, rowActionLabels, scheduleDelete, syncingIds, diff --git a/features/AccountManagement/components/AccountList/hooks/useAccountListItem.ts b/features/AccountManagement/components/AccountList/hooks/useAccountListItem.ts index e1c0d3d7e..f3e8ca782 100644 --- a/features/AccountManagement/components/AccountList/hooks/useAccountListItem.ts +++ b/features/AccountManagement/components/AccountList/hooks/useAccountListItem.ts @@ -3,9 +3,9 @@ import { useCallback, useEffect, useRef, useState } from "react" /** * 管理 AccountList 中每个列表项的交互逻辑,如 hover 效果和菜单操作。 * @returns - * - * - * + * + * + * * hover 状态及对应事件处理函数 */ export const useAccountListItem = () => { diff --git a/features/AccountManagement/components/TempWindowFallbackReminderGate.tsx b/features/AccountManagement/components/TempWindowFallbackReminderGate.tsx index ae7d64cc8..0607fb1be 100644 --- a/features/AccountManagement/components/TempWindowFallbackReminderGate.tsx +++ b/features/AccountManagement/components/TempWindowFallbackReminderGate.tsx @@ -14,10 +14,8 @@ import { */ export function TempWindowFallbackReminderGate() { const { displayData } = useAccountDataContext() - const { - tempWindowFallbackReminder, - updateTempWindowFallbackReminder, - } = useUserPreferencesContext() + const { tempWindowFallbackReminder, updateTempWindowFallbackReminder } = + useUserPreferencesContext() const [isOpen, setIsOpen] = useState(false) const [hasShownInThisSession, setHasShownInThisSession] = useState(false) diff --git a/locales/en/messages.json b/locales/en/messages.json index 1887186e6..059ea2ac6 100644 --- a/locales/en/messages.json +++ b/locales/en/messages.json @@ -94,6 +94,7 @@ "checkingApiKeys": "Checking API key...", "importingToNewApi": "Importing to New API...", "importingToVeloera": "Importing to Veloera...", + "importingToOctopus": "Importing to Octopus...", "createTokenFailed": "Failed to create API token", "tokenNotFound": "Failed to create or find an API token", "retrying": "An error occurred, attempting retry #{{attempt}}" @@ -132,6 +133,17 @@ "importSuccess": "Successfully imported new channel {{channelName}}", "importFailed": "Import failed, an unknown error occurred" }, + "octopus": { + "configMissing": "Please configure Octopus address, username, and password in basic settings first", + "dataFetchFailed": "Failed to fetch Octopus data, please check if the configuration is correct", + "noAnyModels": "No models available", + "noChannelsToSync": "No channels to sync", + "channelExists": "Channel {{channelName}} already exists, no need to import again", + "importSuccess": "Successfully imported new channel {{channelName}}", + "importFailed": "Import failed, an unknown error occurred", + "loginFailed": "Failed to log in to Octopus, please check username and password", + "tokenExpired": "Octopus login expired, re-authenticating..." + }, "cliproxy": { "configMissing": "Please configure CLIProxyAPI Management API base URL and key in settings first", "updateSuccess": "Successfully updated CLIProxy provider {{name}}", diff --git a/locales/en/settings.json b/locales/en/settings.json index 99c32cf1f..a9fd3545c 100644 --- a/locales/en/settings.json +++ b/locales/en/settings.json @@ -165,7 +165,35 @@ "siteTypeLabel": "Managed Site Type", "siteTypeDesc": "Choose the platform used for channel management and model sync", "newApi": "New API", - "veloera": "Veloera" + "veloera": "Veloera", + "octopus": "Octopus" + }, + "octopus": { + "title": "Octopus Integration Settings", + "description": "Configure integration settings for connecting to Octopus self-hosted sites. The system will automatically log in using your credentials and manage the JWT Token.", + "fields": { + "baseUrlLabel": "Base URL", + "baseUrlDesc": "Set the base URL for Octopus site", + "baseUrlPlaceholder": "https://octopus.example.com", + "usernameLabel": "Username", + "usernameDesc": "Octopus admin username for automatic login", + "usernamePlaceholder": "admin", + "passwordLabel": "Password", + "passwordDesc": "Octopus admin password for automatic login", + "passwordPlaceholder": "Enter password", + "showPassword": "Show password", + "hidePassword": "Hide password" + }, + "validation": { + "title": "Validate Configuration", + "description": "Test if username and password are correct", + "validate": "Validate", + "validating": "Validating...", + "success": "Configuration validated successfully, connection is working", + "failed": "Configuration validation failed, please check username and password", + "error": "An error occurred during validation", + "missingFields": "Please fill in all required fields" + } }, "cliProxy": { "title": "CLIProxyAPI Settings", diff --git a/locales/zh_CN/messages.json b/locales/zh_CN/messages.json index e883ba5cc..8012b727f 100644 --- a/locales/zh_CN/messages.json +++ b/locales/zh_CN/messages.json @@ -94,6 +94,7 @@ "checkingApiKeys": "正在检查 API 密钥...", "importingToNewApi": "正在导入到 New API...", "importingToVeloera": "正在导入到 Veloera...", + "importingToOctopus": "正在导入到 Octopus...", "createTokenFailed": "创建 API 令牌失败", "tokenNotFound": "无法创建或找到 API 令牌", "retrying": "出现错误,进行第{{attempt}}次重试" @@ -132,6 +133,17 @@ "importSuccess": "成功导入新渠道 {{channelName}}", "importFailed": "导入失败,发生未知错误" }, + "octopus": { + "configMissing": "请先在基础设置中配置 Octopus 地址、用户名和密码", + "dataFetchFailed": "获取 Octopus 数据失败,请检查配置是否正确", + "noAnyModels": "暂无可用模型", + "noChannelsToSync": "没有可同步的渠道", + "channelExists": "渠道 {{channelName}} 已存在,无需再次导入", + "importSuccess": "成功导入新渠道 {{channelName}}", + "importFailed": "导入失败,发生未知错误", + "loginFailed": "登录 Octopus 失败,请检查用户名和密码", + "tokenExpired": "Octopus 登录已过期,正在重新登录..." + }, "cliproxy": { "configMissing": "请先在设置中配置 CLIProxyAPI 管理接口地址和密钥", "updateSuccess": "已成功更新 CLIProxy 提供商 {{name}}", diff --git a/locales/zh_CN/settings.json b/locales/zh_CN/settings.json index 2f42f6498..de46cb52b 100644 --- a/locales/zh_CN/settings.json +++ b/locales/zh_CN/settings.json @@ -165,7 +165,35 @@ "siteTypeLabel": "站点类型", "siteTypeDesc": "选择用于渠道管理和模型同步的平台", "newApi": "New API", - "veloera": "Veloera" + "veloera": "Veloera", + "octopus": "Octopus" + }, + "octopus": { + "title": "Octopus 集成设置", + "description": "配置用于连接 Octopus 自建站点的集成设置。系统将自动使用您的凭据登录并管理 JWT Token。", + "fields": { + "baseUrlLabel": "基础 URL", + "baseUrlDesc": "设置 Octopus 站点的基础 URL", + "baseUrlPlaceholder": "https://octopus.example.com", + "usernameLabel": "用户名", + "usernameDesc": "Octopus 管理员用户名,用于自动登录", + "usernamePlaceholder": "admin", + "passwordLabel": "密码", + "passwordDesc": "Octopus 管理员密码,用于自动登录", + "passwordPlaceholder": "输入密码", + "showPassword": "显示密码", + "hidePassword": "隐藏密码" + }, + "validation": { + "title": "验证配置", + "description": "测试用户名和密码是否正确", + "validate": "验证配置", + "validating": "验证中...", + "success": "配置验证成功,连接正常", + "failed": "配置验证失败,请检查用户名和密码", + "error": "验证过程中发生错误", + "missingFields": "请填写所有必填字段" + } }, "cliProxy": { "title": "CLIProxyAPI 设置", diff --git a/openspec/config.yaml b/openspec/config.yaml index d92b902fe..873ca02e6 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -47,7 +47,6 @@ context: | - Security/privacy: treat tokens/API keys and backups as sensitive; avoid logging secrets; never commit credentials. - Localization: UI copy must be translatable; update locale keys alongside UI changes. - # Per-artifact rules (optional) # Add custom rules for specific artifacts. # Example: diff --git a/services/apiService/octopus/auth.ts b/services/apiService/octopus/auth.ts new file mode 100644 index 000000000..a5f9f0b76 --- /dev/null +++ b/services/apiService/octopus/auth.ts @@ -0,0 +1,167 @@ +/** + * Octopus 认证服务 + * 处理 JWT Token 的获取、缓存和刷新 + */ +import type { OctopusLoginResponse } from "~/types/octopus" +import type { OctopusConfig } from "~/types/octopusConfig" +import { createLogger } from "~/utils/logger" + +const logger = createLogger("OctopusAuth") + +/** + * Octopus 登录请求 + */ +export interface OctopusLoginRequest { + username: string + password: string + expire?: number +} + +/** + * Token 缓存条目 + */ +interface TokenCacheEntry { + token: string + expireAt: number +} + +/** + * Octopus 认证管理器 + * 负责自动登录和 Token 生命周期管理 + */ +class OctopusAuthManager { + private tokenCache: Map = new Map() + + /** + * 生成缓存键 + */ + private getCacheKey(baseUrl: string, username: string): string { + return `${baseUrl}:${username}` + } + + /** + * 登录到 Octopus 获取 JWT Token + */ + async login( + baseUrl: string, + credentials: OctopusLoginRequest, + ): Promise { + const url = `${baseUrl.replace(/\/$/, "")}/api/v1/user/login` + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(credentials), + }) + + const data = await response.json() + + if (data.code !== 200 || !data.data?.token) { + throw new Error(data.message || "Login failed") + } + + return data.data as OctopusLoginResponse + } + + /** + * 检查 Octopus 认证状态 + */ + async checkStatus(baseUrl: string, jwtToken: string): Promise { + try { + const url = `${baseUrl.replace(/\/$/, "")}/api/v1/user/status` + const response = await fetch(url, { + headers: { Authorization: `Bearer ${jwtToken}` }, + }) + return response.ok + } catch { + return false + } + } + + /** + * 获取有效的 JWT Token + * - 如果缓存中有有效 Token,直接返回 + * - 如果 Token 过期或不存在,自动重新登录获取 + */ + async getValidToken(config: OctopusConfig): Promise { + if (!config.baseUrl || !config.username || !config.password) { + throw new Error("Octopus config is incomplete") + } + + const cacheKey = this.getCacheKey(config.baseUrl, config.username) + const cached = this.tokenCache.get(cacheKey) + + // 检查缓存是否有效(提前 5 分钟刷新) + const bufferTime = 5 * 60 * 1000 + if (cached && cached.expireAt > Date.now() + bufferTime) { + return cached.token + } + + // 检查存储中的缓存 Token + if ( + config.cachedToken && + config.tokenExpireAt && + config.tokenExpireAt > Date.now() + bufferTime + ) { + // 验证 Token 是否仍然有效 + const isValid = await this.checkStatus(config.baseUrl, config.cachedToken) + if (isValid) { + // 更新内存缓存 + this.tokenCache.set(cacheKey, { + token: config.cachedToken, + expireAt: config.tokenExpireAt, + }) + return config.cachedToken + } + } + + // 自动重新登录 + logger.info("Auto-login to Octopus", { baseUrl: config.baseUrl }) + const response = await this.login(config.baseUrl, { + username: config.username, + password: config.password, + }) + + // 解析过期时间 + const expireAt = new Date(response.expire_at).getTime() + + // 更新内存缓存 + this.tokenCache.set(cacheKey, { + token: response.token, + expireAt, + }) + + // 返回新 Token(调用方需要更新存储中的缓存) + return response.token + } + + /** + * 验证配置是否有效(尝试登录) + */ + async validateConfig(config: OctopusConfig): Promise { + try { + await this.getValidToken(config) + return true + } catch (error) { + logger.error("Config validation failed", error) + return false + } + } + + /** + * 清除指定配置的缓存 + */ + clearCache(baseUrl: string, username: string): void { + const cacheKey = this.getCacheKey(baseUrl, username) + this.tokenCache.delete(cacheKey) + } + + /** + * 清除所有缓存 + */ + clearAllCache(): void { + this.tokenCache.clear() + } +} + +export const octopusAuthManager = new OctopusAuthManager() diff --git a/services/apiService/octopus/index.ts b/services/apiService/octopus/index.ts new file mode 100644 index 000000000..c22c76f4e --- /dev/null +++ b/services/apiService/octopus/index.ts @@ -0,0 +1,240 @@ +/** + * Octopus API 服务 + * 提供与 Octopus 后端的所有 API 交互 + */ +import type { + OctopusApiResponse, + OctopusChannel, + OctopusCreateChannelRequest, + OctopusFetchModelRequest, + OctopusUpdateChannelRequest, +} from "~/types/octopus" +import type { OctopusConfig } from "~/types/octopusConfig" +import { createLogger } from "~/utils/logger" + +import { octopusAuthManager } from "./auth" +import { buildOctopusAuthHeaders, normalizeBaseUrl } from "./utils" + +const logger = createLogger("OctopusAPI") + +/** + * 执行 Octopus API 请求 + */ +async function fetchOctopusApi( + config: OctopusConfig, + endpoint: string, + options: RequestInit = {}, +): Promise> { + const token = await octopusAuthManager.getValidToken(config) + const baseUrl = normalizeBaseUrl(config.baseUrl) + const url = `${baseUrl}${endpoint}` + + const response = await fetch(url, { + ...options, + headers: { + ...buildOctopusAuthHeaders(token), + ...(options.headers || {}), + }, + }) + + const data = await response.json() + + // Octopus 返回格式: { success: boolean, data?: T, message?: string } + // 或者 { code: number, message: string, data?: T } + if (data.success === false || (data.code && data.code !== 200)) { + throw new Error(data.message || "API request failed") + } + + return { + success: true, + data: data.data ?? data, + message: data.message || "success", + } +} + +/** + * 获取渠道列表 + */ +export async function listChannels( + config: OctopusConfig, +): Promise { + try { + const result = await fetchOctopusApi( + config, + "/api/v1/channel/list", + ) + return result.data || [] + } catch (error) { + logger.error("Failed to list channels", error) + throw error + } +} + +/** + * 搜索渠道(按名称过滤) + */ +export async function searchChannels( + config: OctopusConfig, + keyword: string, +): Promise { + const channels = await listChannels(config) + if (!keyword) return channels + + const lowerKeyword = keyword.toLowerCase() + return channels.filter( + (ch) => + ch.name.toLowerCase().includes(lowerKeyword) || + ch.base_urls.some((u) => u.url.toLowerCase().includes(lowerKeyword)), + ) +} + +/** + * 创建渠道 + */ +export async function createChannel( + config: OctopusConfig, + data: OctopusCreateChannelRequest, +): Promise> { + try { + const result = await fetchOctopusApi( + config, + "/api/v1/channel/create", + { + method: "POST", + body: JSON.stringify(data), + }, + ) + logger.info("Channel created", { name: data.name }) + return result + } catch (error) { + logger.error("Failed to create channel", error) + throw error + } +} + +/** + * 更新渠道 + */ +export async function updateChannel( + config: OctopusConfig, + data: OctopusUpdateChannelRequest, +): Promise> { + try { + const result = await fetchOctopusApi( + config, + "/api/v1/channel/update", + { + method: "POST", + body: JSON.stringify(data), + }, + ) + logger.info("Channel updated", { id: data.id }) + return result + } catch (error) { + logger.error("Failed to update channel", error) + throw error + } +} + +/** + * 删除渠道 + */ +export async function deleteChannel( + config: OctopusConfig, + channelId: number, +): Promise> { + try { + const result = await fetchOctopusApi( + config, + `/api/v1/channel/delete/${channelId}`, + { + method: "DELETE", + }, + ) + logger.info("Channel deleted", { id: channelId }) + return result + } catch (error) { + logger.error("Failed to delete channel", error) + throw error + } +} + +/** + * 启用/禁用渠道 + */ +export async function toggleChannelEnabled( + config: OctopusConfig, + channelId: number, + enabled: boolean, +): Promise> { + try { + const result = await fetchOctopusApi( + config, + "/api/v1/channel/enable", + { + method: "POST", + body: JSON.stringify({ id: channelId, enabled }), + }, + ) + logger.info("Channel toggled", { id: channelId, enabled }) + return result + } catch (error) { + logger.error("Failed to toggle channel", error) + throw error + } +} + +/** + * 获取上游模型列表 + */ +export async function fetchRemoteModels( + config: OctopusConfig, + channelData: OctopusFetchModelRequest, +): Promise { + try { + const result = await fetchOctopusApi( + config, + "/api/v1/channel/fetch-model", + { + method: "POST", + body: JSON.stringify(channelData), + }, + ) + return result.data || [] + } catch (error) { + logger.error("Failed to fetch remote models", error) + throw error + } +} + +/** + * 触发模型同步 + */ +export async function triggerModelSync( + config: OctopusConfig, +): Promise> { + return await fetchOctopusApi(config, "/api/v1/channel/sync", { + method: "POST", + }) +} + +/** + * 获取上次同步时间 + */ +export async function getLastSyncTime( + config: OctopusConfig, +): Promise { + try { + const result = await fetchOctopusApi( + config, + "/api/v1/channel/last-sync-time", + ) + return result.data || null + } catch (error) { + logger.error("Failed to get last sync time", error) + return null + } +} + +// 重新导出认证管理器 +export { octopusAuthManager } from "./auth" diff --git a/services/apiService/octopus/utils.ts b/services/apiService/octopus/utils.ts new file mode 100644 index 000000000..1bf23ff8a --- /dev/null +++ b/services/apiService/octopus/utils.ts @@ -0,0 +1,40 @@ +/** + * Octopus API 工具函数 + */ + +/** + * 构建 Octopus 认证头 + */ +export function buildOctopusAuthHeaders( + jwtToken: string, +): Record { + return { + Authorization: `Bearer ${jwtToken}`, + "Content-Type": "application/json", + } +} + +/** + * 规范化 Base URL(移除尾部斜杠) + */ +export function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/$/, "") +} + +/** + * 解析逗号分隔的字符串为数组 + */ +export function parseCommaSeparated(value?: string | null): string[] { + if (!value) return [] + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean) +} + +/** + * 将数组转换为逗号分隔的字符串 + */ +export function toCommaSeparated(values: string[]): string { + return values.filter(Boolean).join(",") +} diff --git a/services/managedSiteService.ts b/services/managedSiteService.ts index f3cf7a518..244b3f145 100644 --- a/services/managedSiteService.ts +++ b/services/managedSiteService.ts @@ -1,4 +1,9 @@ -import { NEW_API, VELOERA, type ManagedSiteType } from "~/constants/siteType" +import { + NEW_API, + OCTOPUS, + VELOERA, + type ManagedSiteType, +} from "~/constants/siteType" import type { AccountToken } from "~/entrypoints/options/pages/KeyManagement/type" import type { ApiResponse } from "~/services/apiService/common/type" import type { ApiToken, DisplaySiteData, SiteAccount } from "~/types" @@ -17,6 +22,7 @@ import { } from "~/utils/managedSite" import * as newApiService from "./newApiService/newApiService" +import * as octopusService from "./octopusService/octopusService" import { userPreferences, type UserPreferences } from "./userPreferences" import * as veloeraService from "./veloeraService/veloeraService" @@ -112,6 +118,25 @@ export async function getManagedSiteService(): Promise { const prefs = await userPreferences.getPreferences() const { siteType, messagesKey } = getManagedSiteContext(prefs) + if (siteType === OCTOPUS) { + return { + siteType, + messagesKey, + searchChannel: octopusService.searchChannel, + createChannel: octopusService.createChannel, + updateChannel: octopusService.updateChannel, + deleteChannel: octopusService.deleteChannel, + checkValidConfig: octopusService.checkValidOctopusConfig, + getConfig: octopusService.getOctopusConfig, + fetchAvailableModels: octopusService.fetchAvailableModels, + buildChannelName: octopusService.buildChannelName, + prepareChannelFormData: octopusService.prepareChannelFormData, + buildChannelPayload: octopusService.buildChannelPayload, + findMatchingChannel: octopusService.findMatchingChannel, + autoConfigToManagedSite: octopusService.autoConfigToOctopus, + } + } + if (siteType === VELOERA) { return { siteType, diff --git a/services/modelRedirect/ModelRedirectService.ts b/services/modelRedirect/ModelRedirectService.ts index d45894dd8..cb8007f33 100644 --- a/services/modelRedirect/ModelRedirectService.ts +++ b/services/modelRedirect/ModelRedirectService.ts @@ -4,6 +4,7 @@ * Based on gpt-api-sync logic with enhancements for weighted channel selection */ +import { OCTOPUS } from "~/constants/siteType" import { modelMetadataService } from "~/services/modelMetadata" import { ModelSyncService } from "~/services/modelSync" import type { ManagedSiteChannel } from "~/types/managedSite" @@ -12,6 +13,8 @@ import { ALL_PRESET_STANDARD_MODELS, DEFAULT_MODEL_REDIRECT_PREFERENCES, } from "~/types/managedSiteModelRedirect" +import type { NewApiConfig } from "~/types/newApiConfig" +import type { VeloeraConfig } from "~/types/veloeraConfig" import { createLogger } from "~/utils/logger" import { getManagedSiteConfig } from "~/utils/managedSite" @@ -114,10 +117,22 @@ export class ModelRedirectService { const { siteType, config: managedConfig } = getManagedSiteConfig(prefs) + // Octopus 站点暂不支持 Model Redirect 功能 + if (siteType === OCTOPUS) { + return { + success: false, + updatedChannels: 0, + errors: ["Model redirect is not supported for Octopus sites"], + message: "Model redirect is not supported for Octopus sites", + } + } + + const legacyConfig = managedConfig as NewApiConfig | VeloeraConfig + const service = new ModelSyncService( - managedConfig.baseUrl!, - managedConfig.adminToken!, - managedConfig.userId!, + legacyConfig.baseUrl!, + legacyConfig.adminToken!, + legacyConfig.userId!, undefined, undefined, undefined, diff --git a/services/modelSync/octopusModelSync.ts b/services/modelSync/octopusModelSync.ts new file mode 100644 index 000000000..b2a34d6b0 --- /dev/null +++ b/services/modelSync/octopusModelSync.ts @@ -0,0 +1,236 @@ +/** + * Octopus 模型同步服务 + * 实现 Octopus 站点的模型同步功能 + */ +import * as octopusApi from "~/services/apiService/octopus" +import type { ManagedSiteChannel } from "~/types/managedSite" +import { + BatchExecutionOptions, + ExecutionItemResult, + ExecutionResult, + ExecutionStatistics, +} from "~/types/managedSiteModelSync" +import type { OctopusChannel, OctopusFetchModelRequest } from "~/types/octopus" +import type { OctopusConfig } from "~/types/octopusConfig" +import { createLogger } from "~/utils/logger" + +const logger = createLogger("OctopusModelSync") + +/** + * 从 ManagedSiteChannel 中提取 Octopus 原始数据 + */ +function getOctopusChannelData( + channel: ManagedSiteChannel, +): OctopusChannel | null { + const octopusData = ( + channel as ManagedSiteChannel & { _octopusData?: OctopusChannel } + )._octopusData + return octopusData ?? null +} + +/** + * 获取渠道的上游模型列表 + */ +async function fetchChannelModels( + config: OctopusConfig, + channel: ManagedSiteChannel, +): Promise { + const octopusData = getOctopusChannelData(channel) + if (!octopusData) { + throw new Error("Missing Octopus channel data") + } + + const request: OctopusFetchModelRequest = { + type: octopusData.type, + base_urls: octopusData.base_urls, + keys: octopusData.keys, + proxy: octopusData.proxy, + } + + return await octopusApi.fetchRemoteModels(config, request) +} + +/** + * 更新渠道的模型列表 + */ +async function updateChannelModels( + config: OctopusConfig, + channel: ManagedSiteChannel, + models: string[], +): Promise { + await octopusApi.updateChannel(config, { + id: channel.id, + model: models.join(","), + }) +} + +/** + * 比较两个模型列表是否有变化 + */ +function haveModelsChanged(previous: string[], next: string[]): boolean { + if (previous.length !== next.length) { + return true + } + + const prevSorted = [...previous].sort() + const nextSorted = [...next].sort() + + for (let index = 0; index < prevSorted.length; index += 1) { + if (prevSorted[index] !== nextSorted[index]) { + return true + } + } + + return false +} + +/** + * 对单个渠道执行模型同步 + */ +async function runForChannel( + config: OctopusConfig, + channel: ManagedSiteChannel, + maxRetries: number = 2, +): Promise { + let attempts = 0 + let lastError: any = null + + const oldModels = channel.models + ? channel.models + .split(",") + .map((model) => model.trim()) + .filter(Boolean) + : [] + + while (attempts <= maxRetries) { + try { + const fetchedModels = await fetchChannelModels(config, channel) + const normalizedModels = Array.from( + new Set(fetchedModels.map((model) => model.trim()).filter(Boolean)), + ) + + if (haveModelsChanged(oldModels, normalizedModels)) { + await updateChannelModels(config, channel, normalizedModels) + channel.models = normalizedModels.join(",") + } + + return { + channelId: channel.id, + channelName: channel.name, + ok: true, + attempts, + finishedAt: Date.now(), + oldModels, + newModels: normalizedModels, + message: "Success", + } + } catch (error: any) { + lastError = error + logger.error("Unexpected error for channel", { + channelId: channel.id, + error, + }) + + attempts += 1 + if (attempts > maxRetries) { + break + } + + // Exponential backoff: 1s, 2s, 4s, ... + const backoffMs = Math.pow(2, attempts - 1) * 1000 + await new Promise((resolve) => setTimeout(resolve, backoffMs)) + } + } + + return { + channelId: channel.id, + channelName: channel.name, + ok: false, + httpStatus: lastError?.httpStatus, + message: lastError?.message || "Unknown error", + attempts, + finishedAt: Date.now(), + oldModels, + } +} + +/** + * 批量执行 Octopus 模型同步 + */ +export async function runOctopusBatch( + config: OctopusConfig, + channels: ManagedSiteChannel[], + options: BatchExecutionOptions, +): Promise { + const { concurrency, maxRetries, onProgress } = options + const startedAt = Date.now() + const total = channels.length + const results: (ExecutionItemResult | undefined)[] = new Array(total) + + let completed = 0 + let nextIndex = 0 + + const worker = async () => { + while (true) { + const currentIndex = nextIndex + if (currentIndex >= total) { + return + } + nextIndex++ + + const channel = channels[currentIndex] + let result: ExecutionItemResult + + try { + result = await runForChannel(config, channel, maxRetries) + } catch (error: any) { + logger.error("Unexpected error for channel", { + channelId: channel.id, + error, + }) + result = { + channelId: channel.id, + channelName: channel.name, + ok: false, + message: error?.message || "Unexpected error", + attempts: maxRetries + 1, + finishedAt: Date.now(), + } + } + + results[currentIndex] = result + completed++ + + await onProgress?.({ + completed, + total, + lastResult: result, + }) + } + } + + // Cap workers to total channels to avoid spinning idle workers + const workerCount = Math.max(1, Math.min(concurrency, total)) + const workers = Array.from({ length: workerCount }, () => worker()) + await Promise.all(workers) + + const items = results.filter((item): item is ExecutionItemResult => !!item) + + const endedAt = Date.now() + const successCount = items.filter((item) => item.ok).length + const failureCount = total - successCount + + const statistics: ExecutionStatistics = { + total, + successCount, + failureCount, + durationMs: endedAt - startedAt, + startedAt, + endedAt, + } + + return { + items, + statistics, + } +} diff --git a/services/modelSync/scheduler.ts b/services/modelSync/scheduler.ts index 8e8ff1c9a..bfe35160e 100644 --- a/services/modelSync/scheduler.ts +++ b/services/modelSync/scheduler.ts @@ -1,9 +1,14 @@ import { t } from "i18next" import { RuntimeActionIds } from "~/constants/runtimeActions" +import { OCTOPUS } from "~/constants/siteType" +import * as octopusApi from "~/services/apiService/octopus" import { ModelRedirectService } from "~/services/modelRedirect" import type { ChannelModelFilterRule } from "~/types/channelModelFilters" -import type { ManagedSiteChannel } from "~/types/managedSite" +import type { + ManagedSiteChannel, + ManagedSiteChannelListData, +} from "~/types/managedSite" import { ALL_PRESET_STANDARD_MODELS, DEFAULT_MODEL_REDIRECT_PREFERENCES, @@ -12,6 +17,7 @@ import { ExecutionProgress, ExecutionResult, } from "~/types/managedSiteModelSync" +import type { OctopusConfig } from "~/types/octopusConfig" import { clearAlarm, createAlarm, @@ -24,13 +30,16 @@ import { getErrorMessage } from "~/utils/error" import { createLogger } from "~/utils/logger" import { getManagedSiteAdminConfig, + getManagedSiteConfig, getManagedSiteContext, } from "~/utils/managedSite" import { channelConfigStorage } from "../channelConfigStorage" +import { octopusChannelToManagedSite } from "../octopusService/octopusService" import { DEFAULT_PREFERENCES, userPreferences } from "../userPreferences" import { collectModelsFromExecution } from "./modelCollection" import { ModelSyncService } from "./modelSyncService" +import { runOctopusBatch } from "./octopusModelSync" import { managedSiteModelSyncStorage } from "./storage" const logger = createLogger("ManagedSiteModelSync") @@ -189,7 +198,22 @@ class ModelSyncScheduler { } } - async listChannels() { + async listChannels(): Promise { + const userPrefs = await userPreferences.getPreferences() + const { siteType } = getManagedSiteContext(userPrefs) + + // Octopus 使用独立的 API 服务 + if (siteType === OCTOPUS) { + const { config } = getManagedSiteConfig(userPrefs) + const octopusConfig = config as OctopusConfig + const channels = await octopusApi.listChannels(octopusConfig) + return { + items: channels.map(octopusChannelToManagedSite), + total: channels.length, + type_counts: {}, + } + } + const service = await this.createService() return service.listChannels() } @@ -203,17 +227,29 @@ class ModelSyncScheduler { async executeSync(channelIds?: number[]): Promise { logger.info("Starting execution") - // Initialize service - const service = await this.createService() - // Get preferences from userPreferences const prefs = await userPreferences.getPreferences() - const { messagesKey } = getManagedSiteContext(prefs) + const { siteType, messagesKey } = getManagedSiteContext(prefs) + const config = prefs.managedSiteModelSync ?? DEFAULT_PREFERENCES.managedSiteModelSync! const concurrency = Math.max(1, config.concurrency) const { maxRetries } = config + // Octopus 使用独立的模型同步逻辑 + if (siteType === OCTOPUS) { + return this.executeSyncForOctopus( + channelIds, + prefs, + messagesKey, + concurrency, + maxRetries, + ) + } + + // Initialize service (for non-Octopus sites) + const service = await this.createService() + // List channels const channelListResponse = await service.listChannels() const allChannels = channelListResponse.items @@ -348,6 +384,93 @@ class ModelSyncScheduler { } } + /** + * Execute model sync for Octopus site. + * Octopus uses a different API structure for fetching and updating models. + */ + private async executeSyncForOctopus( + channelIds: number[] | undefined, + prefs: Awaited>, + messagesKey: string, + concurrency: number, + maxRetries: number, + ): Promise { + const { config } = getManagedSiteConfig(prefs) + const octopusConfig = config as OctopusConfig + + // List channels using Octopus API + const octopusChannels = await octopusApi.listChannels(octopusConfig) + const allChannels = octopusChannels.map(octopusChannelToManagedSite) + + // Filter channels if specific IDs provided + let channels: ManagedSiteChannel[] + if (channelIds && channelIds.length > 0) { + channels = allChannels.filter((c) => channelIds.includes(c.id)) + } else { + channels = allChannels + } + + if (channels.length === 0) { + throw new Error(t(`messages:${messagesKey}.noChannelsToSync`)) + } + + // Update progress + this.currentProgress = { + isRunning: true, + total: channels.length, + completed: 0, + failed: 0, + } + + let failureCount = 0 + + let result + try { + // Execute batch sync using Octopus-specific implementation + result = await runOctopusBatch(octopusConfig, channels, { + concurrency, + maxRetries, + onProgress: async (payload) => { + if (!payload.lastResult.ok) { + failureCount += 1 + } + + if (this.currentProgress) { + this.currentProgress.completed = payload.completed + this.currentProgress.lastResult = payload.lastResult + this.currentProgress.currentChannel = payload.lastResult.channelName + this.currentProgress.failed = failureCount + } + this.notifyProgress() + }, + }) + + // Save execution result + await managedSiteModelSyncStorage.saveLastExecution(result) + + // Cache upstream model options for allow-list selection, only if full sync + if (!channelIds) { + const collectedModels = collectModelsFromExecution(result) + if (collectedModels.length > 0) { + await managedSiteModelSyncStorage.saveChannelUpstreamModelOptions( + collectedModels, + ) + } + } + + logger.info("Octopus execution completed", { + successCount: result.statistics.successCount, + total: result.statistics.total, + }) + + return result + } finally { + // Clear progress + this.currentProgress = null + this.notifyProgress() + } + } + /** * Execute sync for failed channels only * @returns ExecutionResult for retry batch. diff --git a/services/octopusService/octopusService.ts b/services/octopusService/octopusService.ts new file mode 100644 index 000000000..bc769b510 --- /dev/null +++ b/services/octopusService/octopusService.ts @@ -0,0 +1,528 @@ +/** + * Octopus Service + * 实现 ManagedSiteService 接口,提供 Octopus 站点的渠道管理功能 + */ +import { t } from "i18next" +import toast from "react-hot-toast" + +import { DEFAULT_OCTOPUS_CHANNEL_FIELDS } from "~/constants/octopus" +import { OCTOPUS } from "~/constants/siteType" +import type { AccountToken } from "~/entrypoints/options/pages/KeyManagement/type" +import { ensureAccountApiToken } from "~/services/accountOperations" +import { accountStorage } from "~/services/accountStorage" +import type { ApiResponse } from "~/services/apiService/common/type" +import * as octopusApi from "~/services/apiService/octopus" +import { fetchOpenAICompatibleModelIds } from "~/services/apiService/openaiCompatible" +import type { + ManagedSiteConfig, + ManagedSiteService, +} from "~/services/managedSiteService" +import { + userPreferences, + type UserPreferences, +} from "~/services/userPreferences" +import type { ApiToken, DisplaySiteData, SiteAccount } from "~/types" +import type { + ChannelFormData, + ChannelMode, + CreateChannelPayload, + ManagedSiteChannel, + ManagedSiteChannelListData, + UpdateChannelPayload, +} from "~/types/managedSite" +import type { + OctopusChannel, + OctopusCreateChannelRequest, + OctopusOutboundType, +} from "~/types/octopus" +import type { OctopusConfig } from "~/types/octopusConfig" +import { getErrorMessage } from "~/utils/error" +import { createLogger } from "~/utils/logger" +import type { ManagedSiteMessagesKey } from "~/utils/managedSite" + +const logger = createLogger("OctopusService") + +/** + * 解析逗号分隔的字符串为数组 + */ +function parseDelimitedList(value?: string | null): string[] { + if (!value) return [] + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean) +} + +/** + * 规范化列表(去重、去空) + */ +function normalizeList(values: string[] = []): string[] { + return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))) +} + +/** + * 为 Octopus 渠道构建 base URL + * Octopus 的 URL 规则需要添加 /v1 后缀 + */ +function buildOctopusBaseUrl(baseUrl: string): string { + let url = baseUrl.trim() + // 移除尾部斜杠 + while (url.endsWith("/")) { + url = url.slice(0, -1) + } + // 如果已经以 /v1 结尾,不再添加 + if (url.endsWith("/v1")) { + return url + } + // 添加 /v1 后缀 + return `${url}/v1` +} + +/** + * 检查偏好设置中是否有有效的 Octopus 配置 + */ +export function hasValidOctopusConfig(prefs: UserPreferences | null): boolean { + if (!prefs?.octopus) return false + const { baseUrl, username, password } = prefs.octopus + return Boolean(baseUrl?.trim() && username?.trim() && password?.trim()) +} + +/** + * 验证 Octopus 配置 + */ +export async function checkValidOctopusConfig(): Promise { + try { + const prefs = await userPreferences.getPreferences() + return hasValidOctopusConfig(prefs) + } catch (error) { + logger.error("Error checking config", error) + return false + } +} + +/** + * 获取 Octopus 配置 + */ +export async function getOctopusConfig(): Promise { + try { + const prefs = await userPreferences.getPreferences() + if (hasValidOctopusConfig(prefs) && prefs.octopus) { + return { + baseUrl: prefs.octopus.baseUrl, + token: "", // Octopus 使用 JWT,token 动态获取 + userId: prefs.octopus.username, + } + } + return null + } catch (error) { + logger.error("Error getting config", error) + return null + } +} + +/** + * 获取完整的 Octopus 配置(包含密码) + */ +async function getFullOctopusConfig(): Promise { + const prefs = await userPreferences.getPreferences() + if (hasValidOctopusConfig(prefs) && prefs.octopus) { + return prefs.octopus + } + return null +} + +/** + * 将 Octopus 渠道转换为通用 ManagedSiteChannel 格式 + */ +export function octopusChannelToManagedSite( + channel: OctopusChannel, +): ManagedSiteChannel { + return { + id: channel.id, + name: channel.name, + type: channel.type, + base_url: channel.base_urls[0]?.url || "", + key: channel.keys[0]?.channel_key || "", + models: channel.model || "", + status: channel.enabled ? 1 : 2, // 1=启用, 2=禁用 + priority: 0, + weight: 0, + group: "", + model_mapping: "", + status_code_mapping: "", + test_model: null, + auto_ban: 0, + created_time: 0, + test_time: 0, + response_time: 0, + balance: 0, + used_quota: 0, + tag: null, + remark: null, + setting: "", + settings: "", + // 存储原始 Octopus 数据以便编辑 + _octopusData: channel, + } as ManagedSiteChannel & { _octopusData?: OctopusChannel } +} + +/** + * 搜索渠道 + */ +export async function searchChannel( + _baseUrl: string, + _accessToken: string, + _userId: number | string, + keyword: string, +): Promise { + try { + const config = await getFullOctopusConfig() + if (!config) return null + + const channels = await octopusApi.searchChannels(config, keyword) + return { + items: channels.map(octopusChannelToManagedSite), + total: channels.length, + type_counts: {}, + } + } catch (error) { + logger.error("Failed to search channels", error) + return null + } +} + +/** + * 创建渠道 + */ +export async function createChannel( + _baseUrl: string, + _adminToken: string, + _userId: number | string, + channelData: CreateChannelPayload, +): Promise> { + try { + const config = await getFullOctopusConfig() + if (!config) { + return { success: false, data: null, message: "Octopus config not found" } + } + + const channel = channelData.channel + const request: OctopusCreateChannelRequest = { + name: channel.name || "", + type: + (channel.type as unknown as OctopusOutboundType) || + DEFAULT_OCTOPUS_CHANNEL_FIELDS.type, + enabled: channel.status === 1, + base_urls: [{ url: channel.base_url || "" }], + keys: [{ enabled: true, channel_key: channel.key || "" }], + model: channel.models, + auto_sync: true, // 默认启用自动同步 + auto_group: 0, + } + + const result = await octopusApi.createChannel(config, request) + return { + success: result.success, + data: result.data, + message: result.message || "success", + } + } catch (error) { + return { + success: false, + data: null, + message: getErrorMessage(error) || "Failed to create channel", + } + } +} + +/** + * 更新渠道 + */ +export async function updateChannel( + _baseUrl: string, + _adminToken: string, + _userId: number | string, + channelData: UpdateChannelPayload & { status?: number }, +): Promise> { + try { + const config = await getFullOctopusConfig() + if (!config) { + return { success: false, data: null, message: "Octopus config not found" } + } + + const result = await octopusApi.updateChannel(config, { + id: channelData.id, + name: channelData.name, + type: channelData.type as unknown as OctopusOutboundType, + enabled: channelData.status === 1, + base_urls: channelData.base_url + ? [{ url: channelData.base_url }] + : undefined, + model: channelData.models, + }) + + return { + success: result.success, + data: result.data, + message: result.message || "success", + } + } catch (error) { + return { + success: false, + data: null, + message: getErrorMessage(error) || "Failed to update channel", + } + } +} + +/** + * 删除渠道 + */ +export async function deleteChannel( + _baseUrl: string, + _adminToken: string, + _userId: number | string, + channelId: number, +): Promise> { + try { + const config = await getFullOctopusConfig() + if (!config) { + return { success: false, data: null, message: "Octopus config not found" } + } + + const result = await octopusApi.deleteChannel(config, channelId) + return { + success: result.success, + data: result.data, + message: result.message || "success", + } + } catch (error) { + return { + success: false, + data: null, + message: getErrorMessage(error) || "Failed to delete channel", + } + } +} + +/** + * 获取可用模型列表 + */ +export async function fetchAvailableModels( + account: DisplaySiteData, + token: ApiToken, +): Promise { + const candidateSources: string[][] = [] + + const tokenModelList = parseDelimitedList(token.models) + if (tokenModelList.length > 0) { + candidateSources.push(tokenModelList) + } + + try { + const upstreamModels = await fetchOpenAICompatibleModelIds({ + baseUrl: account.baseUrl, + apiKey: token.key, + }) + if (upstreamModels?.length > 0) { + candidateSources.push(upstreamModels) + } + } catch (error) { + logger.warn("Failed to fetch upstream models", error) + } + + return normalizeList(candidateSources.flat()) +} + +/** + * 构建渠道名称 + */ +export function buildChannelName( + account: DisplaySiteData, + token: ApiToken, +): string { + let channelName = `${account.name} | ${token.name}`.trim() + if (!channelName.endsWith("(auto)")) { + channelName += " (auto)" + } + return channelName +} + +/** + * 准备渠道表单数据 + */ +export async function prepareChannelFormData( + account: DisplaySiteData, + token: ApiToken | AccountToken, +): Promise { + const availableModels = await fetchOpenAICompatibleModelIds({ + baseUrl: account.baseUrl, + apiKey: token.key, + }) + + if (!availableModels.length) { + throw new Error(t("messages:octopus.noAnyModels")) + } + + return { + name: buildChannelName(account, token), + type: DEFAULT_OCTOPUS_CHANNEL_FIELDS.type, + key: token.key, + base_url: buildOctopusBaseUrl(account.baseUrl), // Octopus 需要 /v1 后缀 + models: normalizeList(availableModels), + groups: ["default"], + priority: 0, + weight: 0, + status: 1, + } +} + +/** + * 构建渠道创建 payload + */ +export function buildChannelPayload( + formData: ChannelFormData, + mode: ChannelMode = "single", +): CreateChannelPayload { + return { + mode, + channel: { + name: formData.name.trim(), + type: formData.type, + key: formData.key.trim(), + base_url: formData.base_url.trim(), + models: normalizeList(formData.models ?? []).join(","), + groups: formData.groups || ["default"], + priority: formData.priority, + weight: formData.weight, + status: formData.status, + }, + } +} + +/** + * 查找匹配的渠道 + */ +export async function findMatchingChannel( + _baseUrl: string, + _adminToken: string, + _userId: number | string, + accountBaseUrl: string, + models: string[], +): Promise { + try { + const config = await getFullOctopusConfig() + if (!config) return null + + const channels = await octopusApi.listChannels(config) + + const match = channels.find((ch) => { + const chBaseUrl = ch.base_urls[0]?.url || "" + const chModels = parseDelimitedList(ch.model) + return ( + chBaseUrl === accountBaseUrl && + chModels.length === models.length && + chModels.every((m) => models.includes(m)) + ) + }) + + return match ? octopusChannelToManagedSite(match) : null + } catch (error) { + logger.error("Failed to find matching channel", error) + return null + } +} + +/** + * 自动配置到 Octopus + */ +export async function autoConfigToOctopus( + account: SiteAccount, + toastId?: string, +): Promise<{ success: boolean; message: string }> { + try { + const config = await getFullOctopusConfig() + if (!config) { + return { success: false, message: t("messages:octopus.configMissing") } + } + + const displaySiteData = accountStorage.convertToDisplayData( + account, + ) as DisplaySiteData + + const apiToken = await ensureAccountApiToken( + account, + displaySiteData, + toastId, + ) + + toast.loading(t("messages:accountOperations.importingToOctopus"), { + id: toastId, + }) + + const formData = await prepareChannelFormData(displaySiteData, apiToken) + + // 检查是否已存在 + const existingChannel = await findMatchingChannel( + config.baseUrl, + "", + "", + displaySiteData.baseUrl, + formData.models, + ) + + if (existingChannel) { + return { + success: false, + message: t("messages:octopus.channelExists", { + channelName: existingChannel.name, + }), + } + } + + const payload = buildChannelPayload(formData) + const result = await createChannel(config.baseUrl, "", "", payload) + + if (result.success) { + toast.success( + t("messages:octopus.importSuccess", { channelName: formData.name }), + { + id: toastId, + }, + ) + return { + success: true, + message: t("messages:octopus.importSuccess", { + channelName: formData.name, + }), + } + } + + throw new Error(result.message) + } catch (error) { + const message = getErrorMessage(error) || t("messages:octopus.importFailed") + toast.error(message, { id: toastId }) + return { success: false, message } + } +} + +/** + * Octopus ManagedSiteService 实现 + */ +export const octopusService: ManagedSiteService = { + siteType: OCTOPUS, + messagesKey: "octopus" as ManagedSiteMessagesKey, + + searchChannel, + createChannel, + updateChannel, + deleteChannel, + checkValidConfig: checkValidOctopusConfig, + getConfig: getOctopusConfig, + fetchAvailableModels, + buildChannelName, + prepareChannelFormData, + buildChannelPayload, + findMatchingChannel, + autoConfigToManagedSite: autoConfigToOctopus, +} diff --git a/services/userPreferences.ts b/services/userPreferences.ts index 86d18fa2c..264be03a8 100644 --- a/services/userPreferences.ts +++ b/services/userPreferences.ts @@ -3,7 +3,12 @@ import { isEqual } from "lodash-es" import { Storage } from "@plasmohq/storage" import { DATA_TYPE_BALANCE, DATA_TYPE_CASHFLOW } from "~/constants" -import { NEW_API, VELOERA, type ManagedSiteType } from "~/constants/siteType" +import { + NEW_API, + OCTOPUS, + VELOERA, + type ManagedSiteType, +} from "~/constants/siteType" import { CURRENT_PREFERENCES_VERSION, migratePreferences, @@ -36,6 +41,7 @@ import { type ModelRedirectPreferences, } from "~/types/managedSiteModelRedirect" import { DEFAULT_NEW_API_CONFIG, NewApiConfig } from "~/types/newApiConfig" +import { DEFAULT_OCTOPUS_CONFIG, OctopusConfig } from "~/types/octopusConfig" import type { SortingPriorityConfig } from "~/types/sorting" import type { ThemeMode } from "~/types/theme" import { @@ -218,7 +224,10 @@ export interface UserPreferences { // Veloera 相关配置 veloera: VeloeraConfig - // 管理站点类型 (用户可以选择管理 New API 或 Veloera) + // Octopus 相关配置 + octopus?: OctopusConfig + + // 管理站点类型 (用户可以选择管理 New API 或 Veloera 或 Octopus) managedSiteType: ManagedSiteType // CLIProxyAPI 管理接口配置 @@ -383,6 +392,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = { lastUpdated: Date.now(), newApi: DEFAULT_NEW_API_CONFIG, veloera: DEFAULT_VELOERA_CONFIG, + octopus: DEFAULT_OCTOPUS_CONFIG, managedSiteType: NEW_API, cliProxy: DEFAULT_CLI_PROXY_CONFIG, claudeCodeRouter: DEFAULT_CLAUDE_CODE_ROUTER_CONFIG, @@ -779,7 +789,25 @@ class UserPreferencesService { } /** - * Update managed site type (new-api or veloera). + * Update Octopus config. + */ + async updateOctopusConfig(config: Partial): Promise { + return this.savePreferences({ + octopus: config, + }) + } + + /** + * Reset Octopus config. + */ + async resetOctopusConfig(): Promise { + return this.savePreferences({ + octopus: DEFAULT_PREFERENCES.octopus, + }) + } + + /** + * Update managed site type (new-api or veloera or octopus). */ async updateManagedSiteType(siteType: ManagedSiteType): Promise { return this.savePreferences({ @@ -792,11 +820,18 @@ class UserPreferencesService { */ async getManagedSiteConfig(): Promise<{ siteType: ManagedSiteType - config: NewApiConfig | VeloeraConfig + config: NewApiConfig | VeloeraConfig | OctopusConfig }> { const prefs = await this.getPreferences() const siteType = prefs.managedSiteType || NEW_API - const config = siteType === VELOERA ? prefs.veloera : prefs.newApi + let config: NewApiConfig | VeloeraConfig | OctopusConfig + if (siteType === OCTOPUS) { + config = prefs.octopus || DEFAULT_OCTOPUS_CONFIG + } else if (siteType === VELOERA) { + config = prefs.veloera + } else { + config = prefs.newApi + } return { siteType, config } } diff --git a/tests/services/managedSiteService/managedSiteService.test.ts b/tests/services/managedSiteService/managedSiteService.test.ts index 9c58225c3..80072b30c 100644 --- a/tests/services/managedSiteService/managedSiteService.test.ts +++ b/tests/services/managedSiteService/managedSiteService.test.ts @@ -12,7 +12,11 @@ vi.mock("~/services/userPreferences", () => ({ vi.mock("~/services/newApiService/newApiService", () => ({ checkValidNewApiConfig: vi.fn(async () => true), - getNewApiConfig: vi.fn(async () => ({ baseUrl: "n", token: "t", userId: "u" })), + getNewApiConfig: vi.fn(async () => ({ + baseUrl: "n", + token: "t", + userId: "u", + })), searchChannel: vi.fn(), createChannel: vi.fn(), updateChannel: vi.fn(), @@ -27,7 +31,11 @@ vi.mock("~/services/newApiService/newApiService", () => ({ vi.mock("~/services/veloeraService/veloeraService", () => ({ checkValidVeloeraConfig: vi.fn(async () => true), - getVeloeraConfig: vi.fn(async () => ({ baseUrl: "v", token: "t", userId: "u" })), + getVeloeraConfig: vi.fn(async () => ({ + baseUrl: "v", + token: "t", + userId: "u", + })), searchChannel: vi.fn(), createChannel: vi.fn(), updateChannel: vi.fn(), @@ -42,7 +50,9 @@ vi.mock("~/services/veloeraService/veloeraService", () => ({ describe("managedSiteService", () => { it("routes to New API service by default", async () => { - const { getManagedSiteService } = await import("~/services/managedSiteService") + const { getManagedSiteService } = await import( + "~/services/managedSiteService" + ) mockGetPreferences.mockResolvedValueOnce({ managedSiteType: NEW_API }) @@ -55,7 +65,9 @@ describe("managedSiteService", () => { }) it("routes to Veloera service when selected", async () => { - const { getManagedSiteService } = await import("~/services/managedSiteService") + const { getManagedSiteService } = await import( + "~/services/managedSiteService" + ) mockGetPreferences.mockResolvedValueOnce({ managedSiteType: VELOERA }) diff --git a/tests/utils/cookieHelper.test.ts b/tests/utils/cookieHelper.test.ts index 1c0bef8c2..6297bdcca 100644 --- a/tests/utils/cookieHelper.test.ts +++ b/tests/utils/cookieHelper.test.ts @@ -30,20 +30,18 @@ describe("cookieHelper", () => { }) it("filters out expired cookies", async () => { - const getAll = vi - .fn() - .mockResolvedValue([ - { - name: "expired", - value: "1", - expirationDate: Date.now() / 1000 - 10, - } as any, - { - name: "valid", - value: "2", - expirationDate: Date.now() / 1000 + 10, - } as any, - ]) + const getAll = vi.fn().mockResolvedValue([ + { + name: "expired", + value: "1", + expirationDate: Date.now() / 1000 - 10, + } as any, + { + name: "valid", + value: "2", + expirationDate: Date.now() / 1000 + 10, + } as any, + ]) ;(globalThis as any).browser.cookies.getAll = getAll const header = await getCookieHeaderForUrl("https://example.com") diff --git a/tests/utils/protectionBypass.test.ts b/tests/utils/protectionBypass.test.ts index b20ace593..264c58617 100644 --- a/tests/utils/protectionBypass.test.ts +++ b/tests/utils/protectionBypass.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach, vi } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" import { isFirefox } from "~/utils/browser" import { @@ -29,7 +29,9 @@ describe("protectionBypass", () => { it("should treat non-Firefox environments as temp-window-only", () => { expect(isProtectionBypassFirefoxEnv()).toBe(false) expect(shouldUseCookieInterceptorForProtectionBypass()).toBe(false) - expect(getProtectionBypassUiVariant()).toBe(ProtectionBypassUiVariants.TempWindowOnly) + expect(getProtectionBypassUiVariant()).toBe( + ProtectionBypassUiVariants.TempWindowOnly, + ) }) it("should use cookie-interceptor variant on Firefox", () => { @@ -48,6 +50,8 @@ describe("protectionBypass", () => { }) expect(isProtectionBypassFirefoxEnv()).toBe(false) - expect(getProtectionBypassUiVariant()).toBe(ProtectionBypassUiVariants.TempWindowOnly) + expect(getProtectionBypassUiVariant()).toBe( + ProtectionBypassUiVariants.TempWindowOnly, + ) }) }) diff --git a/types/octopus.ts b/types/octopus.ts new file mode 100644 index 000000000..5881177aa --- /dev/null +++ b/types/octopus.ts @@ -0,0 +1,270 @@ +/** + * Octopus 渠道类型枚举 + * 对应 Octopus 后端的 OutboundType + */ +export enum OctopusOutboundType { + /** OpenAI 聊天补全 API */ + OpenAIChat = 0, + /** OpenAI 响应模式 */ + OpenAIResponse = 1, + /** Anthropic (Claude) API */ + Anthropic = 2, + /** Google Gemini API */ + Gemini = 3, + /** 火山引擎 API */ + Volcengine = 4, + /** OpenAI 嵌入 API */ + OpenAIEmbedding = 5, +} + +/** + * Octopus 自动分组类型枚举 + */ +export enum OctopusAutoGroupType { + /** 不自动分组 */ + None = 0, + /** 模糊匹配 */ + Fuzzy = 1, + /** 精确匹配 */ + Exact = 2, + /** 正则匹配 */ + Regex = 3, +} + +/** + * Octopus 渠道密钥 + */ +export interface OctopusChannelKey { + /** 密钥唯一标识符 */ + id?: number + /** 所属渠道 ID */ + channel_id?: number + /** 是否启用 */ + enabled: boolean + /** API 密钥值 */ + channel_key: string + /** 备注信息 */ + remark?: string + /** 最后响应状态码 */ + status_code?: number + /** 最后使用时间戳 (Unix 秒) */ + last_use_time_stamp?: number + /** 累计消费金额 */ + total_cost?: number +} + +/** + * Octopus Base URL 对象 + */ +export interface OctopusBaseUrl { + /** API 基础地址 */ + url: string + /** 延迟 (毫秒),用于负载均衡选择 */ + delay?: number +} + +/** + * Octopus 自定义请求头 + */ +export interface OctopusCustomHeader { + /** 请求头名称 */ + header_key: string + /** 请求头值 */ + header_value: string +} + +/** + * Octopus 渠道统计信息 + */ +export interface OctopusChannelStats { + /** 渠道 ID */ + channel_id: number + /** 输入 token 总数 */ + input_token: number + /** 输出 token 总数 */ + output_token: number + /** 输入消费金额 */ + input_cost: number + /** 输出消费金额 */ + output_cost: number + /** 累计等待时间 */ + wait_time: number + /** 成功请求数 */ + request_success: number + /** 失败请求数 */ + request_failed: number +} + +/** + * Octopus 渠道完整对象 + */ +export interface OctopusChannel { + /** 渠道唯一标识符 */ + id: number + /** 渠道名称 */ + name: string + /** 渠道类型 */ + type: OctopusOutboundType + /** 是否启用 */ + enabled: boolean + /** 基础 URL 列表 */ + base_urls: OctopusBaseUrl[] + /** API 密钥列表 */ + keys: OctopusChannelKey[] + /** 支持的模型列表 (逗号分隔) */ + model: string + /** 自定义模型列表 (逗号分隔) */ + custom_model?: string + /** 是否使用代理 */ + proxy: boolean + /** 是否自动同步模型 */ + auto_sync: boolean + /** 自动分组类型 */ + auto_group: OctopusAutoGroupType + /** 自定义请求头列表 */ + custom_header?: OctopusCustomHeader[] + /** 参数覆盖配置 */ + param_override?: string + /** 渠道专用代理地址 */ + channel_proxy?: string + /** 模型匹配正则表达式 */ + match_regex?: string + /** 渠道统计信息 */ + stats?: OctopusChannelStats +} + +/** + * 创建渠道请求 + */ +export interface OctopusCreateChannelRequest { + /** 渠道名称 (必须唯一) */ + name: string + /** 渠道类型 */ + type: OctopusOutboundType + /** 是否启用 (默认 true) */ + enabled?: boolean + /** 基础 URL 列表 */ + base_urls: OctopusBaseUrl[] + /** API 密钥列表 */ + keys: OctopusChannelKey[] + /** 支持的模型列表 */ + model?: string + /** 自定义模型列表 */ + custom_model?: string + /** 是否使用代理 */ + proxy?: boolean + /** 是否自动同步 */ + auto_sync?: boolean + /** 自动分组类型 */ + auto_group?: OctopusAutoGroupType + /** 自定义请求头 */ + custom_header?: OctopusCustomHeader[] + /** 参数覆盖配置 */ + param_override?: string + /** 渠道专用代理 */ + channel_proxy?: string + /** 模型匹配正则 */ + match_regex?: string +} + +/** + * 密钥添加请求 + */ +export interface OctopusKeyAddRequest { + /** 是否启用 */ + enabled?: boolean + /** API 密钥值 */ + channel_key: string + /** 备注信息 */ + remark?: string +} + +/** + * 密钥更新请求 + */ +export interface OctopusKeyUpdateRequest { + /** 要更新的密钥 ID */ + id: number + /** 是否启用 */ + enabled?: boolean + /** 新的 API 密钥值 */ + channel_key?: string + /** 新的备注信息 */ + remark?: string +} + +/** + * 更新渠道请求 + */ +export interface OctopusUpdateChannelRequest { + /** 要更新的渠道 ID (必填) */ + id: number + /** 新名称 */ + name?: string + /** 新渠道类型 */ + type?: OctopusOutboundType + /** 是否启用 */ + enabled?: boolean + /** 新的基础 URL 列表 */ + base_urls?: OctopusBaseUrl[] + /** 新的模型列表 */ + model?: string + /** 新的自定义模型列表 */ + custom_model?: string + /** 是否使用代理 */ + proxy?: boolean + /** 是否自动同步 */ + auto_sync?: boolean + /** 自动分组类型 */ + auto_group?: OctopusAutoGroupType + /** 自定义请求头 */ + custom_header?: OctopusCustomHeader[] + /** 渠道专用代理 */ + channel_proxy?: string + /** 参数覆盖配置 */ + param_override?: string + /** 模型匹配正则 */ + match_regex?: string + /** 要添加的新密钥列表 */ + keys_to_add?: OctopusKeyAddRequest[] + /** 要更新的密钥列表 */ + keys_to_update?: OctopusKeyUpdateRequest[] + /** 要删除的密钥 ID 列表 */ + keys_to_delete?: number[] +} + +/** + * 获取上游模型列表请求 + */ +export interface OctopusFetchModelRequest { + /** 渠道类型 */ + type: OctopusOutboundType + /** 基础 URL 列表 */ + base_urls: OctopusBaseUrl[] + /** API 密钥列表 */ + keys: OctopusChannelKey[] + /** 是否使用代理 */ + proxy?: boolean +} + +/** + * Octopus API 通用响应格式 + */ +export interface OctopusApiResponse { + /** 是否成功 */ + success: boolean + /** 响应数据 */ + data?: T + /** 错误消息 */ + message?: string +} + +/** + * Octopus 登录响应 + */ +export interface OctopusLoginResponse { + /** JWT Token */ + token: string + /** Token 过期时间 */ + expire_at: string +} diff --git a/types/octopusConfig.ts b/types/octopusConfig.ts new file mode 100644 index 000000000..fd1753331 --- /dev/null +++ b/types/octopusConfig.ts @@ -0,0 +1,22 @@ +/** + * Octopus 站点配置 + * 用于存储 Octopus 自建站点的连接信息 + */ +export interface OctopusConfig { + /** Octopus 站点基础 URL */ + baseUrl: string + /** 用户名,用于自动登录 */ + username: string + /** 密码,用于自动登录 */ + password: string + /** 缓存的 JWT Token (系统自动管理) */ + cachedToken?: string + /** Token 过期时间戳 (系统自动管理) */ + tokenExpireAt?: number +} + +export const DEFAULT_OCTOPUS_CONFIG: OctopusConfig = { + baseUrl: "", + username: "", + password: "", +} diff --git a/utils/managedSite.ts b/utils/managedSite.ts index c1992b248..a8ee3be39 100644 --- a/utils/managedSite.ts +++ b/utils/managedSite.ts @@ -1,16 +1,23 @@ -import { NEW_API, VELOERA, type ManagedSiteType } from "~/constants/siteType" +import { + NEW_API, + OCTOPUS, + VELOERA, + type ManagedSiteType, +} from "~/constants/siteType" import type { UserPreferences } from "~/services/userPreferences" import type { NewApiConfig } from "~/types/newApiConfig" +import type { OctopusConfig } from "~/types/octopusConfig" import type { VeloeraConfig } from "~/types/veloeraConfig" export type ManagedSiteLabelKey = | "settings:managedSite.newApi" | "settings:managedSite.veloera" + | "settings:managedSite.octopus" /** * Managed site namespace key used under the `messages` i18n namespace. */ -export type ManagedSiteMessagesKey = "newapi" | "veloera" +export type ManagedSiteMessagesKey = "newapi" | "veloera" | "octopus" export interface ManagedSiteAdminConfig { baseUrl: string @@ -18,7 +25,7 @@ export interface ManagedSiteAdminConfig { userId: string } -export type ManagedSiteConfig = NewApiConfig | VeloeraConfig +export type ManagedSiteConfig = NewApiConfig | VeloeraConfig | OctopusConfig /** * Extracts the selected managed site type and its corresponding config from a @@ -31,7 +38,14 @@ export function getManagedSiteConfigFromPreferences( config: ManagedSiteConfig } { const siteType: ManagedSiteType = preferences.managedSiteType || NEW_API - const config = siteType === VELOERA ? preferences.veloera : preferences.newApi + let config: ManagedSiteConfig + if (siteType === OCTOPUS) { + config = preferences.octopus || { baseUrl: "", username: "", password: "" } + } else if (siteType === VELOERA) { + config = preferences.veloera + } else { + config = preferences.newApi + } return { siteType, config } } @@ -51,6 +65,9 @@ export function getManagedSiteConfig(prefs: UserPreferences): { export function getManagedSiteLabelKey( siteType: ManagedSiteType, ): ManagedSiteLabelKey { + if (siteType === OCTOPUS) { + return "settings:managedSite.octopus" + } return siteType === VELOERA ? "settings:managedSite.veloera" : "settings:managedSite.newApi" @@ -62,6 +79,9 @@ export function getManagedSiteLabelKey( export function getManagedSiteMessagesKeyFromSiteType( siteType: ManagedSiteType, ): ManagedSiteMessagesKey { + if (siteType === OCTOPUS) { + return "octopus" + } return siteType === VELOERA ? "veloera" : "newapi" } @@ -74,16 +94,39 @@ export function getManagedSiteMessagesKeyFromSiteType( export function getManagedSiteAdminConfig( preferences: UserPreferences, ): ManagedSiteAdminConfig | null { - const { config } = getManagedSiteConfigFromPreferences(preferences) + const { siteType, config } = getManagedSiteConfigFromPreferences(preferences) + + // Octopus 使用不同的配置结构 + if (siteType === OCTOPUS) { + const octopusConfig = config as OctopusConfig + if ( + !octopusConfig?.baseUrl || + !octopusConfig?.username || + !octopusConfig?.password + ) { + return null + } + return { + baseUrl: octopusConfig.baseUrl, + adminToken: "", // Octopus 使用 JWT,动态获取 + userId: octopusConfig.username, + } + } - if (!config?.baseUrl || !config?.adminToken || !config?.userId) { + // New API / Veloera 使用 adminToken + const legacyConfig = config as NewApiConfig | VeloeraConfig + if ( + !legacyConfig?.baseUrl || + !legacyConfig?.adminToken || + !legacyConfig?.userId + ) { return null } return { - baseUrl: config.baseUrl, - adminToken: config.adminToken, - userId: config.userId, + baseUrl: legacyConfig.baseUrl, + adminToken: legacyConfig.adminToken, + userId: legacyConfig.userId, } } diff --git a/utils/selectOptions.ts b/utils/selectOptions.ts index d679faa46..02e423013 100644 --- a/utils/selectOptions.ts +++ b/utils/selectOptions.ts @@ -31,7 +31,9 @@ export function groupsToOptions( * @param models Channel models from upstream. * @returns Options for model selection. */ -export function modelsToOptions(models: ChannelModel[]): CompactMultiSelectOption[] { +export function modelsToOptions( + models: ChannelModel[], +): CompactMultiSelectOption[] { return models.map((model) => ({ label: model.name, value: model.id })) } From 7e535064d335b39457a73f4adef42269c97f2683 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 14:53:09 +0800 Subject: [PATCH 02/19] fix(types): extend ChannelFormData.type to support OctopusOutboundType ChannelFormData.type was strictly typed as ChannelType, but handleTypeChange in useChannelForm accepts both ChannelType and OctopusOutboundType. This caused a type mismatch when assigning the new type value to formData.type. Extend the type field to accept the union type to support both New API/Veloera and Octopus channel type scenarios. --- types/newapi.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/types/newapi.ts b/types/newapi.ts index 57aa338f5..26a2e181a 100644 --- a/types/newapi.ts +++ b/types/newapi.ts @@ -1,5 +1,7 @@ import { ChannelType } from "~/constants" +import type { OctopusOutboundType } from "./octopus" + /** * Group data from New API */ @@ -60,7 +62,7 @@ export interface ChannelDefaults { */ export interface ChannelFormData { name: string - type: ChannelType + type: ChannelType | OctopusOutboundType key: string base_url: string models: string[] From fe3652179ce6493449f2fca8f10e2597187ce66b Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 15:05:16 +0800 Subject: [PATCH 03/19] fix(octopus): trim input values before saving in OctopusSettings handlers Trim baseUrl, username, and password values in their respective change handlers to ensure persisted values match validated values and avoid storing leading/trailing whitespace. Fixes https://github.com/qixing-jk/all-api-hub/pull/442#discussion_r2777430315 --- .../options/pages/BasicSettings/components/OctopusSettings.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx b/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx index 887dd81aa..c40b0ea56 100644 --- a/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx +++ b/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx @@ -51,18 +51,21 @@ export default function OctopusSettings() { }, [octopusPassword]) const handleBaseUrlChange = async (url: string) => { + url = url.trim() if (url === octopusBaseUrl) return const success = await updateOctopusBaseUrl(url) showUpdateToast(success, t("octopus.fields.baseUrlLabel")) } const handleUsernameChange = async (username: string) => { + username = username.trim() if (username === octopusUsername) return const success = await updateOctopusUsername(username) showUpdateToast(success, t("octopus.fields.usernameLabel")) } const handlePasswordChange = async (password: string) => { + password = password.trim() if (password === octopusPassword) return const success = await updateOctopusPassword(password) showUpdateToast(success, t("octopus.fields.passwordLabel")) From a5a4dc3f37e1fbaf037cea7eba1777f760fa0310 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 15:11:59 +0800 Subject: [PATCH 04/19] fix(octopus): hide priority and weight columns when isOctopus is true Octopus does not use group, priority, or weight concepts. Update both the initial columnVisibility state and the useEffect to hide all three columns when isOctopus is true. Fixes: https://github.com/qixing-jk/all-api-hub/pull/442#discussion_r2777430317 --- entrypoints/options/pages/ManagedSiteChannels/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/entrypoints/options/pages/ManagedSiteChannels/index.tsx b/entrypoints/options/pages/ManagedSiteChannels/index.tsx index a990a3aff..e8cf1f6cf 100644 --- a/entrypoints/options/pages/ManagedSiteChannels/index.tsx +++ b/entrypoints/options/pages/ManagedSiteChannels/index.tsx @@ -122,6 +122,8 @@ export default function ManagedSiteChannels({ const [columnVisibility, setColumnVisibility] = useState({ base_url: false, group: !isOctopus, // Octopus 没有分组概念,隐藏分组列 + priority: !isOctopus, // Octopus 没有优先级概念,隐藏优先级列 + weight: !isOctopus, // Octopus 没有权重概念,隐藏权重列 }) const [pagination, setPagination] = useState({ pageIndex: 0, @@ -177,11 +179,13 @@ export default function ManagedSiteChannels({ void refreshChannels() }, [refreshChannels]) - // 当站点类型变化时,更新分组列的可见性 + // 当站点类型变化时,更新分组、优先级、权重列的可见性 useEffect(() => { setColumnVisibility((prev) => ({ ...prev, group: !isOctopus, + priority: !isOctopus, + weight: !isOctopus, })) }, [isOctopus]) From 240038fe6f06b72024615c84ad62d7719491f16d Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 15:16:42 +0800 Subject: [PATCH 05/19] fix(octopus): handle non-JSON error responses in login Check response.ok before calling response.json() to avoid SyntaxError when server returns non-JSON error bodies (e.g., HTML error pages). Now properly extracts error message from JSON or falls back to text. Fixes: https://github.com/qixing-jk/all-api-hub/pull/442#discussion_r2777430319 --- services/apiService/octopus/auth.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/services/apiService/octopus/auth.ts b/services/apiService/octopus/auth.ts index a5f9f0b76..b1b62763e 100644 --- a/services/apiService/octopus/auth.ts +++ b/services/apiService/octopus/auth.ts @@ -54,6 +54,19 @@ class OctopusAuthManager { body: JSON.stringify(credentials), }) + if (!response.ok) { + let errorBody: string + try { + const errorJson = await response.json() + errorBody = errorJson.message || JSON.stringify(errorJson) + } catch { + errorBody = await response.text() + } + throw new Error( + `Login failed: HTTP ${response.status} - ${errorBody || "Unknown error"}`, + ) + } + const data = await response.json() if (data.code !== 200 || !data.data?.token) { From 3424a0509033192418bb32bc48290e568878a2f5 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 15:21:35 +0800 Subject: [PATCH 06/19] fix(octopus): add robust error handling for non-JSON responses in fetchOctopusApi - Check response.ok before parsing JSON to handle HTTP errors properly - Inspect Content-Type header before calling response.json() - Read response.text() for non-JSON error pages (e.g., HTML 502 pages) - Include HTTP status, statusText, and response body in error messages - Wrap JSON parsing in try/catch to prevent SyntaxError on malformed responses Fixes: https://github.com/qixing-jk/all-api-hub/pull/442#discussion_r2777430321 --- services/apiService/octopus/index.ts | 52 +++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/services/apiService/octopus/index.ts b/services/apiService/octopus/index.ts index c22c76f4e..268bb1764 100644 --- a/services/apiService/octopus/index.ts +++ b/services/apiService/octopus/index.ts @@ -37,18 +37,60 @@ async function fetchOctopusApi( }, }) - const data = await response.json() + // 检查 HTTP 状态码,处理非成功响应 + if (!response.ok) { + const contentType = response.headers.get("Content-Type") || "" + let errorMessage: string + + if (contentType.includes("application/json")) { + // 尝试解析 JSON 错误响应 + try { + const errorData = await response.json() + errorMessage = + errorData.message || errorData.error || JSON.stringify(errorData) + } catch { + errorMessage = await response.text() + } + } else { + // 非 JSON 响应,读取文本 + errorMessage = await response.text() + } + + throw new Error( + `HTTP ${response.status} ${response.statusText}: ${errorMessage}`, + ) + } + + // 检查 Content-Type 是否为 JSON + const contentType = response.headers.get("Content-Type") || "" + if (!contentType.includes("application/json")) { + const text = await response.text() + throw new Error( + `Expected JSON response but got ${contentType || "unknown content type"}: ${text.slice(0, 200)}`, + ) + } + + let data: unknown + try { + data = await response.json() + } catch { + throw new Error(`Failed to parse JSON response from ${endpoint}`) + } // Octopus 返回格式: { success: boolean, data?: T, message?: string } // 或者 { code: number, message: string, data?: T } - if (data.success === false || (data.code && data.code !== 200)) { - throw new Error(data.message || "API request failed") + const responseData = data as Record + if ( + responseData.success === false || + (responseData.code && responseData.code !== 200) + ) { + throw new Error((responseData.message as string) || "API request failed") } return { success: true, - data: data.data ?? data, - message: data.message || "success", + data: (responseData.data ?? data) as T, + message: (responseData.message as string) || "success", } } From 130c88aa723ced096b51e1ded482fd2ec8bb963e Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 15:33:06 +0800 Subject: [PATCH 07/19] fix(octopus): return null instead of envelope when responseData.data is undefined The previous implementation of `fetchOctopusApi` used `responseData.data ?? data` which would fall back to the entire response object (containing success, message, etc.) when `responseData.data` was undefined. This leaked envelope fields into the typed payload. Now returns `null` when data is missing, ensuring callers get a clean `T | null` instead of a potentially polluted envelope object. Ref: https://github.com/qixing-jk/all-api-hub/pull/442#discussion_r2777430322 --- services/apiService/octopus/index.ts | 2 +- types/octopus.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/apiService/octopus/index.ts b/services/apiService/octopus/index.ts index 268bb1764..490ad70b8 100644 --- a/services/apiService/octopus/index.ts +++ b/services/apiService/octopus/index.ts @@ -89,7 +89,7 @@ async function fetchOctopusApi( return { success: true, - data: (responseData.data ?? data) as T, + data: (responseData.data as T | undefined) ?? null, message: (responseData.message as string) || "success", } } diff --git a/types/octopus.ts b/types/octopus.ts index 5881177aa..9a9bdaae3 100644 --- a/types/octopus.ts +++ b/types/octopus.ts @@ -253,8 +253,8 @@ export interface OctopusFetchModelRequest { export interface OctopusApiResponse { /** 是否成功 */ success: boolean - /** 响应数据 */ - data?: T + /** 响应数据(无数据时为 null) */ + data?: T | null /** 错误消息 */ message?: string } From 73edf4768f1953e670d937f8dc7f7dc0d3e188d5 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 15:40:22 +0800 Subject: [PATCH 08/19] fix(octopus): avoid mutating input channel and use typed error handling - Remove mutation of channel.models in runForChannel - Change lastError and catch clause from any to unknown - Use getErrorMessage() for safe error message extraction - Use ApiError instanceof check for httpStatus extraction Fixes: https://github.com/qixing-jk/all-api-hub/pull/442#discussion_r2777430323 --- services/modelSync/octopusModelSync.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/services/modelSync/octopusModelSync.ts b/services/modelSync/octopusModelSync.ts index b2a34d6b0..d0c890439 100644 --- a/services/modelSync/octopusModelSync.ts +++ b/services/modelSync/octopusModelSync.ts @@ -2,6 +2,7 @@ * Octopus 模型同步服务 * 实现 Octopus 站点的模型同步功能 */ +import { ApiError } from "~/services/apiService/common/errors" import * as octopusApi from "~/services/apiService/octopus" import type { ManagedSiteChannel } from "~/types/managedSite" import { @@ -12,6 +13,7 @@ import { } from "~/types/managedSiteModelSync" import type { OctopusChannel, OctopusFetchModelRequest } from "~/types/octopus" import type { OctopusConfig } from "~/types/octopusConfig" +import { getErrorMessage } from "~/utils/error" import { createLogger } from "~/utils/logger" const logger = createLogger("OctopusModelSync") @@ -93,7 +95,7 @@ async function runForChannel( maxRetries: number = 2, ): Promise { let attempts = 0 - let lastError: any = null + let lastError: unknown = null const oldModels = channel.models ? channel.models @@ -111,7 +113,6 @@ async function runForChannel( if (haveModelsChanged(oldModels, normalizedModels)) { await updateChannelModels(config, channel, normalizedModels) - channel.models = normalizedModels.join(",") } return { @@ -124,7 +125,7 @@ async function runForChannel( newModels: normalizedModels, message: "Success", } - } catch (error: any) { + } catch (error: unknown) { lastError = error logger.error("Unexpected error for channel", { channelId: channel.id, @@ -146,8 +147,9 @@ async function runForChannel( channelId: channel.id, channelName: channel.name, ok: false, - httpStatus: lastError?.httpStatus, - message: lastError?.message || "Unknown error", + httpStatus: + lastError instanceof ApiError ? lastError.statusCode : undefined, + message: getErrorMessage(lastError), attempts, finishedAt: Date.now(), oldModels, From ff43617e16cb1a70ca86ba055d89f7c0488c5973 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 15:50:14 +0800 Subject: [PATCH 09/19] fix(octopus): validate config in listChannels and executeSyncForOctopus Add config validation to Octopus branches in listChannels and executeSyncForOctopus methods, matching the validation pattern used in createService. This ensures user-friendly error messages when config is missing/invalid, instead of leaking raw auth/fetch errors. Fixes: https://github.com/qixing-jk/all-api-hub/pull/442#discussion_r2777430327 --- services/modelSync/scheduler.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/services/modelSync/scheduler.ts b/services/modelSync/scheduler.ts index bfe35160e..429b74a05 100644 --- a/services/modelSync/scheduler.ts +++ b/services/modelSync/scheduler.ts @@ -200,12 +200,22 @@ class ModelSyncScheduler { async listChannels(): Promise { const userPrefs = await userPreferences.getPreferences() - const { siteType } = getManagedSiteContext(userPrefs) + const { siteType, messagesKey } = getManagedSiteContext(userPrefs) // Octopus 使用独立的 API 服务 if (siteType === OCTOPUS) { const { config } = getManagedSiteConfig(userPrefs) const octopusConfig = config as OctopusConfig + + // Validate config like createService does + if ( + !octopusConfig?.baseUrl || + !octopusConfig?.username || + !octopusConfig?.password + ) { + throw new Error(t(`messages:${messagesKey}.configMissing`)) + } + const channels = await octopusApi.listChannels(octopusConfig) return { items: channels.map(octopusChannelToManagedSite), @@ -398,6 +408,15 @@ class ModelSyncScheduler { const { config } = getManagedSiteConfig(prefs) const octopusConfig = config as OctopusConfig + // Validate config like createService does + if ( + !octopusConfig?.baseUrl || + !octopusConfig?.username || + !octopusConfig?.password + ) { + throw new Error(t(`messages:${messagesKey}.configMissing`)) + } + // List channels using Octopus API const octopusChannels = await octopusApi.listChannels(octopusConfig) const allChannels = octopusChannels.map(octopusChannelToManagedSite) From 6757fd3708f95e320eafacc63979d5dc25442ae3 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 16:04:55 +0800 Subject: [PATCH 10/19] refactor(octopus): define explicit OctopusChannelWithData composite type - Add OctopusChannelWithData type in types/managedSite.ts extending NewApiChannel with required _octopusData field - Change octopusChannelToManagedSite return type to OctopusChannelWithData and remove inline type assertion - Add isOctopusChannelWithData type guard in octopusModelSync.ts to replace ad-hoc casts Fixes: https://github.com/qixing-jk/all-api-hub/pull/442#discussion_r2777430328 --- services/modelSync/octopusModelSync.ts | 22 +++++++++++++++++----- services/octopusService/octopusService.ts | 19 +++++++++++++++++-- types/managedSite.ts | 14 ++++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/services/modelSync/octopusModelSync.ts b/services/modelSync/octopusModelSync.ts index d0c890439..322e3d4d3 100644 --- a/services/modelSync/octopusModelSync.ts +++ b/services/modelSync/octopusModelSync.ts @@ -4,7 +4,10 @@ */ import { ApiError } from "~/services/apiService/common/errors" import * as octopusApi from "~/services/apiService/octopus" -import type { ManagedSiteChannel } from "~/types/managedSite" +import type { + ManagedSiteChannel, + OctopusChannelWithData, +} from "~/types/managedSite" import { BatchExecutionOptions, ExecutionItemResult, @@ -18,16 +21,25 @@ import { createLogger } from "~/utils/logger" const logger = createLogger("OctopusModelSync") +/** + * 类型守卫:检查 channel 是否为 OctopusChannelWithData + */ +function isOctopusChannelWithData( + channel: ManagedSiteChannel, +): channel is OctopusChannelWithData { + return "_octopusData" in channel && channel._octopusData != null +} + /** * 从 ManagedSiteChannel 中提取 Octopus 原始数据 */ function getOctopusChannelData( channel: ManagedSiteChannel, ): OctopusChannel | null { - const octopusData = ( - channel as ManagedSiteChannel & { _octopusData?: OctopusChannel } - )._octopusData - return octopusData ?? null + if (isOctopusChannelWithData(channel)) { + return channel._octopusData + } + return null } /** diff --git a/services/octopusService/octopusService.ts b/services/octopusService/octopusService.ts index bc769b510..c26610732 100644 --- a/services/octopusService/octopusService.ts +++ b/services/octopusService/octopusService.ts @@ -28,6 +28,7 @@ import type { CreateChannelPayload, ManagedSiteChannel, ManagedSiteChannelListData, + OctopusChannelWithData, UpdateChannelPayload, } from "~/types/managedSite" import type { @@ -136,7 +137,7 @@ async function getFullOctopusConfig(): Promise { */ export function octopusChannelToManagedSite( channel: OctopusChannel, -): ManagedSiteChannel { +): OctopusChannelWithData { return { id: channel.id, name: channel.name, @@ -156,14 +157,28 @@ export function octopusChannelToManagedSite( test_time: 0, response_time: 0, balance: 0, + balance_updated_time: 0, used_quota: 0, tag: null, remark: null, setting: "", settings: "", + // NewApiChannel 额外字段 + openai_organization: null, + other: "", + other_info: "", + param_override: null, + header_override: null, + channel_info: { + is_multi_key: false, + multi_key_size: 0, + multi_key_status_list: null, + multi_key_polling_index: 0, + multi_key_mode: "", + }, // 存储原始 Octopus 数据以便编辑 _octopusData: channel, - } as ManagedSiteChannel & { _octopusData?: OctopusChannel } + } } /** diff --git a/types/managedSite.ts b/types/managedSite.ts index d00a21d23..018307466 100644 --- a/types/managedSite.ts +++ b/types/managedSite.ts @@ -1,3 +1,12 @@ +/** + * Octopus 渠道与 ManagedSiteChannel 的复合类型 + * 用于在 ManagedSiteChannel 基础上附加原始 Octopus 渠道数据 + * + * 注意:此类型扩展 ManagedSiteChannel 的所有字段,并添加必填的 _octopusData 字段 + */ +import type { NewApiChannel } from "./newapi" +import type { OctopusChannel } from "./octopus" + export { CHANNEL_MODE, CHANNEL_STATUS } from "./newapi" export type { @@ -16,3 +25,8 @@ export type { NewApiChannel as ManagedSiteChannel, NewApiChannelListData as ManagedSiteChannelListData, } from "./newapi" + +export type OctopusChannelWithData = NewApiChannel & { + /** 原始 Octopus 渠道数据 (必填) */ + _octopusData: OctopusChannel +} From c7e14f4fe63af732790cc6605afd7c0d85b7d654 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 16:29:01 +0800 Subject: [PATCH 11/19] fix(octopus): add proper ChannelType to OctopusOutboundType mapping Replace incorrect double-casting (channel.type as unknown as OctopusOutboundType) with a proper mapping function that: - Validates and preserves OctopusOutboundType values (0-5) from Octopus forms - Maps ChannelType values to corresponding OctopusOutboundType for future use - Falls back to DEFAULT_OCTOPUS_CHANNEL_FIELDS.type for unsupported types Fixes: https://github.com/qixing-jk/all-api-hub/pull/442#discussion_r2777430329 --- services/octopusService/octopusService.ts | 61 +++++++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/services/octopusService/octopusService.ts b/services/octopusService/octopusService.ts index c26610732..dd081622c 100644 --- a/services/octopusService/octopusService.ts +++ b/services/octopusService/octopusService.ts @@ -5,6 +5,7 @@ import { t } from "i18next" import toast from "react-hot-toast" +import { ChannelType } from "~/constants" import { DEFAULT_OCTOPUS_CHANNEL_FIELDS } from "~/constants/octopus" import { OCTOPUS } from "~/constants/siteType" import type { AccountToken } from "~/entrypoints/options/pages/KeyManagement/type" @@ -31,10 +32,10 @@ import type { OctopusChannelWithData, UpdateChannelPayload, } from "~/types/managedSite" +import { OctopusOutboundType } from "~/types/octopus" import type { OctopusChannel, OctopusCreateChannelRequest, - OctopusOutboundType, } from "~/types/octopus" import type { OctopusConfig } from "~/types/octopusConfig" import { getErrorMessage } from "~/utils/error" @@ -61,6 +62,53 @@ function normalizeList(values: string[] = []): string[] { return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))) } +/** + * 将 ChannelType (New API 渠道类型 0-55) 映射为 OctopusOutboundType (0-5) + * Octopus 使用不同的类型枚举来表示协议转换器类型 + * @param channelType - New API 的 ChannelType 值或 OctopusOutboundType 值 + * @param isOctopusType - 如果为 true,表示 channelType 已经是 OctopusOutboundType,直接返回 + * @returns 对应的 OctopusOutboundType 值 + */ +function mapChannelTypeToOctopusOutboundType( + channelType: ChannelType | OctopusOutboundType | number | undefined, + isOctopusType = false, +): OctopusOutboundType { + // 如果明确指定是 Octopus 类型,且值在有效范围内,直接返回 + if (isOctopusType && channelType !== undefined) { + if ( + channelType >= OctopusOutboundType.OpenAIChat && + channelType <= OctopusOutboundType.OpenAIEmbedding + ) { + return channelType as OctopusOutboundType + } + // 无效的 Octopus 类型,回退到默认值 + return DEFAULT_OCTOPUS_CHANNEL_FIELDS.type + } + + // 对于大于 5 的值,肯定是 ChannelType,需要映射 + // 对于 0-5 范围内的值,如果不是明确的 isOctopusType,则当作 ChannelType 处理 + switch (channelType) { + // Anthropic 系列 (ChannelType.Anthropic = 14) + case ChannelType.Anthropic: + return OctopusOutboundType.Anthropic + + // Gemini 系列 (ChannelType.Gemini = 24, ChannelType.VertexAi = 41) + case ChannelType.Gemini: + case ChannelType.VertexAi: + return OctopusOutboundType.Gemini + + // 火山引擎 (ChannelType.VolcEngine = 45) + case ChannelType.VolcEngine: + return OctopusOutboundType.Volcengine + + // 其他所有类型都使用 OpenAI Chat 兼容模式 + // 包括: OpenAI, Azure, Ollama, DeepSeek, Moonshot, OpenRouter, Mistral 等 + // 以及 ChannelType 0-5 范围内的值(Unknown, OpenAI, Midjourney, Azure, Ollama, MidjourneyPlus) + default: + return DEFAULT_OCTOPUS_CHANNEL_FIELDS.type + } +} + /** * 为 Octopus 渠道构建 base URL * Octopus 的 URL 规则需要添加 /v1 后缀 @@ -224,9 +272,8 @@ export async function createChannel( const channel = channelData.channel const request: OctopusCreateChannelRequest = { name: channel.name || "", - type: - (channel.type as unknown as OctopusOutboundType) || - DEFAULT_OCTOPUS_CHANNEL_FIELDS.type, + // Octopus 表单使用 OctopusTypeSelector,type 已经是 OctopusOutboundType + type: mapChannelTypeToOctopusOutboundType(channel.type, true), enabled: channel.status === 1, base_urls: [{ url: channel.base_url || "" }], keys: [{ enabled: true, channel_key: channel.key || "" }], @@ -268,7 +315,11 @@ export async function updateChannel( const result = await octopusApi.updateChannel(config, { id: channelData.id, name: channelData.name, - type: channelData.type as unknown as OctopusOutboundType, + // Octopus 表单使用 OctopusTypeSelector,type 已经是 OctopusOutboundType + type: + channelData.type !== undefined + ? mapChannelTypeToOctopusOutboundType(channelData.type, true) + : undefined, enabled: channelData.status === 1, base_urls: channelData.base_url ? [{ url: channelData.base_url }] From 690f9a6ede1e04849c4cf505b246235ba1880b8e Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 16:34:07 +0800 Subject: [PATCH 12/19] fix(octopus): normalize accountBaseUrl in findMatchingChannel Fixes channel matching by applying buildOctopusBaseUrl to normalize the incoming accountBaseUrl before comparison. This ensures consistency with prepareChannelFormData which adds /v1 suffix when creating channels. Closes: https://github.com/qixing-jk/all-api-hub/pull/442#discussion_r2777430330 --- services/octopusService/octopusService.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/octopusService/octopusService.ts b/services/octopusService/octopusService.ts index dd081622c..d1f7af401 100644 --- a/services/octopusService/octopusService.ts +++ b/services/octopusService/octopusService.ts @@ -482,11 +482,14 @@ export async function findMatchingChannel( const channels = await octopusApi.listChannels(config) + // 规范化 accountBaseUrl,与 prepareChannelFormData 保持一致 + const normalizedBase = buildOctopusBaseUrl(accountBaseUrl) + const match = channels.find((ch) => { const chBaseUrl = ch.base_urls[0]?.url || "" const chModels = parseDelimitedList(ch.model) return ( - chBaseUrl === accountBaseUrl && + chBaseUrl === normalizedBase && chModels.length === models.length && chModels.every((m) => models.includes(m)) ) From d0986eb4c9923e32d9e02dc3bcc258e7f8b37c83 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 16:41:52 +0800 Subject: [PATCH 13/19] fix(preferences): add migration v12 to initialize octopus config Bump CURRENT_PREFERENCES_VERSION from 11 to 12 and add migration function that initializes missing userPreferences.octopus field with DEFAULT_OCTOPUS_CONFIG for existing users during migration. Fixes #442 (review comment) --- .../preferences/preferencesMigration.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/services/configMigration/preferences/preferencesMigration.ts b/services/configMigration/preferences/preferencesMigration.ts index e1cd4dc73..0ff129d2c 100644 --- a/services/configMigration/preferences/preferencesMigration.ts +++ b/services/configMigration/preferences/preferencesMigration.ts @@ -13,6 +13,7 @@ import { DEFAULT_ACCOUNT_AUTO_REFRESH, type AccountAutoRefresh, } from "~/types/accountAutoRefresh" +import { DEFAULT_OCTOPUS_CONFIG } from "~/types/octopusConfig" import { createLogger } from "~/utils/logger" import type { UserPreferences } from "../../userPreferences" @@ -21,7 +22,7 @@ import { migrateSortingConfig } from "./sortingConfigMigration" const logger = createLogger("PreferencesMigration") // Current version of the preferences schema -export const CURRENT_PREFERENCES_VERSION = 11 +export const CURRENT_PREFERENCES_VERSION = 12 /** * Migration function type @@ -256,6 +257,22 @@ const migrations: Record = { preferencesVersion: 11, } }, + + // Version 11 -> 12: Initialize octopus config if missing + 12: (prefs: UserPreferences): UserPreferences => { + logger.debug( + "Migrating preferences from v11 to v12 (octopus config initialization)", + ) + + const storedOctopus = (prefs as any).octopus + const octopus = storedOctopus ? storedOctopus : DEFAULT_OCTOPUS_CONFIG + + return { + ...prefs, + octopus, + preferencesVersion: 12, + } + }, } /** From 2e06cca68e0862b10a12accb8d76d0fd92978474 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 17:16:23 +0800 Subject: [PATCH 14/19] fix(octopus): address code review issues for type safety and error handling - Broaden UpdateChannelPayload.type to ChannelType | OctopusOutboundType - Fix double response stream consumption in auth.ts and index.ts - Add expireAt validation with fallback TTL in auth.ts - Fix code:0 being skipped by truthy check in index.ts - Persist trimmed values in OctopusSettings validation - Add clarifying comment for Octopus sync omitting ModelRedirectService --- .../components/OctopusSettings.tsx | 11 ++++++++++ services/apiService/octopus/auth.ts | 22 ++++++++++++++----- services/apiService/octopus/index.ts | 13 ++++++----- services/modelSync/scheduler.ts | 9 ++++++++ types/newapi.ts | 2 +- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx b/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx index c40b0ea56..00cc6c48c 100644 --- a/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx +++ b/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx @@ -81,6 +81,11 @@ export default function OctopusSettings() { return } + // Persist trimmed values to ensure stored inputs match validated values + setLocalBaseUrl(trimmedUrl) + setLocalUsername(trimmedUsername) + setLocalPassword(trimmedPassword) + setIsValidating(true) try { const isValid = await octopusAuthManager.validateConfig({ @@ -90,6 +95,12 @@ export default function OctopusSettings() { }) if (isValid) { + // Persist validated config to storage + await Promise.all([ + updateOctopusBaseUrl(trimmedUrl), + updateOctopusUsername(trimmedUsername), + updateOctopusPassword(trimmedPassword), + ]) toast.success(t("octopus.validation.success")) } else { toast.error(t("octopus.validation.failed")) diff --git a/services/apiService/octopus/auth.ts b/services/apiService/octopus/auth.ts index b1b62763e..fc6427d1b 100644 --- a/services/apiService/octopus/auth.ts +++ b/services/apiService/octopus/auth.ts @@ -55,12 +55,14 @@ class OctopusAuthManager { }) if (!response.ok) { + // Read body once as text, then try to parse as JSON + const bodyText = await response.text() let errorBody: string try { - const errorJson = await response.json() - errorBody = errorJson.message || JSON.stringify(errorJson) + const errorJson = JSON.parse(bodyText) + errorBody = errorJson.message || bodyText } catch { - errorBody = await response.text() + errorBody = bodyText } throw new Error( `Login failed: HTTP ${response.status} - ${errorBody || "Unknown error"}`, @@ -135,8 +137,18 @@ class OctopusAuthManager { password: config.password, }) - // 解析过期时间 - const expireAt = new Date(response.expire_at).getTime() + // 解析过期时间,验证有效性 + const parsedExpireAt = new Date(response.expire_at).getTime() + const defaultTTL = 24 * 60 * 60 * 1000 // 24 hours fallback + let expireAt: number + if (Number.isFinite(parsedExpireAt)) { + expireAt = parsedExpireAt + } else { + logger.warn("Invalid expire_at from server, using default TTL", { + expire_at: response.expire_at, + }) + expireAt = Date.now() + defaultTTL + } // 更新内存缓存 this.tokenCache.set(cacheKey, { diff --git a/services/apiService/octopus/index.ts b/services/apiService/octopus/index.ts index 490ad70b8..df1abcf58 100644 --- a/services/apiService/octopus/index.ts +++ b/services/apiService/octopus/index.ts @@ -42,18 +42,21 @@ async function fetchOctopusApi( const contentType = response.headers.get("Content-Type") || "" let errorMessage: string + // Read body once as text, then try to parse as JSON + const rawBody = await response.text() + if (contentType.includes("application/json")) { // 尝试解析 JSON 错误响应 try { - const errorData = await response.json() + const errorData = JSON.parse(rawBody) errorMessage = errorData.message || errorData.error || JSON.stringify(errorData) } catch { - errorMessage = await response.text() + errorMessage = rawBody } } else { - // 非 JSON 响应,读取文本 - errorMessage = await response.text() + // 非 JSON 响应,使用已读取的文本 + errorMessage = rawBody } throw new Error( @@ -82,7 +85,7 @@ async function fetchOctopusApi( const responseData = data as Record if ( responseData.success === false || - (responseData.code && responseData.code !== 200) + (responseData.code !== undefined && responseData.code !== 200) ) { throw new Error((responseData.message as string) || "API request failed") } diff --git a/services/modelSync/scheduler.ts b/services/modelSync/scheduler.ts index 429b74a05..363bc0f58 100644 --- a/services/modelSync/scheduler.ts +++ b/services/modelSync/scheduler.ts @@ -397,6 +397,15 @@ class ModelSyncScheduler { /** * Execute model sync for Octopus site. * Octopus uses a different API structure for fetching and updating models. + * + * NOTE: This Octopus-specific sync path intentionally omits ModelRedirectService + * mappings (unlike executeSync for New API/Veloera). This is because: + * 1. runOctopusBatch / octopusApi.updateChannel only update the model list directly + * 2. Octopus channels initialize model_mapping as an empty string + * 3. Redirect logic is not applicable to Octopus's channel architecture + * + * If redirect behavior is ever required for Octopus, refer to ModelRedirectService + * and the executeSync method for the pattern used by New API/Veloera channels. */ private async executeSyncForOctopus( channelIds: number[] | undefined, diff --git a/types/newapi.ts b/types/newapi.ts index 26a2e181a..25071678b 100644 --- a/types/newapi.ts +++ b/types/newapi.ts @@ -90,7 +90,7 @@ export interface UpdateChannelPayload { * 渠道ID */ id: number - type?: ChannelType + type?: ChannelType | OctopusOutboundType max_input_tokens?: number other?: string models?: string From a19b35d9f52a27fdffcb58161a174b00b50cda4e Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 17:44:45 +0800 Subject: [PATCH 15/19] refactor(octopus): simplify token caching to memory-only Remove token persistence fields from OctopusConfig since Octopus tokens have short default expiry (15 minutes) and can be customized via login expire parameter. Memory-only caching in service worker is sufficient. - Remove cachedToken and tokenExpireAt from OctopusConfig - Remove storage cache check logic from getValidToken - Reduce buffer time from 5 min to 1 min (matches short token TTL) - Update default TTL fallback from 24h to 15min (Octopus default) --- services/apiService/octopus/auth.ts | 32 ++++++++--------------------- types/octopusConfig.ts | 4 ---- 2 files changed, 8 insertions(+), 28 deletions(-) diff --git a/services/apiService/octopus/auth.ts b/services/apiService/octopus/auth.ts index fc6427d1b..106effe0a 100644 --- a/services/apiService/octopus/auth.ts +++ b/services/apiService/octopus/auth.ts @@ -95,8 +95,11 @@ class OctopusAuthManager { /** * 获取有效的 JWT Token - * - 如果缓存中有有效 Token,直接返回 + * - 如果内存缓存中有有效 Token,直接返回 * - 如果 Token 过期或不存在,自动重新登录获取 + * + * 注意:Token 仅缓存在内存中,不持久化到存储。 + * Octopus 默认 token 有效期为 15 分钟,可通过登录时的 expire 参数自定义。 */ async getValidToken(config: OctopusConfig): Promise { if (!config.baseUrl || !config.username || !config.password) { @@ -106,31 +109,13 @@ class OctopusAuthManager { const cacheKey = this.getCacheKey(config.baseUrl, config.username) const cached = this.tokenCache.get(cacheKey) - // 检查缓存是否有效(提前 5 分钟刷新) - const bufferTime = 5 * 60 * 1000 + // 检查内存缓存是否有效(提前 1 分钟刷新,因为默认有效期较短) + const bufferTime = 1 * 60 * 1000 if (cached && cached.expireAt > Date.now() + bufferTime) { return cached.token } - // 检查存储中的缓存 Token - if ( - config.cachedToken && - config.tokenExpireAt && - config.tokenExpireAt > Date.now() + bufferTime - ) { - // 验证 Token 是否仍然有效 - const isValid = await this.checkStatus(config.baseUrl, config.cachedToken) - if (isValid) { - // 更新内存缓存 - this.tokenCache.set(cacheKey, { - token: config.cachedToken, - expireAt: config.tokenExpireAt, - }) - return config.cachedToken - } - } - - // 自动重新登录 + // 自动登录获取新 Token logger.info("Auto-login to Octopus", { baseUrl: config.baseUrl }) const response = await this.login(config.baseUrl, { username: config.username, @@ -139,7 +124,7 @@ class OctopusAuthManager { // 解析过期时间,验证有效性 const parsedExpireAt = new Date(response.expire_at).getTime() - const defaultTTL = 24 * 60 * 60 * 1000 // 24 hours fallback + const defaultTTL = 15 * 60 * 1000 // 15 minutes fallback (Octopus default) let expireAt: number if (Number.isFinite(parsedExpireAt)) { expireAt = parsedExpireAt @@ -156,7 +141,6 @@ class OctopusAuthManager { expireAt, }) - // 返回新 Token(调用方需要更新存储中的缓存) return response.token } diff --git a/types/octopusConfig.ts b/types/octopusConfig.ts index fd1753331..c0c4274fd 100644 --- a/types/octopusConfig.ts +++ b/types/octopusConfig.ts @@ -9,10 +9,6 @@ export interface OctopusConfig { username: string /** 密码,用于自动登录 */ password: string - /** 缓存的 JWT Token (系统自动管理) */ - cachedToken?: string - /** Token 过期时间戳 (系统自动管理) */ - tokenExpireAt?: number } export const DEFAULT_OCTOPUS_CONFIG: OctopusConfig = { From 6e32fecd4ecc250a35bc972fc322e5f82b7175b4 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 8 Feb 2026 23:27:39 +0800 Subject: [PATCH 16/19] refactor: extract shared utils and remove unused octopus functions - Create utils/string.ts with parseDelimitedList and normalizeList - Add normalizeBaseUrl to utils/url.ts - Remove duplicate local functions from octopusService, newApiService, veloeraService - Remove unused functions from octopus API service: - checkStatus (auth.ts) - toggleChannelEnabled, triggerModelSync, getLastSyncTime (index.ts) - parseCommaSeparated, toCommaSeparated, normalizeBaseUrl (utils.ts) --- services/apiService/octopus/auth.ts | 15 ------ services/apiService/octopus/index.ts | 57 +---------------------- services/apiService/octopus/utils.ts | 25 ---------- services/newApiService/newApiService.ts | 19 +------- services/octopusService/octopusService.ts | 19 +------- services/veloeraService/veloeraService.ts | 19 +------- utils/string.ts | 25 ++++++++++ utils/url.ts | 9 ++++ 8 files changed, 39 insertions(+), 149 deletions(-) create mode 100644 utils/string.ts diff --git a/services/apiService/octopus/auth.ts b/services/apiService/octopus/auth.ts index 106effe0a..d6081e841 100644 --- a/services/apiService/octopus/auth.ts +++ b/services/apiService/octopus/auth.ts @@ -78,21 +78,6 @@ class OctopusAuthManager { return data.data as OctopusLoginResponse } - /** - * 检查 Octopus 认证状态 - */ - async checkStatus(baseUrl: string, jwtToken: string): Promise { - try { - const url = `${baseUrl.replace(/\/$/, "")}/api/v1/user/status` - const response = await fetch(url, { - headers: { Authorization: `Bearer ${jwtToken}` }, - }) - return response.ok - } catch { - return false - } - } - /** * 获取有效的 JWT Token * - 如果内存缓存中有有效 Token,直接返回 diff --git a/services/apiService/octopus/index.ts b/services/apiService/octopus/index.ts index df1abcf58..add2adb9e 100644 --- a/services/apiService/octopus/index.ts +++ b/services/apiService/octopus/index.ts @@ -11,9 +11,10 @@ import type { } from "~/types/octopus" import type { OctopusConfig } from "~/types/octopusConfig" import { createLogger } from "~/utils/logger" +import { normalizeBaseUrl } from "~/utils/url" import { octopusAuthManager } from "./auth" -import { buildOctopusAuthHeaders, normalizeBaseUrl } from "./utils" +import { buildOctopusAuthHeaders } from "./utils" const logger = createLogger("OctopusAPI") @@ -204,31 +205,6 @@ export async function deleteChannel( } } -/** - * 启用/禁用渠道 - */ -export async function toggleChannelEnabled( - config: OctopusConfig, - channelId: number, - enabled: boolean, -): Promise> { - try { - const result = await fetchOctopusApi( - config, - "/api/v1/channel/enable", - { - method: "POST", - body: JSON.stringify({ id: channelId, enabled }), - }, - ) - logger.info("Channel toggled", { id: channelId, enabled }) - return result - } catch (error) { - logger.error("Failed to toggle channel", error) - throw error - } -} - /** * 获取上游模型列表 */ @@ -252,34 +228,5 @@ export async function fetchRemoteModels( } } -/** - * 触发模型同步 - */ -export async function triggerModelSync( - config: OctopusConfig, -): Promise> { - return await fetchOctopusApi(config, "/api/v1/channel/sync", { - method: "POST", - }) -} - -/** - * 获取上次同步时间 - */ -export async function getLastSyncTime( - config: OctopusConfig, -): Promise { - try { - const result = await fetchOctopusApi( - config, - "/api/v1/channel/last-sync-time", - ) - return result.data || null - } catch (error) { - logger.error("Failed to get last sync time", error) - return null - } -} - // 重新导出认证管理器 export { octopusAuthManager } from "./auth" diff --git a/services/apiService/octopus/utils.ts b/services/apiService/octopus/utils.ts index 1bf23ff8a..c1c4126ff 100644 --- a/services/apiService/octopus/utils.ts +++ b/services/apiService/octopus/utils.ts @@ -13,28 +13,3 @@ export function buildOctopusAuthHeaders( "Content-Type": "application/json", } } - -/** - * 规范化 Base URL(移除尾部斜杠) - */ -export function normalizeBaseUrl(baseUrl: string): string { - return baseUrl.replace(/\/$/, "") -} - -/** - * 解析逗号分隔的字符串为数组 - */ -export function parseCommaSeparated(value?: string | null): string[] { - if (!value) return [] - return value - .split(",") - .map((item) => item.trim()) - .filter(Boolean) -} - -/** - * 将数组转换为逗号分隔的字符串 - */ -export function toCommaSeparated(values: string[]): string { - return values.filter(Boolean).join(",") -} diff --git a/services/newApiService/newApiService.ts b/services/newApiService/newApiService.ts index 6f027e16e..62a8a37d6 100644 --- a/services/newApiService/newApiService.ts +++ b/services/newApiService/newApiService.ts @@ -24,6 +24,7 @@ import type { import { isArraysEqual } from "~/utils" import { getErrorMessage } from "~/utils/error" import { createLogger } from "~/utils/logger" +import { normalizeList, parseDelimitedList } from "~/utils/string" import { UserPreferences, userPreferences } from "../userPreferences" @@ -32,24 +33,6 @@ import { UserPreferences, userPreferences } from "../userPreferences" */ const logger = createLogger("NewApiService") -/** - * Parses a comma-delimited string into a trimmed string array, skipping blanks. - */ -function parseDelimitedList(value?: string | null): string[] { - if (!value) return [] - return value - .split(/[,]/) - .map((item) => item.trim()) - .filter(Boolean) -} - -/** - * Normalizes a list of strings by trimming entries and removing duplicates. - */ -function normalizeList(values: string[] = []): string[] { - return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))) -} - /** * 搜索指定关键词的渠道 * @param baseUrl New API 的基础 URL diff --git a/services/octopusService/octopusService.ts b/services/octopusService/octopusService.ts index d1f7af401..fd951c375 100644 --- a/services/octopusService/octopusService.ts +++ b/services/octopusService/octopusService.ts @@ -41,27 +41,10 @@ import type { OctopusConfig } from "~/types/octopusConfig" import { getErrorMessage } from "~/utils/error" import { createLogger } from "~/utils/logger" import type { ManagedSiteMessagesKey } from "~/utils/managedSite" +import { normalizeList, parseDelimitedList } from "~/utils/string" const logger = createLogger("OctopusService") -/** - * 解析逗号分隔的字符串为数组 - */ -function parseDelimitedList(value?: string | null): string[] { - if (!value) return [] - return value - .split(",") - .map((item) => item.trim()) - .filter(Boolean) -} - -/** - * 规范化列表(去重、去空) - */ -function normalizeList(values: string[] = []): string[] { - return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))) -} - /** * 将 ChannelType (New API 渠道类型 0-55) 映射为 OctopusOutboundType (0-5) * Octopus 使用不同的类型枚举来表示协议转换器类型 diff --git a/services/veloeraService/veloeraService.ts b/services/veloeraService/veloeraService.ts index 50f1a59f2..5fabc0ffa 100644 --- a/services/veloeraService/veloeraService.ts +++ b/services/veloeraService/veloeraService.ts @@ -24,6 +24,7 @@ import type { import { isArraysEqual } from "~/utils" import { getErrorMessage } from "~/utils/error" import { createLogger } from "~/utils/logger" +import { normalizeList, parseDelimitedList } from "~/utils/string" import { UserPreferences, userPreferences } from "../userPreferences" @@ -32,24 +33,6 @@ import { UserPreferences, userPreferences } from "../userPreferences" */ const logger = createLogger("VeloeraService") -/** - * Parses a comma-delimited string into a trimmed string array, skipping blanks. - */ -function parseDelimitedList(value?: string | null): string[] { - if (!value) return [] - return value - .split(/[,]/) - .map((item) => item.trim()) - .filter(Boolean) -} - -/** - * Normalizes a list of strings by trimming entries and removing duplicates. - */ -function normalizeList(values: string[] = []): string[] { - return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))) -} - /** * Searches channels matching the keyword. */ diff --git a/utils/string.ts b/utils/string.ts new file mode 100644 index 000000000..7cc98bc9b --- /dev/null +++ b/utils/string.ts @@ -0,0 +1,25 @@ +/** + * 字符串处理工具函数 + */ + +/** + * 解析逗号分隔的字符串为数组 + * @param value - 逗号分隔的字符串 + * @returns 去除空白后的字符串数组 + */ +export function parseDelimitedList(value?: string | null): string[] { + if (!value) return [] + return value + .split(/[,]/) + .map((item) => item.trim()) + .filter(Boolean) +} + +/** + * 规范化字符串数组(去重、去空、trim) + * @param values - 字符串数组 + * @returns 规范化后的数组 + */ +export function normalizeList(values: string[] = []): string[] { + return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))) +} diff --git a/utils/url.ts b/utils/url.ts index caa895679..59fdc86f4 100644 --- a/utils/url.ts +++ b/utils/url.ts @@ -234,3 +234,12 @@ export function coerceBaseUrlToPathSuffix( return trimmed.replace(/\/+$/, "") } } + +/** + * 移除 URL 尾部斜杠 + * @param baseUrl - 原始 URL + * @returns 移除尾部斜杠后的 URL + */ +export function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/$/, "") +} From cf2eca511a142e8a0c59dcff55355b7d3356d6b5 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Mon, 9 Feb 2026 22:54:09 +0800 Subject: [PATCH 17/19] feat(octopus): add model/group list APIs and CORS error hint - Add fetchAvailableModels and fetchGroups with JWT authentication - Add fetchSiteUserGroups and fetchAccountAvailableModels with common API signatures - Register OCTOPUS in siteOverrideMap for proper API routing - Add 403 CORS configuration hint in auth error handling - Add i18n translations for corsError message --- locales/en/messages.json | 3 +- locales/zh_CN/messages.json | 3 +- services/apiService/index.ts | 3 + services/apiService/octopus/auth.ts | 11 +++ services/apiService/octopus/index.ts | 128 +++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 2 deletions(-) diff --git a/locales/en/messages.json b/locales/en/messages.json index 25c51ac2c..5b41bbf18 100644 --- a/locales/en/messages.json +++ b/locales/en/messages.json @@ -143,7 +143,8 @@ "importSuccess": "Successfully imported new channel {{channelName}}", "importFailed": "Import failed, an unknown error occurred", "loginFailed": "Failed to log in to Octopus, please check username and password", - "tokenExpired": "Octopus login expired, re-authenticating..." + "tokenExpired": "Octopus login expired, re-authenticating...", + "corsError": "This may be caused by CORS configuration. Please check the CORS whitelist setting in Octopus settings." }, "cliproxy": { "configMissing": "Please configure CLIProxyAPI Management API base URL and key in settings first", diff --git a/locales/zh_CN/messages.json b/locales/zh_CN/messages.json index 1160ae625..d901d87f5 100644 --- a/locales/zh_CN/messages.json +++ b/locales/zh_CN/messages.json @@ -143,7 +143,8 @@ "importSuccess": "成功导入新渠道 {{channelName}}", "importFailed": "导入失败,发生未知错误", "loginFailed": "登录 Octopus 失败,请检查用户名和密码", - "tokenExpired": "Octopus 登录已过期,正在重新登录..." + "tokenExpired": "Octopus 登录已过期,正在重新登录...", + "corsError": "这可能是 CORS 配置问题。请在 Octopus 设置中检查 CORS 白名单配置。" }, "cliproxy": { "configMissing": "请先在设置中配置 CLIProxyAPI 管理接口地址和密钥", diff --git a/services/apiService/index.ts b/services/apiService/index.ts index 99335ce10..cbab63e5e 100644 --- a/services/apiService/index.ts +++ b/services/apiService/index.ts @@ -2,6 +2,7 @@ import { ANYROUTER, DONE_HUB, NEW_API, + OCTOPUS, ONE_HUB, SUB2API, VELOERA, @@ -11,6 +12,7 @@ import { import * as anyrouterAPI from "./anyrouter" import * as commonAPI from "./common" +import * as octopusAPI from "./octopus" import * as oneHubAPI from "./oneHub" import * as sub2apiAPI from "./sub2api" import * as veloeraAPI from "./veloera" @@ -25,6 +27,7 @@ const siteOverrideMap = { [NEW_API]: commonAPI, [WONG_GONGYI]: wongAPI, [SUB2API]: sub2apiAPI, + [OCTOPUS]: octopusAPI, } as const // 添加类型定义 diff --git a/services/apiService/octopus/auth.ts b/services/apiService/octopus/auth.ts index d6081e841..882b2f8b3 100644 --- a/services/apiService/octopus/auth.ts +++ b/services/apiService/octopus/auth.ts @@ -2,6 +2,8 @@ * Octopus 认证服务 * 处理 JWT Token 的获取、缓存和刷新 */ +import i18next from "i18next" + import type { OctopusLoginResponse } from "~/types/octopus" import type { OctopusConfig } from "~/types/octopusConfig" import { createLogger } from "~/utils/logger" @@ -64,6 +66,15 @@ class OctopusAuthManager { } catch { errorBody = bodyText } + + // 针对 403 错误添加 CORS 配置提示 + if (response.status === 403) { + const corsHint = i18next.t("messages:octopus.corsError") + throw new Error( + `Login failed: HTTP 403 - ${errorBody || "Forbidden"}. ${corsHint}`, + ) + } + throw new Error( `Login failed: HTTP ${response.status} - ${errorBody || "Unknown error"}`, ) diff --git a/services/apiService/octopus/index.ts b/services/apiService/octopus/index.ts index add2adb9e..c7864312f 100644 --- a/services/apiService/octopus/index.ts +++ b/services/apiService/octopus/index.ts @@ -2,6 +2,7 @@ * Octopus API 服务 * 提供与 Octopus 后端的所有 API 交互 */ +import { userPreferences } from "~/services/userPreferences" import type { OctopusApiResponse, OctopusChannel, @@ -13,6 +14,7 @@ import type { OctopusConfig } from "~/types/octopusConfig" import { createLogger } from "~/utils/logger" import { normalizeBaseUrl } from "~/utils/url" +import type { ApiServiceRequest } from "../common/type" import { octopusAuthManager } from "./auth" import { buildOctopusAuthHeaders } from "./utils" @@ -228,5 +230,131 @@ export async function fetchRemoteModels( } } +/** + * Octopus LLMInfo 类型(模型价格信息) + */ +interface OctopusLLMInfo { + name: string + input: number + output: number + cache_read: number + cache_write: number +} + +/** + * Octopus Group 类型(分组信息) + */ +interface OctopusGroup { + id: number + name: string + mode: number + match_regex: string + first_token_time_out: number + items: Array<{ + id: number + group_id: number + channel_id: number + model_name: string + priority: number + weight: number + }> +} + +/** + * 获取可用模型列表 + * 调用 Octopus 的 /api/v1/model/list 端点,返回模型名称数组 + */ +export async function fetchAvailableModels( + config: OctopusConfig, +): Promise { + try { + const result = await fetchOctopusApi( + config, + "/api/v1/model/list", + ) + return (result.data || []).map((model) => model.name) + } catch (error) { + logger.error("Failed to fetch available models", error) + throw error + } +} + +/** + * 获取分组列表 + * 调用 Octopus 的 /api/v1/group/list 端点,返回分组名称数组 + */ +export async function fetchGroups(config: OctopusConfig): Promise { + try { + const result = await fetchOctopusApi( + config, + "/api/v1/group/list", + ) + return (result.data || []).map((group) => group.name) + } catch (error) { + logger.error("Failed to fetch groups", error) + throw error + } +} + +/** + * 获取站点分组列表(符合 common API 签名) + * 使用 Octopus JWT 认证调用 /api/v1/group/list + * 注意:忽略 request 中的 auth 参数,使用 Octopus 配置中的凭据 + */ +export async function fetchSiteUserGroups( + _request: ApiServiceRequest, +): Promise { + try { + const prefs = await userPreferences.getPreferences() + const octopusConfig = prefs?.octopus + if ( + !octopusConfig?.baseUrl || + !octopusConfig?.username || + !octopusConfig?.password + ) { + logger.warn("Octopus config not available, returning empty groups") + return [] + } + return await fetchGroups({ + baseUrl: octopusConfig.baseUrl, + username: octopusConfig.username, + password: octopusConfig.password, + }) + } catch (error) { + logger.error("Failed to fetch site user groups", error) + return [] + } +} + +/** + * 获取账号可用模型列表(符合 common API 签名) + * 使用 Octopus JWT 认证调用 /api/v1/model/list + * 注意:忽略 request 中的 auth 参数,使用 Octopus 配置中的凭据 + */ +export async function fetchAccountAvailableModels( + _request: ApiServiceRequest, +): Promise { + try { + const prefs = await userPreferences.getPreferences() + const octopusConfig = prefs?.octopus + if ( + !octopusConfig?.baseUrl || + !octopusConfig?.username || + !octopusConfig?.password + ) { + logger.warn("Octopus config not available, returning empty models") + return [] + } + return await fetchAvailableModels({ + baseUrl: octopusConfig.baseUrl, + username: octopusConfig.username, + password: octopusConfig.password, + }) + } catch (error) { + logger.error("Failed to fetch account available models", error) + return [] + } +} + // 重新导出认证管理器 export { octopusAuthManager } from "./auth" From f27f5204acc77e064fc7799178b373bebeb2b93e Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Mon, 9 Feb 2026 23:37:22 +0800 Subject: [PATCH 18/19] fix(octopus): guard base_urls and url with optional chaining in searchChannels --- services/apiService/octopus/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/apiService/octopus/index.ts b/services/apiService/octopus/index.ts index c7864312f..98df47505 100644 --- a/services/apiService/octopus/index.ts +++ b/services/apiService/octopus/index.ts @@ -132,7 +132,7 @@ export async function searchChannels( return channels.filter( (ch) => ch.name.toLowerCase().includes(lowerKeyword) || - ch.base_urls.some((u) => u.url.toLowerCase().includes(lowerKeyword)), + ch.base_urls?.some((u) => u.url?.toLowerCase().includes(lowerKeyword)), ) } From e3adedac7f3beb39775f3e08f68e4b9a2b0e2b2d Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Wed, 11 Feb 2026 19:21:24 +0800 Subject: [PATCH 19/19] fix(octopus): surface login error details to UI toast - Change validateConfig to return { success, error? } instead of boolean so the caller can display the actual error message - Prioritize server-returned message in login errors; fall back to HTTP status + body when no structured message is available - On 403, append CORS whitelist hint after the server message - OctopusSettings now shows the specific error in toast, falling back to the generic i18n message only when no detail is available --- .../components/OctopusSettings.tsx | 6 ++--- services/apiService/octopus/auth.ts | 26 ++++++++++++------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx b/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx index 00cc6c48c..b64a1e0e7 100644 --- a/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx +++ b/entrypoints/options/pages/BasicSettings/components/OctopusSettings.tsx @@ -88,13 +88,13 @@ export default function OctopusSettings() { setIsValidating(true) try { - const isValid = await octopusAuthManager.validateConfig({ + const result = await octopusAuthManager.validateConfig({ baseUrl: trimmedUrl, username: trimmedUsername, password: trimmedPassword, }) - if (isValid) { + if (result.success) { // Persist validated config to storage await Promise.all([ updateOctopusBaseUrl(trimmedUrl), @@ -103,7 +103,7 @@ export default function OctopusSettings() { ]) toast.success(t("octopus.validation.success")) } else { - toast.error(t("octopus.validation.failed")) + toast.error(result.error || t("octopus.validation.failed")) } } catch { toast.error(t("octopus.validation.error")) diff --git a/services/apiService/octopus/auth.ts b/services/apiService/octopus/auth.ts index 882b2f8b3..4fec9ca7f 100644 --- a/services/apiService/octopus/auth.ts +++ b/services/apiService/octopus/auth.ts @@ -59,24 +59,24 @@ class OctopusAuthManager { if (!response.ok) { // Read body once as text, then try to parse as JSON const bodyText = await response.text() - let errorBody: string + let serverMessage: string | undefined try { const errorJson = JSON.parse(bodyText) - errorBody = errorJson.message || bodyText + serverMessage = errorJson.message || undefined } catch { - errorBody = bodyText + // not JSON, ignore } // 针对 403 错误添加 CORS 配置提示 if (response.status === 403) { const corsHint = i18next.t("messages:octopus.corsError") - throw new Error( - `Login failed: HTTP 403 - ${errorBody || "Forbidden"}. ${corsHint}`, - ) + const detail = serverMessage || "Forbidden" + throw new Error(`${detail}\n${corsHint}`) } throw new Error( - `Login failed: HTTP ${response.status} - ${errorBody || "Unknown error"}`, + serverMessage || + `HTTP ${response.status} - ${bodyText || "Unknown error"}`, ) } @@ -142,14 +142,20 @@ class OctopusAuthManager { /** * 验证配置是否有效(尝试登录) + * 返回包含错误信息的结果,便于 UI 展示具体错误原因 */ - async validateConfig(config: OctopusConfig): Promise { + async validateConfig( + config: OctopusConfig, + ): Promise<{ success: boolean; error?: string }> { try { await this.getValidToken(config) - return true + return { success: true } } catch (error) { logger.error("Config validation failed", error) - return false + return { + success: false, + error: error instanceof Error ? error.message : undefined, + } } }