@three333/termbuddy 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@three333/termbuddy",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,11 @@ import type {
10
10
  export function AiConsole(props: {
11
11
  onClose: () => void;
12
12
  onStartCountdown: (minutes: number) => void;
13
+ onShowBubble?: (args: {
14
+ text: string;
15
+ target: "local" | "buddy";
16
+ durationMs: number;
17
+ }) => void;
13
18
  onThrowProjectile: (
14
19
  kind: ProjectileKind,
15
20
  direction: ProjectileDirection
@@ -20,6 +25,7 @@ export function AiConsole(props: {
20
25
  const [input, setInput] = useState("");
21
26
  const [apiKey, setApiKey] = useState<string | null>(null);
22
27
  const [keyDraft, setKeyDraft] = useState("");
28
+ const [cursorOn, setCursorOn] = useState(true);
23
29
  const [keyStatus, setKeyStatus] = useState<
24
30
  "loading" | "missing" | "ready" | "saving"
25
31
  >("loading");
@@ -41,14 +47,27 @@ export function AiConsole(props: {
41
47
  };
42
48
  }, []);
43
49
 
50
+ useEffect(() => {
51
+ const handle = setInterval(() => setCursorOn((v) => !v), 500);
52
+ return () => clearInterval(handle);
53
+ }, []);
54
+
44
55
  const agent = useAiAgent({
45
56
  localName: props.localName,
46
57
  peerName: props.peerName,
47
58
  onStartCountdown: props.onStartCountdown,
59
+ onShowBubble: props.onShowBubble,
48
60
  onThrowProjectile: props.onThrowProjectile,
49
61
  apiKey: apiKey ?? undefined,
50
62
  });
51
63
 
64
+ const resetApiKey = () => {
65
+ setApiKey(null);
66
+ setKeyDraft("");
67
+ setKeyStatus("missing");
68
+ agent.resetApiKeyError();
69
+ };
70
+
52
71
  const helpLine = useMemo(
53
72
  () => "示例:倒计时20分钟 / 聊会天 / 和别人互动一下",
54
73
  []
@@ -61,6 +80,11 @@ export function AiConsole(props: {
61
80
  return;
62
81
  }
63
82
 
83
+ if (agent.apiKeyError && (ch === "r" || ch === "R")) {
84
+ resetApiKey();
85
+ return;
86
+ }
87
+
64
88
  if (keyStatus !== "ready") {
65
89
  if (key.return) {
66
90
  const draft = keyDraft.trim();
@@ -113,6 +137,8 @@ export function AiConsole(props: {
113
137
  <Text color="gray">
114
138
  {keyStatus === "saving"
115
139
  ? "Saving…"
140
+ : agent.apiKeyError
141
+ ? "Press R to reset API"
116
142
  : agent.busy
117
143
  ? "Thinking…"
118
144
  : "Esc Close"}
@@ -161,12 +187,16 @@ export function AiConsole(props: {
161
187
  >
162
188
  <Text color="green">{">"} </Text>
163
189
  {keyStatus === "ready" ? (
164
- <Text>{input}</Text>
190
+ <>
191
+ <Text>{input}</Text>
192
+ {cursorOn ? <Text inverse> </Text> : <Text> </Text>}
193
+ </>
165
194
  ) : (
166
195
  <Text>
167
196
  {keyDraft.length === 0
168
197
  ? ""
169
198
  : "*".repeat(Math.min(64, keyDraft.length))}
199
+ {cursorOn ? <Text inverse> </Text> : <Text> </Text>}
170
200
  </Text>
171
201
  )}
172
202
  </Box>
@@ -20,12 +20,16 @@ export function StatusHeader(props: {
20
20
  status: ConnectionStatus;
21
21
  hostIp?: string;
22
22
  tcpPort?: number;
23
+ peerCount?: number;
23
24
  }) {
24
25
  const st = statusText(props.status);
25
26
  return (
26
27
  <Box>
27
28
  <Box>
28
29
  <Text color={st.color}>{st.label}</Text>
30
+ {props.peerCount !== undefined && props.peerCount > 0 && (
31
+ <Text color="cyan"> ({props.peerCount} online)</Text>
32
+ )}
29
33
  {props.role === "host" ? (
30
34
  <Text color="gray">
31
35
  {props.tcpPort ? ` — TCP :${props.tcpPort}` : ""}
@@ -0,0 +1,60 @@
1
+ import React, { useMemo } from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ function wrapText(text: string, maxWidth: number) {
5
+ const chars = Array.from(text);
6
+ const lines: string[] = [];
7
+ for (let i = 0; i < chars.length; i += maxWidth) {
8
+ lines.push(chars.slice(i, i + maxWidth).join(""));
9
+ }
10
+ return lines.length ? lines : [""];
11
+ }
12
+
13
+ function renderBubbleLines(text: string, maxInnerWidth: number) {
14
+ const contentLines = wrapText(text, maxInnerWidth);
15
+ const innerWidth = Math.min(
16
+ maxInnerWidth,
17
+ Math.max(...contentLines.map((l) => Array.from(l).length), 1)
18
+ );
19
+
20
+ const top = `╭${"─".repeat(innerWidth + 2)}╮`;
21
+
22
+ // Create a bottom line with a little tail "v" in the middle
23
+ const tailPos = Math.floor((innerWidth + 2) / 2);
24
+ const bottomChars = Array.from(`╰${"─".repeat(innerWidth + 2)}╯`);
25
+ if (bottomChars[tailPos]) bottomChars[tailPos] = "v"; // Simple tail
26
+ const bottom = bottomChars.join("");
27
+
28
+ const middle = contentLines.map((l) => {
29
+ const pad = innerWidth - Array.from(l).length;
30
+ return `│ ${l}${" ".repeat(Math.max(0, pad))} │`;
31
+ });
32
+ return [top, ...middle, bottom];
33
+ }
34
+
35
+ export function BubbleSprite(props: {
36
+ text: string;
37
+ maxInnerWidth?: number;
38
+ color?: string;
39
+ }) {
40
+ const trimmed = props.text.trim();
41
+ const maxInnerWidth = props.maxInnerWidth ?? 18;
42
+
43
+ const lines = useMemo(() => {
44
+ if (!trimmed) return null;
45
+ return renderBubbleLines(trimmed, maxInnerWidth);
46
+ }, [maxInnerWidth, trimmed]);
47
+
48
+ if (!lines) return null;
49
+
50
+ return (
51
+ <Box flexDirection="column" alignItems="center">
52
+ {lines.map((line, i) => (
53
+ <Text key={`bubble:${i}`} color={props.color ?? "gray"}>
54
+ {line}
55
+ </Text>
56
+ ))}
57
+ </Box>
58
+ );
59
+ }
60
+
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
  import { Box, Text } from "ink";
3
3
  import type { ActivityState } from "../../protocol.js";
4
+ import { BubbleSprite } from "./BubbleSprite.js";
4
5
 
5
6
  const SPRITES: Record<
6
7
  ActivityState,
@@ -26,19 +27,30 @@ const SPRITES: Record<
26
27
  export function BuddyAvatar(props: {
27
28
  state: ActivityState;
28
29
  variant?: "frames" | "compact";
30
+ bubbleText?: string | null;
29
31
  marginTop?: number;
30
32
  }) {
31
33
  const sprite = SPRITES[props.state];
32
34
  if (props.variant === "compact") {
33
35
  return (
34
- <Box marginTop={props.marginTop ?? 1}>
36
+ <Box
37
+ flexDirection="column"
38
+ alignItems="center"
39
+ marginTop={props.marginTop ?? 1}
40
+ >
41
+ {props.bubbleText ? <BubbleSprite text={props.bubbleText} /> : null}
35
42
  <Text color={sprite.color}>{sprite.compact}</Text>
36
43
  </Box>
37
44
  );
38
45
  }
39
46
 
40
47
  return (
41
- <Box flexDirection="column" marginTop={props.marginTop ?? 1}>
48
+ <Box
49
+ flexDirection="column"
50
+ alignItems="center"
51
+ marginTop={props.marginTop ?? 1}
52
+ >
53
+ {props.bubbleText ? <BubbleSprite text={props.bubbleText} /> : null}
42
54
  {sprite.frames.map((line, i) => (
43
55
  <Text key={`${props.state}:${i}`} color={sprite.color}>
44
56
  {line}
@@ -0,0 +1,61 @@
1
+ import { tool } from "langchain";
2
+
3
+ export type BubbleTarget = "local" | "buddy";
4
+
5
+ export function createBubbleTool(options: {
6
+ onShowBubble?: (args: {
7
+ text: string;
8
+ target: BubbleTarget;
9
+ durationMs: number;
10
+ }) => void;
11
+ }) {
12
+ return tool(
13
+ async (input: { text?: string; target?: BubbleTarget; durationMs?: number }) => {
14
+ const text = String(input.text ?? "").trim();
15
+ if (!text) return "气泡内容为空。";
16
+
17
+ const target: BubbleTarget =
18
+ input.target === "buddy" || input.target === "local"
19
+ ? input.target
20
+ : "local";
21
+
22
+ const durationRaw = Number(input.durationMs ?? 2500);
23
+ const durationMs =
24
+ Number.isFinite(durationRaw) && durationRaw > 0
25
+ ? Math.min(15_000, Math.max(300, Math.floor(durationRaw)))
26
+ : 2500;
27
+
28
+ options.onShowBubble?.({ text, target, durationMs });
29
+ return `已显示气泡:${text}`;
30
+ },
31
+ {
32
+ name: "show_bubble",
33
+ description: "在小猫头像上显示一个气泡(短消息提示)。",
34
+ schema: {
35
+ type: "object",
36
+ properties: {
37
+ text: {
38
+ type: "string",
39
+ minLength: 1,
40
+ maxLength: 120,
41
+ description: "气泡里的文字",
42
+ },
43
+ target: {
44
+ type: "string",
45
+ enum: ["local", "buddy"],
46
+ description: "显示在哪一侧的小猫上(local=我,buddy=同桌)",
47
+ },
48
+ durationMs: {
49
+ type: "integer",
50
+ minimum: 300,
51
+ maximum: 15000,
52
+ description: "显示时长(毫秒,可选)",
53
+ },
54
+ },
55
+ required: ["text"],
56
+ additionalProperties: false,
57
+ },
58
+ }
59
+ );
60
+ }
61
+
@@ -64,4 +64,3 @@ export function createInteractionTool(options: {
64
64
  }
65
65
  );
66
66
  }
67
-
@@ -1,4 +1,4 @@
1
1
  export { createCountdownTool } from "./createCountdownTool.js";
2
+ export { createBubbleTool } from "./createBubbleTool.js";
2
3
  export { createInteractionTool } from "./createInteractionTool.js";
3
4
  export { createSessionInfoTool } from "./createSessionInfoTool.js";
4
-
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { createAgent } from "langchain";
3
3
  import { ChatOpenAI } from "@langchain/openai";
4
4
  import {
5
+ createBubbleTool,
5
6
  createCountdownTool,
6
7
  createInteractionTool,
7
8
  createSessionInfoTool,
@@ -54,7 +55,9 @@ function createSystemPrompt(context: { localName: string; peerName: string }) {
54
55
  "默认隐形;被 / 唤醒时出现。风格:极简、干练、少废话。",
55
56
  "你可以使用工具来操控应用功能(例如倒计时)。",
56
57
  "如果用户提到“倒计时/专注/计时/countdown”,优先调用 start_countdown。",
58
+ "如果用户提到“气泡/泡泡/提示/说一句”,优先调用 show_bubble。",
57
59
  "如果用户提到“互动/扔/投掷/throw”,优先调用 throw_projectile。",
60
+ "当用户明确要求投掷 N 次且 1<=N<=100 时,必须按 N 执行:重复调用 throw_projectile 共 N 次,不要改成“示意/少量几次”。超过 100 则分批多次调用。",
58
61
  `当前上下文:我叫 ${context.localName};同桌叫 ${context.peerName}。`,
59
62
  ].join("\n");
60
63
  }
@@ -63,11 +66,17 @@ export function useAiAgent(options: {
63
66
  localName: string;
64
67
  peerName: string;
65
68
  onStartCountdown?: (minutes: number) => void;
69
+ onShowBubble?: (args: {
70
+ text: string;
71
+ target: "local" | "buddy";
72
+ durationMs: number;
73
+ }) => void;
66
74
  onThrowProjectile?: (kind: ProjectileKind, direction: ProjectileDirection) => void;
67
75
  apiKey?: string;
68
76
  }) {
69
77
  const [lines, setLines] = useState<AiLine[]>([]);
70
78
  const [busy, setBusy] = useState(false);
79
+ const [apiKeyError, setApiKeyError] = useState(false);
71
80
 
72
81
  const agentRef = useRef<Awaited<ReturnType<typeof createAgent>> | null>(null);
73
82
  const agentInitRef = useRef<Promise<
@@ -108,6 +117,10 @@ export function useAiAgent(options: {
108
117
  onStartCountdown: options.onStartCountdown,
109
118
  });
110
119
 
120
+ const showBubble = createBubbleTool({
121
+ onShowBubble: options.onShowBubble,
122
+ });
123
+
111
124
  const sessionInfo = createSessionInfoTool({
112
125
  localName: options.localName,
113
126
  peerName: options.peerName,
@@ -129,7 +142,7 @@ export function useAiAgent(options: {
129
142
  });
130
143
  return createAgent({
131
144
  model: llm,
132
- tools: [startCountdown, interaction, sessionInfo],
145
+ tools: [startCountdown, showBubble, interaction, sessionInfo],
133
146
  systemPrompt: createSystemPrompt({
134
147
  localName: options.localName,
135
148
  peerName: options.peerName,
@@ -144,6 +157,7 @@ export function useAiAgent(options: {
144
157
  options.apiKey,
145
158
  options.localName,
146
159
  options.onStartCountdown,
160
+ options.onShowBubble,
147
161
  options.onThrowProjectile,
148
162
  options.peerName,
149
163
  ]);
@@ -194,9 +208,19 @@ export function useAiAgent(options: {
194
208
  }
195
209
  } catch (e) {
196
210
  const msg = e instanceof Error ? e.message : String(e);
197
- if (msg === "missing_api_key")
198
- updateLine(aiAt, "请先在 AI Console 输入 DeepSeek API Key。");
199
- else updateLine(aiAt, `(AI 出错)${msg}`);
211
+ const isApiKeyError =
212
+ msg === "missing_api_key" ||
213
+ msg.includes("401") ||
214
+ msg.includes("403") ||
215
+ msg.includes("Unauthorized") ||
216
+ msg.includes("Invalid API");
217
+
218
+ if (isApiKeyError) {
219
+ setApiKeyError(true);
220
+ updateLine(aiAt, "API Key 错误或失效,请重新输入(按 R 键)");
221
+ } else {
222
+ updateLine(aiAt, `(AI 出错)${msg}`);
223
+ }
200
224
  } finally {
201
225
  setBusy(false);
202
226
  }
@@ -204,9 +228,13 @@ export function useAiAgent(options: {
204
228
  [append, ensureAgent, updateLine]
205
229
  );
206
230
 
231
+ const resetApiKeyError = useCallback(() => {
232
+ setApiKeyError(false);
233
+ }, []);
234
+
207
235
  useEffect(() => {
208
236
  return () => abortRef.current?.abort();
209
237
  }, []);
210
238
 
211
- return { lines, ask, busy };
239
+ return { lines, ask, busy, apiKeyError, resetApiKeyError };
212
240
  }