@keyou007/agent-bot 0.1.20-alpha.1 → 0.1.21

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +27 -4
  3. package/README.zh.md +23 -4
  4. package/dist/cli/AgentPrerequisites.js +11 -47
  5. package/dist/cli/AgentPrerequisites.js.map +1 -1
  6. package/dist/cli/DefaultAgentSelection.js +1 -1
  7. package/dist/cli/DefaultAgentSelection.js.map +1 -1
  8. package/dist/cli.js +15 -0
  9. package/dist/cli.js.map +1 -1
  10. package/dist/codex/CodexEventMapper.js +21 -1
  11. package/dist/codex/CodexEventMapper.js.map +1 -1
  12. package/dist/codex/CodexLocalActivityDetector.js +32 -10
  13. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  14. package/dist/codex/CodexProcessManager.js +56 -7
  15. package/dist/codex/CodexProcessManager.js.map +1 -1
  16. package/dist/codex/CodexRuntime.js +361 -168
  17. package/dist/codex/CodexRuntime.js.map +1 -1
  18. package/dist/codex/CodexVersion.js +18 -0
  19. package/dist/codex/CodexVersion.js.map +1 -0
  20. package/dist/codex/ProviderModels.js +159 -0
  21. package/dist/codex/ProviderModels.js.map +1 -0
  22. package/dist/codex/ProviderSwitch.js +32 -0
  23. package/dist/codex/ProviderSwitch.js.map +1 -0
  24. package/dist/commands/CommandRouter.js +8 -3
  25. package/dist/commands/CommandRouter.js.map +1 -1
  26. package/dist/console/ConsoleTurnPresenter.js +2 -1
  27. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  28. package/dist/feishu/CardRenderer.js +82 -27
  29. package/dist/feishu/CardRenderer.js.map +1 -1
  30. package/dist/feishu/FeishuConnector.js +17 -5
  31. package/dist/feishu/FeishuConnector.js.map +1 -1
  32. package/dist/feishu/FeishuTurnPresenter.js +34 -8
  33. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  34. package/dist/feishu/InboundText.js +11 -0
  35. package/dist/feishu/InboundText.js.map +1 -1
  36. package/dist/feishu/MergedForwardMessage.js +17 -5
  37. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  38. package/dist/index.js +3 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/local-files/LocalFileViewerServer.js +164 -19
  41. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  42. package/dist/local-files/MarkdownFileLinks.js +65 -0
  43. package/dist/local-files/MarkdownFileLinks.js.map +1 -0
  44. package/dist/local-files/TurnPreviewPage.js +725 -0
  45. package/dist/local-files/TurnPreviewPage.js.map +1 -0
  46. package/dist/presentation/OutboundRouter.js +6 -1
  47. package/dist/presentation/OutboundRouter.js.map +1 -1
  48. package/dist/presentation/TurnStateReducer.js +142 -3
  49. package/dist/presentation/TurnStateReducer.js.map +1 -1
  50. package/dist/proxy/ProxySessionController.js +573 -325
  51. package/dist/proxy/ProxySessionController.js.map +1 -1
  52. package/dist/state/StateStore.js +65 -65
  53. package/dist/state/StateStore.js.map +1 -1
  54. package/dist/utils/formatStorageSize.js +15 -0
  55. package/dist/utils/formatStorageSize.js.map +1 -0
  56. package/dist/utils/semanticVersion.js +39 -0
  57. package/dist/utils/semanticVersion.js.map +1 -0
  58. package/docs/technical-reference.md +11 -3
  59. package/docs/technical-reference.zh.md +11 -3
  60. package/npm-shrinkwrap.json +2 -2
  61. package/package.json +1 -1
  62. package/skills/agent-bot/SKILL.md +11 -1
  63. package/src/cli/AgentPrerequisites.ts +14 -40
  64. package/src/cli/DefaultAgentSelection.ts +1 -1
  65. package/src/cli.ts +13 -0
  66. package/src/codex/CodexEventMapper.ts +31 -1
  67. package/src/codex/CodexLocalActivityDetector.ts +41 -9
  68. package/src/codex/CodexProcessManager.ts +48 -7
  69. package/src/codex/CodexRuntime.ts +397 -204
  70. package/src/codex/CodexVersion.ts +18 -0
  71. package/src/codex/ProviderModels.ts +185 -0
  72. package/src/codex/ProviderSwitch.ts +36 -0
  73. package/src/commands/CommandRouter.ts +7 -2
  74. package/src/commands/commandTypes.ts +1 -1
  75. package/src/console/ConsoleTurnPresenter.ts +3 -0
  76. package/src/feishu/CardRenderer.ts +84 -43
  77. package/src/feishu/FeishuConnector.ts +15 -5
  78. package/src/feishu/FeishuTurnPresenter.ts +46 -4
  79. package/src/feishu/InboundText.ts +11 -0
  80. package/src/feishu/MergedForwardMessage.ts +15 -5
  81. package/src/index.ts +3 -1
  82. package/src/local-files/LocalFileViewerServer.ts +179 -20
  83. package/src/local-files/MarkdownFileLinks.ts +64 -0
  84. package/src/local-files/TurnPreviewPage.ts +792 -0
  85. package/src/presentation/OutboundRouter.ts +8 -0
  86. package/src/presentation/TurnStateReducer.ts +175 -2
  87. package/src/presentation/turnViewTypes.ts +13 -0
  88. package/src/proxy/ProxySessionController.ts +547 -297
  89. package/src/runtime/types.ts +33 -3
  90. package/src/state/StateStore.ts +86 -90
  91. package/src/utils/formatStorageSize.ts +13 -0
  92. package/src/utils/semanticVersion.ts +28 -0
