@three333/termbuddy 0.1.0 → 0.1.1
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/dist/cli.js +1097 -260
- package/dist/cli.js.map +1 -1
- package/package.json +3 -2
- package/pnpm-workspace.yaml +2 -0
- package/src/app/App.tsx +94 -53
- package/src/app/index.ts +1 -2
- package/src/components/AiConsole.tsx +171 -73
- package/src/components/StatusHeader.tsx +36 -36
- package/src/components/index.ts +8 -4
- package/src/components/sprite/BuddyAvatar.tsx +49 -0
- package/src/components/sprite/CountdownClockSprite.tsx +146 -0
- package/src/components/sprite/ProjectileThrowSprite.tsx +86 -0
- package/src/components/tool/createCountdownTool.ts +32 -0
- package/src/components/tool/createInteractionTool.ts +67 -0
- package/src/components/tool/createSessionInfoTool.ts +29 -0
- package/src/components/tool/index.ts +4 -0
- package/src/hooks/globalKeyboard.ts +146 -0
- package/src/hooks/index.ts +5 -7
- package/src/hooks/useActivityMonitor.ts +61 -24
- package/src/hooks/useAiAgent.ts +200 -165
- package/src/hooks/useBroadcaster.ts +55 -47
- package/src/hooks/useScanner.ts +59 -55
- package/src/hooks/useTcpSync.ts +166 -145
- package/src/net/broadcast.ts +21 -21
- package/src/net/index.ts +1 -2
- package/src/page/LeavePage.tsx +85 -0
- package/src/{views → page}/MainMenu.tsx +32 -28
- package/src/page/NicknamePrompt.tsx +62 -0
- package/src/{views → page}/RoomScanner.tsx +4 -1
- package/src/page/Session.tsx +364 -0
- package/src/page/index.ts +5 -0
- package/src/storage/apiKey.ts +36 -0
- package/src/types.ts +8 -0
- package/src/components/AvatarDisplay.tsx +0 -18
- package/src/components/BuddyAvatar.tsx +0 -32
- package/src/hooks/useCountdown.ts +0 -42
- package/src/views/Session.tsx +0 -127
- package/src/views/index.ts +0 -4
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { tool } from "langchain";
|
|
2
|
+
import type {
|
|
3
|
+
ProjectileDirection,
|
|
4
|
+
ProjectileKind,
|
|
5
|
+
} from "../sprite/ProjectileThrowSprite.js";
|
|
6
|
+
|
|
7
|
+
const KIND_ALIASES: Array<{ kind: ProjectileKind; keys: string[] }> = [
|
|
8
|
+
{ kind: "ROSE", keys: ["rose", "花", "玫瑰", "🌹", "love"] },
|
|
9
|
+
{ kind: "POOP", keys: ["poop", "屎", "💩", "大便"] },
|
|
10
|
+
{ kind: "HAMMER", keys: ["hammer", "锤", "🔨", "敲", "打"] },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function normalizeKind(raw: unknown): ProjectileKind | null {
|
|
14
|
+
if (typeof raw !== "string") return null;
|
|
15
|
+
const upper = raw.toUpperCase().trim();
|
|
16
|
+
if (upper === "ROSE" || upper === "POOP" || upper === "HAMMER") return upper;
|
|
17
|
+
|
|
18
|
+
const lower = raw.toLowerCase();
|
|
19
|
+
for (const item of KIND_ALIASES) {
|
|
20
|
+
if (item.keys.some((k) => lower.includes(k))) return item.kind;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeDirection(raw: unknown): ProjectileDirection | null {
|
|
26
|
+
if (typeof raw !== "string") return null;
|
|
27
|
+
const upper = raw.toUpperCase().trim();
|
|
28
|
+
if (upper === "LEFT_TO_RIGHT" || upper === "RIGHT_TO_LEFT") return upper;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createInteractionTool(options: {
|
|
33
|
+
onThrow?: (kind: ProjectileKind, direction: ProjectileDirection) => void;
|
|
34
|
+
}) {
|
|
35
|
+
return tool(
|
|
36
|
+
async (input: { kind?: string; direction?: string; message?: string }) => {
|
|
37
|
+
const kind = normalizeKind(input.kind ?? "") ?? "ROSE";
|
|
38
|
+
const direction = normalizeDirection(input.direction) ?? "LEFT_TO_RIGHT";
|
|
39
|
+
options.onThrow?.(kind, direction);
|
|
40
|
+
const msg = (input.message ?? "").trim();
|
|
41
|
+
return msg ? `已投掷 ${kind}:${msg}` : `已投掷 ${kind}。`;
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "throw_projectile",
|
|
45
|
+
description: "和同桌互动:投掷一个小物品(🌹/💩/🔨)。",
|
|
46
|
+
schema: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
kind: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description:
|
|
52
|
+
"投掷物类型(ROSE/POOP/HAMMER,或任意描述如“玫瑰/锤子/💩”)",
|
|
53
|
+
},
|
|
54
|
+
direction: {
|
|
55
|
+
type: "string",
|
|
56
|
+
enum: ["LEFT_TO_RIGHT", "RIGHT_TO_LEFT"],
|
|
57
|
+
description: "飞行方向",
|
|
58
|
+
},
|
|
59
|
+
message: { type: "string", description: "附带一句话(可选)" },
|
|
60
|
+
},
|
|
61
|
+
required: [],
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { tool } from "langchain";
|
|
2
|
+
|
|
3
|
+
export function createSessionInfoTool(options: {
|
|
4
|
+
localName: string;
|
|
5
|
+
peerName: string;
|
|
6
|
+
}) {
|
|
7
|
+
return tool(
|
|
8
|
+
async () => {
|
|
9
|
+
return JSON.stringify(
|
|
10
|
+
{
|
|
11
|
+
localName: options.localName,
|
|
12
|
+
peerName: options.peerName,
|
|
13
|
+
},
|
|
14
|
+
null,
|
|
15
|
+
2
|
|
16
|
+
);
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "session_info",
|
|
20
|
+
description: "获取当前会话上下文(本地昵称、同桌昵称)。",
|
|
21
|
+
schema: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {},
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type GlobalKeyboardBackend = "uiohook" | "xinput";
|
|
4
|
+
|
|
5
|
+
type Listener = () => void;
|
|
6
|
+
|
|
7
|
+
let backend: GlobalKeyboardBackend | null = null;
|
|
8
|
+
let started = false;
|
|
9
|
+
let starting: Promise<GlobalKeyboardBackend | null> | null = null;
|
|
10
|
+
let stopBackend: (() => void) | null = null;
|
|
11
|
+
|
|
12
|
+
const listeners = new Set<Listener>();
|
|
13
|
+
|
|
14
|
+
function emitKeydown() {
|
|
15
|
+
for (const listener of listeners) {
|
|
16
|
+
try {
|
|
17
|
+
listener();
|
|
18
|
+
} catch {
|
|
19
|
+
// ignore
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function tryStartUiohook(): Promise<GlobalKeyboardBackend | null> {
|
|
25
|
+
try {
|
|
26
|
+
const mod = (await import("uiohook-napi")) as unknown as {
|
|
27
|
+
uIOhook?: {
|
|
28
|
+
on: (event: "keydown", listener: () => void) => unknown;
|
|
29
|
+
removeListener?: (event: "keydown", listener: () => void) => unknown;
|
|
30
|
+
start: () => void;
|
|
31
|
+
stop: () => void;
|
|
32
|
+
};
|
|
33
|
+
default?: { uIOhook?: unknown };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const uIOhook =
|
|
37
|
+
mod.uIOhook ??
|
|
38
|
+
((mod.default as { uIOhook?: unknown } | undefined)?.uIOhook as
|
|
39
|
+
| {
|
|
40
|
+
on: (event: "keydown", listener: () => void) => unknown;
|
|
41
|
+
removeListener?: (event: "keydown", listener: () => void) => unknown;
|
|
42
|
+
start: () => void;
|
|
43
|
+
stop: () => void;
|
|
44
|
+
}
|
|
45
|
+
| undefined);
|
|
46
|
+
if (!uIOhook) return null;
|
|
47
|
+
|
|
48
|
+
const onKeydown = () => emitKeydown();
|
|
49
|
+
uIOhook.on("keydown", onKeydown);
|
|
50
|
+
uIOhook.start();
|
|
51
|
+
|
|
52
|
+
stopBackend = () => {
|
|
53
|
+
uIOhook.removeListener?.("keydown", onKeydown);
|
|
54
|
+
uIOhook.stop();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return "uiohook";
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tryStartXinput(): Promise<GlobalKeyboardBackend | null> {
|
|
64
|
+
if (process.platform !== "linux") return Promise.resolve(null);
|
|
65
|
+
if (!process.env.DISPLAY) return Promise.resolve(null);
|
|
66
|
+
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
let resolved = false;
|
|
69
|
+
const child = spawn("xinput", ["test-xi2", "--root"], {
|
|
70
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const resolveOnce = (value: GlobalKeyboardBackend | null) => {
|
|
74
|
+
if (resolved) return;
|
|
75
|
+
resolved = true;
|
|
76
|
+
resolve(value);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
child.once("error", () => {
|
|
80
|
+
resolveOnce(null);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// If we got here, treat it as started; parsing may continue.
|
|
84
|
+
resolveOnce("xinput");
|
|
85
|
+
|
|
86
|
+
let buf = "";
|
|
87
|
+
child.stdout?.setEncoding("utf8");
|
|
88
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
89
|
+
buf += chunk;
|
|
90
|
+
while (true) {
|
|
91
|
+
const idx = buf.indexOf("\n");
|
|
92
|
+
if (idx === -1) break;
|
|
93
|
+
const line = buf.slice(0, idx);
|
|
94
|
+
buf = buf.slice(idx + 1);
|
|
95
|
+
if (/KeyPress/.test(line)) emitKeydown();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
stopBackend = () => {
|
|
100
|
+
child.stdout?.removeAllListeners();
|
|
101
|
+
child.removeAllListeners();
|
|
102
|
+
child.kill();
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function ensureGlobalKeyboard(): Promise<GlobalKeyboardBackend | null> {
|
|
108
|
+
if (started) return backend;
|
|
109
|
+
if (starting) return starting;
|
|
110
|
+
|
|
111
|
+
starting = (async () => {
|
|
112
|
+
const uiohook = await tryStartUiohook();
|
|
113
|
+
if (uiohook) return uiohook;
|
|
114
|
+
return await tryStartXinput();
|
|
115
|
+
})();
|
|
116
|
+
|
|
117
|
+
backend = await starting;
|
|
118
|
+
started = backend !== null;
|
|
119
|
+
if (!started) stopBackend = null;
|
|
120
|
+
starting = null;
|
|
121
|
+
|
|
122
|
+
return backend;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stopIfIdle() {
|
|
126
|
+
if (listeners.size > 0) return;
|
|
127
|
+
if (!started) return;
|
|
128
|
+
started = false;
|
|
129
|
+
backend = null;
|
|
130
|
+
const stop = stopBackend;
|
|
131
|
+
stopBackend = null;
|
|
132
|
+
try {
|
|
133
|
+
stop?.();
|
|
134
|
+
} catch {
|
|
135
|
+
// ignore
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function subscribeGlobalKeydown(listener: Listener): () => void {
|
|
140
|
+
listeners.add(listener);
|
|
141
|
+
return () => {
|
|
142
|
+
listeners.delete(listener);
|
|
143
|
+
stopIfIdle();
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
export {useActivityMonitor} from
|
|
2
|
-
export {useAiAgent} from
|
|
3
|
-
export {useBroadcaster} from
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
6
|
-
export {useTcpSync} from './useTcpSync.js';
|
|
7
|
-
|
|
1
|
+
export { useActivityMonitor } from "./useActivityMonitor.js";
|
|
2
|
+
export { useAiAgent } from "./useAiAgent.js";
|
|
3
|
+
export { useBroadcaster } from "./useBroadcaster.js";
|
|
4
|
+
export { useScanner } from "./useScanner.js";
|
|
5
|
+
export { useTcpSync } from "./useTcpSync.js";
|
|
@@ -1,25 +1,62 @@
|
|
|
1
|
-
import {useEffect, useRef, useState} from
|
|
2
|
-
import {useInput} from
|
|
3
|
-
import type {ActivityState} from
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useInput } from "ink";
|
|
3
|
+
import type { ActivityState } from "../protocol.js";
|
|
4
|
+
import {
|
|
5
|
+
ensureGlobalKeyboard,
|
|
6
|
+
subscribeGlobalKeydown,
|
|
7
|
+
} from "./globalKeyboard.js";
|
|
8
|
+
|
|
9
|
+
export function useActivityMonitor(options?: {
|
|
10
|
+
idleAfterMs?: number;
|
|
11
|
+
source?: "ink" | "keyboard";
|
|
12
|
+
}): {
|
|
13
|
+
state: ActivityState;
|
|
14
|
+
} {
|
|
15
|
+
const idleAfterMs = options?.idleAfterMs ?? 1500;
|
|
16
|
+
const [state, setState] = useState<ActivityState>("IDLE");
|
|
17
|
+
|
|
18
|
+
const lastActivityRef = useRef<number>(Date.now());
|
|
19
|
+
|
|
20
|
+
const markActive = useCallback(() => {
|
|
21
|
+
lastActivityRef.current = Date.now();
|
|
22
|
+
setState("TYPING");
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
useInput(() => {
|
|
26
|
+
markActive();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Optional global keyboard activity.
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const rawSource =
|
|
32
|
+
options?.source ?? process.env.TERMBUDDY_ACTIVITY_SOURCE ?? "ink";
|
|
33
|
+
|
|
34
|
+
// Back-compat: previous env value.
|
|
35
|
+
const source = rawSource === "xinput" ? "keyboard" : rawSource;
|
|
36
|
+
if (source !== "keyboard") return;
|
|
37
|
+
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
let unsub: (() => void) | null = null;
|
|
40
|
+
void (async () => {
|
|
41
|
+
const ok = await ensureGlobalKeyboard();
|
|
42
|
+
if (cancelled) return;
|
|
43
|
+
if (!ok) return;
|
|
44
|
+
unsub = subscribeGlobalKeydown(markActive);
|
|
45
|
+
})();
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
unsub?.();
|
|
50
|
+
};
|
|
51
|
+
}, [markActive, options?.source]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const id = setInterval(() => {
|
|
55
|
+
const delta = Date.now() - lastActivityRef.current;
|
|
56
|
+
if (delta >= idleAfterMs) setState("IDLE");
|
|
57
|
+
}, 200);
|
|
58
|
+
return () => clearInterval(id);
|
|
59
|
+
}, [idleAfterMs]);
|
|
60
|
+
|
|
61
|
+
return { state };
|
|
25
62
|
}
|