Files
Cyrene_v2/frontend/web/src/components/ChatInput.tsx
T
Aska 2248ddce15
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(web): 用户侧体验补齐——思维链/工具卡数据源 + 图片附件 + 语音上行 + home/vision/voice 真表现
- 打通 thinking/tool_call 运行时数据源:openai 发 thinking_*(reasoning_content)/tool_call_*
  (delta.tool_calls 累积重新组装 Args 非 JSON 串);anthropic_stream 发 thinking_*(content_block
  thinking start/delta/stop)。ws_bridge 已映射 thinking.*/tool_call.*,前端渲染活。
- 前端消息模型 + 附件:ChatMessage 加 image?;ChatInput 加附件按钮(file→base64)、预览/移除、
  image-aware onSend(text,image);VisionMessageBody 升级图像卡+识别 chip;VoiceMessageBody
  升级通话控制卡(live2D预留/波形/静音/挂断);HomeMessageBody 读真实 deviceUpdates(去掉
  sampleDevices 猜)。
- 激活 voice_input:ChatShell onVoice 不再 no-op → sendUp voice_input + 切 voice mode。
- addTool 改按工具名 upsert(修复 tool_call.start/end 重复卡);ws_mode_switch 应用 agent 驱动
  mode;addUserMessage/beginAssistant 带 image/mode;chatStream/useChat.send 带 image。
- 新增 image/phone 图标。

已知限制(标注):图片仅前端收集(后端 chatRequest 无 image 字段未接真推理);流式工具卡无真实
工具名(StreamEvent 只带 ID,回退 tool_<id前缀>);mode 由 agent 驱动但后端未逐消息下发 mode
(mode_switch 回显+默认当前 mode 兜底);voice 为 UI+voice_input 上行(真实通话后置)。
go build/vet/test + 前端 tsc 绿。
2026-09-06 17:43:03 +08:00

121 lines
4.3 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Button } from "./primitives";
import { Icon } from "./Icon";
export interface ChatInputProps {
onSend: (text: string, image?: string) => void;
sending: boolean;
onVoice: () => void;
placeholder?: string;
}
export function ChatInput({
onSend,
sending,
onVoice,
placeholder = "给昔涟发消息…",
}: ChatInputProps): React.ReactNode {
const [value, setValue] = useState("");
const [pendingImage, setPendingImage] = useState<string | undefined>(undefined);
const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
// auto-grow the textarea up to a max height
useEffect(() => {
const el = taRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 140) + "px";
}, [value]);
const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") setPendingImage(reader.result);
};
reader.readAsDataURL(f);
e.target.value = "";
};
const canSend = value.trim().length > 0 || Boolean(pendingImage);
const submit = () => {
if (sending || !canSend) return;
onSend(value.trim(), pendingImage);
setValue("");
setPendingImage(undefined);
};
return (
<div className="glass sticky bottom-0 border-t border-[var(--mu-glass-border)] p-3 md:p-4">
<div className="mx-auto flex max-w-2xl flex-col gap-2">
{pendingImage ? (
<div className="relative inline-flex w-fit overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)]">
<img
src={pendingImage}
alt="待发送图片"
className="h-20 w-20 object-cover"
/>
<button
onClick={() => setPendingImage(undefined)}
aria-label="移除图片"
className="absolute right-1 top-1 inline-flex h-5 w-5 items-center justify-center rounded-full bg-[var(--mu-surface-4)]/90 text-[var(--mu-text-hi)] transition-transform duration-200 hover:scale-110"
>
<Icon name="close" size={12} />
</button>
</div>
) : null}
<div className="flex items-end gap-2 rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-2)] p-2 shadow-[var(--mu-shadow-1)]">
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={onPickFile}
/>
<button
onClick={() => fileRef.current?.click()}
aria-label="发送图片"
title="发送图片"
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-base transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
>
<Icon name="image" size={18} />
</button>
<textarea
ref={taRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder={placeholder}
className="max-h-[140px] flex-1 resize-none bg-transparent px-2 py-2 text-sm text-[var(--mu-text-hi)] outline-none placeholder:text-[var(--mu-text-lo)]"
/>
<button
onClick={onVoice}
aria-label="语音"
title="发起语音通话"
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-base transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
>
<Icon name="mic" size={18} />
</button>
<Button
variant="primary"
onClick={submit}
disabled={sending || !canSend}
className="shrink-0"
>
{sending ? "发送中…" : "发送"}
</Button>
</div>
</div>
</div>
);
}