chatccc 0.2.203 → 0.2.205

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.
@@ -0,0 +1,170 @@
1
+ import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
2
+
3
+ export type AgentActivityKind =
4
+ | "starting"
5
+ | "thinking"
6
+ | "tool"
7
+ | "processing"
8
+ | "responding"
9
+ | "searching"
10
+ | "compacting";
11
+
12
+ /** The user-visible activity of a running Agent turn. */
13
+ export interface AgentActivity {
14
+ kind: AgentActivityKind;
15
+ /** Time when the current activity began, used for truthful elapsed time. */
16
+ startedAt: number;
17
+ toolName?: string;
18
+ toolCount?: number;
19
+ }
20
+
21
+ interface ActiveTool {
22
+ id: string;
23
+ name: string;
24
+ startedAt: number;
25
+ }
26
+
27
+ export interface AgentActivityTracker {
28
+ activity: AgentActivity;
29
+ activeTools: Map<string, ActiveTool>;
30
+ nextAnonymousToolId: number;
31
+ }
32
+
33
+ export function createAgentActivityTracker(now = Date.now()): AgentActivityTracker {
34
+ return {
35
+ activity: { kind: "starting", startedAt: now },
36
+ activeTools: new Map(),
37
+ nextAnonymousToolId: 1,
38
+ };
39
+ }
40
+
41
+ function sameVisibleActivity(left: AgentActivity, right: AgentActivity): boolean {
42
+ return left.kind === right.kind
43
+ && left.toolName === right.toolName
44
+ && left.toolCount === right.toolCount;
45
+ }
46
+
47
+ function setActivity(tracker: AgentActivityTracker, next: AgentActivity): boolean {
48
+ if (sameVisibleActivity(tracker.activity, next)) return false;
49
+ tracker.activity = next;
50
+ return true;
51
+ }
52
+
53
+ function refreshToolActivity(tracker: AgentActivityTracker): boolean {
54
+ const tools = [...tracker.activeTools.values()];
55
+ const first = tools[0];
56
+ if (!first) return false;
57
+ return setActivity(tracker, {
58
+ kind: "tool",
59
+ startedAt: first.startedAt,
60
+ toolName: first.name,
61
+ toolCount: tools.length,
62
+ });
63
+ }
64
+
65
+ function removeCompletedTool(tracker: AgentActivityTracker, toolUseId: string): void {
66
+ if (toolUseId && tracker.activeTools.delete(toolUseId)) return;
67
+
68
+ // Older adapters did not always include a tool ID. Prefer an anonymous entry;
69
+ // if there is only one active call, it is still safe to match that result.
70
+ const anonymousId = [...tracker.activeTools.keys()].find((id) => id.startsWith("anonymous:"));
71
+ if (anonymousId) {
72
+ tracker.activeTools.delete(anonymousId);
73
+ } else if (tracker.activeTools.size === 1) {
74
+ const onlyId = tracker.activeTools.keys().next().value as string | undefined;
75
+ if (onlyId) tracker.activeTools.delete(onlyId);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Applies one normalized Agent event and returns whether the persisted activity
81
+ * changed. Tool activity takes precedence while a tool call is still active.
82
+ */
83
+ export function updateAgentActivity(
84
+ tracker: AgentActivityTracker,
85
+ block: UnifiedBlock,
86
+ now = Date.now(),
87
+ ): boolean {
88
+ if (block.type === "tool_use") {
89
+ const id = block.id || `anonymous:${tracker.nextAnonymousToolId++}`;
90
+ const existing = tracker.activeTools.get(id);
91
+ tracker.activeTools.set(id, {
92
+ id,
93
+ name: block.name || "未知工具",
94
+ startedAt: existing?.startedAt ?? now,
95
+ });
96
+ return refreshToolActivity(tracker);
97
+ }
98
+
99
+ if (block.type === "tool_result") {
100
+ removeCompletedTool(tracker, block.tool_use_id);
101
+ if (tracker.activeTools.size > 0) return refreshToolActivity(tracker);
102
+ return setActivity(tracker, { kind: "processing", startedAt: now });
103
+ }
104
+
105
+ if (tracker.activeTools.size > 0) return false;
106
+
107
+ switch (block.type) {
108
+ case "thinking":
109
+ case "redacted_thinking":
110
+ return setActivity(tracker, { kind: "thinking", startedAt: now });
111
+ case "text":
112
+ case "text_final":
113
+ return setActivity(tracker, { kind: "responding", startedAt: now });
114
+ case "search_result":
115
+ return setActivity(tracker, { kind: "searching", startedAt: now });
116
+ case "compact_boundary":
117
+ return setActivity(tracker, { kind: "compacting", startedAt: now });
118
+ }
119
+ }
120
+
121
+ function formatElapsed(startedAt: number, now: number): string {
122
+ const totalSeconds = Math.max(0, Math.floor((now - startedAt) / 1000));
123
+ if (totalSeconds < 60) return `${totalSeconds}秒`;
124
+ const totalMinutes = Math.floor(totalSeconds / 60);
125
+ const seconds = totalSeconds % 60;
126
+ if (totalMinutes < 60) return `${totalMinutes}分${seconds}秒`;
127
+ const hours = Math.floor(totalMinutes / 60);
128
+ const minutes = totalMinutes % 60;
129
+ return `${hours}小时${minutes}分`;
130
+ }
131
+
132
+ function displayToolName(name: string | undefined): string {
133
+ const normalized = (name || "未知工具").replace(/\s+/g, " ").trim();
134
+ return normalized.length > 24 ? `${normalized.slice(0, 23)}…` : normalized;
135
+ }
136
+
137
+ export function formatAgentActivityTitle(
138
+ activity: AgentActivity | undefined,
139
+ now = Date.now(),
140
+ ): string {
141
+ if (!activity) return "正在处理";
142
+
143
+ let label: string;
144
+ switch (activity.kind) {
145
+ case "starting":
146
+ label = "正在启动 Agent";
147
+ break;
148
+ case "thinking":
149
+ label = "思考中";
150
+ break;
151
+ case "tool": {
152
+ const count = Math.max(1, activity.toolCount ?? 1);
153
+ label = `正在执行 ${displayToolName(activity.toolName)}${count > 1 ? ` 等 ${count} 项` : ""}`;
154
+ break;
155
+ }
156
+ case "processing":
157
+ label = "正在处理工具结果";
158
+ break;
159
+ case "responding":
160
+ label = "正在生成回复";
161
+ break;
162
+ case "searching":
163
+ label = "正在处理搜索结果";
164
+ break;
165
+ case "compacting":
166
+ label = "正在整理上下文";
167
+ break;
168
+ }
169
+ return `${label} · ${formatElapsed(activity.startedAt, now)}`;
170
+ }
package/src/config.ts CHANGED
@@ -150,10 +150,11 @@ export interface RawStreamLogsConfig {
150
150
  ccc: RawStreamAgentLogConfig;
151
151
  }
152
152
 
153
- export interface AppConfig {
154
- feishu: FeishuConfig;
155
- platforms: PlatformsConfig;
156
- chromeDevtools: ChromeDevtoolsConfig;
153
+ export interface AppConfig {
154
+ feishu: FeishuConfig;
155
+ platforms: PlatformsConfig;
156
+ webUi: { openOnStart: boolean };
157
+ chromeDevtools: ChromeDevtoolsConfig;
157
158
  port: number;
158
159
  gitTimeoutSeconds: number;
159
160
  /** 若为 false,AI 生成过程中用户发送消息不会打断,须先点「停止」再发送新消息 */
@@ -418,9 +419,10 @@ function normalizeRawStreamAgentLogConfig(raw: unknown): RawStreamAgentLogConfig
418
419
 
419
420
  function loadConfig(): AppConfig {
420
421
  const defaults: AppConfig = {
421
- feishu: { appId: "", appSecret: "" },
422
- platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
423
- chromeDevtools: { enabled: false, port: 15166, chromePath: "" },
422
+ feishu: { appId: "", appSecret: "" },
423
+ platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
424
+ webUi: { openOnStart: true },
425
+ chromeDevtools: { enabled: false, port: 15166, chromePath: "" },
424
426
  port: 18080,
425
427
  gitTimeoutSeconds: 180,
426
428
  allowInterrupt: false,
@@ -495,6 +497,7 @@ function loadConfig(): AppConfig {
495
497
  };
496
498
  codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; alternativeModel?: unknown; effort?: unknown };
497
499
  ccc?: { DEEPSEEK_API_KEY?: unknown; DEEPSEEK_BASE_URL?: unknown; model?: unknown };
500
+ webUi?: { openOnStart?: unknown };
498
501
  chromeDevtools?: { enabled?: unknown; port?: unknown; chromePath?: unknown };
499
502
  rawStreamLogs?: unknown;
500
503
  };
@@ -510,6 +513,7 @@ function loadConfig(): AppConfig {
510
513
  const cursorRaw = (parsed.cursor ?? {}) as NonNullable<typeof parsed.cursor>;
511
514
  const codexRaw = (parsed.codex ?? {}) as NonNullable<typeof parsed.codex>;
512
515
  const cccRaw = (parsed.ccc ?? {}) as NonNullable<typeof parsed.ccc>;
516
+ const webUiRaw = (parsed.webUi ?? {}) as NonNullable<typeof parsed.webUi>;
513
517
  const chromeDevtoolsRaw = (parsed.chromeDevtools ?? {}) as NonNullable<typeof parsed.chromeDevtools>;
514
518
  const rawStreamLogsRaw = typeof parsed.rawStreamLogs === "object" && parsed.rawStreamLogs !== null
515
519
  ? parsed.rawStreamLogs as unknown as Record<string, unknown>
@@ -577,7 +581,7 @@ function loadConfig(): AppConfig {
577
581
  appId: feishu.appId ?? "",
578
582
  appSecret: feishu.appSecret ?? "",
579
583
  },
580
- platforms: {
584
+ platforms: {
581
585
  feishu: {
582
586
  enabled: typeof (parsed.platforms as unknown as Record<string, unknown> | undefined)?.feishu === "object"
583
587
  ? Boolean(((parsed.platforms as unknown as Record<string, unknown>).feishu as Record<string, unknown>).enabled ?? true)
@@ -595,9 +599,13 @@ function loadConfig(): AppConfig {
595
599
  reuseTokenOnStart: typeof (parsed.platforms as unknown as Record<string, unknown> | undefined)?.ilink === "object"
596
600
  ? Boolean(((parsed.platforms as unknown as Record<string, unknown>).ilink as Record<string, unknown>).reuseTokenOnStart ?? true)
597
601
  : true,
598
- },
599
- },
600
- chromeDevtools: {
602
+ },
603
+ },
604
+ webUi: {
605
+ // 兼容升级前没有 webUi 字段的 config.json:缺省仍按“自动打开”处理。
606
+ openOnStart: typeof webUiRaw.openOnStart === "boolean" ? webUiRaw.openOnStart : true,
607
+ },
608
+ chromeDevtools: {
601
609
  enabled: typeof chromeDevtoolsRaw.enabled === "boolean" ? chromeDevtoolsRaw.enabled : false,
602
610
  port: Number.isInteger(chromeDevtoolsPort) && chromeDevtoolsPort >= 1 && chromeDevtoolsPort <= 65535
603
611
  ? chromeDevtoolsPort
package/src/index.ts CHANGED
@@ -28,12 +28,18 @@ import { WSClient, EventDispatcher, Domain } from "@larksuiteoapi/node-sdk";
28
28
  import WebSocket from "ws";
29
29
 
30
30
  import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging, waitForPortFree } from "./shared.ts";
31
- import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
31
+ import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
32
+ import {
33
+ buildWebUiUrl,
34
+ openWebUiInDefaultBrowser,
35
+ shouldAutoOpenWebUi,
36
+ } from "./startup-lifecycle.ts";
32
37
  import { buildPlatformStartupPlan } from "./platform-startup.ts";
33
38
  import { makeTraceId, logTrace } from "./trace.ts";
34
39
  import {
35
- CHATCCC_PORT,
36
- APP_ID,
40
+ CHATCCC_PORT,
41
+ config,
42
+ APP_ID,
37
43
  APP_SECRET,
38
44
  FEISHU_ENABLED,
39
45
  FEISHU_PLATFORM_TYPE,
@@ -720,11 +726,17 @@ async function startConfiguredPlatforms(
720
726
  // Main
721
727
  // ---------------------------------------------------------------------------
722
728
 
723
- async function main(): Promise<void> {
724
- appendStartupTrace("main: entered", {
725
- argv: process.argv.join(" ").slice(0, 400),
726
- CHATCCC_PORT,
727
- PROJECT_ROOT,
729
+ async function main(): Promise<void> {
730
+ // 用户直接运行 chatccc 时打开系统默认浏览器;由 `/restart`、`/update`
731
+ // Web UI 拉起的替代进程会携带内部标记,不重复打扰用户。
732
+ const autoOpenWebUi = shouldAutoOpenWebUi({
733
+ openOnStart: config.webUi.openOnStart,
734
+ });
735
+ appendStartupTrace("main: entered", {
736
+ argv: process.argv.join(" ").slice(0, 400),
737
+ CHATCCC_PORT,
738
+ PROJECT_ROOT,
739
+ autoOpenWebUi,
728
740
  });
729
741
 
730
742
  // 黑匣子:所有未捕获异常 / 信号 / beforeExit 都同步写入 startup-trace.log(appendFileSync)。
@@ -780,9 +792,18 @@ async function main(): Promise<void> {
780
792
  console.log(`${"=".repeat(60)}`);
781
793
  console.log(` 发送消息: POST http://127.0.0.1:${SIM_PORT}/api/sim/inject-message`);
782
794
  console.log(` 消息日志: ~/.chatccc/sim/messages.jsonl`);
783
- console.log(`${"=".repeat(60)}\n`);
784
-
785
- installShutdownHandlers(simServer);
795
+ console.log(`${"=".repeat(60)}\n`);
796
+
797
+ if (autoOpenWebUi) {
798
+ const url = buildWebUiUrl(SIM_PORT);
799
+ const opened = openWebUiInDefaultBrowser(SIM_PORT);
800
+ appendStartupTrace(
801
+ opened ? "web-ui: opening simulate browser" : "web-ui: simulate browser unavailable",
802
+ { url },
803
+ );
804
+ }
805
+
806
+ installShutdownHandlers(simServer);
786
807
  return;
787
808
  }
788
809
 
@@ -830,8 +851,9 @@ async function main(): Promise<void> {
830
851
  if (!APP_ID.trim() || !APP_SECRET.trim()) {
831
852
  // 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
832
853
  // 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
833
- startSetupMode(CHATCCC_PORT, {
834
- onActivate: async (httpServer: Server) => {
854
+ startSetupMode(CHATCCC_PORT, {
855
+ openBrowser: autoOpenWebUi,
856
+ onActivate: async (httpServer: Server) => {
835
857
  reloadRuntimeConfig("setup-activate");
836
858
  appendStartupTrace("setup-activate: reloaded config from disk", {
837
859
  appIdMaskAfterReload: maskAppId(APP_ID),
@@ -867,17 +889,35 @@ async function main(): Promise<void> {
867
889
  await waitForPortFree(CHATCCC_PORT);
868
890
  appendStartupTrace("main: port free confirmed", { CHATCCC_PORT });
869
891
  }
870
- const httpServer = createServer(createUiRouter());
871
- await listenWithRetry(httpServer, CHATCCC_PORT, "127.0.0.1").catch((err: NodeJS.ErrnoException) => {
892
+ const httpServer = createServer(createUiRouter());
893
+ await listenWithRetry(httpServer, CHATCCC_PORT, "127.0.0.1").catch((err: NodeJS.ErrnoException) => {
872
894
  console.error(`\n[启动] 本地中继 WebSocket 监听失败:端口 ${CHATCCC_PORT}(${err.code ?? "?"} — ${err.message})`);
873
895
  console.error(
874
896
  " 处理建议: 关闭占用该端口的其它程序,或在 config.json 的 port 字段里改成其它未占用端口(如 18081)。"
875
897
  );
876
898
  printServiceDidNotStart(`本地中继端口 ${CHATCCC_PORT} 无法监听(${err.code ?? "?"} — ${err.message})`);
877
- process.exit(1);
878
- });
879
-
880
- await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
899
+ process.exit(1);
900
+ });
901
+
902
+ // 必须等 HTTP server 真正监听后再发起打开请求,避免浏览器先到一步看到
903
+ // ERR_CONNECTION_REFUSED。Chrome CDP 守护仍保持自己原有的独立行为。
904
+ if (autoOpenWebUi) {
905
+ const url = buildWebUiUrl(CHATCCC_PORT);
906
+ const opened = openWebUiInDefaultBrowser(CHATCCC_PORT);
907
+ if (opened) {
908
+ console.log(`[WEB-UI] 已请求系统默认浏览器打开: ${url}`);
909
+ appendStartupTrace("web-ui: opening default browser", { url });
910
+ } else {
911
+ appendStartupTrace("web-ui: default browser unavailable", { url });
912
+ }
913
+ } else {
914
+ appendStartupTrace("web-ui: default browser skipped by lifecycle or preference", {
915
+ url: buildWebUiUrl(CHATCCC_PORT),
916
+ openOnStart: config.webUi.openOnStart,
917
+ });
918
+ }
919
+
920
+ await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
881
921
 
882
922
  installShutdownHandlers(httpServer);
883
923
  }
@@ -91,6 +91,7 @@ import { applySharedPrefix } from "./shared-prefix.ts";
91
91
  import { cwdDisplayName, sessionChatName } from "./session-name.ts";
92
92
  import { reloadRuntimeConfig } from "./runtime-reload.ts";
93
93
  import { acquireUpdateCommandGuard } from "./update-command-guard.ts";
94
+ import { createInternalRestartEnv } from "./startup-lifecycle.ts";
94
95
  export { type PlatformAdapter } from "./platform-adapter.ts";
95
96
  import type { ChatAvatarUsageHints, PlatformAdapter } from "./platform-adapter.ts";
96
97
  import type { CodexUsageSummary } from "./feishu-api.ts";
@@ -480,7 +481,12 @@ function syncUpdateAndRestart(): void {
480
481
 
481
482
  // 3. spawn new chatccc
482
483
  try {
483
- const child = spawn(binPath, [], { detached: true, stdio: "ignore", shell: true });
484
+ const child = spawn(binPath, [], {
485
+ detached: true,
486
+ stdio: "ignore",
487
+ shell: true,
488
+ env: createInternalRestartEnv(),
489
+ });
484
490
  child.unref();
485
491
  updLog(`spawn new chatccc OK, childPid=${child.pid}, bin=${binPath}`);
486
492
  appendStartupTrace("update: spawn OK", { childPid: child.pid, binPath });
@@ -545,9 +551,10 @@ export async function handleCommand(
545
551
  appendStartupTrace("restart: spawn begin", { fromPid: process.pid });
546
552
  const child = spawn("npx", ["tsx", "src/index.ts"], {
547
553
  cwd: PROJECT_ROOT,
548
- detached: true,
549
- stdio: "ignore",
550
- shell: true,
554
+ detached: true,
555
+ stdio: "ignore",
556
+ shell: true,
557
+ env: createInternalRestartEnv(),
551
558
  });
552
559
 
553
560
  child.on("error", (err) => {
@@ -139,8 +139,10 @@ export interface DisplayCardState {
139
139
  cardId: string;
140
140
  sequence: number;
141
141
  cardBusy: boolean;
142
- cardCreatedAt: number;
143
- lastSentContent: string;
142
+ cardCreatedAt: number;
143
+ lastSentContent: string;
144
+ /** Last rendered activity header; elapsed time can change without body output. */
145
+ lastSentHeaderTitle?: string;
144
146
  streamErrorNotified: boolean;
145
147
  /** 所属 session */
146
148
  sessionId: string;
@@ -153,8 +155,8 @@ export interface DisplayCardState {
153
155
  lastSentAccLen?: number;
154
156
  /** WeChat delta: 上次发送时的 finalReply */
155
157
  lastSentFinalReply?: string;
156
- /** 点点点动画计数器(统一 display loop 每个卡片独立计数) */
157
- dotCount: number;
158
+ /** Liveness animation counter; the explicit activity header conveys Agent state. */
159
+ dotCount: number;
158
160
  }
159
161
 
160
162
  export const displayCards = new Map<string, DisplayCardState>();