Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions frontend/src/components/ProviderSegment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@ import { Plus } from "lucide-react";

import { useI18n } from "../i18n";
import { byProviderCreatedAt } from "../state/ranking";
import type { ProviderId, StatusResponse } from "../types/api";
import type { ProtocolId, ProviderId, StatusResponse } from "../types/api";
import { SelectField } from "./SelectField";

export function ProviderSegment({
value,
providers,
onAdd,
onChange,
protocol,
}: {
value: ProviderId;
providers: StatusResponse["providers"];
onAdd: () => void;
onChange: (value: ProviderId) => void;
protocol?: ProtocolId | "";
}) {
const { t } = useI18n();
return (
Expand All @@ -28,7 +30,11 @@ export function ProviderSegment({
label={t("模型服务")}
value={value}
onChange={onChange}
options={byProviderCreatedAt(providers).map(([id, provider]) => ({ value: id, label: provider.name }))}
options={byProviderCreatedAt(providers).map(([id, provider]) => ({
value: id,
label: provider.name,
disabled: Boolean(protocol) && (protocol === "anthropic" ? !provider.anthropic_base_url : !provider.base_url),
}))}
/>
<button className="provider-add-button" type="button" onClick={onAdd} aria-label={t("新增 Provider")} title={t("新增 Provider")}>
<Plus size={17} />
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/SelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}

interface SelectFieldProps {
Expand Down Expand Up @@ -62,7 +63,7 @@ export function SelectField({ value, options, onChange, label, id, className = "

const commit = (index: number) => {
const option = options[index];
if (option) onChange(option.value);
if (option && !option.disabled) onChange(option.value);
close(true);
};

Expand Down Expand Up @@ -202,7 +203,8 @@ export function SelectField({ value, options, onChange, label, id, className = "
id={`${listId}-${index}`}
role="option"
aria-selected={option.value === value}
className={`select-field-option${index === activeIndex ? " is-active" : ""}`}
className={`select-field-option${index === activeIndex ? " is-active" : ""}${option.disabled ? " is-disabled" : ""}`}
aria-disabled={option.disabled || undefined}
// Mouse move rather than hover in CSS, so the keyboard's active
// option and the pointer's cannot disagree.
onMouseMove={() => setActiveIndex(index)}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ const english = {
"官网(可选)": "Website (optional)",
"OpenAI 兼容 Base URL": "OpenAI-compatible base URL",
"API 地址:": "API URL: ",
"Anthropic 兼容 Base URL(可选)": "Anthropic-compatible base URL (optional)",
"Anthropic 兼容 Base URL": "Anthropic-compatible base URL",
"OpenAI 兼容": "OpenAI-compatible",
"Anthropic 兼容": "Anthropic-compatible",
"删除 {name}": "Delete {name}",
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/pages/AgentProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { ProviderSegment } from "../components/ProviderSegment";
import { useI18n } from "../i18n";
import { desktopApps, desktopProfileUsable, desktopProfiles, desktopProtocol, profileAgentIdForDesktop } from "../state/desktopSetup";
import { useWizard } from "../state/WizardContext";
import { byProfileCreatedAt } from "../state/ranking";
import type { ProfileSummary, ProviderId } from "../types/api";
import { byProfileCreatedAt, byProviderCreatedAt } from "../state/ranking";
import type { ProfileSummary, ProviderId, ProtocolId } from "../types/api";

interface ProfileDraft {
id: string;
Expand Down Expand Up @@ -42,6 +42,7 @@ export function AgentProfilePage() {
const catalog = status?.catalog.find((item) => item.id === owner);
const targetName = app?.name || catalog?.name || agentId;
const currentAgent = status?.agents[owner];
const protocol = (app ? desktopProtocol(app) : catalog?.protocol || "") as ProtocolId | "";
const [selectedId, setSelectedId] = useState("");
const [draft, setDraft] = useState<ProfileDraft | null>(null);
const [busy, setBusy] = useState(false);
Expand Down Expand Up @@ -85,7 +86,7 @@ export function AgentProfilePage() {
const canApply = Boolean(selected && desktopProfileUsable(status, selected));

const openCreate = () => {
const provider = Object.keys(status.providers)[0] || "ppio";
const provider = byProviderCreatedAt(status.providers).find(([, meta]) => protocol === "anthropic" ? meta.anthropic_base_url : meta.base_url)?.[0] || "ppio";
const current = selected || profiles[0];
const baseID = `${owner || "agent"}-${provider}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
const ids = new Set(status.profiles.map((profile) => profile.id));
Expand Down Expand Up @@ -205,6 +206,7 @@ export function AgentProfilePage() {
providers={status.providers}
onAdd={() => navigate(`/providers/new?returnTo=${encodeURIComponent(`/agents/${agentId}`)}`)}
onChange={(provider) => setDraft({ ...draft, provider })}
protocol={protocol}
/>
</div>
<div className="field-stack profile-editor-wide">
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/pages/ProfilesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ export function ProfilesPage() {
});
};

const providerForProtocol = (protocol: string, current: ProviderId) => {
if (!protocol || (status.providers[current] && (protocol === "anthropic" ? status.providers[current].anthropic_base_url : status.providers[current].base_url))) return current;
return byProviderCreatedAt(status.providers).find(([, provider]) => protocol === "anthropic" ? provider.anthropic_base_url : provider.base_url)?.[0] || current;
};

const save = async (event: FormEvent) => {
event.preventDefault();
if (!editor || !canSave) return;
Expand Down Expand Up @@ -213,7 +218,7 @@ export function ProfilesPage() {
id="profile-protocol"
label={t("API 类型")}
value={editor.protocol}
onChange={(protocol) => setEditor({ ...editor, protocol })}
onChange={(protocol) => setEditor({ ...editor, protocol, provider: providerForProtocol(protocol, editor.provider) })}
options={[
{ value: "", label: t("请选择 API 类型") },
...(Object.keys(PROTOCOL_LABELS) as ProtocolId[]).map((protocol) => ({ value: protocol, label: PROTOCOL_LABELS[protocol] })),
Expand All @@ -227,6 +232,7 @@ export function ProfilesPage() {
providers={status.providers}
onAdd={() => navigate(`/providers/new?returnTo=${encodeURIComponent("/profiles")}`)}
onChange={(provider) => setEditor({ ...editor, provider })}
protocol={editor.protocol as ProtocolId}
/>
</div>
<div className="field-stack profile-editor-wide">
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/ProviderKeyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function ProviderKeyPage() {
providers={state.status?.providers ?? {}}
onAdd={() => navigate(`/providers/new?returnTo=${encodeURIComponent("/setup/provider")}`)}
onChange={changeProvider}
protocol={protocols.length === 1 ? protocols[0] : ""}
/>

<div className="provider-form">
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/pages/ProvidersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,11 @@ export function ProvidersPage({ create = false }: { create?: boolean }) {
</div>
<div className="field-stack provider-editor-wide">
<label htmlFor="provider-base-url">{t("OpenAI 兼容 Base URL")}</label>
<input id="provider-base-url" type="url" value={editor.base_url} onChange={(event) => setEditor({ ...editor, base_url: event.target.value })} placeholder="https://api.example.com/openai" required />
<input id="provider-base-url" type="url" value={editor.base_url} onChange={(event) => setEditor({ ...editor, base_url: event.target.value })} placeholder="https://api.example.com/openai/v1" />
</div>
<div className="field-stack provider-editor-wide">
<label htmlFor="provider-anthropic-url">{t("Anthropic 兼容 Base URL(可选)")}</label>
<input id="provider-anthropic-url" type="url" value={editor.anthropic_base_url} onChange={(event) => setEditor({ ...editor, anthropic_base_url: event.target.value })} placeholder="https://api.example.com/anthropic" />
<label htmlFor="provider-anthropic-url">{t("Anthropic 兼容 Base URL")}</label>
<input id="provider-anthropic-url" type="url" value={editor.anthropic_base_url} onChange={(event) => setEditor({ ...editor, anthropic_base_url: event.target.value })} placeholder="https://api.example.com/anthropic/v1" />
</div>
<div className="field-stack provider-editor-wide">
<label htmlFor="provider-home">{t("官网(可选)")}</label>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/state/wizardReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ export const initialWizardState: WizardState = {
activationNext: "",
};

function latestProvider(status: StatusResponse | null): ProviderId {
return byProviderCreatedAt(status?.providers ?? {})[0]?.[0] || "ppio";
function latestProvider(status: StatusResponse | null, protocol?: string): ProviderId {
return byProviderCreatedAt(status?.providers ?? {}).find(([, provider]) => !protocol || (protocol === "anthropic" ? provider.anthropic_base_url : provider.base_url))?.[0] || "ppio";
}

export type WizardAction =
Expand Down Expand Up @@ -182,6 +182,7 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
return {
...state,
selectedAgentIds: [action.agentId],
provider: latestProvider(state.status, state.status?.catalog.find((item) => item.id === action.agentId)?.protocol || undefined),
desktopProfileId: state.selectedAgentIds[0] === action.agentId ? state.desktopProfileId : "",
// Profile IDs and labels are derived from the selected Agent and
// Provider. Do not carry a prior run's profile into a new pairing.
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,12 @@ button:active:not(:disabled) {
background: var(--surface-subtle);
}

.select-field-option.is-disabled {
color: var(--text-tertiary);
opacity: 0.65;
cursor: not-allowed;
}

.select-field-check {
flex: 0 0 auto;
color: var(--blue);
Expand Down
2 changes: 1 addition & 1 deletion internal/app/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ func (u *UseCases) SaveProfile(ctx context.Context, options SaveProfileOptions)
ID: options.ID,
Label: options.Label,
Provider: options.Provider,
BaseURL: target.BaseURL,
BaseURL: target.BaseFor(options.Protocol),
// Keep explicit keys accepted by callers, but do not copy a
// Provider-resolved key into every Profile secret file.
APIKey: providedKey,
Expand Down
11 changes: 11 additions & 0 deletions internal/app/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"

"github.com/MaimoryLab/OneAgent/internal/platform"
"github.com/MaimoryLab/OneAgent/internal/provider"
)

func TestStatusUsesInjectedHomeAndCommandLookup(t *testing.T) {
Expand Down Expand Up @@ -198,6 +199,16 @@ func TestSaveProfileCanSwitchAKeylessProfileProvider(t *testing.T) {
}
}

func TestSaveProfileUsesProtocolEndpoint(t *testing.T) {
core := NewUseCases(StatusOptions{Home: t.TempDir(), Platform: platform.For("linux", "amd64"), Lookup: func(string) (string, bool) { return "", false }})
if _, err := core.SaveProvider(context.Background(), provider.Entry{ID: "anthropic-only", Name: "Anthropic", AnthropicBaseURL: "https://api.example.test/anthropic"}); err != nil {
t.Fatal(err)
}
if _, err := core.SaveProfile(context.Background(), SaveProfileOptions{ID: "anthropic", Provider: "anthropic-only", Model: "model", ConfigMode: "provider", Protocol: "anthropic"}); err != nil {
t.Fatal(err)
}
}

func TestStatusProjectsAgentBindingsWithoutUnknownFields(t *testing.T) {
home := t.TempDir()
path := filepath.Join(home, ".oneagent", "agents", "codex.json")
Expand Down
9 changes: 7 additions & 2 deletions internal/provider/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,13 @@ func validateEntry(entry Entry) error {
if entry.Name == "" {
return oneerrors.New(oneerrors.InvalidRequest, "Provider name is required")
}
if _, err := ValidateBaseURL(entry.BaseURL); err != nil {
return err
if entry.BaseURL == "" && entry.AnthropicBaseURL == "" {
return oneerrors.New(oneerrors.InvalidRequest, "At least one API base URL is required")
}
if entry.BaseURL != "" {
if _, err := ValidateBaseURL(entry.BaseURL); err != nil {
return err
}
}
if entry.Home != "" {
if _, err := ValidateBaseURL(entry.Home); err != nil {
Expand Down
15 changes: 15 additions & 0 deletions internal/provider/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,18 @@ func TestStorePersistsCRUDAndPrivateKey(t *testing.T) {
t.Fatal("deleted Provider was still readable")
}
}

func TestStoreAcceptsEitherAPIEndpoint(t *testing.T) {
store := NewStore(t.TempDir(), securefs.New(securefs.Options{OS: "linux"}))
for _, entry := range []Entry{
{ID: "anthropic-only", Name: "Anthropic", AnthropicBaseURL: "https://api.example.test/anthropic"},
{ID: "openai-only", Name: "OpenAI", BaseURL: "https://api.example.test/openai"},
} {
if _, err := store.Save(context.Background(), entry); err != nil {
t.Fatalf("save %s: %v", entry.ID, err)
}
}
if _, err := store.Save(context.Background(), Entry{ID: "empty", Name: "Empty"}); err == nil {
t.Fatal("empty Provider unexpectedly saved")
}
}
Loading