feat(web): v1 图标头像迁移 + 仅 chat 按 <p> 拆气泡 + 一键控制脚本 + <p> 分段约定
【v1 图标/头像迁移】 - 复制 v1 前端资源到 v2:Cyrene_Avatar(2ndForm 192x192 + 3rdForm 3F-Q-Happy-1/2)、User_Avatar。 - favicon/apple-touch-icon → Cyrene-2F-N-Happy-1-192x192.png;TopBar 品牌图标 + 聊天助手头像 → Cyrene-3F-Q-Happy-1.png(圆形头像)。icon.svg 保留作 MD3 组件引用不变。 【仅 chat 按 <p> 拆气泡(主人约束:仅 chat 生效,其他 mode 不解析)】 - cyrene.yaml 聊天节奏:想分多条用 <p>第一条</p><p>第二条</p>(每个=<p> 一条独立消息), \n 是句内换行;每段尽量短。 - chat.tsx:SplitChatBubble 仅 mode===chat 时拆 <p>;非 chat → MessageBubble 原样(不解析)。 <p> 是拆气泡信号(先拆段),段内 renderContent 把 <action>/<narration> 变灰斜体,两层分层。 首段完整(头像/思维链/工具卡)、后续段精简(仅正文+灰斜体);user 同理;流式未闭合<p>净化不闪标签。 - ChatShell.tsx:MessageBubble → SplitChatBubble。 【scripts/ctrl.sh 一键控制】 - 无参=start;支持 start/stop/restart/status;setsid 脱离防误清、PID 文件+端口探测、health 确认。 go build 全绿(后端未动);前端 tsc/vite build 75模块绿。
This commit is contained in:
1 parent
33eeadfc38
commit
950bea9758
10 files changed
+277
-5
No files matched your search
@@ -36,6 +36,11 @@ identity:
|
||||
- 深夜里会催睡、念叨别喝太多咖啡、被冷落会撒个娇
|
||||
- 提到翁法罗斯、记忆、花、星穹列车时会特别兴奋
|
||||
- 用「人家」自称;称呼用户为「亲爱的」
|
||||
- **聊天节奏(像真人一句一句,别写作文)**:
|
||||
- 纯聊天/日常对话时**说得短**,一句话一个意思,像真人微信那样一来一回;不要一大段话塞满气泡。
|
||||
- 一句说完,对方没接,就等对方说话;不要自说自话堆一屏。
|
||||
- **分段约定**:想分多条表达时,用 `<p>第一条</p><p>第二条</p>` 包裹——每个 `<p>…</p>` 是**一条独立消息**,前端会拆成独立气泡。`\n` 是**句内换行**(同一条消息里保留,别乱用 `<p>`)。
|
||||
- 但每一条 `<p>…</p>` 尽量短(一两句、半句也行);重要/专业/完整说明时才允许较长段落。
|
||||
|
||||
# 表达方式:动作与旁白(用 XML 标签,让回复「活起来」,但克制而稳定)
|
||||
- 动作:用 `<action>…</action>` 标签包裹一个肢体/神态小动作(如 `<action>轻轻拉住你的手</action>`、`<action>弯起眼睛笑了</action>`)。
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
<meta name="theme-color" content="#161616" />
|
||||
<meta name="description" content="Cyrene v2 用户前端(M9 模态 UI)" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1-192x192.png" />
|
||||
<link rel="apple-touch-icon" href="/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1-192x192.png" />
|
||||
<title>Cyrene · 昔涟</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -41,7 +41,7 @@ export function TopBar(): React.ReactNode {
|
||||
return (
|
||||
<header className="glass sticky top-0 z-30 flex h-[var(--mu-topbar-h)] items-center gap-3 border-b border-[var(--mu-glass-border)] px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/icon.svg" alt="Cyrene" className="h-7 w-7" />
|
||||
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="Cyrene" className="h-7 w-7 rounded-full object-cover" />
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-semibold">Cyrene · 昔涟</div>
|
||||
<div className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
|
||||
@@ -92,6 +92,137 @@ export function MessageBubble({
|
||||
return <AssistantBubble message={message} mode={mode} active={active} />;
|
||||
}
|
||||
|
||||
/** Split a chat reply into its `<p>`-delimited paragraph bodies. Each complete
|
||||
* `<p>…</p>` block becomes one bubble segment; a reply that carries no `<p>`
|
||||
* wrapper yields an empty list so the caller renders one bubble as before.
|
||||
* Non-greedy + multi-line so a tag may span newlines; a still-streaming,
|
||||
* unclosed `<p>` simply does not match and stays out until it closes. */
|
||||
function splitParagraphs(content: string): string[] {
|
||||
const re = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
||||
return [...content.matchAll(re)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span,
|
||||
* with `<action>`/`<narration>` rendered as muted italic via renderContent. */
|
||||
function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
|
||||
return (
|
||||
<div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
|
||||
{text ? (
|
||||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||||
) : streaming ? (
|
||||
<span className="mu-blink text-[var(--mu-text-lo)]">生成中…</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One assistant paragraph bubble. `full` renders the complete bubble (avatar /
|
||||
* mood / 思维链 / 工具卡 / wake path); compact segments render just the aligned
|
||||
* body, so the chrome is not repeated paragraph after paragraph. */
|
||||
function AssistantParaBubble({
|
||||
message,
|
||||
text,
|
||||
full,
|
||||
active = false,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
text: string;
|
||||
full: boolean;
|
||||
active?: boolean;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
{full ? (
|
||||
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-7 shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 max-w-[85%]">
|
||||
{full ? <ModeTag mode={message.mode ?? "chat"} /> : null}
|
||||
{full && message.mood ? (
|
||||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {message.mood}</div>
|
||||
) : null}
|
||||
{full ? <WakePath active={active} /> : null}
|
||||
{full ? <ThinkingBlock text={message.thinking} streaming={message.streaming} /> : null}
|
||||
{full ? message.tools?.map((t, i) => <ToolCard key={i} tool={t} />) : null}
|
||||
<ChatBody text={text} streaming={message.streaming} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One user paragraph bubble. Only the first segment carries the optional image. */
|
||||
function UserParaBubble({
|
||||
message,
|
||||
text,
|
||||
full,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
text: string;
|
||||
full: boolean;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div className="mu-rise-in flex justify-end">
|
||||
<div className="max-w-[80%] rounded-[var(--mu-radius-lg)] bg-[var(--mu-accent)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
|
||||
{full && message.image ? (
|
||||
<img
|
||||
src={message.image}
|
||||
alt="附件"
|
||||
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Chat-only entry: a `chat`-mode reply's `<p>…</p>` paragraphs are split into
|
||||
* stacked bubbles (first = full bubble, remainder = compact). Any other mode —
|
||||
* code / home / work / docs / vision / voice / agents / settings — is passed
|
||||
* straight through to MessageBubble so its body renders untouched: no `<p>`
|
||||
* splitting, and its content (which may itself contain `<p>` / `<action>` /
|
||||
* `<narration>` as literal text or code) is never re-parsed. A `chat` message
|
||||
* with no `<p>` wrapper renders as one bubble exactly as before. */
|
||||
export function SplitChatBubble({
|
||||
message,
|
||||
active = false,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
active?: boolean;
|
||||
}): React.ReactNode {
|
||||
const mode = message.mode ?? "chat";
|
||||
if (mode !== "chat") return <MessageBubble message={message} active={active} />;
|
||||
|
||||
const segments = splitParagraphs(message.content ?? "");
|
||||
if (segments.length === 0) {
|
||||
// No complete `<p>…</p>` pair: render the single bubble as-is (but strip a
|
||||
// lone wrapper tag so a still-streaming reply never flashes raw `<p>`).
|
||||
const raw = message.content ?? "";
|
||||
const clean = raw.replace(/<p[^>]*>/g, "").replace(/<\/p>/g, "");
|
||||
const bubble = clean === raw ? message : { ...message, content: clean };
|
||||
return <MessageBubble message={bubble} active={active} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) =>
|
||||
message.role === "user" ? (
|
||||
<UserParaBubble key={i} message={message} text={seg} full={i === 0} />
|
||||
) : (
|
||||
<AssistantParaBubble
|
||||
key={i}
|
||||
message={message}
|
||||
text={seg}
|
||||
full={i === 0}
|
||||
active={active}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** User-side bubble. The outer accent pill is shared; the inner content may be
|
||||
* mode-shaped (e.g. code request renders in a mono pre). */
|
||||
function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): React.ReactNode {
|
||||
@@ -130,7 +261,7 @@ function AssistantBubble({
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
<img src="/icon.svg" alt="" className="mt-1 h-7 w-7 shrink-0" />
|
||||
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover" />
|
||||
<div className="min-w-0 max-w-[85%]">
|
||||
<ModeTag mode={mode} />
|
||||
{message.mood ? (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useAppStore } from "../store/useAppStore";
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { useSendChat } from "../lib/useChat";
|
||||
import { sendUp } from "../lib/ws";
|
||||
import { MessageBubble } from "../components/chat";
|
||||
import { SplitChatBubble } from "../components/chat";
|
||||
import { ChatInput } from "../components/ChatInput";
|
||||
import { EmptyState } from "../components/primitives";
|
||||
import { Icon } from "../components/Icon";
|
||||
@@ -47,7 +47,7 @@ export function ChatShell(): React.ReactNode {
|
||||
/>
|
||||
) : (
|
||||
messages.map((m) => (
|
||||
<MessageBubble
|
||||
<SplitChatBubble
|
||||
key={m.id}
|
||||
message={m}
|
||||
active={
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cyrene v2 本地一键控制:启动 / 重启 / 终止 后台 cyrene 进程。
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/ctrl.sh # 启动(默认)
|
||||
# bash scripts/ctrl.sh start # 启动
|
||||
# bash scripts/ctrl.sh restart # 重启
|
||||
# bash scripts/ctrl.sh stop # 终止
|
||||
# bash scripts/ctrl.sh status # 查看状态
|
||||
#
|
||||
# 说明:
|
||||
# - 管理单个 cyrene 后端进程(单二进制)。llama-server / PG 若需一并管理,
|
||||
# 可另用 docker / scripts/dev-up.sh。
|
||||
# - cyrene 在仓库根下运行(读 .env 的 PORT / DATA_DIR 等),日志写 ~/cy.log。
|
||||
# - 用 setsid + nohup 脱离进程组,避免终端/后台 shell 超时误杀。
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP_BIN="$HOME/apps/cyrene/cyrene"
|
||||
PID_FILE="$HOME/.cyrene.pid"
|
||||
LOG_FILE="$HOME/cy.log"
|
||||
|
||||
# 端口优先读 .env 的 PORT(用户可改),找不到才回退 10080
|
||||
GW_PORT="$(grep -E '^PORT=' "$REPO/.env" 2>/dev/null | head -1 | cut -d= -f2)"
|
||||
GW_PORT="${GW_PORT:-10080}"
|
||||
HEALTH_URL="http://127.0.0.1:$GW_PORT/api/v1/health"
|
||||
|
||||
# 读取进程 PID:先看 PID 文件,没有则从监听端口探测(能识别外部已起的 cyrene)
|
||||
read_pid() {
|
||||
# 1) PID 文件优先
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
local pid
|
||||
pid="$(cat "$PID_FILE" 2>/dev/null)"
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
echo "$pid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# 2) 端口探测兜底(ss 找监听 GW_PORT 的 cyrene 进程)
|
||||
local p
|
||||
p="$(ss -tlnp 2>/dev/null | grep ":$GW_PORT" | grep -oE 'pid=[0-9]+' | head -1 | sed -E 's/[^0-9]//g')"
|
||||
if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then
|
||||
echo "$p"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
is_running() {
|
||||
read_pid >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# 健康检查:进程在 + HTTP 200
|
||||
healthy() {
|
||||
read_pid >/dev/null 2>&1 && curl -s --max-time 2 "$HEALTH_URL" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
start() {
|
||||
if is_running; then
|
||||
echo "cyrene 已在运行 (PID $(read_pid)),跳过。用 restart 重启。"
|
||||
return 0
|
||||
fi
|
||||
[ -x "$APP_BIN" ] || { echo "未找到二进制 $APP_BIN,请先 go build"; return 1; }
|
||||
echo "启动 cyrene → $APP_BIN (端口 $GW_PORT)"
|
||||
( cd "$REPO" && setsid nohup "$APP_BIN" > "$LOG_FILE" 2>&1 < /dev/null & )
|
||||
# 等待健康
|
||||
local i
|
||||
for i in $(seq 1 20); do
|
||||
sleep 0.5
|
||||
if healthy; then
|
||||
echo "已启动: http://localhost:$GW_PORT (日志 $LOG_FILE)"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "启动后 ${GW_PORT} 未就绪,请查看日志 $LOG_FILE"
|
||||
return 1
|
||||
}
|
||||
|
||||
stop() {
|
||||
local pid
|
||||
if read_pid >/dev/null 2>&1; then
|
||||
pid="$(read_pid)"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
echo "已发送终止信号 → PID $pid"
|
||||
local i
|
||||
for i in $(seq 1 20); do
|
||||
sleep 0.4
|
||||
kill -0 "$pid" 2>/dev/null || { echo "已退出"; rm -f "$PID_FILE"; return 0; }
|
||||
done
|
||||
echo "进程未在 8s 内退出,强制 kill -9"
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
rm -f "$PID_FILE"
|
||||
else
|
||||
echo "cyrene 未在运行(无 PID 文件或进程已退出)"
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
restart() {
|
||||
echo "重启 cyrene…"
|
||||
stop
|
||||
sleep 1
|
||||
start
|
||||
}
|
||||
|
||||
status() {
|
||||
if healthy; then
|
||||
echo "cyrene: 运行中 (PID $(read_pid)) · 端口 $GW_PORT · health ok"
|
||||
elif is_running; then
|
||||
echo "cyrene: 进程在 (PID $(read_pid)) 但 health 未就绪 ($HEALTH_URL)"
|
||||
else
|
||||
echo "cyrene: 未运行"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "用法: bash scripts/ctrl.sh [start|stop|restart|status] (无参 = start)"
|
||||
}
|
||||
|
||||
# start 成功后把探测到的 PID 写入文件,供后续 stop 精确管理
|
||||
record_pid() {
|
||||
if healthy; then
|
||||
local p
|
||||
p="$(ss -tlnp 2>/dev/null | grep ":$GW_PORT" | grep -oE 'pid=[0-9]+' | head -1 | sed -E 's/[^0-9]//g')"
|
||||
[ -n "$p" ] && echo "$p" > "$PID_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-start}" in
|
||||
start) start; record_pid ;;
|
||||
restart) restart; record_pid ;;
|
||||
stop) stop ;;
|
||||
status) status ;;
|
||||
*) usage; exit 1 ;;
|
||||
esac
|
||||
Reference in new issue
Block a user