Skip to content
Open
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
87 changes: 86 additions & 1 deletion src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import os from "node:os";
import path from "node:path";
import test from "node:test";
import YAML from "yaml";
import { configPath, initializeWorkspace, isOnboardingComplete, loadConfig, markOnboardingComplete, oobeProgressPath, readOobeProgress, readSettings, saveOobeProgress, saveSettings, type OobeProgress } from "./config.js";
import { OFFICIAL_VISION_MODEL, configPath, initializeWorkspace, isOnboardingComplete, loadConfig, markOnboardingComplete, oobeProgressPath, readOobeProgress, readSettings, resolveVisionAgentConfig, saveOobeProgress, saveSettings, type OobeProgress } from "./config.js";
import type { SecAgentConfig } from "./types.js";
import { SYSTEM_PROMPT } from "./system-prompt.js";

function temporaryWorkspace(): string {
Expand Down Expand Up @@ -121,3 +122,87 @@ test("defaults and persists Windows update preferences", () => {
fs.rmSync(workspace, { recursive: true, force: true });
}
});

test("persists the vision model id with the settings", () => {
const workspace = temporaryWorkspace();
try {
initializeWorkspace(workspace);
const settings = readSettings(workspace);
const saved = saveSettings(workspace, { ...settings, visionModelId: "custom-provider:vision" });
assert.equal(saved.visionModelId, "custom-provider:vision");
assert.equal(readSettings(workspace).visionModelId, "custom-provider:vision");
// Clearing the value removes the persisted key.
const cleared = saveSettings(workspace, { ...settings, visionModelId: undefined });
assert.equal(cleared.visionModelId, undefined);
assert.equal(readSettings(workspace).visionModelId, undefined);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});

function multiModelConfig(overrides: Partial<SecAgentConfig> = {}): SecAgentConfig {
return {
version: 1,
workspace: "/tmp/secagent-test-ws",
agent: {
provider: "openai-compatible",
model: "main",
apiKeyEnv: "MAIN_KEY",
baseUrl: "https://main.test/v1",
endpoint: "/chat/completions",
maxTokens: 100,
systemPrompt: "main",
models: [
{ id: "main", provider: "openai-compatible", model: "main", apiKeyEnv: "MAIN_KEY", baseUrl: "https://main.test/v1", endpoint: "/chat/completions", maxTokens: 100 },
{ id: "vision", provider: "openai-compatible", model: "vision-model", apiKeyEnv: "VISION_KEY", baseUrl: "https://vision.test/v1", endpoint: "/chat/completions", maxTokens: 100 },
{ id: "sectl-official:deepseek-v4-flash", provider: "openai-responses", model: "deepseek-v4-flash", apiKeyEnv: "SECTL_OFFICIAL_TOKEN", baseUrl: "https://relay.test/v1", endpoint: "/responses", maxTokens: 100 }
]
},
mcp: { servers: {} },
...overrides
} as SecAgentConfig;
}

test("resolveVisionAgentConfig honors an explicit vision model id without mutating the source config", () => {
const config = multiModelConfig({ defaults: { modelId: "main", visionModelId: "vision" } });
const vision = resolveVisionAgentConfig(config);
assert.ok(vision);
assert.equal(vision.agent.model, "vision-model");
assert.equal(vision.agent.apiKeyEnv, "VISION_KEY");
assert.equal(vision.agent.baseUrl, "https://vision.test/v1");
// The caller's config keeps its own model.
assert.equal(config.agent.model, "main");
});

test("resolveVisionAgentConfig returns undefined for a stale vision model id", () => {
const config = multiModelConfig({ defaults: { visionModelId: "does-not-exist" } });
assert.equal(resolveVisionAgentConfig(config), undefined);
});

test("resolveVisionAgentConfig falls back to the official virtual-vision model in official mode", () => {
const previous = process.env.SECTL_OFFICIAL_TOKEN;
process.env.SECTL_OFFICIAL_TOKEN = "test-token";
try {
const config = multiModelConfig({ defaults: { customModelMode: false } });
const vision = resolveVisionAgentConfig(config);
assert.ok(vision);
assert.equal(vision.agent.model, OFFICIAL_VISION_MODEL);
assert.equal(vision.agent.apiKeyEnv, "SECTL_OFFICIAL_TOKEN");
assert.equal(vision.agent.endpoint, "/responses");
} finally {
if (previous === undefined) delete process.env.SECTL_OFFICIAL_TOKEN;
else process.env.SECTL_OFFICIAL_TOKEN = previous;
}
});

test("resolveVisionAgentConfig does not fall back in custom model mode", () => {
const previous = process.env.SECTL_OFFICIAL_TOKEN;
process.env.SECTL_OFFICIAL_TOKEN = "test-token";
try {
const config = multiModelConfig({ defaults: { customModelMode: true } });
assert.equal(resolveVisionAgentConfig(config), undefined);
} finally {
if (previous === undefined) delete process.env.SECTL_OFFICIAL_TOKEN;
else process.env.SECTL_OFFICIAL_TOKEN = previous;
}
});
48 changes: 46 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import { SYSTEM_PROMPT } from "./system-prompt.js";

export const DEFAULT_GOOGLE_MODEL = "gemini-2.5-flash";
export const DEFAULT_MAX_TOKENS = 16_384;
/**
* Client-facing virtual vision model served by the official relay. The relay routes this
* model id to a vision-capable upstream, so the client does not need to know which concrete
* model is behind it. Only the client part lives in this repository; the relay contract is
* documented in the README/settings help text.
*/
export const OFFICIAL_VISION_MODEL = "virtual-vision";
const ONBOARDING_MARKER = ".oobe-complete";
const OOBE_PROGRESS_FILE = ".oobe-progress.json";
const LEGACY_AGENT_MODEL_FIELDS = ["provider", "model", "apiKeyEnv", "baseUrl", "endpoint", "anthropicVersion", "maxTokens"] as const;
Expand Down Expand Up @@ -303,6 +310,41 @@ export function useConfiguredModel(config: SecAgentConfig, id?: string): void {
config.agent = { ...config.agent, ...selected, model: dynamicModel || selectedModels[profileIndex] || selectedModels[0] || DEFAULT_GOOGLE_MODEL, maxTokens: selected.maxTokens || config.agent.maxTokens, systemPrompt: config.agent.systemPrompt, models: config.agent.models };
}

/**
* Return a copy of the config with `agent` resolved to the given model id, without
* mutating the caller's config. Unlike `useConfiguredModel`, this is safe to call for
* a secondary (e.g. vision) model while the main session keeps using its own model.
*/
export function resolveModelConfig(config: SecAgentConfig, modelId: string): SecAgentConfig {
const next = { ...config, agent: { ...config.agent } };
useConfiguredModel(next, modelId);
if (!next.agent.models?.length) throw new Error(`未找到配置模型:${modelId}`);
return next;
}

/**
* Resolve the dedicated image-recognition model config, if any.
* 1. An explicitly configured `defaults.visionModelId` wins.
* 2. In official (non-custom) mode, fall back to the relay's virtual-vision model so
* the feature works without any manual setup.
* Returns undefined when no vision model is configured or the id is stale — the vision
* tool is then simply not exposed to the main agent.
*/
export function resolveVisionAgentConfig(config: SecAgentConfig): SecAgentConfig | undefined {
const id = config.defaults?.visionModelId;
if (id) {
try { return resolveModelConfig(config, id); }
catch { return undefined; }
}
if (config.defaults?.customModelMode === false
&& process.env.SECTL_OFFICIAL_TOKEN
&& config.agent.models?.some((model) => model.id.startsWith("sectl-official:"))) {
try { return resolveModelConfig(config, `official:sectl-official:${OFFICIAL_VISION_MODEL}`); }
catch { return undefined; }
}
return undefined;
}

export interface SettingsPayload {
providers: Array<ProviderConfig & { apiKey?: string; apiKeyConfigured?: boolean }>;
/** Compatibility field for older IPC callers; the settings UI uses providers. */
Expand All @@ -315,6 +357,8 @@ export interface SettingsPayload {
mcp: { servers: Record<string, McpServerConfig> };
defaultModelId?: string;
defaultReasoningEffort?: ReasoningEffort;
/** Model used by the dedicated image-recognition tool when the main model has no vision input. */
visionModelId?: string;
autostart?: boolean;
/** On by default: an autostart launch stays in the tray instead of opening the main window. */
autostartHidden?: boolean;
Expand All @@ -338,7 +382,7 @@ export function readSettings(workspaceInput: string): SettingsPayload {
maxTokens: config.agent.maxTokens
}];
const providers = config.agent.providers?.length ? config.agent.providers : groupLegacyModels(configured);
return { providers: providers.map((provider) => ({ ...provider, apiKeyConfigured: Boolean(process.env[provider.apiKeyEnv]) })), models: configured.map((model) => ({ ...model, apiKeyConfigured: Boolean(process.env[model.apiKeyEnv]) })), tts: { voice: config.tts?.voice || DEFAULT_TTS_VOICE, rate: config.tts?.rate || DEFAULT_TTS_RATE }, wake: { hotkey: config.wake?.hotkey || DEFAULT_WAKE_HOTKEY, ...(config.wake?.modelId ? { modelId: config.wake.modelId } : {}), voiceEnabled: config.wake?.voiceEnabled === true, voicePhrase: config.wake?.voicePhrase || DEFAULT_WAKE_PHRASE }, speech: { betterRecognition: config.speech?.betterRecognition === true }, updates: { ...(config.updates || DEFAULT_UPDATE_PREFERENCES) }, telemetry: { enabled: config.telemetry?.enabled !== false }, mcp: config.mcp, defaultModelId: config.defaults?.modelId, defaultReasoningEffort: config.defaults?.reasoningEffort, autostart: config.defaults?.autostart === true, autostartHidden: config.defaults?.autostartHidden !== false, customModelMode: config.defaults?.customModelMode ?? false };
return { providers: providers.map((provider) => ({ ...provider, apiKeyConfigured: Boolean(process.env[provider.apiKeyEnv]) })), models: configured.map((model) => ({ ...model, apiKeyConfigured: Boolean(process.env[model.apiKeyEnv]) })), tts: { voice: config.tts?.voice || DEFAULT_TTS_VOICE, rate: config.tts?.rate || DEFAULT_TTS_RATE }, wake: { hotkey: config.wake?.hotkey || DEFAULT_WAKE_HOTKEY, ...(config.wake?.modelId ? { modelId: config.wake.modelId } : {}), voiceEnabled: config.wake?.voiceEnabled === true, voicePhrase: config.wake?.voicePhrase || DEFAULT_WAKE_PHRASE }, speech: { betterRecognition: config.speech?.betterRecognition === true }, updates: { ...(config.updates || DEFAULT_UPDATE_PREFERENCES) }, telemetry: { enabled: config.telemetry?.enabled !== false }, mcp: config.mcp, defaultModelId: config.defaults?.modelId, defaultReasoningEffort: config.defaults?.reasoningEffort, visionModelId: config.defaults?.visionModelId, autostart: config.defaults?.autostart === true, autostartHidden: config.defaults?.autostartHidden !== false, customModelMode: config.defaults?.customModelMode ?? false };
}

function groupLegacyModels(models: ModelProfile[]): ProviderConfig[] {
Expand Down Expand Up @@ -388,7 +432,7 @@ export function saveSettings(workspaceInput: string, payload: SettingsPayload):
raw.updates = nextUpdates;
raw.telemetry = nextTelemetry;
raw.mcp = payload.mcp;
raw.defaults = { modelId: payload.defaultModelId || undefined, reasoningEffort: payload.defaultReasoningEffort || undefined, customModelMode: Boolean(payload.customModelMode), autostart: payload.autostart === true, autostartHidden: payload.autostartHidden !== false };
raw.defaults = { modelId: payload.defaultModelId || undefined, reasoningEffort: payload.defaultReasoningEffort || undefined, customModelMode: Boolean(payload.customModelMode), autostart: payload.autostart === true, autostartHidden: payload.autostartHidden !== false, visionModelId: payload.visionModelId || undefined };
delete (raw as SecAgentConfig & { policy?: unknown }).policy;
fs.writeFileSync(file, YAML.stringify(raw), "utf8");
return readSettings(workspace);
Expand Down
13 changes: 9 additions & 4 deletions src/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { DEFAULT_WORKSPACE } from "../paths.js";
import { configuredModels, configPath, DEFAULT_TELEMETRY_SETTINGS, initializeWorkspace, isOnboardingComplete, loadConfig, markOnboardingComplete, readOobeProgress, readSettings, saveOobeProgress, saveSettings, useConfiguredModel, writeWorkspaceEnv, type OobeProgress, type SettingsPayload } from "../config.js";
import { configuredModels, configPath, DEFAULT_TELEMETRY_SETTINGS, initializeWorkspace, isOnboardingComplete, loadConfig, markOnboardingComplete, OFFICIAL_VISION_MODEL, readOobeProgress, readSettings, resolveVisionAgentConfig, saveOobeProgress, saveSettings, useConfiguredModel, writeWorkspaceEnv, type OobeProgress, type SettingsPayload } from "../config.js";
import { loadEnabledSkills } from "../skills.js";
import { AuditStore } from "../audit.js";
import { SecAgentRuntime, type TraceEvent } from "../runtime.js";
Expand Down Expand Up @@ -861,8 +861,11 @@ ipcMain.handle("models:list", async () => {
// 自定义模型模式开启:只加入后台允许的官方真实模型与本地自定义模型。
return [...visibleRemote, ...options];
}
// 关闭:官方档位模式 —— 下拉只有快速/标准/深度三个虚拟档位,看不到具体模型。
return visibleRemote.filter((model) => (OFFICIAL_TIER_IDS as readonly string[]).includes(model.model));
// 关闭:官方档位模式 —— 下拉只有快速/标准/深度三个虚拟档位,看不到具体模型;
// 另外提供一个识图虚拟模型(virtual-vision),它只作为识图工具的后端模型,
// 不作为主 Agent 模型出现在前端下拉中(前端按 vision 标记过滤)。
return visibleRemote.filter((model) => (OFFICIAL_TIER_IDS as readonly string[]).includes(model.model) || model.model === OFFICIAL_VISION_MODEL)
.map((model) => ({ ...model, vision: model.model === OFFICIAL_VISION_MODEL }));
} catch { return customModelMode ? options : []; }
});
ipcMain.handle("providers:list", async () => {
Expand Down Expand Up @@ -1526,10 +1529,12 @@ ipcMain.handle("sessions:send", async (_event, id: string, text: string, modelId
logMain("ipc.sessions.send", { sessionId: id, text });
trace({ stage: "user.request", data: { text } });
const skills = [...loadEnabledSkills(config), ...(pluginManager?.getSkills() || [])];
const visionConfig = resolveVisionAgentConfig(config);
if (visionConfig) logMain("session.vision-model", { model: visionConfig.agent.model, provider: visionConfig.agent.provider, baseUrl: visionConfig.agent.baseUrl });
const runtimeConfig = isWakeRequest
? { ...config, agent: { ...config.agent, systemPrompt: `${config.agent.systemPrompt}\n\n## 快速唤起输出协议\n${QUICK_WAKE_OUTPUT_PROMPT}` } }
: config;
runtime = new SecAgentRuntime(runtimeConfig, audit, skills, trace, pluginManager);
runtime = new SecAgentRuntime(runtimeConfig, audit, skills, trace, pluginManager, visionConfig);
const previousReadSkillNames = before.messages.flatMap((message) => message.toolCalls || []).filter((call) => call.name === "secagent__read_skill" || call.name === "read_skill").map((call) => typeof (call.arguments as { name?: unknown })?.name === "string" ? (call.arguments as { name: string }).name : "");
const result = await runtime.run(historyInput(before, text), selectedReasoningEffort, conversationInput(before, text, attachments), abortController.signal, { previousAutoLoadedSkills: before.autoLoadedSkills, previousReadSkillNames, preRule });
if (result.autoLoadedSkills?.length) {
Expand Down
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { initializeWorkspace, loadConfig, normalizeAndValidate, useConfiguredModel } from "./config.js";
import { initializeWorkspace, loadConfig, normalizeAndValidate, resolveVisionAgentConfig, useConfiguredModel } from "./config.js";
import { DEFAULT_WORKSPACE, expandPath } from "./paths.js";
import { loadEnabledSkills } from "./skills.js";
import { AuditStore } from "./audit.js";
Expand Down Expand Up @@ -160,7 +160,8 @@ async function openRuntime(workspace: string, modelId: string | undefined, trace
const plugins = new PluginManager(workspace);
await plugins.initialize();
const skills = [...loadEnabledSkills(config), ...plugins.getSkills()];
return { runtime: new SecAgentRuntime(config, audit, skills, trace, plugins), audit, plugins, config };
const visionConfig = resolveVisionAgentConfig(config);
return { runtime: new SecAgentRuntime(config, audit, skills, trace, plugins, visionConfig), audit, plugins, config };
}

async function closeRuntime(handle: RuntimeHandle | undefined): Promise<void> {
Expand Down
33 changes: 33 additions & 0 deletions src/model-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,36 @@ test("persisted tool calls and results are restored to the next OpenAI request",
delete process.env.TEST_MODEL_KEY;
}
});

test("a single-turn sub-agent encodes user attachments as OpenAI image parts", async () => {
const originalFetch = globalThis.fetch;
process.env.TEST_MODEL_KEY = "test-key";
let requestBody: Record<string, unknown> | undefined;
globalThis.fetch = async (_url, init) => {
requestBody = JSON.parse(String(init?.body || "{}")) as Record<string, unknown>;
return response('data: {"choices":[{"delta":{"content":"red"}}]}\n\ndata: [DONE]\n\n');
};
try {
// Tool-less vision sub-agent: allowEmptyTools and no runtime prompts.
const agent = new ModelToolAgent(config(), [], undefined, undefined, false, true);
const conversation: ConversationMessage[] = [
{
role: "user",
content: "图中是什么颜色",
attachments: [{ id: "a1", name: "test.png", mimeType: "image/png", dataUrl: "data:image/png;base64,iVBORw0KGgo=", size: 6 }]
}
];
const result = await agent.run("图中是什么颜色", [], async () => {
throw new Error("识图模型不应调用工具");
}, "low", conversation);
assert.equal(result, "red");
const messages = requestBody?.messages as Array<Record<string, unknown>>;
const content = messages[1]?.content as Array<Record<string, unknown>>;
assert.ok(Array.isArray(content));
assert.deepEqual(content[0], { type: "text", text: "图中是什么颜色" });
assert.deepEqual(content[1], { type: "image_url", image_url: { url: "data:image/png;base64,iVBORw0KGgo=" } });
} finally {
globalThis.fetch = originalFetch;
delete process.env.TEST_MODEL_KEY;
}
});
Loading
Loading