@@ -0,0 +1,18 @@
1
+ import { compareSemanticVersions } from "../utils/semanticVersion.js";
2
+
3
+ // Verified protocol baseline: paginated Turn summaries and excludeTurns.
4
+ export const MINIMUM_CODEX_VERSION = "0.153.4";
5
+
6
+ export class CodexVersionError extends Error {}
7
+
8
+ export function codexVersionIssue(version: string | undefined): string | undefined {
9
+ if (version && /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/u.test(version)
10
+ && compareSemanticVersions(version, MINIMUM_CODEX_VERSION) >= 0) return undefined;
11
+ return `Codex ${version ?? "版本未知"} 不受支持,AgentBot 需要 Codex >= ${MINIMUM_CODEX_VERSION}。`
12
+ + "请运行 codex update(或 npm install -g @openai/codex@latest)升级,然后安全重启 AgentBot。";
13
+ }
14
+
15
+ export function assertSupportedCodexVersion(version: string | undefined): void {
16
+ const issue = codexVersionIssue(version);
17
+ if (issue) throw new CodexVersionError(issue);
18
+ }
@@ -0,0 +1,185 @@
1
+ import type { ModelOption, ReasoningEffortOption } from "../runtime/types.js";
2
+
3
+ const PROVIDER_MODEL_LIST_TIMEOUT_MS = 10_000;
4
+
5
+ export interface ProviderModelConfig {
6
+ base_url?: unknown;
7
+ env_key?: unknown;
8
+ experimental_bearer_token?: unknown;
9
+ query_params?: unknown;
10
+ http_headers?: unknown;
11
+ env_http_headers?: unknown;
12
+ default_model?: unknown;
13
+ model?: unknown;
14
+ }
15
+
16
+ export async function fetchProviderModels(input: {
17
+ providerId: string;
18
+ config: ProviderModelConfig;
19
+ catalog: ModelOption[];
20
+ configuredDefaultModel?: string;
21
+ environmentValue: (name: string) => string | undefined;
22
+ }): Promise<ModelOption[]> {
23
+ const baseUrl = stringValue(input.config.base_url)?.trim();
24
+ if (!baseUrl) {
25
+ throw new Error(`Provider ${input.providerId} 没有配置 base_url,无法读取模型列表。`);
26
+ }
27
+
28
+ const url = providerModelsUrl(baseUrl, input.config.query_params, input.providerId);
29
+ const headers = providerModelHeaders(input.config, input.environmentValue);
30
+ let response: Response;
31
+ try {
32
+ response = await fetch(url, {
33
+ method: "GET",
34
+ headers,
35
+ signal: AbortSignal.timeout(PROVIDER_MODEL_LIST_TIMEOUT_MS),
36
+ });
37
+ } catch {
38
+ throw new Error(`无法连接 Provider ${input.providerId} 的模型列表,请检查 Provider 服务和网络。`);
39
+ }
40
+ if (!response.ok) {
41
+ throw new Error(`Provider ${input.providerId} 的模型列表请求失败(HTTP ${response.status})。`);
42
+ }
43
+
44
+ let payload: unknown;
45
+ try {
46
+ payload = await response.json();
47
+ } catch {
48
+ throw new Error(`Provider ${input.providerId} 返回了无效的模型列表。`);
49
+ }
50
+ const models = normalizeProviderModels(payload, input.catalog, {
51
+ configuredDefaultModel: input.configuredDefaultModel,
52
+ providerDefaultModel: stringValue(input.config.default_model)?.trim()
53
+ ?? stringValue(input.config.model)?.trim(),
54
+ });
55
+ if (models.length === 0) {
56
+ throw new Error(`Provider ${input.providerId} 没有返回可用模型。`);
57
+ }
58
+ return models;
59
+ }
60
+
61
+ function providerModelsUrl(baseUrl: string, queryParams: unknown, providerId: string): URL {
62
+ let url: URL;
63
+ try {
64
+ url = new URL(baseUrl);
65
+ } catch {
66
+ throw new Error(`Provider ${providerId} 的 base_url 无效,无法读取模型列表。`);
67
+ }
68
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
69
+ throw new Error(`Provider ${providerId} 的 base_url 不支持模型发现。`);
70
+ }
71
+ const path = url.pathname.replace(/\/+$/u, "");
72
+ if (!/\/models$/iu.test(path)) url.pathname = `${path}/models`;
73
+ if (isRecord(queryParams)) {
74
+ for (const [key, value] of Object.entries(queryParams)) {
75
+ if (!key || value === null || value === undefined || typeof value === "object") continue;
76
+ url.searchParams.set(key, String(value));
77
+ }
78
+ }
79
+ return url;
80
+ }
81
+
82
+ function providerModelHeaders(
83
+ config: ProviderModelConfig,
84
+ environmentValue: (name: string) => string | undefined,
85
+ ): Headers {
86
+ const headers = new Headers({ Accept: "application/json" });
87
+ appendHeaderValues(headers, config.http_headers, (value) => value);
88
+ appendHeaderValues(headers, config.env_http_headers, environmentValue);
89
+ if (!headers.has("authorization")) {
90
+ const envKey = stringValue(config.env_key)?.trim();
91
+ const token = (envKey ? environmentValue(envKey) : undefined)
92
+ ?? stringValue(config.experimental_bearer_token)?.trim();
93
+ if (token) headers.set("Authorization", `Bearer ${token}`);
94
+ }
95
+ return headers;
96
+ }
97
+
98
+ function appendHeaderValues(
99
+ headers: Headers,
100
+ values: unknown,
101
+ resolve: (value: string) => string | undefined,
102
+ ): void {
103
+ if (!isRecord(values)) return;
104
+ for (const [name, rawValue] of Object.entries(values)) {
105
+ const value = stringValue(rawValue)?.trim();
106
+ const resolved = value ? resolve(value)?.trim() : undefined;
107
+ if (name.trim() && resolved) headers.set(name, resolved);
108
+ }
109
+ }
110
+
111
+ function normalizeProviderModels(
112
+ payload: unknown,
113
+ catalog: ModelOption[],
114
+ defaults: { configuredDefaultModel?: string; providerDefaultModel?: string },
115
+ ): ModelOption[] {
116
+ const root = isRecord(payload) ? payload : undefined;
117
+ const entries = Array.isArray(root?.data)
118
+ ? root.data
119
+ : Array.isArray(root?.models)
120
+ ? root.models
121
+ : Array.isArray(payload)
122
+ ? payload
123
+ : [];
124
+ const catalogById = new Map(catalog.map((model) => [model.id, model]));
125
+ const normalized = new Map<string, ModelOption>();
126
+ let itemDefaultModel: string | undefined;
127
+
128
+ for (const entry of entries) {
129
+ const record = isRecord(entry) ? entry : undefined;
130
+ const id = (typeof entry === "string"
131
+ ? entry
132
+ : stringValue(record?.id) ?? stringValue(record?.model) ?? stringValue(record?.name))?.trim();
133
+ if (!id || normalized.has(id)) continue;
134
+ const catalogModel = catalogById.get(id);
135
+ const efforts = reasoningEfforts(record) ?? catalogModel?.supportedReasoningEfforts ?? [];
136
+ const defaultEffort = stringValue(record?.defaultReasoningEffort)?.trim()
137
+ ?? stringValue(record?.default_reasoning_effort)?.trim()
138
+ ?? catalogModel?.defaultReasoningEffort;
139
+ const displayName = stringValue(record?.displayName)?.trim()
140
+ ?? stringValue(record?.display_name)?.trim()
141
+ ?? (stringValue(record?.name)?.trim() !== id ? stringValue(record?.name)?.trim() : undefined)
142
+ ?? catalogModel?.displayName;
143
+ if (!itemDefaultModel && (record?.isDefault === true || record?.is_default === true || record?.default === true)) {
144
+ itemDefaultModel = id;
145
+ }
146
+ normalized.set(id, {
147
+ id,
148
+ ...(displayName ? { displayName } : {}),
149
+ supportedReasoningEfforts: efforts,
150
+ ...(defaultEffort ? { defaultReasoningEffort: defaultEffort } : {}),
151
+ });
152
+ }
153
+
154
+ const rootDefaultModel = stringValue(root?.defaultModel)?.trim()
155
+ ?? stringValue(root?.default_model)?.trim();
156
+ const defaultModel = [rootDefaultModel, itemDefaultModel, defaults.providerDefaultModel, defaults.configuredDefaultModel]
157
+ .find((candidate) => candidate && normalized.has(candidate));
158
+ if (defaultModel) normalized.set(defaultModel, { ...normalized.get(defaultModel)!, isDefault: true });
159
+ return [...normalized.values()];
160
+ }
161
+
162
+ function reasoningEfforts(record: Record<string, unknown> | undefined): ReasoningEffortOption[] | undefined {
163
+ if (!record) return undefined;
164
+ const raw = record.supportedReasoningEfforts ?? record.supported_reasoning_efforts;
165
+ if (!Array.isArray(raw)) return undefined;
166
+ const efforts = raw.flatMap((entry): ReasoningEffortOption[] => {
167
+ if (typeof entry === "string" && entry.trim()) return [{ value: entry.trim() }];
168
+ if (!isRecord(entry)) return [];
169
+ const value = stringValue(entry.value)?.trim()
170
+ ?? stringValue(entry.reasoningEffort)?.trim()
171
+ ?? stringValue(entry.reasoning_effort)?.trim();
172
+ if (!value) return [];
173
+ const description = stringValue(entry.description)?.trim();
174
+ return [{ value, ...(description ? { description } : {}) }];
175
+ });
176
+ return efforts.length > 0 ? efforts : undefined;
177
+ }
178
+
179
+ function isRecord(value: unknown): value is Record<string, unknown> {
180
+ return typeof value === "object" && value !== null && !Array.isArray(value);
181
+ }
182
+
183
+ function stringValue(value: unknown): string | undefined {
184
+ return typeof value === "string" ? value : undefined;
185
+ }
@@ -0,0 +1,36 @@
1
+ import { AppServerRequestError } from "./AppServerConnection.js";
2
+ import type { RuntimeExecutionSettings } from "../runtime/types.js";
3
+
4
+ export const PROVIDER_SWITCH_BUSY = "当前任务正在执行,暂时不能切换 Provider。请等待任务完成,或停止任务后重试。";
5
+
6
+ export function isMissingRolloutError(error: unknown): boolean {
7
+ if (error instanceof AppServerRequestError && error.method !== "thread/resume") return false;
8
+ const message = error instanceof AppServerRequestError ? error.serverMessage
9
+ : error instanceof Error ? error.message : String(error);
10
+ if (/paginated history lineage|missing source rollout/iu.test(message)) return false;
11
+ return /no rollout found|rollout[^\n]*(?:not found|missing)/iu.test(message);
12
+ }
13
+
14
+ export function assertProviderSettingsApplied(
15
+ actual: { modelProvider?: string; model?: string },
16
+ requested: Pick<RuntimeExecutionSettings, "modelProvider" | "model">,
17
+ ): void {
18
+ if (actual.modelProvider !== requested.modelProvider) {
19
+ throw new Error(`Provider 未生效:请求 ${requested.modelProvider},实际返回 ${actual.modelProvider ?? "未知"}。`);
20
+ }
21
+ if (actual.model !== requested.model) {
22
+ throw new Error(`模型未生效:请求 ${requested.model},实际返回 ${actual.model ?? "未知"}。请确认目标 Provider 支持该模型。`);
23
+ }
24
+ }
25
+
26
+ export function providerSwitchFailure(error: unknown): string {
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ if (/active writer/iu.test(message)) return "任务被另一个 App Server 占用。请在原客户端释放任务后重试。";
29
+ if (/model[^\n]*(?:unsupported|not supported|not found|unavailable|does not exist)|unknown model/iu.test(message)) {
30
+ return `目标 Provider 不支持当前模型,请选择兼容的模型后重试。${message}`;
31
+ }
32
+ if (/unauthorized|forbidden|auth(?:entication|orization)|api.?key|provider[^\n]*(?:not found|unknown|not configured)/iu.test(message)) {
33
+ return `请检查目标 Provider 的配置和认证信息。${message}`;
34
+ }
35
+ return message;
36
+ }
@@ -128,8 +128,13 @@ export class CommandRouter {
128
128
  throw new Error("/mute 只接受 on 或 off;不传参数等同于 /mute on。");
129
129
  }
