@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/.claude/settings.local.json +8 -0
- package/dist/cli.js +568 -176
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/src/components/AiConsole.tsx +31 -1
- package/src/components/StatusHeader.tsx +4 -0
- package/src/components/sprite/BubbleSprite.tsx +60 -0
- package/src/components/sprite/BuddyAvatar.tsx +14 -2
- package/src/components/tool/createBubbleTool.ts +61 -0
- package/src/components/tool/createInteractionTool.ts +0 -1
- package/src/components/tool/index.ts +1 -1
- package/src/hooks/useAiAgent.ts +33 -5
- package/src/hooks/useTcpSync.ts +328 -94
- package/src/page/Session.tsx +114 -17
- package/src/protocol.ts +14 -2
- package/src/storage/apiKey.ts +5 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
+
|
package/src/hooks/useAiAgent.ts
CHANGED
|
@@ -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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
}
|