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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ BootAgent supports detection, configuration, launch, or installation guidance ac

| CLI and local Agents | Desktop Agents |
| --- | --- |
| Codex · Claude Code · OpenCode | DSH Desktop · Claude Desktop |
| Codex · Claude Code · OpenCode | DeepSeek Harness · Claude Desktop |
| Kilo CLI · Aider · OpenClaw | ChatGPT Desktop · WorkBuddy |
| Hermes Agent · Kimi Code · Pi | WorkBuddy AI · ZCode |
| DeepSeek Harness (local web app) | |
Expand Down
2 changes: 1 addition & 1 deletion README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ BootAgent 会根据每个 Agent 的官方约定提供检测、配置、启动、

| CLI 和本地 Agent | 桌面 Agent |
| --- | --- |
| Codex · Claude Code · OpenCode | DSH Desktop · Claude Desktop |
| Codex · Claude Code · OpenCode | DeepSeek Harness · Claude Desktop |
| Kilo CLI · Aider · OpenClaw | ChatGPT Desktop · WorkBuddy |
| Hermes Agent · Kimi Code · Pi | WorkBuddy AI · ZCode |
| DeepSeek Harness(本地 Web 应用) | |
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/icons/agents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ describe("AgentIcon", () => {
const assetIds = AGENT_ICON_IDS.filter((id) => agentMarkKind(id) === "asset");
// chatgpt-desktop is a desktop Agent rather than a CLI, and it reuses the
// OpenAI mark because it is OpenAI's own product sharing Codex's config.
// dsh-desktop reuses the DeepSeek mark for the same reason: it drives
// DeepSeek, though anywhere-labs rather than DeepSeek publishes it.
// dsh-desktop reuses the DeepSeek mark for the same reason: it is
// DeepSeek's own desktop shell around the dsh CLI.
expect(assetIds.sort()).toEqual(["chatgpt-desktop", "claude-code", "claude-desktop", "codex", "dsh", "dsh-desktop", "hermes", "kilo-cli", "kimi-code", "openclaw", "opencode", "pi"]);
for (const id of assetIds) {
const rights = agentMarkRights(id);
Expand Down
14 changes: 5 additions & 9 deletions frontend/src/components/icons/agents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,11 @@ const MARKS: Record<string, Mark> = {
source: assetRightsManifest.assets.dsh.source,
rights: assetRightsManifest.assets.dsh,
},
// DSH Desktop reuses the CLI Harness mark: both drive DeepSeek, and the whale
// is what identifies whose model is behind them. Keyed by desktop Agent id
// because the desktop card looks itself up by id, as with chatgpt-desktop
// above -- without this entry the card fell back to the generic Bot glyph.
//
// Unlike chatgpt-desktop, this is not the vendor's own app: anywhere-labs
// publishes it. The mark still says DeepSeek because that is the model it
// talks to, so the Definition carries Unofficial and the UI states the
// publisher rather than letting the mark imply it.
// DeepSeek Harness (the desktop shell) reuses the CLI Harness mark: it is
// DeepSeek's own application around the same dsh, so the whale is the right
// identity for both. Keyed by desktop Agent id because the desktop card looks
// itself up by id, as with chatgpt-desktop above -- without this entry the
// card fell back to the generic Bot glyph.
"dsh-desktop": {
kind: "asset",
markup: deepseekMark,
Expand Down
58 changes: 34 additions & 24 deletions frontend/src/pages/AgentSelectionPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";

import type { AgentCatalogItem, StatusResponse } from "../types/api";
import type { AgentCatalogItem, DesktopAgentStatus, StatusResponse } from "../types/api";
import { AgentSelectionPage } from "./AgentSelectionPage";

const dispatch = vi.fn();
Expand Down Expand Up @@ -38,15 +38,14 @@ const CATALOG: AgentCatalogItem[] = [
}));

/**
* Three desktop Agents: a third-party build, one with a registered image mark,
* and one with a vendor bitmap. DSH Desktop leads, matching the order
* desktopapp.Definitions() returns.
* Three desktop Agents: two with registered image marks and one with a vendor
* bitmap. DeepSeek Harness leads, matching the order desktopapp.Definitions()
* returns.
*/
const DESKTOP_AGENTS = [
const DESKTOP_AGENTS: DesktopAgentStatus[] = [
{
id: "dsh-desktop", name: "DSH Desktop", installed: false, supported: true,
id: "dsh-desktop", name: "DeepSeek Harness", installed: false, supported: true,
source: "unknown", version: null, profileAgentId: "dsh", profileId: null, protocol: "openai",
unofficial: true,
},
{
id: "chatgpt-desktop", name: "ChatGPT Desktop", installed: false, supported: true,
Expand All @@ -58,7 +57,7 @@ const DESKTOP_AGENTS = [
},
];

function renderPage({ desktop = false }: { desktop?: boolean } = {}) {
function renderPage({ desktop = false, desktopAgents = DESKTOP_AGENTS }: { desktop?: boolean; desktopAgents?: DesktopAgentStatus[] } = {}) {
dispatch.mockClear();
mockState = {
status: {
Expand Down Expand Up @@ -93,7 +92,7 @@ function renderPage({ desktop = false }: { desktop?: boolean } = {}) {
backups: {},
environment: null,
environmentError: null,
desktopAgents: desktop ? DESKTOP_AGENTS : [],
desktopAgents: desktop ? desktopAgents : [],
profiles: [],
activeProfile: null,
firstRun: false,
Expand Down Expand Up @@ -160,36 +159,47 @@ describe("AgentSelectionPage", () => {
};
});
// Every row must resolve through the icon registry rather than a literal:
// DSH Desktop and ChatGPT Desktop to licensed vectors, ZCode to Z.ai's own
// bitmap. DSH Desktop had no registry entry of its own and fell back to the
// generic Bot glyph, which is what "asset" here guards against.
// DeepSeek Harness and ChatGPT Desktop to licensed vectors, ZCode to Z.ai's
// own bitmap. The dsh-desktop id once had no registry entry of its own and
// fell back to the generic Bot glyph, which is what "asset" here guards
// against.
expect(marks).toEqual([
{ name: "选择 DSH Desktop", kind: "asset" },
{ name: "选择 DeepSeek Harness", kind: "asset" },
{ name: "选择 ChatGPT Desktop", kind: "asset" },
{ name: "选择 ZCode", kind: "raster" },
]);
});

it("does not advertise a third-party desktop build as the official application", () => {
// The mark is DeepSeek's because that is the model the app drives, but
// anywhere-labs publishes it. Without the disclaimer the row pairs a vendor
// mark with "install the official desktop application", which together read
// as a vendor download.
renderPage({ desktop: true });
const row = screen.getByLabelText("选择 DSH Desktop").closest(".agent-row");
// A build the backend marks unofficial must not pair a vendor mark with
// "install the official desktop application", which together read as a
// vendor download. None of the shipped entries carry the flag today -- the
// dsh-desktop row is DeepSeek's own app since the switch from the
// anywhere-labs build -- so the flag is exercised on a synthetic row.
renderPage({
desktop: true,
desktopAgents: [
{ ...DESKTOP_AGENTS[0], id: "third-party-desktop", name: "Third Party", profileAgentId: "third-party", unofficial: true },
...DESKTOP_AGENTS,
],
});
const row = screen.getByLabelText("选择 Third Party").closest(".agent-row");
expect(row?.textContent).toContain("第三方桌面应用,非官方出品");
expect(row?.textContent).not.toContain("安装官方桌面应用");
// The vendors' own apps must not pick up the disclaimer.
const official = screen.getByLabelText("选择 ChatGPT Desktop").closest(".agent-row");
expect(official?.textContent).toContain("安装官方桌面应用");
for (const name of ["DeepSeek Harness", "ChatGPT Desktop"]) {
const official = screen.getByLabelText(`选择 ${name}`).closest(".agent-row");
expect(official?.textContent).toContain("安装官方桌面应用");
expect(official?.textContent).not.toContain("第三方桌面应用,非官方出品");
}
});

it("puts the desktop downloads in the order the backend returns", () => {
// The page must not re-sort: DSH Desktop leads because Definitions() puts it
// first, and a client-side sort here would silently override that.
// The page must not re-sort: DeepSeek Harness leads because Definitions()
// puts it first, and a client-side sort here would silently override that.
renderPage({ desktop: true });
expect(screen.getAllByLabelText(/^选择 /).map((radio) => radio.getAttribute("aria-label"))).toEqual([
"选择 DSH Desktop",
"选择 DeepSeek Harness",
"选择 ChatGPT Desktop",
"选择 ZCode",
]);
Expand Down
15 changes: 13 additions & 2 deletions internal/app/desktopapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ func (u *UseCases) writeDesktopAgentConfig(ctx context.Context, definition deskt
if strings.TrimSpace(definition.ConfigPath) == "" {
return "", false, nil
}
path := filepath.Join(u.status.Home, filepath.FromSlash(definition.ConfigPath))
path := u.desktopAgentConfigPath(definition)
if err := writeManagedAgentConfig(ctx, writer, definition.ID, catalog.Agent{
ConfigAdapter: definition.ConfigAdapter,
}, path, dshRouteProviderID(target, ""), target.Name, target.BaseFor(protocol), target.APIKey, model, "", false); err != nil {
Expand Down Expand Up @@ -318,11 +318,22 @@ func (u *UseCases) publicDesktopAgentStatus(value desktopapp.Status) DesktopAgen
status.Protocol = provider.ProtocolForAdapter(shared.ConfigAdapter)
}
} else if definition.ConfigPath != "" && value.Supported {
status.ConfigPath = filepath.Join(u.status.Home, filepath.FromSlash(definition.ConfigPath))
status.ConfigPath = u.desktopAgentConfigPath(definition)
}
return status
}

// desktopAgentConfigPath is the document a desktop Agent's configuration is
// written to. For DeepSeek Harness that is the patch of the profile the Electron
// shell owns, once the shell has created it; the definition's ConfigPath is the
// legacy fallback an older harness still reads.
func (u *UseCases) desktopAgentConfigPath(definition desktopapp.Definition) string {
if definition.ConfigAdapter == desktopapp.ConfigAdapterDSH {
return configWriter.ResolveDSHConfigPath(u.status.Home, configWriter.DSHDesktopProfile)
}
return filepath.Join(u.status.Home, filepath.FromSlash(definition.ConfigPath))
}

func knownDesktopAgentID(value string) (string, error) {
value = strings.TrimSpace(value)
if _, ok := desktopapp.DefinitionFor(value); ok {
Expand Down
7 changes: 7 additions & 0 deletions internal/app/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,13 @@ func configPath(home, osID string, agent catalog.Agent) string {
if agent.ConfigPath == "" {
return ""
}
// dsh moved its live configuration into the profile `dsh web` boots between
// 0.1.5 and 0.1.7. The manifest keeps the legacy path as the default; on a
// machine where the new release has already created its profile, that
// profile's patch is the document that is actually read.
if agent.ConfigAdapter == "dsh" {
return configWriter.ResolveDSHConfigPath(home, configWriter.DSHWebProfile)
}
relative := agent.ConfigPath
if osID == "windows" && agent.WindowsConfigPath != "" {
relative = agent.WindowsConfigPath
Expand Down
5 changes: 2 additions & 3 deletions internal/app/testdata/status-empty-linux-arm64.json
Original file line number Diff line number Diff line change
Expand Up @@ -553,16 +553,15 @@
"environmentError": null,
"desktopAgents": [
{
"home": "https://dshdesktop.cn",
"home": "https://www.deepseek.com/harness/",
"id": "dsh-desktop",
"installed": false,
"name": "DSH Desktop",
"name": "DeepSeek Harness",
"profileAgentId": "dsh",
"profileId": null,
"protocol": "openai",
"source": "unknown",
"supported": false,
"unofficial": true,
"version": null
},
{
Expand Down
81 changes: 63 additions & 18 deletions internal/config/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,26 +193,57 @@ func ReadAiderConfig(text string) Detected {
// an endpoint -- the shipped route's endpoint is dsh's own fact, not something
// this file records.
func ReadDSHConfig(text string) Detected {
var parsed struct {
PiAI struct {
Providers map[string]struct {
BaseURL string `yaml:"baseURL"`
Models []struct {
ID string `yaml:"id"`
} `yaml:"models"`
} `yaml:"providers"`
} `yaml:"llm-pi-ai"`
Selection struct {
Provider string `yaml:"provider"`
Model string `yaml:"model"`
} `yaml:"agent-default-model"`
}
if err := yaml.Unmarshal([]byte(text), &parsed); err != nil {
// Both documents carry the same two sections; only the container differs.
// The 0.1.7 profile patch is a sequence of rows keyed by entry id, the
// legacy settings.yaml a mapping keyed by section name. The root node's kind
// says which, without guessing from the path.
var root yaml.Node
if err := yaml.Unmarshal([]byte(text), &root); err != nil {
return unreadable(fmt.Sprintf("YAML 无法解析:%v", err))
}
route, managed := parsed.PiAI.Providers[dshOwnedRoute]
if !managed && parsed.Selection.Provider == dshOfficialRoute {
return Detected{Model: parsed.Selection.Model}
var piAI dshPiAISection
var selection dshDefaultModelSection
if len(root.Content) == 1 && root.Content[0].Kind == yaml.SequenceNode {
// The first row addressing an id is the one read, matching the writer,
// which edits the first row it finds. dsh's own tooling does not produce
// duplicates; if a hand edit did, decoding a later row into the same
// struct would merge the two, and the file would read as something no
// single row says.
seen := make(map[string]bool, 2)
for _, row := range root.Content[0].Content {
var entry struct {
ID string `yaml:"id"`
Config yaml.Node `yaml:"config"`
}
if row.Decode(&entry) != nil || seen[entry.ID] {
continue
}
switch entry.ID {
case dshPiAIEntryID:
seen[entry.ID] = true
if entry.Config.Decode(&piAI) != nil {
return unreadable("llm-pi-ai 配置无法解析")
}
case dshDefaultModelEntryID:
seen[entry.ID] = true
if entry.Config.Decode(&selection) != nil {
return unreadable("agent-default-model 配置无法解析")
}
}
}
} else {
var parsed struct {
PiAI dshPiAISection `yaml:"llm-pi-ai"`
Selection dshDefaultModelSection `yaml:"agent-default-model"`
}
if err := root.Decode(&parsed); err != nil {
return unreadable(fmt.Sprintf("YAML 无法解析:%v", err))
}
piAI, selection = parsed.PiAI, parsed.Selection
}
route, managed := piAI.Providers[dshOwnedRoute]
if !managed && selection.Provider == dshOfficialRoute {
return Detected{Model: selection.Model}
}
model := ""
// The first entry, not a search for a match: the route's catalog is ordered
Expand All @@ -224,6 +255,20 @@ func ReadDSHConfig(text string) Detected {
return Detected{BaseURL: route.BaseURL, Model: model, ManagedByBootAgent: managed}
}

type dshPiAISection struct {
Providers map[string]struct {
BaseURL string `yaml:"baseURL"`
Models []struct {
ID string `yaml:"id"`
} `yaml:"models"`
} `yaml:"providers"`
}

type dshDefaultModelSection struct {
Provider string `yaml:"provider"`
Model string `yaml:"model"`
}

func ReadHermesConfig(text string) Detected {
var parsed struct {
Model struct {
Expand Down
Loading
Loading