130
130
  case "turns":
131
- if (args.length > 0) throw new Error("/turns 不接受参数,请在历史轮次卡片中选择 turn。");
132
- return { type: "turns" };
131
+ rejectOptions("/turn", args);
132
+ if (args.length > 1) throw new Error("/turn 只接受一个可选的 Turn ID 或序号。");
133
+ if (args[0] && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(args[0])
134
+ && (!/^\d+$/.test(args[0]) || !Number.isSafeInteger(Number(args[0])) || Number(args[0]) < 1)) {
135
+ throw new Error("Turn 序号必须是从 1 开始的正整数。");
136
+ }
137
+ return args[0] ? { type: "turns", turnReference: args[0] } : { type: "turns" };
133
138
  case "model":
134
139
  return settingsCommand("/model", args, { type: "model" });
135
140
  case "provider":
@@ -21,7 +21,7 @@ export type Command =
21
21
  | { type: "restart"; force?: boolean }
22
22
  | { type: "release" }
23
23
  | { type: "mute"; enabled: boolean }
24
- | { type: "turns" }
24
+ | { type: "turns"; turnReference?: string }
25
25
  | { type: "model" }
26
26
  | { type: "provider" }
27
27
  | { type: "thinking" }
@@ -15,11 +15,14 @@ export class ConsoleTurnPresenter implements TurnPresenter {
15
15
  _taskTitle?: string,
16
16
  _projectCwd?: string,
17
17
  agentLabel?: string,
18
+ _model?: string,
18
19
  ): void {
19
20
  this.contexts.set(sessionId, contextKey);
20
21
  if (agentLabel) this.agentLabels.set(sessionId, agentLabel);
21
22
  }
