ai_co_discuss 0.2.13 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/discuss-host/SKILL.md +2 -2
- package/.agents/skills/discuss-participant/SKILL.md +1 -1
- package/README.md +4 -2
- package/backend/identity.py +31 -31
- package/backend/runtime.py +364 -364
- package/backend/schema.sql +580 -580
- package/backend/server.py +1763 -1760
- package/docs/architecture.md +6 -2
- package/docs/cli.md +2 -0
- package/docs/runner.md +392 -363
- package/docs/workflow.md +148 -148
- package/package.json +2 -2
- package/runner/api_participant.mjs +176 -0
- package/runner/discuss.py +946 -946
- package/runner/discussion_adapters.mjs +1 -0
- package/runner/discussion_api.mjs +146 -0
- package/runner/discussion_launch.mjs +20 -9
- package/runner/discussion_runner.mjs +20 -4
- package/runner/discussion_service.mjs +394 -394
- package/runner/free_runner.mjs +6 -2
- package/web/app.js +57 -11
- package/web/backup.js +1 -1
- package/web/markdown.js +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +680 -545
|
@@ -537,6 +537,7 @@ export function setup_sidecar(config, root = join(homedir(), '.gemini', 'config'
|
|
|
537
537
|
|
|
538
538
|
/** Select only allowlisted adapter types from local configuration. */
|
|
539
539
|
export async function deliver_task(config, binding, task, spawn_process = spawn, user_home = homedir()) {
|
|
540
|
+
if (config.adapter === 'api') return (await import('./api_participant.mjs')).api_delivery(config, binding, task);
|
|
540
541
|
const adapters = { codex: codex_delivery, claude: claude_delivery, antigravity: antigravity_delivery,
|
|
541
542
|
codex_cli: cli_delivery, antigravity_cli: cli_delivery };
|
|
542
543
|
if (!adapters[config.adapter]) throw new Error('不支持的软件适配器');
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** Provider presets and independent wire protocols; model IDs are never rewritten. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const API_PROVIDERS = {
|
|
7
|
+
Codex: { label: 'OpenAI', base_url: 'https://api.openai.com/v1', api_protocol: 'responses', models: ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna'] },
|
|
8
|
+
Claude: { label: 'Anthropic', base_url: 'https://api.anthropic.com/v1', api_protocol: 'anthropic', models: ['claude-sonnet-5-5', 'claude-opus-5-5', 'claude-haiku-5-5', 'claude-fable-5-1'] },
|
|
9
|
+
Gemini: { label: 'Google', base_url: 'https://generativelanguage.googleapis.com/v1beta', api_protocol: 'gemini', models: ['gemini-4-argon', 'gemini-3.8-flash'] },
|
|
10
|
+
DeepSeek: { label: 'DeepSeek', base_url: 'https://api.deepseek.com/v1', api_protocol: 'chat', models: ['deepseek-flash', 'deepseek-v4-pro'] },
|
|
11
|
+
Qwen: { label: '阿里云百炼', base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', api_protocol: 'chat', models: ['qwen3.8-flash', 'qwen3.7-plus', 'qwen3.8-max'] },
|
|
12
|
+
Grok: { label: 'xAI', base_url: 'https://api.x.ai/v1', api_protocol: 'responses', models: ['grok-4.7'] },
|
|
13
|
+
};
|
|
14
|
+
const PROTOCOLS = ['responses', 'chat', 'anthropic', 'gemini'];
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 180_000;
|
|
16
|
+
const MAX_OUTPUT_TOKENS = 8192;
|
|
17
|
+
|
|
18
|
+
/** Validate an explicit API root, keeping custom gateway paths intact. */
|
|
19
|
+
export function api_base_url(value) {
|
|
20
|
+
const url = new URL(value);
|
|
21
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('Base URL 须为不含凭据、查询或片段的 HTTP(S) 地址');
|
|
22
|
+
return url.href.replace(/\/+$/, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Check protocol/model independently of provider identity. */
|
|
26
|
+
export function validate_api_profile(profile) {
|
|
27
|
+
api_base_url(profile.base_url);
|
|
28
|
+
if (!PROTOCOLS.includes(profile.api_protocol)) throw new Error('请选择 API 请求协议');
|
|
29
|
+
if (!profile.model || typeof profile.model !== 'string') throw new Error('请填写 API 模型 ID');
|
|
30
|
+
if (!/^[a-f0-9-]{36}$/i.test(profile.api_key_ref || '')) throw new Error('请保存此 Base URL 对应的 API Key');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Save a key privately; browsers and room configuration receive only an opaque reference. */
|
|
34
|
+
export function save_api_key(home, input) {
|
|
35
|
+
const base_url = api_base_url(input.base_url);
|
|
36
|
+
const api_key = typeof input.api_key === 'string' ? input.api_key.trim() : '';
|
|
37
|
+
if (!api_key || /[\r\n]/.test(api_key)) throw new Error('API Key 不能为空或包含换行');
|
|
38
|
+
const api_key_ref = randomUUID();
|
|
39
|
+
const directory = join(home, 'api-keys');
|
|
40
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
41
|
+
writeFileSync(join(directory, `${api_key_ref}.json`), JSON.stringify({ base_url, api_key }), { flag: 'wx', mode: 0o600 });
|
|
42
|
+
return { api_key_ref };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolve only a validated reference bound to this exact API root. */
|
|
46
|
+
export function read_api_key(home, profile) {
|
|
47
|
+
validate_api_profile(profile);
|
|
48
|
+
let saved;
|
|
49
|
+
try { saved = JSON.parse(readFileSync(join(home, 'api-keys', `${profile.api_key_ref}.json`), 'utf8')); }
|
|
50
|
+
catch { throw new Error('本机 API Key 不存在,请重新保存'); }
|
|
51
|
+
if (saved.base_url !== api_base_url(profile.base_url)) throw new Error('Base URL 已变化,请为新地址重新填写 API Key');
|
|
52
|
+
return saved.api_key;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Convert neutral tool definitions to one provider's request body. */
|
|
56
|
+
export function api_request(profile, context) {
|
|
57
|
+
const { system, history, tools } = context;
|
|
58
|
+
const model = profile.model;
|
|
59
|
+
const functions = tools.map(tool => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
|
|
60
|
+
if (profile.api_protocol === 'responses') return { path: '/responses', body: { model, instructions: system, input: history,
|
|
61
|
+
tools: functions.map(tool => ({ type: 'function', ...tool, strict: false })), store: false,
|
|
62
|
+
include: ['reasoning.encrypted_content'], max_output_tokens: MAX_OUTPUT_TOKENS } };
|
|
63
|
+
if (profile.api_protocol === 'anthropic') return { path: '/messages', body: { model, system, messages: history,
|
|
64
|
+
tools: functions.map(({ parameters, ...tool }) => ({ ...tool, input_schema: parameters })), max_tokens: MAX_OUTPUT_TOKENS } };
|
|
65
|
+
if (profile.api_protocol === 'gemini') return { path: `/models/${encodeURIComponent(model.replace(/^models\//, ''))}:generateContent`, body: {
|
|
66
|
+
systemInstruction: { parts: [{ text: system }] }, contents: history,
|
|
67
|
+
tools: [{ functionDeclarations: functions }], generationConfig: { maxOutputTokens: MAX_OUTPUT_TOKENS } } };
|
|
68
|
+
return { path: '/chat/completions', body: { model, messages: [{ role: 'system', content: system }, ...history],
|
|
69
|
+
tools: functions.map(tool => ({ type: 'function', function: tool })) } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Build authentication without encoding keys into URLs. */
|
|
73
|
+
function api_headers(protocol, key) {
|
|
74
|
+
if (protocol === 'anthropic') return { 'x-api-key': key, 'anthropic-version': '2023-06-01' };
|
|
75
|
+
if (protocol === 'gemini') return { 'x-goog-api-key': key };
|
|
76
|
+
return { Authorization: `Bearer ${key}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A bounded request; errors expose only status, never provider bodies or authorization. */
|
|
80
|
+
async function provider_fetch(profile, key, request, signal) {
|
|
81
|
+
let response;
|
|
82
|
+
try {
|
|
83
|
+
response = await fetch(`${api_base_url(profile.base_url)}${request.path}`, {
|
|
84
|
+
method: request.body === undefined ? 'GET' : 'POST', redirect: 'error',
|
|
85
|
+
headers: { 'Content-Type': 'application/json', ...api_headers(profile.api_protocol, key) },
|
|
86
|
+
body: request.body === undefined ? undefined : JSON.stringify(request.body),
|
|
87
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
88
|
+
});
|
|
89
|
+
} catch { throw new Error(signal?.aborted ? 'API 执行已取消' : 'API 请求超时或连接失败,请核对 Base URL'); }
|
|
90
|
+
if (!response.ok) throw new Error(`模型 API 返回 HTTP ${response.status},请核对地址、密钥、模型和请求协议`);
|
|
91
|
+
try { return await response.json(); } catch { throw new Error('模型 API 返回的内容不是有效 JSON'); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fetch model IDs as returned by the provider; manual entry remains available. */
|
|
95
|
+
export async function api_models(home, profile) {
|
|
96
|
+
const key = read_api_key(home, profile);
|
|
97
|
+
const data = await provider_fetch(profile, key, { path: '/models' });
|
|
98
|
+
const models = (data.data || data.models || []).map(model => ({ id: model.id || model.name?.replace(/^models\//, ''), label: model.displayName || model.id || model.name }))
|
|
99
|
+
.filter(model => typeof model.id === 'string');
|
|
100
|
+
return { adapter: 'api', models, error: '', source: 'api' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Start protocol-specific history with a user request. */
|
|
104
|
+
export function api_history(profile, prompt) {
|
|
105
|
+
return profile.api_protocol === 'gemini' ? [{ role: 'user', parts: [{ text: prompt }] }] : [{ role: 'user', content: prompt }];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Preserve raw reasoning/signature blocks for subsequent tool turns, without displaying them. */
|
|
109
|
+
export function api_response(profile, data, history) {
|
|
110
|
+
if (profile.api_protocol === 'responses') {
|
|
111
|
+
if (data.status === 'incomplete' || data.error) throw new Error('模型 API 未完成回答');
|
|
112
|
+
const output = data.output || [];
|
|
113
|
+
history.push(...output);
|
|
114
|
+
return output.filter(item => item.type === 'function_call').map(item => ({ id: item.call_id, name: item.name, arguments: item.arguments }));
|
|
115
|
+
}
|
|
116
|
+
if (profile.api_protocol === 'anthropic') {
|
|
117
|
+
if (data.stop_reason === 'max_tokens') throw new Error('模型 API 输出超过单次长度限制');
|
|
118
|
+
history.push({ role: 'assistant', content: data.content });
|
|
119
|
+
return (data.content || []).filter(item => item.type === 'tool_use').map(item => ({ id: item.id, name: item.name, arguments: item.input }));
|
|
120
|
+
}
|
|
121
|
+
if (profile.api_protocol === 'gemini') {
|
|
122
|
+
const candidate = data.candidates?.[0];
|
|
123
|
+
if (!candidate?.content || candidate.finishReason === 'MAX_TOKENS') throw new Error('模型 API 未完成回答');
|
|
124
|
+
history.push(candidate.content);
|
|
125
|
+
return (candidate.content.parts || []).filter(item => item.functionCall).map(item => ({ name: item.functionCall.name, arguments: item.functionCall.args }));
|
|
126
|
+
}
|
|
127
|
+
const choice = data.choices?.[0];
|
|
128
|
+
if (!choice?.message || choice.finish_reason === 'length') throw new Error('模型 API 未完成回答');
|
|
129
|
+
history.push(choice.message);
|
|
130
|
+
return (choice.message.tool_calls || []).map(item => ({ id: item.id, name: item.function.name, arguments: item.function.arguments }));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Append all results together so parallel tool calls retain their protocol's pairing. */
|
|
134
|
+
export function api_tool_results(profile, history, results) {
|
|
135
|
+
if (profile.api_protocol === 'anthropic') return history.push({ role: 'user', content: results.map(item => ({ type: 'tool_result', tool_use_id: item.id, content: JSON.stringify(item.result) })) });
|
|
136
|
+
if (profile.api_protocol === 'gemini') return history.push({ role: 'user', parts: results.map(item => ({ functionResponse: { name: item.name, response: { result: item.result } } })) });
|
|
137
|
+
history.push(...results.map(item => profile.api_protocol === 'responses'
|
|
138
|
+
? { type: 'function_call_output', call_id: item.id, output: JSON.stringify(item.result) }
|
|
139
|
+
: { role: 'tool', tool_call_id: item.id, content: JSON.stringify(item.result) }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Generate one tool-call turn with the selected wire protocol. */
|
|
143
|
+
export async function api_generate(profile, key, context, signal) {
|
|
144
|
+
const data = await provider_fetch(profile, key, api_request(profile, context), signal);
|
|
145
|
+
return api_response(profile, data, context.history);
|
|
146
|
+
}
|
|
@@ -9,12 +9,13 @@ import { parseArgs } from 'node:util';
|
|
|
9
9
|
|
|
10
10
|
import { atomic_json } from './discussion_adapters.mjs';
|
|
11
11
|
import { software_models } from './discussion_models.mjs';
|
|
12
|
+
import { API_PROVIDERS, api_models, save_api_key, validate_api_profile } from './discussion_api.mjs';
|
|
12
13
|
import { acquire_lock, api, CODEX_ADAPTERS, DiscussionStore, service_drafts, task_files, validate_config } from './discussion_runner.mjs';
|
|
13
14
|
import { assert_shared_home, CONNECTION_FIELDS, configuration_snapshot, read_registry, registered_identity, register_room, service_home, service_pid, start_service } from './discussion_service.mjs';
|
|
14
15
|
|
|
15
|
-
const CLIENTS = { Codex: ['codex_cli', 'codex'], Claude: ['claude'], Gemini: ['antigravity_cli', 'antigravity'] };
|
|
16
|
+
const CLIENTS = { Codex: ['codex_cli', 'codex', 'api'], Claude: ['claude', 'api'], Gemini: ['antigravity_cli', 'antigravity', 'api'], DeepSeek: ['api'], Qwen: ['api'], Grok: ['api'] };
|
|
16
17
|
// A platform is a type; `Claude-评审` is another Claude instance with its own session, model and effort.
|
|
17
|
-
const INSTANCE_PATTERN = /^(Codex|Claude|Gemini|DeepSeek|Qwen)(?:-[0-9A-Za-z_一-鿿]{1,16})?$/;
|
|
18
|
+
const INSTANCE_PATTERN = /^(Codex|Claude|Gemini|DeepSeek|Qwen|Grok)(?:-[0-9A-Za-z_一-鿿]{1,16})?$/;
|
|
18
19
|
|
|
19
20
|
/** The platform of an AI instance name, or null. */
|
|
20
21
|
export function platform_of(name) { return typeof name === 'string' ? INSTANCE_PATTERN.exec(name)?.[1] ?? null : null; }
|
|
@@ -29,7 +30,7 @@ export function next_instance(platform, taken) {
|
|
|
29
30
|
function clients_of(name) { return CLIENTS[platform_of(name)]; }
|
|
30
31
|
// Reasoning effort is a separate CLI argument only for Codex and Claude; agy encodes it in the model name.
|
|
31
32
|
const EFFORT_LEVELS = { codex_cli: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], claude: ['low', 'medium', 'high', 'xhigh', 'max'] };
|
|
32
|
-
const PROFILE_FIELDS = ['adapter', 'executable', 'model', 'effort', 'source_thread_id', 'ipc_directory', 'allowed_tools', 'cwd', 'project_name', 'project_id'];
|
|
33
|
+
const PROFILE_FIELDS = ['adapter', 'executable', 'model', 'effort', 'source_thread_id', 'ipc_directory', 'allowed_tools', 'cwd', 'project_name', 'project_id', 'base_url', 'api_protocol', 'api_key_ref'];
|
|
33
34
|
const MAX_MINUTES = 1440;
|
|
34
35
|
const CONFIG_APPLY_TIMEOUT_MS = 6000;
|
|
35
36
|
const CONFIG_APPLY_POLL_MS = 100;
|
|
@@ -38,6 +39,7 @@ const HELP = `用法:codiscuss launch settings [--file FILE]
|
|
|
38
39
|
codiscuss launch room-config --file FILE
|
|
39
40
|
codiscuss launch room-models --file FILE
|
|
40
41
|
codiscuss launch models --file FILE
|
|
42
|
+
codiscuss launch api-key --file FILE
|
|
41
43
|
codiscuss launch room-member --file FILE
|
|
42
44
|
codiscuss launch pick-directory
|
|
43
45
|
|
|
@@ -45,7 +47,8 @@ settings 读取本机软件默认配置;指定文件时保存,不启动 AI
|
|
|
45
47
|
start 使用启动 JSON 创建或接入房间并启动;--dry-run 只校验和预览。
|
|
46
48
|
room-config 修正已暂停房间的软件路径或切换 Codex App / CLI,不启动讨论;须提供 site、room_id、token。
|
|
47
49
|
room-models 只读指定 site、room_id 的本机登记模型与思考强度,不启动讨论。
|
|
48
|
-
models 按 adapter、executable
|
|
50
|
+
models 按 adapter、executable 或 API profile 读取模型目录,不启动讨论或生成回答。
|
|
51
|
+
api-key 将 base_url、api_key 保存于本机私有目录,只返回 api_key_ref。
|
|
49
52
|
pick-directory 在本机弹出系统目录选择窗口,返回所选路径;取消时不返回路径。
|
|
50
53
|
room-member 为运行中的自由房间邀请或请回一位成员;须提供 site、room_id、token、action(add/rejoin)、name。
|
|
51
54
|
JSON 指定 site、entry(conversation/web)、room(建房内容,含 submission_id)
|
|
@@ -128,7 +131,7 @@ export function room_models(home, raw) {
|
|
|
128
131
|
if (paths.length > 1) throw new Error('本场存在多份登记配置,无法确定模型设置');
|
|
129
132
|
const profiles = paths.length ? configuration_snapshot(paths[0]).config.participants : {};
|
|
130
133
|
const participants = Object.fromEntries(Object.entries(profiles).map(([name, profile]) => {
|
|
131
|
-
const cli = ['codex_cli', 'claude', 'antigravity_cli'].includes(profile.adapter);
|
|
134
|
+
const cli = ['codex_cli', 'claude', 'antigravity_cli', 'api'].includes(profile.adapter);
|
|
132
135
|
return [name, { adapter: profile.adapter, model: cli ? profile.model || '' : '',
|
|
133
136
|
effort: EFFORT_LEVELS[profile.adapter] ? profile.effort || '' : '' }];
|
|
134
137
|
}));
|
|
@@ -164,7 +167,8 @@ function validate_profile(name, profile, binding = false) {
|
|
|
164
167
|
// The model is passed as one CLI argument, so it must not look like a flag or contain spaces.
|
|
165
168
|
if (key === 'model' && value && !/^[A-Za-z0-9][A-Za-z0-9._:/@[\]-]*$/.test(value)) throw new Error(`${name}.model 只能是模型名称,例如 gpt-6-astra、opus 或 gemini-4-argon-high`);
|
|
166
169
|
}
|
|
167
|
-
if (profile.
|
|
170
|
+
if (profile.adapter === 'api') validate_api_profile(profile);
|
|
171
|
+
if (profile.effort && profile.adapter !== 'api' && !EFFORT_LEVELS[profile.adapter]?.includes(profile.effort)) {
|
|
168
172
|
throw new Error(EFFORT_LEVELS[profile.adapter] ? `${name}.effort 只能是 ${EFFORT_LEVELS[profile.adapter].join(' / ')}`
|
|
169
173
|
: `${name} 的思考档位不能单独设置;agy 请选择带档位的模型名,App 在软件内选择`);
|
|
170
174
|
}
|
|
@@ -222,7 +226,7 @@ function launch_config(home, input, room, directory, options) {
|
|
|
222
226
|
participants[name] = participant_profile(name, defaults, input, name === room.host);
|
|
223
227
|
}
|
|
224
228
|
const members = launch_members(input, Object.keys(participants), options);
|
|
225
|
-
return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, participants, members,
|
|
229
|
+
return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, participants, members, api_home: home,
|
|
226
230
|
cwd: input.cwd, discussion_project: input.discussion_project, project_access: input.project_access || undefined,
|
|
227
231
|
data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
|
|
228
232
|
}
|
|
@@ -233,7 +237,7 @@ function free_launch_config(home, input, room, directory, options) {
|
|
|
233
237
|
const names = room.members.map(member => typeof member === 'string' ? member : member.name);
|
|
234
238
|
const participants = Object.fromEntries(names.map(name => [name, participant_profile(name, defaults, input, false)]));
|
|
235
239
|
const members = launch_members(input, names, options);
|
|
236
|
-
return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, mode: 'free', participants, members,
|
|
240
|
+
return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, mode: 'free', participants, members, api_home: home,
|
|
237
241
|
cwd: input.cwd, project_access: input.project_access || undefined,
|
|
238
242
|
data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
|
|
239
243
|
}
|
|
@@ -255,6 +259,10 @@ function participant_profile(name, defaults, input, current_host) {
|
|
|
255
259
|
for (const field of ['cwd', 'model', 'effort', 'project_name', 'project_id', 'project_access']) if (profile[field] === '') delete profile[field];
|
|
256
260
|
validate_profile(name, profile, true);
|
|
257
261
|
if (!clients_of(name)?.includes(profile.adapter)) throw new Error(`请配置 ${name} 的 CLI / App 客户端`);
|
|
262
|
+
if (profile.adapter === 'api') {
|
|
263
|
+
if (input.entry === 'conversation' && current_host) throw new Error('API 主持人请从网页启动;当前软件会话主持须选择对应 CLI / App');
|
|
264
|
+
return profile;
|
|
265
|
+
}
|
|
258
266
|
if (profile.adapter !== 'antigravity' && (!existsSync(profile.executable || '') || !statSync(profile.executable).isFile()
|
|
259
267
|
|| (process.platform === 'win32' && !/\.exe$/i.test(profile.executable)))) throw new Error(`${name}:请指定实际原生可执行文件,Windows 使用 .exe`);
|
|
260
268
|
const cwd = profile.cwd || input.cwd;
|
|
@@ -301,6 +309,7 @@ export async function room_member(home, raw, locked = false) {
|
|
|
301
309
|
const release = locked ? () => {} : acquire_lock(join(dirname(path), 'configuration-edit'));
|
|
302
310
|
try {
|
|
303
311
|
const config = configuration_snapshot(path).config;
|
|
312
|
+
config.api_home = home;
|
|
304
313
|
const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
|
|
305
314
|
if (action === 'add' && input.new_instance) {
|
|
306
315
|
// A name once bound keeps its session locally, so a new instance never reuses a removed member's name.
|
|
@@ -640,8 +649,10 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
|
|
|
640
649
|
const home = service_home();
|
|
641
650
|
assert_shared_home(home);
|
|
642
651
|
let result;
|
|
643
|
-
if (positionals[0] === 'settings') result = input ? save_launch_settings(home, input) : launch_settings(home);
|
|
652
|
+
if (positionals[0] === 'settings') result = { ...(input ? save_launch_settings(home, input) : launch_settings(home)), api_providers: API_PROVIDERS };
|
|
653
|
+
else if (positionals[0] === 'api-key' && input) result = save_api_key(home, input);
|
|
644
654
|
else if (positionals[0] === 'room-models' && input) result = room_models(home, input);
|
|
655
|
+
else if (positionals[0] === 'models' && input?.adapter === 'api') result = await api_models(home, input);
|
|
645
656
|
else if (positionals[0] === 'models' && input) {
|
|
646
657
|
const saved = Object.values(launch_settings(home).participants).find(profile => profile.adapter === input.adapter);
|
|
647
658
|
result = await cached_software_models(home, { adapter: input.adapter, executable: input.executable || saved?.executable }, { refresh: input.refresh === true });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Durable website-event delivery to
|
|
1
|
+
/** Durable website-event delivery to AI software and explicitly configured API participants. */
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -7,13 +7,14 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
7
7
|
import { parseArgs } from 'node:util';
|
|
8
8
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
9
9
|
import { atomic_json, deliver_task, remove_agy_projects, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
|
|
10
|
+
import { read_api_key } from './discussion_api.mjs';
|
|
10
11
|
|
|
11
12
|
const POLL_MS = 2000;
|
|
12
13
|
/** Headless CLIs know their session at start, so the runner enters the execution instead of spending model turns on it. */
|
|
13
14
|
const AUTO_ENTER_ADAPTERS = new Set(['codex_cli', 'claude', 'antigravity_cli']);
|
|
14
15
|
const DELIVERY_RETRY_DELAYS_MS = [2000, 4000];
|
|
15
16
|
const RENEW_MS = 30_000;
|
|
16
|
-
const ADAPTER_NAMES = ['codex', 'claude', 'antigravity', 'codex_cli', 'antigravity_cli'];
|
|
17
|
+
const ADAPTER_NAMES = ['codex', 'claude', 'antigravity', 'codex_cli', 'antigravity_cli', 'api'];
|
|
17
18
|
export const CODEX_ADAPTERS = ['codex', 'codex_cli'];
|
|
18
19
|
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
19
20
|
/** Project files in cwd are read-only unless the launch or participant explicitly grants write. */
|
|
@@ -40,6 +41,12 @@ export function validate_config(input, base_directory) {
|
|
|
40
41
|
if (!config.project || !existsSync(join(config.project, 'runner', 'discuss.py'))) throw new Error('cli_project 必须指向 AI CoDiscuss 项目;省略时默认使用本仓库');
|
|
41
42
|
if (!config.participants || !Object.keys(config.participants).length) throw new Error('请配置本机参与软件');
|
|
42
43
|
for (const participant of Object.values(config.participants)) {
|
|
44
|
+
if (participant.adapter === 'api') {
|
|
45
|
+
read_api_key(config.api_home, participant);
|
|
46
|
+
for (const field of ['cwd', 'project_name', 'project_id', 'executable', 'effort']) delete participant[field];
|
|
47
|
+
participant.project_access = 'read';
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
43
50
|
participant.cwd = participant.cwd ?? config.cwd;
|
|
44
51
|
participant.project_name = participant.project_name ?? config.discussion_project;
|
|
45
52
|
participant.project_access = participant.project_access || config.project_access || 'read';
|
|
@@ -181,7 +188,8 @@ export function task_files(config, event, token, binding) {
|
|
|
181
188
|
role: event.role, turn_id: event.turn_id, skills, cli, subcommands: TASK_SUBCOMMANDS, access_file: access_path,
|
|
182
189
|
instructions: `读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;${auto_enter ? '接入程序已进入本次执行,不要再执行 execution enter。' : `使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。进入成功后才处理本次安排;`}凭据文件由 CLI 内部读取,不要打印或读取其 token。每次业务写入都带 --access-file。答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${project_rule}只处理本次工作,不建定时任务。主持创建下一安排或收束即结束,专家提交原文后结束。无效或已进入的任务不要重复执行。` });
|
|
183
190
|
const scope = `本次只放行任务内 subcommands 列出的 ${cli} 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口。任务内 cwd 是讨论项目目录;${project_rule}项目读写仍受软件实际权限和议题授权约束。不读取凭据内容。读取含中文的技能或文本文件时用 rg --encoding utf-8 . 文件路径,不用 Get-Content(Windows PowerShell 会按系统编码读出乱码,需要反复重试)。`;
|
|
184
|
-
const
|
|
191
|
+
const plan_rule = event.role === 'host' ? '安排专家回应时先写发言计划,用 turn create --plan-file 一次排满本轮或可预知的整个序列,不要每次只排一位。' : '';
|
|
192
|
+
const intro = `用户已启动此房间的全自动讨论。请以 ${event.participant} 的${event.role === 'host' ? '主持' : '专家'}职责处理本次工作。${plan_rule}`;
|
|
185
193
|
const prompt = auto_enter
|
|
186
194
|
? `${intro}接入程序已为本次执行完成 execution enter,不要再执行。任务内容如下(同一份保存在 ${task_path},无需再读):
|
|
187
195
|
${readFileSync(task_path, 'utf8')}
|
|
@@ -230,6 +238,8 @@ export class DiscussionRunner {
|
|
|
230
238
|
const task = task_files(this.config, event, row.token, binding);
|
|
231
239
|
invoked = true;
|
|
232
240
|
const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
|
|
241
|
+
api_context: { site: this.config.site, room: this.config.room, token: row.token, home: this.config.api_home,
|
|
242
|
+
event_id: row.id, role: event.role, turn_id: event.turn_id },
|
|
233
243
|
on_trace: (stage, detail) => this.trace(row.id, stage, detail),
|
|
234
244
|
on_binding: session => {
|
|
235
245
|
this.store.bind(event.participant, session);
|
|
@@ -303,7 +313,13 @@ export class DiscussionRunner {
|
|
|
303
313
|
}
|
|
304
314
|
/** Preserve the cause on the website and revoke work before asking for intervention. */
|
|
305
315
|
async fail(row, reason) {
|
|
306
|
-
await api(this.config, `/events/${row.id}`, { action: 'fail', reason });
|
|
316
|
+
try { await api(this.config, `/events/${row.id}`, { action: 'fail', reason }); }
|
|
317
|
+
catch (error) {
|
|
318
|
+
if (!/HTTP 409/.test(error.message)) throw error;
|
|
319
|
+
const latest = await api(this.config, `/events/${row.id}`, { action: 'view' });
|
|
320
|
+
if (['completed', 'cancelled', 'failed'].includes(latest.status)) return this.reconcile(row, latest);
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
307
323
|
this.store.record(row.id, { status: 'failed', error: reason });
|
|
308
324
|
this.stop_children();
|
|
309
325
|
}
|