mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-04 00:01:14 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f1b6633b7 | |||
| ed2b0dabad | |||
| 29e52fecd6 | |||
| 576f7485bf | |||
| 9330bdb318 | |||
| 3c6af91e8d |
@@ -2,6 +2,14 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.12.1 - 2026-07-31
|
||||
|
||||
+ [新增] 画布右侧Agent支持按当前账号可用范围选择Codex 模型与推理强度。
|
||||
+ [优化] Canvas Agent 升级至最新 Codex,启动时检查版本更新,
|
||||
+ [优化] 统一通过公共日志输出 Info 以上信息,美化转发日志时间。
|
||||
+ [优化] Agent高速流式回复取消逐词动画排队和当前消息离屏占位。
|
||||
+ [修复] Agent首次发送消息时立即保留并展示用户内容,不再晚于思考状态。
|
||||
|
||||
## v0.12.0 - 2026-07-30
|
||||
|
||||
+ [新增] 画布 Agent 支持三档 Codex 权限,并可在对话中审批操作。
|
||||
|
||||
Generated
+1532
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@basketikun/canvas-agent",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -22,8 +22,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@openai/codex": "0.145.0",
|
||||
"@openai/codex": "0.146.0",
|
||||
"express": "^5.1.0",
|
||||
"strip-ansi": "7.2.0",
|
||||
"winston": "^3.19.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
|
||||
@@ -2,11 +2,12 @@ import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import stripAnsi from "strip-ansi";
|
||||
|
||||
import { VERSION } from "../config.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { field, type JsonRecord } from "../utils/value.js";
|
||||
import type { CodexNotificationParams, CodexPlanUpdate, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js";
|
||||
import type { CodexNotificationParams, CodexPlanUpdate, CodexReasoningEffort, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js";
|
||||
import type { AgentEmit, AgentPermissionMode } from "./types.js";
|
||||
|
||||
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
|
||||
@@ -43,7 +44,7 @@ export class CodexAppClient {
|
||||
const client = new CodexAppClient(child, emit);
|
||||
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const text = chunk.toString();
|
||||
const text = stripAnsi(chunk.toString()).replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/, "");
|
||||
logger.warn("Codex app-server stderr", { text });
|
||||
emit("agent_log", { text });
|
||||
});
|
||||
@@ -91,6 +92,11 @@ export class CodexAppClient {
|
||||
return this.request("thread/archive", { threadId });
|
||||
}
|
||||
|
||||
/** 查询当前账号可用的 Codex 模型。 */
|
||||
listModels() {
|
||||
return this.request("model/list", { limit: 100, includeHidden: false });
|
||||
}
|
||||
|
||||
/** 返回指定线程在当前进程中收到的最新任务计划。 */
|
||||
planUpdates(threadId: string) {
|
||||
return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId);
|
||||
@@ -104,9 +110,9 @@ export class CodexAppClient {
|
||||
}
|
||||
|
||||
/** 启动一个 Codex turn 并等待完成通知。 */
|
||||
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void) {
|
||||
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, model?: string, effort?: CodexReasoningEffort, onTurn?: (turnId: string) => void) {
|
||||
this.currentThreadId = threadId;
|
||||
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode) });
|
||||
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
|
||||
const turnId = turn.id;
|
||||
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
||||
this.currentTurnId = turnId;
|
||||
|
||||
@@ -6,6 +6,15 @@ export type CodexTurnError = JsonRecord & { message: string };
|
||||
export type CodexItem = JsonRecord & { id: string; type: string; text?: string };
|
||||
export type CodexPlanStep = { step: string; status: "pending" | "inProgress" | "completed" };
|
||||
export type CodexPlanUpdate = { threadId: string; turnId: string; explanation?: string | null; plan: CodexPlanStep[]; turnStatus?: string };
|
||||
export type CodexReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
|
||||
export type CodexModel = JsonRecord & {
|
||||
id: string;
|
||||
model: string;
|
||||
displayName: string;
|
||||
defaultReasoningEffort: CodexReasoningEffort;
|
||||
supportedReasoningEfforts: Array<{ reasoningEffort: CodexReasoningEffort; description?: string }>;
|
||||
isDefault?: boolean;
|
||||
};
|
||||
|
||||
export type CodexTurnInput =
|
||||
| { type: "text"; text: string; text_elements: [] }
|
||||
@@ -53,8 +62,12 @@ type CodexRequestSpec = {
|
||||
params: { threadId: string };
|
||||
result: Record<string, never>;
|
||||
};
|
||||
"model/list": {
|
||||
params: { limit: number; includeHidden: boolean };
|
||||
result: { data: CodexModel[]; nextCursor: string | null };
|
||||
};
|
||||
"turn/start": {
|
||||
params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "on-request"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" } };
|
||||
params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "on-request"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" }; model?: string; effort?: CodexReasoningEffort };
|
||||
result: { turn: CodexTurn };
|
||||
};
|
||||
"turn/interrupt": {
|
||||
|
||||
@@ -6,9 +6,10 @@ import { logger } from "../utils/logger.js";
|
||||
import { errorMessage, field } from "../utils/value.js";
|
||||
import { CodexAppClient } from "./codex-client.js";
|
||||
import { summarizeCodexThread, threadMessages } from "./codex-history.js";
|
||||
import type { CodexReasoningEffort } from "./codex-protocol.js";
|
||||
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
|
||||
|
||||
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
|
||||
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; model?: string; effort?: CodexReasoningEffort; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
|
||||
|
||||
let codexQueue: Promise<unknown> = Promise.resolve();
|
||||
let codexApp: CodexAppClient | null = null;
|
||||
@@ -71,6 +72,11 @@ export async function listCodexThreads(emit: AgentEmit, options: { cwd: string;
|
||||
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
||||
}
|
||||
|
||||
/** 查询当前账号可用于新任务的 Codex 模型。 */
|
||||
export async function listCodexModels(emit: AgentEmit) {
|
||||
return await (await getCodexApp(emit)).listModels();
|
||||
}
|
||||
|
||||
/** 读取指定 Codex 线程及其聊天历史。 */
|
||||
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
const app = await getCodexApp(emit);
|
||||
@@ -115,7 +121,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
|
||||
options.onThread?.(threadId);
|
||||
unmaterializedThreadIds.delete(threadId);
|
||||
try {
|
||||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
|
||||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn);
|
||||
} catch (error) {
|
||||
if (!isRecoverableThreadError(error)) throw error;
|
||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||
@@ -123,7 +129,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
|
||||
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
|
||||
options.onThread?.(threadId);
|
||||
unmaterializedThreadIds.delete(threadId);
|
||||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
|
||||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Codex turn failed", error);
|
||||
|
||||
@@ -4,11 +4,13 @@ import path from "node:path";
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
|
||||
import { runClaudeTurn } from "../agent/claude.js";
|
||||
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
|
||||
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexModels, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
|
||||
import type { CodexReasoningEffort } from "../agent/codex-protocol.js";
|
||||
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
|
||||
import { CanvasSession } from "../canvas/session.js";
|
||||
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { checkVersions } from "../version-check.js";
|
||||
|
||||
/** 启动仅监听本机的 Canvas Agent HTTP 服务。 */
|
||||
export function startHttpServer() {
|
||||
@@ -95,6 +97,7 @@ export function startHttpServer() {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
res.json({ ok: true, workspace });
|
||||
});
|
||||
app.get("/agent/codex/models", route(async (_req, res) => res.json({ ok: true, ...(await listCodexModels(emit)) })));
|
||||
app.get("/agent/codex/threads", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
|
||||
@@ -145,7 +148,9 @@ export function startHttpServer() {
|
||||
const prompt = String(req.body?.prompt || "");
|
||||
if (!prompt.trim()) return res.status(400).json({ ok: false, error: "请输入任务内容" });
|
||||
const clientId = String(req.body?.clientId || "");
|
||||
logger.info("Codex turn accepted", { threadId: req.body?.threadId, promptLength: prompt.length, attachmentCount: attachments.length });
|
||||
const model = String(req.body?.model || "") || undefined;
|
||||
const effort = reasoningEffort(req.body?.effort);
|
||||
logger.info("Codex turn accepted", { threadId: req.body?.threadId, model: model || "default", reasoningEffort: effort || "default", promptLength: prompt.length, attachmentCount: attachments.length });
|
||||
session.setCodexState({ busy: true, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
|
||||
try {
|
||||
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
|
||||
@@ -173,6 +178,8 @@ export function startHttpServer() {
|
||||
threadId,
|
||||
cwd: workspace.workspacePath,
|
||||
permissionMode: permissionMode(req.body?.permissionMode),
|
||||
model,
|
||||
effort,
|
||||
appEmit: emit,
|
||||
onStart: clientId ? () => session.bindClient(clientId) : undefined,
|
||||
onThread: (actualThreadId) => {
|
||||
@@ -188,7 +195,7 @@ export function startHttpServer() {
|
||||
},
|
||||
onTurn: (actualTurnId) => {
|
||||
turnId = actualTurnId;
|
||||
logger.info("Codex turn started", { threadId, turnId });
|
||||
logger.info("Codex turn started", { threadId, turnId, model: model || "default", reasoningEffort: effort || "default" });
|
||||
session.setCodexState({ busy: true, threadId, turnId });
|
||||
},
|
||||
onFinish: () => {
|
||||
@@ -223,6 +230,7 @@ export function startHttpServer() {
|
||||
|
||||
app.listen(port, "127.0.0.1", () => {
|
||||
console.log("Infinite Canvas Agent");
|
||||
checkVersions();
|
||||
console.log(`Local URL: ${config.url}`);
|
||||
console.log(`Connect token: ${config.token}`);
|
||||
console.log("Codex MCP is not installed by this command.");
|
||||
@@ -247,6 +255,10 @@ function permissionMode(value: unknown): AgentPermissionMode {
|
||||
return value === "automatic" || value === "full" ? value : "request";
|
||||
}
|
||||
|
||||
function reasoningEffort(value: unknown): CodexReasoningEffort | undefined {
|
||||
return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max" || value === "ultra" ? value : undefined;
|
||||
}
|
||||
|
||||
/** 使用当前操作系统的文件管理器定位本地文件。 */
|
||||
function revealLocalFile(filePath: string, isDirectory: boolean) {
|
||||
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
||||
|
||||
@@ -11,47 +11,44 @@ import {formatDateForFilename} from "./date.js";
|
||||
export class Logger {
|
||||
readonly enabled = process.argv.includes("--debug");
|
||||
readonly filePath = this.enabled ? path.join(os.homedir(), ".infinite-canvas", "logs", `canvas-agent-${formatDateForFilename()}.log`) : "";
|
||||
private readonly logger: WinstonLogger | null;
|
||||
private readonly logger: WinstonLogger;
|
||||
|
||||
/** 根据命令行 Debug 参数初始化日志输出。 */
|
||||
/** 普通模式输出 Info 以上日志,Debug 模式额外输出 Debug 并写入文件。 */
|
||||
constructor() {
|
||||
if (!this.enabled) {
|
||||
this.logger = null;
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(this.filePath), {recursive: true});
|
||||
const line = format.printf(({level, message, timestamp, details}) => `${timestamp} ${level.toUpperCase()} ${message}${formatDetails(details)}`);
|
||||
const output = format.combine(format.timestamp({format: "YYYY-MM-DD HH:mm:ss"}), line);
|
||||
if (this.enabled) fs.mkdirSync(path.dirname(this.filePath), {recursive: true});
|
||||
this.logger = winston.createLogger({
|
||||
level: "debug",
|
||||
level: this.enabled ? "debug" : "info",
|
||||
transports: [
|
||||
new transports.Console({format: format.combine(format.timestamp({format: "HH:mm:ss"}), line)}),
|
||||
new transports.File({filename: this.filePath, format: format.combine(format.timestamp({format: "HH:mm:ss"}), line)}),
|
||||
new transports.Console({format: output}),
|
||||
...(this.enabled ? [new transports.File({filename: this.filePath, format: output})] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 输出 Debug 级别日志。 */
|
||||
debug(message: string, details?: unknown) {
|
||||
if (details === undefined) this.logger?.debug(message);
|
||||
else this.logger?.debug(message, {details: sanitize(details)});
|
||||
if (details === undefined) this.logger.debug(message);
|
||||
else this.logger.debug(message, {details: sanitize(details)});
|
||||
}
|
||||
|
||||
/** 输出 Info 级别日志。 */
|
||||
info(message: string, details?: unknown) {
|
||||
if (details === undefined) this.logger?.info(message);
|
||||
else this.logger?.info(message, {details: sanitize(details)});
|
||||
if (details === undefined) this.logger.info(message);
|
||||
else this.logger.info(message, {details: sanitize(details)});
|
||||
}
|
||||
|
||||
/** 输出 Warn 级别日志。 */
|
||||
warn(message: string, details?: unknown) {
|
||||
if (details === undefined) this.logger?.warn(message);
|
||||
else this.logger?.warn(message, {details: sanitize(details)});
|
||||
if (details === undefined) this.logger.warn(message);
|
||||
else this.logger.warn(message, {details: sanitize(details)});
|
||||
}
|
||||
|
||||
/** 输出 Error 级别日志。 */
|
||||
error(message: string, details?: unknown) {
|
||||
if (details === undefined) this.logger?.error(message);
|
||||
else this.logger?.error(message, {details: sanitize(details)});
|
||||
if (details === undefined) this.logger.error(message);
|
||||
else this.logger.error(message, {details: sanitize(details)});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { VERSION } from "./config.js";
|
||||
import { logger } from "./utils/logger.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CODEX_VERSION = String((require("@openai/codex/package.json") as { version: string }).version);
|
||||
|
||||
/** 输出当前版本,并在后台检查 npm 最新版本。 */
|
||||
export function checkVersions() {
|
||||
const localCodexVersion = commandVersion("codex");
|
||||
logger.info("Canvas Agent version", { version: VERSION });
|
||||
logger.info("Bundled Codex version", { version: CODEX_VERSION });
|
||||
logger.info("Local Codex version", { version: localCodexVersion || "not found" });
|
||||
if (!localCodexVersion) {
|
||||
logger.warn("Local Codex was not found. Install the latest version with: npm install -g @openai/codex@latest");
|
||||
} else if (localCodexVersion !== CODEX_VERSION) {
|
||||
logger.warn(`Bundled Codex ${CODEX_VERSION} does not match local Codex ${localCodexVersion}. Keep both current with: npm install -g @openai/codex@latest && npx -y @basketikun/canvas-agent@latest`);
|
||||
}
|
||||
void checkLatestVersions(localCodexVersion);
|
||||
}
|
||||
|
||||
/** 查询 npm,提醒升级不再维护的旧版本。 */
|
||||
async function checkLatestVersions(localCodexVersion: string) {
|
||||
try {
|
||||
const [latestAgent, latestCodex] = await Promise.all([
|
||||
npmVersion("@basketikun/canvas-agent"),
|
||||
npmVersion("@openai/codex"),
|
||||
]);
|
||||
if (isOlder(VERSION, latestAgent)) logger.warn(`Update available: Canvas Agent ${VERSION} -> ${latestAgent}. Run: npx -y @basketikun/canvas-agent@latest`);
|
||||
if (isOlder(CODEX_VERSION, latestCodex)) logger.warn(`Update available: bundled Codex ${CODEX_VERSION} -> ${latestCodex}. Upgrade Canvas Agent with: npx -y @basketikun/canvas-agent@latest`);
|
||||
if (localCodexVersion && isOlder(localCodexVersion, latestCodex)) logger.warn(`Update available: local Codex ${localCodexVersion} -> ${latestCodex}. Run: npm install -g @openai/codex@latest`);
|
||||
} catch {
|
||||
logger.warn("Unable to check the latest npm versions; startup will continue.");
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取本机命令输出中的语义版本号。 */
|
||||
function commandVersion(command: string) {
|
||||
try {
|
||||
return execFileSync(command, ["--version"], { encoding: "utf8", timeout: 5_000 }).match(/\d+\.\d+\.\d+/)?.[0] || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取 npm 包的最新版本。 */
|
||||
async function npmVersion(name: string) {
|
||||
const command = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const { stdout } = await execFileAsync(command, ["view", name, "version"], { encoding: "utf8", timeout: 10_000 });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/** 比较仅包含数字段的稳定版语义版本。 */
|
||||
function isOlder(current: string, latest: string) {
|
||||
const left = current.split(".").map(Number);
|
||||
const right = latest.split(".").map(Number);
|
||||
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
||||
if ((left[index] || 0) !== (right[index] || 0)) return (left[index] || 0) < (right[index] || 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
|
||||
# 待测试
|
||||
|
||||
- Agent 模型设置:连接 Canvas Agent 后,输入框左下方应显示当前 Codex 模型与推理强度;模型列表应来自当前账号实际可用模型且不显示内部审查模型或重复项,切换模型后强度选项随模型能力更新且无空白选项,刷新页面后保留选择,发送任务时实际使用所选模型和强度;本地控制台与右侧「日志」应记录本轮使用的模型和推理强度。
|
||||
- Agent 新对话响应:一轮对话完成后点击「新对话」,聊天内容应立即清空并进入空白对话,不出现等待或新建按钮卡顿;此时不应生成空历史记录,第一次发送消息时才创建线程;多个标签页应同步进入空白对话,点击后立即发送也不得误发到上一条会话。
|
||||
- Agent 读取画布卡片:读取当前画布完成后,卡片应按非零类型显示文本、图片、配置、视频、音频、分组、其他节点及连线数量,例如「3 个文本、5 张图片、2 个配置、4 条连线」;空画布应显示「当前画布为空」,执行失败时仍应显示错误信息,刷新恢复历史后统计保持一致。
|
||||
- Agent 首次发送响应:在空白新对话中输入内容并按回车后,输入框应立即清空、用户消息应立即出现在对话中,再显示「正在思考」;线程创建或发送失败时,原输入和附件应恢复;任务运行期间输入的新草稿不应在请求成功后被清空。
|
||||
@@ -13,8 +14,9 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
- Agent 顶部栏:标题只显示垂直居中的「Agent」,连接、对话、历史、日志、新对话和收起操作应位于同一行;面板较窄时标签仍可横向滚动且操作不应错位。
|
||||
- Agent Markdown 样式:右侧 Agent 回复中的代码块应为横向占满消息区域、无语言标题和无双层边框的紧凑代码条,单行内容不应保留纵向大面积空白,复制操作仅在悬停时弱化显示;行内代码、链接在浅色和深色主题下应清晰;点击外部链接后应显示中文紧凑确认弹窗,长路径能够正常换行且复制、继续打开和关闭操作可用;点击 `/Users/`、`/home/` 等本地绝对文件路径时应改为提示在系统文件管理器中定位,支持复制路径,且浏览器地址不应跳转为 localhost 文件路径。
|
||||
- Agent 工具确认模式:右侧面板标题栏不应再显示「工具确认」开关;对话输入框左下方应显示确认模式选择,默认选中「自动确认」,画布写入工具应直接执行;切换为「手动确认」后,画布写入工具应在对话中展示等待确认卡片,并支持批准或拒绝。
|
||||
- Canvas Agent Codex 升级:启动 Canvas Agent 后应能正常连接 Codex、创建或恢复会话、发送消息并调用画布工具,实际运行的 Codex CLI 版本应为 0.145.0;运行中停止任务应只中断当前 turn,随后无需重启 Agent 即可继续发送新任务。
|
||||
- Canvas Agent Debug:使用 `npx -y @basketikun/canvas-agent --debug` 启动后,终端应按“时间 级别 消息 详情”的纯文本单行格式输出日志并显示日志文件路径,`~/.infinite-canvas/logs/` 下应按启动日期生成相同格式的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;普通启动保持原有简洁输出,日志中不应出现连接 token 或图片 Data URL 原文。
|
||||
- Canvas Agent Codex 升级:启动 Canvas Agent 后应输出 Canvas Agent、内置 Codex 和本机 Codex 版本,实际运行的内置 Codex 应为 0.146.0;内置与本机版本不一致、未安装本机 Codex,或 npm 存在更新版本时应显示对应升级提醒,npm 检查失败不应阻止服务启动;Agent 应能正常连接 Codex、创建或恢复会话、发送消息并调用画布工具,运行中停止任务应只中断当前 turn,随后无需重启 Agent 即可继续发送新任务。
|
||||
- Canvas Agent Debug:普通启动应通过公共 logger 输出 Info、Warn 和 Error 日志;使用 `npx -y @basketikun/canvas-agent --debug` 启动后还应输出 Debug 日志并显示日志文件路径,终端统一采用“`YYYY-MM-DD HH:mm:ss` 级别 消息 详情”的纯文本单行格式,`~/.infinite-canvas/logs/` 下应按启动日期生成相同格式的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;日志中不应出现连接 token 或图片 Data URL 原文。
|
||||
- Canvas Agent Codex 日志:Codex app-server 输出带颜色或样式控制符及 UTC 时间的 stderr 时,本地日志和网页诊断日志应只显示一份 `YYYY-MM-DD HH:mm:ss` 本地时间和干净文本,不应出现重复 ISO 时间或 `[2m`、`[31m` 等 ANSI 转义内容。
|
||||
- Agent HTTP 诊断日志:网页发送一条普通消息后,本地 Debug 日志不应重复输出 `/health`、`/canvas/state`、`/canvas/activate` 成功请求、流式增量或完整会话响应,只保留 HTTP 请求与 Codex 生命周期摘要;右侧「日志」应以单行时间线展示发送、开始、回复、工具、完成用量和错误,不再输出 userMessage started/completed、流式摘要、重复 threadId 或大段原始 JSON。
|
||||
- Agent 对话统计:用户消息应右对齐并使用透明无气泡的简洁排版,用户和 Codex 两侧均不显示人物头像,消息下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。
|
||||
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。模型繁忙等任务失败时应立即结束等待状态,在对话中显示中文错误原因和重试建议,诊断日志不应再把失败轮次记为“处理完成”,刷新历史后错误仍应保留。新建尚未发送首条消息的空会话不应反复出现历史读取失败。
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { Button, Dropdown, Tooltip } from "antd";
|
||||
import { ArrowUp, Check, ChevronUp, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Square, X } from "lucide-react";
|
||||
import { ArrowUp, Check, ChevronUp, Cpu, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Square, X } from "lucide-react";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { isPlainEnterKey } from "@/lib/keyboard-event";
|
||||
import type { AgentPermissionMode } from "@/stores/use-agent-store";
|
||||
import type { AgentModel, AgentPermissionMode, AgentReasoningEffort } from "@/stores/use-agent-store";
|
||||
import type { AgentChatAttachment } from "./agent-chat-message";
|
||||
|
||||
export function AgentChatComposer({
|
||||
@@ -23,6 +24,11 @@ export function AgentChatComposer({
|
||||
onConfirmToolsChange,
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
models,
|
||||
model,
|
||||
reasoningEffort,
|
||||
onModelChange,
|
||||
onReasoningEffortChange,
|
||||
left,
|
||||
}: {
|
||||
prompt: string;
|
||||
@@ -40,6 +46,11 @@ export function AgentChatComposer({
|
||||
onConfirmToolsChange?: (confirmTools: boolean) => void;
|
||||
permissionMode?: AgentPermissionMode;
|
||||
onPermissionModeChange?: (permissionMode: AgentPermissionMode) => void;
|
||||
models?: AgentModel[];
|
||||
model?: string;
|
||||
reasoningEffort?: AgentReasoningEffort | "";
|
||||
onModelChange?: (model: string) => void;
|
||||
onReasoningEffortChange?: (effort: AgentReasoningEffort) => void;
|
||||
left?: ReactNode;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -95,6 +106,7 @@ export function AgentChatComposer({
|
||||
) : null}
|
||||
{onConfirmToolsChange ? <ToolConfirmationMenu confirmTools={Boolean(confirmTools)} theme={theme} onChange={onConfirmToolsChange} /> : null}
|
||||
{permissionMode && onPermissionModeChange ? <PermissionModeMenu permissionMode={permissionMode} theme={theme} onChange={onPermissionModeChange} /> : null}
|
||||
{models?.length && model && reasoningEffort && onModelChange && onReasoningEffortChange ? <AgentModelControls models={models} model={model} reasoningEffort={reasoningEffort} onModelChange={onModelChange} onReasoningEffortChange={onReasoningEffortChange} /> : null}
|
||||
{left}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
@@ -110,6 +122,41 @@ export function AgentChatComposer({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentModelControls({ models, model, reasoningEffort, onModelChange, onReasoningEffortChange }: { models: AgentModel[]; model: string; reasoningEffort: AgentReasoningEffort; onModelChange: (model: string) => void; onReasoningEffortChange: (effort: AgentReasoningEffort) => void }) {
|
||||
const current = models.find((item) => item.model === model) || models[0];
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Select value={model} onValueChange={onModelChange}>
|
||||
<SelectTrigger className="h-9 min-w-0 max-w-36 rounded-full border-0 bg-transparent px-2.5 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 dark:bg-transparent dark:hover:bg-white/10" title={current.displayName || current.model} aria-label="选择 Codex 模型">
|
||||
<Cpu className="size-3.5 shrink-0 opacity-70" />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{current.displayName || current.model}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent data-canvas-no-zoom position="popper" side="top" align="start" sideOffset={6} className="z-[1200] w-64 rounded-xl border border-border/70 bg-popover p-1 shadow-xl">
|
||||
{models.map((item) => <SelectItem key={item.model} value={item.model}>{item.displayName || item.model}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={reasoningEffort} onValueChange={(value) => onReasoningEffortChange(value as AgentReasoningEffort)}>
|
||||
<SelectTrigger className="h-9 rounded-full border-0 bg-transparent px-2.5 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 dark:bg-transparent dark:hover:bg-white/10" aria-label="选择推理强度">
|
||||
<span>{effortLabels[reasoningEffort]}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent data-canvas-no-zoom position="popper" side="top" align="start" sideOffset={6} className="z-[1200] min-w-32 rounded-xl border border-border/70 bg-popover p-1 shadow-xl">
|
||||
{current.supportedReasoningEfforts.map((item) => <SelectItem key={item.reasoningEffort} value={item.reasoningEffort}>{effortLabels[item.reasoningEffort]}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const effortLabels: Record<AgentReasoningEffort, string> = {
|
||||
minimal: "最低",
|
||||
low: "轻度",
|
||||
medium: "中",
|
||||
high: "高",
|
||||
xhigh: "极高",
|
||||
max: "最高",
|
||||
ultra: "Ultra",
|
||||
};
|
||||
|
||||
function PermissionModeMenu({ permissionMode, theme, onChange }: { permissionMode: AgentPermissionMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (permissionMode: AgentPermissionMode) => void }) {
|
||||
const current = permissionOptions.find((item) => item.key === permissionMode) || permissionOptions[0];
|
||||
return (
|
||||
|
||||
@@ -23,6 +23,7 @@ const streamdownProps = {
|
||||
openLink: "继续打开",
|
||||
},
|
||||
} as const;
|
||||
const streamdownAnimation = { duration: 20, stagger: 0, sep: "word" } as const;
|
||||
|
||||
function AgentLinkModal({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProps) {
|
||||
const { message } = App.useApp();
|
||||
@@ -127,7 +128,7 @@ export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: {
|
||||
{isUser ? (
|
||||
<div className="whitespace-pre-wrap break-words">{item.text}</div>
|
||||
) : (
|
||||
<Streamdown {...streamdownProps} animated isAnimating={!!item.streamId}>{item.text}</Streamdown>
|
||||
<Streamdown {...streamdownProps} animated={streamdownAnimation} isAnimating={!!item.streamId}>{item.text}</Streamdown>
|
||||
)}
|
||||
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} alignRight={isUser} /> : null}
|
||||
{item.meta ? <div className={`mt-1 text-[11px] tabular-nums opacity-55 ${isUser ? "text-right" : ""}`}>{item.meta}</div> : null}
|
||||
@@ -231,7 +232,7 @@ function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?:
|
||||
</div>
|
||||
</summary>
|
||||
<div className="break-words pb-1 pl-6 pr-2 text-xs leading-5 [&_code]:rounded [&_code]:px-1 [&_p]:my-1 [&_pre]:my-2" style={{ color: theme.node.muted }}>
|
||||
<Streamdown {...streamdownProps} animated isAnimating={running}>{text}</Streamdown>
|
||||
<Streamdown {...streamdownProps} animated={streamdownAnimation} isAnimating={running}>{text}</Streamdown>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AgentApprovalCard, AgentChatMessage, AgentPendingToolCard, AgentToolCar
|
||||
import { agentMessageToChatMessage, currentPlanMessage, isPlanMessage, latestPlanMessage, toolCallDetail, toolName, workingActivity } from "./agent-event-formatters";
|
||||
|
||||
const SCROLL_BOTTOM_THRESHOLD = 48;
|
||||
const historyMessageStyle = { contentVisibility: "auto", containIntrinsicSize: "0 80px" } as const;
|
||||
|
||||
export function AgentChatTimeline({
|
||||
theme,
|
||||
@@ -101,7 +102,7 @@ export function AgentTaskProgress({ theme, busy }: { theme: (typeof canvasThemes
|
||||
|
||||
const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme }: { item: AgentChatItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<div style={{ contentVisibility: "auto", containIntrinsicSize: "0 80px" }}>
|
||||
<div style={item.streamId ? undefined : historyMessageStyle}>
|
||||
<AgentChatMessage item={agentMessageToChatMessage(item)} theme={theme} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { App, Button, Tooltip } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import { Bot, History, MessageSquare, PanelRightClose, PlugZap, Plus, Terminal } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -12,7 +13,7 @@ import { uploadImage } from "@/services/image-storage";
|
||||
import { deleteAgentThreadMessages, readAgentUserMessages, saveAgentUserMessage } from "@/services/agent-chat-storage";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAgentStore, type AgentCanvasContext, type AgentChatItem, type AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
import { useAgentStore, type AgentCanvasContext, type AgentChatItem, type AgentModel, type AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentReasoningEffort, type AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { isSiteTool, runSiteTool } from "@/lib/agent/agent-site-tools";
|
||||
import { activateAgentClient, discoverAgentConfig, fetchAgentJson, postCodexApproval, postState, postToolResult } from "./agent-api";
|
||||
@@ -60,10 +61,13 @@ import { AgentPanelTabs } from "./agent-panel-tabs";
|
||||
const MAX_ATTACHMENTS = 6;
|
||||
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
|
||||
const DEFAULT_AGENT_URL = "http://127.0.0.1:17371";
|
||||
const AGENT_REASONING_EFFORTS = new Set<AgentReasoningEffort>(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
|
||||
const AGENT_REASONING_LABELS: Record<AgentReasoningEffort, string> = { minimal: "最低", low: "轻度", medium: "中", high: "高", xhigh: "极高", max: "最高", ultra: "Ultra" };
|
||||
|
||||
type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
|
||||
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
|
||||
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] };
|
||||
type AgentModelsResponse = { ok?: boolean; data?: AgentModel[] };
|
||||
type AgentCodexState = { busy?: boolean; threadId?: string; turnId?: string };
|
||||
type AgentHelloEvent = { ok?: boolean; clientId?: string; codex?: AgentCodexState };
|
||||
type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; emptyThread?: boolean; draftThread?: boolean };
|
||||
@@ -78,7 +82,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
// 注意:canvasContext 不在此订阅内 —— 它在拖拽/resize 时会被 project 每帧写入,
|
||||
// 但面板只在 ref 同步与防抖 postState 中用到它、渲染层从不读它。若把它放进订阅,
|
||||
// 面板会随画布每帧重渲染(性能问题,也是 #185 崩溃的放大器)。改为下方 subscribe 命令式监听。
|
||||
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, permissionMode, activity, connectError, pendingTool, pendingApprovals } = useAgentStore(
|
||||
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, permissionMode, models, model, reasoningEffort, activity, connectError, pendingTool, pendingApprovals } = useAgentStore(
|
||||
useShallow((state) => ({
|
||||
width: state.width,
|
||||
url: state.url,
|
||||
@@ -98,6 +102,9 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
activeTab: state.activeTab,
|
||||
confirmTools: state.confirmTools,
|
||||
permissionMode: state.permissionMode,
|
||||
models: state.models,
|
||||
model: state.model,
|
||||
reasoningEffort: state.reasoningEffort,
|
||||
activity: state.activity,
|
||||
connectError: state.connectError,
|
||||
pendingTool: state.pendingTool,
|
||||
@@ -224,7 +231,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
enqueueEvent(async () => {
|
||||
const nextThreadId = data.activeThreadId ?? data.threadId ?? "";
|
||||
const current = useAgentStore.getState();
|
||||
const keepPendingMessage = Boolean(data.emptyThread && current.sending && current.activeThreadId === nextThreadId);
|
||||
const keepPendingMessage = Boolean(data.emptyThread && current.sending && current.messages.some((message) => message.role === "user"));
|
||||
pendingToolRef.current = null;
|
||||
setAgentState({ activeThreadId: nextThreadId, ...(keepPendingMessage ? {} : { messages: [] }), tokenUsage: null, pendingTool: null, pendingApprovals: [] });
|
||||
if (!data.draftThread) await loadThreads(Boolean(data.emptyThread));
|
||||
@@ -277,6 +284,30 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
if (connected) void loadThreads();
|
||||
}, [connected, loadThreads]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
void fetchAgentJson<AgentModelsResponse>(endpoint, token, "/agent/codex/models").then(({ data = [] }) => {
|
||||
const names = new Set<string>();
|
||||
const models = data.flatMap((item) => {
|
||||
const name = item.displayName || item.model;
|
||||
const efforts = item.supportedReasoningEfforts.filter(({ reasoningEffort }) => AGENT_REASONING_EFFORTS.has(reasoningEffort));
|
||||
if (item.model === "codex-auto-review" || names.has(name) || !efforts.length) return [];
|
||||
names.add(name);
|
||||
const defaultReasoningEffort = efforts.some((effort) => effort.reasoningEffort === item.defaultReasoningEffort) ? item.defaultReasoningEffort : efforts[0].reasoningEffort;
|
||||
return [{ ...item, supportedReasoningEfforts: efforts, defaultReasoningEffort }];
|
||||
});
|
||||
if (!models.length) return;
|
||||
const savedModel = useAgentStore.getState().model;
|
||||
const current = models.find((item) => item.model === savedModel) || models.find((item) => item.isDefault) || models[0];
|
||||
const savedEffort = useAgentStore.getState().reasoningEffort;
|
||||
const efforts = current.supportedReasoningEfforts.map((item) => item.reasoningEffort);
|
||||
const nextEffort = efforts.includes(savedEffort as AgentReasoningEffort) ? savedEffort as AgentReasoningEffort : current.defaultReasoningEffort || efforts[0];
|
||||
localStorage.setItem("canvas-agent-model", current.model);
|
||||
localStorage.setItem("canvas-agent-reasoning-effort", nextEffort);
|
||||
setAgentState({ models, model: current.model, reasoningEffort: nextEffort });
|
||||
}).catch((error) => addEventLog("读取模型列表失败", error));
|
||||
}, [connected, endpoint, setAgentState, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
const activate = () => void activateAgentClient(endpoint, token, clientIdRef.current);
|
||||
@@ -313,7 +344,9 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
setAgentState({ activeThreadId: threadId, tokenUsage: null });
|
||||
}
|
||||
if (files.length) void saveAgentUserMessage(threadId, { id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files }).catch(() => undefined);
|
||||
addEventLog("发送任务", `${compactText(text) || "仅附件"}${files.length ? ` · 附件 ${files.length}` : ""}`);
|
||||
const modelName = models.find((item) => item.model === model)?.displayName || model || "默认模型";
|
||||
const effortName = reasoningEffort ? AGENT_REASONING_LABELS[reasoningEffort] : "默认强度";
|
||||
addEventLog("发送任务", `${modelName} · ${effortName}${files.length ? ` · 附件 ${files.length}` : ""} · ${compactText(text) || "仅附件"}`);
|
||||
const data = await fetchAgentJson<{ threadId?: string }>(endpoint, token, "/agent/codex/turn", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -324,6 +357,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
clientId: clientIdRef.current,
|
||||
threadId,
|
||||
permissionMode,
|
||||
model,
|
||||
effort: reasoningEffort,
|
||||
attachments: files.map(({ id, name, type, size, width, height, dataUrl }) => ({ id, name, type, size, width, height, dataUrl })),
|
||||
}),
|
||||
});
|
||||
@@ -673,7 +708,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const value = normalizeText(text) || title;
|
||||
const last = useAgentStore.getState().eventLogs.at(-1);
|
||||
if (last?.title === title && last.text === value) return;
|
||||
pushEventLog({ id: `${Date.now()}-${Math.random()}`, time: new Date().toLocaleTimeString(), title, text: value, raw });
|
||||
pushEventLog({ id: `${Date.now()}-${Math.random()}`, time: dayjs().format("YYYY-MM-DD HH:mm:ss"), title, text: value, raw });
|
||||
};
|
||||
|
||||
const upsertActivityMessage = (item: AgentChatItem) => {
|
||||
@@ -903,6 +938,21 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
onConfirmToolsChange={(confirmTools) => setAgentState({ confirmTools })}
|
||||
permissionMode={permissionMode}
|
||||
onPermissionModeChange={changePermissionMode}
|
||||
models={models}
|
||||
model={model}
|
||||
reasoningEffort={reasoningEffort}
|
||||
onModelChange={(model) => {
|
||||
const selected = models.find((item) => item.model === model);
|
||||
if (!selected) return;
|
||||
const effort = selected.defaultReasoningEffort || selected.supportedReasoningEfforts[0]?.reasoningEffort;
|
||||
localStorage.setItem("canvas-agent-model", model);
|
||||
if (effort) localStorage.setItem("canvas-agent-reasoning-effort", effort);
|
||||
setAgentState({ model, ...(effort ? { reasoningEffort: effort } : {}) });
|
||||
}}
|
||||
onReasoningEffortChange={(reasoningEffort) => {
|
||||
localStorage.setItem("canvas-agent-reasoning-effort", reasoningEffort);
|
||||
setAgentState({ reasoningEffort });
|
||||
}}
|
||||
left={
|
||||
attachments.length ? (
|
||||
<span className="text-[11px]" style={{ color: theme.node.muted }}>
|
||||
|
||||
@@ -8,6 +8,15 @@ export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; t
|
||||
export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown };
|
||||
export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } & Record<string, unknown> };
|
||||
export type AgentPermissionMode = "request" | "automatic" | "full";
|
||||
export type AgentReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
|
||||
export type AgentModel = {
|
||||
id: string;
|
||||
model: string;
|
||||
displayName: string;
|
||||
defaultReasoningEffort: AgentReasoningEffort;
|
||||
supportedReasoningEfforts: Array<{ reasoningEffort: AgentReasoningEffort; description?: string }>;
|
||||
isDefault?: boolean;
|
||||
};
|
||||
export type AgentPendingApproval = { requestId: string; method: string; threadId?: string; turnId?: string; itemId?: string; reason?: string; command?: unknown; cwd?: string; grantRoot?: string; networkApprovalContext?: unknown; permissions?: unknown };
|
||||
export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot; undoOps: () => CanvasAgentSnapshot | null; canUndo: boolean };
|
||||
export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number };
|
||||
@@ -43,6 +52,9 @@ type AgentStore = {
|
||||
activeTab: AgentPanelTab;
|
||||
confirmTools: boolean;
|
||||
permissionMode: AgentPermissionMode;
|
||||
models: AgentModel[];
|
||||
model: string;
|
||||
reasoningEffort: AgentReasoningEffort | "";
|
||||
activity: string;
|
||||
connectError: string;
|
||||
pendingTool: AgentPendingToolCall | null;
|
||||
@@ -86,6 +98,9 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
activeTab: "setup",
|
||||
confirmTools: false,
|
||||
permissionMode: typeof window === "undefined" ? "request" : (localStorage.getItem("canvas-agent-permission-mode") as AgentPermissionMode) || "request",
|
||||
models: [],
|
||||
model: typeof window === "undefined" ? "" : localStorage.getItem("canvas-agent-model") || "",
|
||||
reasoningEffort: typeof window === "undefined" ? "" : (localStorage.getItem("canvas-agent-reasoning-effort") as AgentReasoningEffort) || "",
|
||||
activity: "就绪",
|
||||
connectError: "",
|
||||
pendingTool: null,
|
||||
|
||||
Reference in New Issue
Block a user