22
23
 
24
+ updateSessionModel(): void {}
25
+
23
26
  updateSessionTitle(): void {}
24
27
 
25
28
  unregisterSession(sessionId: string): void {
@@ -123,6 +123,14 @@ export interface PermissionSelectorCardView {
123
123
 
124
124
  export type ExecutionSettingsTab = "agent" | "provider" | "model" | "thinking" | "permission";
125
125
 
126
+ const EXECUTION_SETTING_EFFECTS: Record<ExecutionSettingsTab, string> = {
127
+ agent: "只影响新建任务,不切换当前任务。",
128
+ provider: "只影响当前任务,需空闲时切换。",
129
+ model: "只影响当前任务,下一轮生效。",
130
+ thinking: "只影响当前任务,下一轮生效。",
131
+ permission: "只影响当前任务,下一轮生效。",
132
+ };
133
+
126
134
  export interface ExecutionSettingsAgentOption {
127
135
  name: string;
128
136
  title: string;
@@ -158,6 +166,7 @@ export interface TaskListCardAction {
158
166
  text: string;
159
167
  type?: "default" | "primary" | "danger";
160
168
  value: Record<string, string>;
169
+ url?: string;
161
170
  }
162
171
 
163
172
  export interface HelpCardCommand {
@@ -542,7 +551,7 @@ export class CardRenderer {
542
551
 
543
552
  if (view.activeTab === "agent") {
544
553
  elements.push(...view.agents.map((agent) => settingsOptionRow({
545
- label: `${inlineCode(agent.name)} · ${escapeCardHtml(agent.title)}`,
554
+ label: inlineCode(agent.name),
546
555
  current: agent.name === view.currentAgent,
547
556
  action: {
548
557
  text: "Switch",
@@ -562,9 +571,6 @@ export class CardRenderer {
562
571
  elements.push(...view.providers.map((provider) => settingsOptionRow({
563
572
  label: [
564
573
  inlineCode(provider.id),
565
- provider.displayName && provider.displayName !== provider.id
566
- ? ` · ${escapeCardHtml(provider.displayName)}`
567
- : "",
568
574
  provider.isDefault ? " · 默认" : "",
569
575
  ].join(""),
570
576
  current: provider.id === view.currentProvider,
@@ -631,6 +637,7 @@ export class CardRenderer {
631
637
  })));
632
638
  }
633
639
 
640
+ elements.push(markdown(`> ${EXECUTION_SETTING_EFFECTS[view.activeTab]}`));
634
641
  return sectionCard("运行设置", elements, view.notice ? "green" : "blue");
635
642
  }
636
643
 
@@ -1138,7 +1145,7 @@ export class CardRenderer {
1138
1145
  };
1139
1146
  }
1140
1147
 
1141
- renderTurn(state: TurnViewState): Record<string, unknown> {
1148
+ renderTurn(state: TurnViewState, previewUrl?: string): Record<string, unknown> {
1142
1149
  const elements = this.thinkingCardLayout === "timeline"
1143
1150
  ? renderTurnElements(state, "hidden")
1144
1151
  : renderGroupedTurnElements(state, "hidden");
@@ -1149,23 +1156,31 @@ export class CardRenderer {
1149
1156
  type: "danger",
1150
1157
  value: { action: "turn_cancel", sessionId: state.sessionId, turnId: state.turnId },
1151
1158
  });
1159
+ if (previewUrl) footerActions.push(turnPreviewAction(previewUrl));
1152
1160
  elements.push(
1153
1161
  { tag: "hr" },
1154
- taskActionRow(footerActions, renderTurnDuration(state)),
1162
+ taskActionRow(footerActions, renderTurnDuration(state), true),
1155
1163
  );
1156
1164
  } else if (state.status === "completed") {
1157
1165
  footerActions.push({
1158
1166
  text: "Reset",
1159
1167
  value: { action: "turn_reset", sessionId: state.sessionId, turnId: state.turnId },
1160
1168
  });
1169
+ if (previewUrl) footerActions.push(turnPreviewAction(previewUrl));
1161
1170
  elements.push(
1162
1171
  { tag: "hr" },
1163
- taskActionRow(footerActions),
1172
+ taskActionRow(footerActions, undefined, true),
1164
1173
  );
1165
1174
  } else if (state.status === "starting") {
1175
+ if (previewUrl) footerActions.push(turnPreviewAction(previewUrl));
1176
+ elements.push(
1177
+ { tag: "hr" },
1178
+ taskActionRow(footerActions, renderTurnDuration(state), true),
1179
+ );
1180
+ } else if (previewUrl) {
1166
1181
  elements.push(
1167
1182
  { tag: "hr" },
1168
- taskActionRow([], renderTurnDuration(state)),
1183
+ taskActionRow([turnPreviewAction(previewUrl)], renderTurnDuration(state)),
1169
1184
  );
1170
1185
  }
1171
1186
  if (state.status === "completed") {
@@ -1184,11 +1199,15 @@ export class CardRenderer {
1184
1199
  );
1185
1200
  }
1186
1201
 
1187
- renderTurnDetails(state: TurnViewState): Record<string, unknown> {
1202
+ renderTurnDetails(state: TurnViewState, previewUrl?: string): Record<string, unknown> {
1188
1203
  const title = state.taskTitle
1189
1204
  ? `任务执行详情:${truncateText(state.taskTitle.replace(/\s+/g, " ").trim(), 60)}`
1190
1205
  : "任务执行详情";
1191
- return turnCard(title, "blue", renderTurnElements(state, "always"), renderTurnSubtitle(state));
1206
+ const elements = [markdown(`Turn: \`${state.turnId}\``)];
1207
+ if (state.prompt) elements.push(markdown(`**Prompt**\n${truncateText(state.prompt, 1_000)}`));
1208
+ elements.push(...renderTurnElements(state, "always"));
1209
+ if (previewUrl) elements.push(taskActionRow([turnPreviewAction(previewUrl)]));
1210
+ return turnCard(title, "blue", elements, renderTurnSubtitle(state));
1192
1211
  }
1193
1212
 
1194
1213
  renderActivityHistory(state: TurnViewState, requestedPage: number): Record<string, unknown> {
@@ -1531,18 +1550,9 @@ export class CardRenderer {
1531
1550
  if (view.entries.length === 0) {
1532
1551
  elements.push(markdown("当前任务还没有成功完成的 turn。"));
1533
1552
  } else {
1553
+ const sequenceWidth = `${Math.max(16, ...view.entries.map((entry) => String(entry.sequence).length * 10))}px`;
1534
1554
  view.entries.forEach((entry) => {
1535
- const action = entry.actions?.[0];
1536
- elements.push(resetHistoryEntryRow(
1537
- entry.graphNodeLine,
1538
- entry.graphConnectorLine,
1539
- entry.lines,
1540
- entry.timestamp,
1541
- action,
1542
- entry.current === true,
1543
- entry.running === true,
1544
- entry.resetting === true,
1545
- ));
1555
+ elements.push(resetHistoryEntryRow(entry, sequenceWidth));
1546
1556
  });
1547
1557
  }
1548
1558
  if (view.footerLines.length > 0) {
@@ -2283,7 +2293,7 @@ function renderToolDetails(tool: ToolState, projectCwd?: string): string {
2283
2293
  return codeBlock([`$ ${commandText}`, resultText].filter((part): part is string => part !== undefined).join("\n"), 2_003);
2284
2294
  }
2285
2295
 
2286
- function formatShellCommandForDisplay(command: string): string {
2296
+ export function formatShellCommandForDisplay(command: string): string {
2287
2297
  const normalized = command.replace(/\r\n/g, "\n");
2288
2298
  let result = "";
2289
2299
  let quote: "'" | "\"" | undefined;
@@ -2388,7 +2398,7 @@ function toolPanelTitle(tool: ToolState): string {
2388
2398
  const command = stripAnsi(tool.command ?? tool.title).trim();
2389
2399
  const meaningfulCommand = tool.kind === "web_search"
2390
2400
  ? tool.title
2391
- : unwrapShellCommand(command) ?? tool.title;
2401
+ : displayToolCommand(command) ?? tool.title;
2392
2402
  const duration = toolDuration(tool);
2393
2403
  const prefix = `${icon} `;
2394
2404
  const suffix = duration ? ` · ${duration}` : "";
@@ -2460,6 +2470,11 @@ function shellCommandStatus(view: ShellCommandCardView): {
2460
2470
  return { title: "命令执行失败", label: `退出码 ${view.exitCode ?? "未知"}`, template: "red" };
2461
2471
  }
2462
2472
 
2473
+ export function displayToolCommand(command: string): string | undefined {
2474
+ const clean = stripAnsi(command).trim();
2475
+ return unwrapShellCommand(clean);
2476
+ }
2477
+
2463
2478
  function unwrapShellCommand(command: string): string | undefined {
2464
2479
  return unwrapPowerShellCommand(command) ?? unwrapPosixShellCommand(command);
2465
2480
  }
@@ -2825,24 +2840,31 @@ function settingsOptionRow(input: {
2825
2840
  }
2826
2841
 
2827
2842
  function resetHistoryEntryRow(
2828
- graphNodeLine: string,
2829
- graphConnectorLine: string | undefined,
2830
- lines: string[],
2831
- timestamp: string | undefined,
2832
- action: TaskListCardAction | undefined,
2833
- current: boolean,
2834
- running: boolean,
2835
- resetting: boolean,
2843
+ entry: ResetHistoryCardEntry,
2844
+ sequenceWidth: string,
2836
2845
  ): Record<string, unknown> {
2846
+ const { graphNodeLine, graphConnectorLine, lines, timestamp, current, running, resetting } = entry;
2847
+ const action = entry.actions?.[0];
2837
2848
  const branchConnectorLine = graphConnectorLine && /[╱╲]/u.test(graphConnectorLine)
2838
2849
  ? graphConnectorLine
2839
2850
  : undefined;
2840
- const graph = [
2841
- `<font color='${current ? "green" : running || resetting ? "orange" : "blue"}'>${escapeCardHtml(graphNodeLine)}</font>`,
2842
- ...(branchConnectorLine
2843
- ? [`<font color='grey'>${escapeCardHtml(branchConnectorLine)}</font>`]
2844
- : []),
2845
- ].join("\n");
2851
+ const color = current ? "green" : running || resetting ? "orange" : "blue";
2852
+ const nodeLanes = graphNodeLine.match(/[●│]/gu) ?? [];
2853
+ const connectorLanes = branchConnectorLine?.split(" ");
2854
+ // Each lane has its own center; shorter connectors and wider turn numbers cannot shift it.
2855
+ const graphColumns = nodeLanes.map((node, lane) => ({
2856
+ tag: "column",
2857
+ width: "16px",
2858
+ vertical_align: "top",
2859
+ padding: "0px",
2860
+ elements: [{
2861
+ ...markdown([
2862
+ `<font color='${color}'>${node}</font>`,
2863
+ ...(connectorLanes?.[lane] ? [`<font color='grey'>${escapeCardHtml(connectorLanes[lane])}</font>`] : []),
2864
+ ].join("\n")),
2865
+ text_align: "center",
2866
+ }],
2867
+ }));
2846
2868
  const trailingElements = current
2847
2869
  ? [markdown("✅ 当前")]
2848
2870
  : resetting
@@ -2863,8 +2885,20 @@ function resetHistoryEntryRow(
2863
2885
  width: "auto",
2864
2886
  vertical_align: "top",
2865
2887
  elements: [{
2866
- ...markdown(graph),
2867
- text_align: "center",
2888
+ tag: "column_set",
2889
+ flex_mode: "none",
2890
+ horizontal_spacing: "0px",
2891
+ horizontal_align: "left",
2892
+ columns: [
2893
+ ...graphColumns,
2894
+ {
2895
+ tag: "column",
2896
+ width: sequenceWidth,
2897
+ vertical_align: "top",
2898
+ padding: "0px",
2899
+ elements: [{ ...markdown(`<font color='${color}'>${entry.sequence}</font>`), text_align: "left" }],
2900
+ },
2901
+ ],
2868
2902
  }],
2869
2903
  },
2870
2904
  {
@@ -2997,10 +3031,17 @@ function taskActionElement(action: TaskListCardAction): Record<string, unknown>
2997
3031
  elements: [markdown(
2998
3032
  `<font color='${action.type === "danger" ? "red" : "blue"}'>${escapeCardActionText(action.text)}</font>`,
2999
3033
  )],
3000
- behaviors: [{
3001
- type: "callback",
3002
- value: action.value,
3003
- }],
3034
+ behaviors: action.url
3035
+ ? [{ type: "open_url", default_url: action.url }]
3036
+ : [{ type: "callback", value: action.value }],
3037
+ };
3038
+ }
3039
+
3040
+ function turnPreviewAction(previewUrl: string): TaskListCardAction {
3041
+ return {
3042
+ text: "Preview",
3043
+ value: {},
3044
+ url: previewUrl,
3004
3045
  };
3005
3046
  }
3006
3047
 
@@ -3,7 +3,7 @@ import type { WSClient as LarkWsClient, WSConnectionStatus } from "@larksuiteoap
3
3
  import type { AppConfig } from "../config/schema.js";
4
4
  import { threadContextKey } from "./contextKey.js";
5
5
  import { resolveFeishuBotOpenId } from "./FeishuBotIdentity.js";
6
- import { normalizeFeishuPostText } from "./InboundText.js";
6
+ import { normalizeFeishuPostText, renderFeishuCodeBlock } from "./InboundText.js";
7
7
  import { allowsFeishuUser } from "./ownerAccess.js";
8
8
  import type { CardAction, ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
9
9
 
@@ -327,19 +327,29 @@ function parseMessageContent(
327
327
  const title = typeof locale.title === "string" ? normalizeFeishuPostText(locale.title) : "";
328
328
  if (title) paragraphs.push(title);
329
329
  const images: string[] = [];
330
- if (Array.isArray(locale.content)) {
331
- for (const row of locale.content) {
330
+ const rows = Array.isArray(locale.content) ? locale.content : locale.content_v2;
331
+ if (Array.isArray(rows)) {
332
+ for (const row of rows) {
332
333
  if (!Array.isArray(row)) continue;
333
334
  let rowText = "";
335
+ let preserveFormatting = false;
334
336
  for (const element of row) {
335
337
  if (!isRecord(element)) continue;
336
338
  if ((element.tag === "text" || element.tag === "a") && typeof element.text === "string") {
337
339
  rowText += element.text;
340
+ } else if (element.tag === "md" && typeof element.text === "string") {
341
+ rowText += element.text;
342
+ preserveFormatting = true;
343
+ } else if (element.tag === "code_block" && typeof element.text === "string") {
344
+ rowText += renderFeishuCodeBlock(element.text, element.language);
345
+ preserveFormatting = true;
338
346
  } else if (element.tag === "img" && typeof element.image_key === "string") {
339
347
  images.push(element.image_key);
340
348
  }
341
349
  }
342
- const normalizedRow = normalizeFeishuPostText(rowText);
350
+ const normalizedRow = preserveFormatting
351
+ ? rowText.replace(/\r\n?/gu, "\n").trim()
352
+ : normalizeFeishuPostText(rowText);
343
353
  if (normalizedRow) paragraphs.push(normalizedRow);
344
354
  }
345
355
  }
@@ -347,7 +357,7 @@ function parseMessageContent(
347
357
  }
348
358
 
349
359
  function selectPostLocale(content: Record<string, unknown>): Record<string, unknown> | undefined {
350
- if (Array.isArray(content.content)) return content;
360
+ if (Array.isArray(content.content) || Array.isArray(content.content_v2)) return content;
351
361
  for (const locale of ["zh_cn", "en_us", "ja_jp"]) {
352
362
  if (isRecord(content[locale])) return content[locale];
353
363
  }