440 lines
10 KiB
TypeScript
440 lines
10 KiB
TypeScript
import * as React from "react";
|
|
import { api, type ChatWebSocket } from "../api/client";
|
|
import type { ChatInputHandle } from "../components/ChatInput";
|
|
import type { Message, ProviderConfig } from "../types";
|
|
|
|
const { useCallback, useRef, useState } = React;
|
|
|
|
type SetState<T> = React.Dispatch<React.SetStateAction<T>>;
|
|
|
|
interface UseChatSendParams {
|
|
messages: Message[];
|
|
loading: boolean;
|
|
model: string;
|
|
enableTools: boolean;
|
|
claudeSessionId: string | null;
|
|
streamingContent: string;
|
|
setClaudeSessionId: SetState<string | null>;
|
|
setMessages: SetState<Message[]>;
|
|
setLoading: SetState<boolean>;
|
|
setStreamingContent: SetState<string>;
|
|
setStreamingThinking: SetState<string>;
|
|
setActivityStatus: SetState<string | null>;
|
|
setSideQuestion: SetState<{
|
|
question: string;
|
|
response: string;
|
|
loading: boolean;
|
|
} | null>;
|
|
chatInputRef: React.RefObject<ChatInputHandle | null>;
|
|
wsRef: React.MutableRefObject<ChatWebSocket | null>;
|
|
queuedMessagesRef: React.MutableRefObject<{ id: string; text: string }[]>;
|
|
setQueuedMessages: SetState<{ id: string; text: string }[]>;
|
|
queueIdCounterRef: React.MutableRefObject<number>;
|
|
clearMessages: () => void;
|
|
projectPath: string;
|
|
}
|
|
|
|
export interface UseChatSendResult {
|
|
sendMessage: (text: string) => Promise<void>;
|
|
sendMessageBatch: (texts: string[]) => Promise<void>;
|
|
cancelGeneration: () => Promise<void>;
|
|
handleSaveApiKey: () => Promise<void>;
|
|
clearSession: () => Promise<void>;
|
|
showApiKeyDialog: boolean;
|
|
setShowApiKeyDialog: SetState<boolean>;
|
|
apiKeyInput: string;
|
|
setApiKeyInput: SetState<string>;
|
|
}
|
|
|
|
export function useChatSend({
|
|
messages,
|
|
loading,
|
|
model,
|
|
enableTools,
|
|
claudeSessionId,
|
|
streamingContent,
|
|
setClaudeSessionId,
|
|
setMessages,
|
|
setLoading,
|
|
setStreamingContent,
|
|
setStreamingThinking,
|
|
setActivityStatus,
|
|
setSideQuestion,
|
|
chatInputRef,
|
|
wsRef,
|
|
queuedMessagesRef,
|
|
setQueuedMessages,
|
|
queueIdCounterRef,
|
|
clearMessages,
|
|
projectPath,
|
|
}: UseChatSendParams): UseChatSendResult {
|
|
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false);
|
|
const [apiKeyInput, setApiKeyInput] = useState("");
|
|
const pendingMessageRef = useRef<string>("");
|
|
|
|
const sendMessage = useCallback(
|
|
async (messageText: string) => {
|
|
if (!messageText.trim()) return;
|
|
|
|
if (/^\/reset\s*$/i.test(messageText)) {
|
|
setMessages([]);
|
|
setClaudeSessionId(null);
|
|
setStreamingContent("");
|
|
setStreamingThinking("");
|
|
setActivityStatus(null);
|
|
setMessages([
|
|
{
|
|
role: "assistant",
|
|
content: "Session reset. Starting a fresh conversation.",
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
|
|
const slashMatch = messageText.match(/^\/(\S+)(?:\s+([\s\S]*))?$/);
|
|
if (slashMatch) {
|
|
const cmd = slashMatch[1].toLowerCase();
|
|
const args = (slashMatch[2] ?? "").trim();
|
|
|
|
if (cmd !== "btw") {
|
|
const knownCommands = new Set([
|
|
"status",
|
|
"assign",
|
|
"start",
|
|
"show",
|
|
"move",
|
|
"delete",
|
|
"cost",
|
|
"git",
|
|
"overview",
|
|
"rebuild",
|
|
"loc",
|
|
"help",
|
|
"ambient",
|
|
"htop",
|
|
"rmtree",
|
|
"timer",
|
|
"unblock",
|
|
"unreleased",
|
|
"setup",
|
|
]);
|
|
|
|
if (knownCommands.has(cmd)) {
|
|
setMessages((prev: Message[]) => [
|
|
...prev,
|
|
{ role: "user", content: messageText },
|
|
]);
|
|
try {
|
|
const result = await api.botCommand(cmd, args, undefined);
|
|
setMessages((prev: Message[]) => [
|
|
...prev,
|
|
{ role: "assistant", content: result.response },
|
|
]);
|
|
} catch (e) {
|
|
setMessages((prev: Message[]) => [
|
|
...prev,
|
|
{
|
|
role: "assistant",
|
|
content: `**Error running command:** ${e}`,
|
|
},
|
|
]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setMessages((prev: Message[]) => [
|
|
...prev,
|
|
{ role: "user", content: messageText },
|
|
{
|
|
role: "assistant",
|
|
content: `Unknown command: \`/${cmd}\`. Type \`/help\` to see available commands.`,
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const btwMatch = messageText.match(/^\/btw\s+(.+)/s);
|
|
if (btwMatch) {
|
|
const question = btwMatch[1].trim();
|
|
setSideQuestion({ question, response: "", loading: true });
|
|
|
|
const isClaudeCode = model === "claude-code-pty";
|
|
const provider = isClaudeCode
|
|
? "claude-code"
|
|
: model.startsWith("claude-")
|
|
? "anthropic"
|
|
: "ollama";
|
|
const config: ProviderConfig = {
|
|
provider,
|
|
model,
|
|
base_url: "http://localhost:11434",
|
|
enable_tools: false,
|
|
};
|
|
wsRef.current?.sendSideQuestion(question, messages, config);
|
|
return;
|
|
}
|
|
|
|
if (loading) {
|
|
const newItem = {
|
|
id: String(queueIdCounterRef.current++),
|
|
text: messageText,
|
|
};
|
|
queuedMessagesRef.current = [...queuedMessagesRef.current, newItem];
|
|
setQueuedMessages([...queuedMessagesRef.current]);
|
|
return;
|
|
}
|
|
|
|
const isClaudeCode = model === "claude-code-pty";
|
|
if (!isClaudeCode && model.startsWith("claude-")) {
|
|
const hasKey = await api.getAnthropicApiKeyExists();
|
|
if (!hasKey) {
|
|
pendingMessageRef.current = messageText;
|
|
setShowApiKeyDialog(true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const fileRefs = [...messageText.matchAll(/(^|[\s\n])@([^\s@]+)/g)].map(
|
|
(m) => m[2],
|
|
);
|
|
let expandedText = messageText;
|
|
if (fileRefs.length > 0) {
|
|
const expansions = await Promise.allSettled(
|
|
fileRefs.map(async (ref) => {
|
|
const contents = await api.readFile(ref);
|
|
return { ref, contents };
|
|
}),
|
|
);
|
|
for (const result of expansions) {
|
|
if (result.status === "fulfilled") {
|
|
expandedText += `\n\n[File: ${result.value.ref}]\n\`\`\`\n${result.value.contents}\n\`\`\``;
|
|
}
|
|
}
|
|
}
|
|
|
|
const userMsg: Message = { role: "user", content: expandedText };
|
|
const newHistory = [...messages, userMsg];
|
|
|
|
setMessages(newHistory);
|
|
setLoading(true);
|
|
setStreamingContent("");
|
|
setStreamingThinking("");
|
|
setActivityStatus(null);
|
|
|
|
try {
|
|
const provider = isClaudeCode
|
|
? "claude-code"
|
|
: model.startsWith("claude-")
|
|
? "anthropic"
|
|
: "ollama";
|
|
const config: ProviderConfig = {
|
|
provider,
|
|
model,
|
|
base_url: "http://localhost:11434",
|
|
enable_tools: enableTools,
|
|
...(isClaudeCode && claudeSessionId
|
|
? { session_id: claudeSessionId }
|
|
: {}),
|
|
};
|
|
wsRef.current?.sendChat(newHistory, config);
|
|
} catch (e) {
|
|
console.error("Chat error:", e);
|
|
const errorMessage = String(e);
|
|
if (!errorMessage.includes("Chat cancelled by user")) {
|
|
setMessages((prev: Message[]) => [
|
|
...prev,
|
|
{ role: "assistant", content: `**Error:** ${e}` },
|
|
]);
|
|
}
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[
|
|
messages,
|
|
loading,
|
|
model,
|
|
enableTools,
|
|
claudeSessionId,
|
|
setClaudeSessionId,
|
|
setMessages,
|
|
setLoading,
|
|
setStreamingContent,
|
|
setStreamingThinking,
|
|
setActivityStatus,
|
|
setSideQuestion,
|
|
wsRef,
|
|
queuedMessagesRef,
|
|
setQueuedMessages,
|
|
queueIdCounterRef,
|
|
],
|
|
);
|
|
|
|
const sendMessageBatch = useCallback(
|
|
async (messageTexts: string[]) => {
|
|
if (messageTexts.length === 0) return;
|
|
|
|
const userMsgs: Message[] = messageTexts.map((text) => ({
|
|
role: "user",
|
|
content: text,
|
|
}));
|
|
const newHistory = [...messages, ...userMsgs];
|
|
|
|
setMessages(newHistory);
|
|
setLoading(true);
|
|
setStreamingContent("");
|
|
setStreamingThinking("");
|
|
setActivityStatus(null);
|
|
|
|
try {
|
|
const isClaudeCode = model === "claude-code-pty";
|
|
const provider = isClaudeCode
|
|
? "claude-code"
|
|
: model.startsWith("claude-")
|
|
? "anthropic"
|
|
: "ollama";
|
|
const config: ProviderConfig = {
|
|
provider,
|
|
model,
|
|
base_url: "http://localhost:11434",
|
|
enable_tools: enableTools,
|
|
...(isClaudeCode && claudeSessionId
|
|
? { session_id: claudeSessionId }
|
|
: {}),
|
|
};
|
|
wsRef.current?.sendChat(newHistory, config);
|
|
} catch (e) {
|
|
console.error("Chat error:", e);
|
|
const errorMessage = String(e);
|
|
if (!errorMessage.includes("Chat cancelled by user")) {
|
|
setMessages((prev: Message[]) => [
|
|
...prev,
|
|
{ role: "assistant", content: `**Error:** ${e}` },
|
|
]);
|
|
}
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[
|
|
messages,
|
|
model,
|
|
enableTools,
|
|
claudeSessionId,
|
|
setMessages,
|
|
setLoading,
|
|
setStreamingContent,
|
|
setStreamingThinking,
|
|
setActivityStatus,
|
|
wsRef,
|
|
],
|
|
);
|
|
|
|
const cancelGeneration = useCallback(async () => {
|
|
if (queuedMessagesRef.current.length > 0) {
|
|
const queued = queuedMessagesRef.current
|
|
.map((item) => item.text)
|
|
.join("\n");
|
|
chatInputRef.current?.appendToInput(queued);
|
|
}
|
|
queuedMessagesRef.current = [];
|
|
setQueuedMessages([]);
|
|
try {
|
|
wsRef.current?.cancel();
|
|
await api.cancelChat();
|
|
|
|
if (streamingContent) {
|
|
setMessages((prev: Message[]) => [
|
|
...prev,
|
|
{ role: "assistant", content: streamingContent },
|
|
]);
|
|
setStreamingContent("");
|
|
}
|
|
|
|
setStreamingThinking("");
|
|
setLoading(false);
|
|
setActivityStatus(null);
|
|
} catch (e) {
|
|
console.error("Failed to cancel chat:", e);
|
|
}
|
|
}, [
|
|
streamingContent,
|
|
queuedMessagesRef,
|
|
setQueuedMessages,
|
|
chatInputRef,
|
|
wsRef,
|
|
setMessages,
|
|
setStreamingContent,
|
|
setStreamingThinking,
|
|
setLoading,
|
|
setActivityStatus,
|
|
]);
|
|
|
|
const handleSaveApiKey = useCallback(async () => {
|
|
if (!apiKeyInput.trim()) return;
|
|
|
|
try {
|
|
await api.setAnthropicApiKey(apiKeyInput);
|
|
setShowApiKeyDialog(false);
|
|
setApiKeyInput("");
|
|
|
|
const pendingMessage = pendingMessageRef.current;
|
|
pendingMessageRef.current = "";
|
|
|
|
if (pendingMessage.trim()) {
|
|
sendMessage(pendingMessage);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to save API key:", e);
|
|
alert(`Failed to save API key: ${e}`);
|
|
}
|
|
}, [apiKeyInput, sendMessage]);
|
|
|
|
const clearSession = useCallback(async () => {
|
|
const confirmed = window.confirm(
|
|
"Are you sure? This will clear all messages and reset the conversation context.",
|
|
);
|
|
|
|
if (confirmed) {
|
|
try {
|
|
await api.cancelChat();
|
|
wsRef.current?.cancel();
|
|
} catch (e) {
|
|
console.error("Failed to cancel chat:", e);
|
|
}
|
|
|
|
clearMessages();
|
|
setStreamingContent("");
|
|
setStreamingThinking("");
|
|
setLoading(false);
|
|
setActivityStatus(null);
|
|
setClaudeSessionId(null);
|
|
try {
|
|
localStorage.removeItem(`storykit-claude-session-id:${projectPath}`);
|
|
} catch {
|
|
// Ignore — quota or security errors.
|
|
}
|
|
}
|
|
}, [
|
|
wsRef,
|
|
clearMessages,
|
|
setStreamingContent,
|
|
setStreamingThinking,
|
|
setLoading,
|
|
setActivityStatus,
|
|
setClaudeSessionId,
|
|
projectPath,
|
|
]);
|
|
|
|
return {
|
|
sendMessage,
|
|
sendMessageBatch,
|
|
cancelGeneration,
|
|
handleSaveApiKey,
|
|
clearSession,
|
|
showApiKeyDialog,
|
|
setShowApiKeyDialog,
|
|
apiKeyInput,
|
|
setApiKeyInput,
|
|
};
|
|
}
|