Add secret, injection, and message controls to the workbench
Why: Keys must be added without writing masks back, and a turn needs copy, exclude, and regenerate without deleting history. Evidence: npx tsc -b. Session notes and candidate switches call the new conversation and message routes. Impact: Settings gains key, lore, and slash-template panels. Excluded messages stay visible and are dimmed. Refs: docs/records/2026-10-04-sillytavern-review.md
This commit is contained in:
+14
@@ -110,6 +110,20 @@ export const api = {
|
||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
|
||||
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
regenerate: (convId: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/regenerate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||
listSecrets: () => auth.fetch(`${BASE}/api/secrets`).then(r => json<{ id: string; service: string; label: string; active: boolean; mask: string }[]>(r)),
|
||||
createSecret: (service: string, label: string, value: string) => auth.fetch(`${BASE}/api/secrets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service, label, value }) }).then(r => json(r)),
|
||||
activateSecret: (id: string) => auth.fetch(`${BASE}/api/secrets/${id}/activate`, { method: 'POST' }),
|
||||
deleteSecret: (id: string) => auth.fetch(`${BASE}/api/secrets/${id}`, { method: 'DELETE' }),
|
||||
listContextEntries: () => auth.fetch(`${BASE}/api/context-entries`).then(r => json<{ id: string; keys: string[]; secondary?: string; logic: string; content: string; constant: boolean; scanDepth: number; budgetChars: number; enabled: boolean }[]>(r)),
|
||||
saveContextEntry: (body: Record<string, unknown>) => auth.fetch(`${BASE}/api/context-entries`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
|
||||
deleteContextEntry: (id: string) => auth.fetch(`${BASE}/api/context-entries/${id}`, { method: 'DELETE' }),
|
||||
listPromptCommands: () => auth.fetch(`${BASE}/api/prompt-commands`).then(r => json<{ id: string; name: string; title: string; template: string }[]>(r)),
|
||||
savePromptCommand: (body: { name: string; title: string; template: string }) => auth.fetch(`${BASE}/api/prompt-commands`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
|
||||
deletePromptCommand: (id: string) => auth.fetch(`${BASE}/api/prompt-commands/${id}`, { method: 'DELETE' }),
|
||||
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Btn, SettingRow, SettingsGroup } from '@ui'
|
||||
import { api } from '../api'
|
||||
|
||||
const services = [
|
||||
['models.chat', '主模型'],
|
||||
['models.lightweight', '轻量模型'],
|
||||
['models.embedding', 'Embedding'],
|
||||
['models.rerank', 'Reranker'],
|
||||
['tools.tavily', 'Tavily'],
|
||||
] as const
|
||||
|
||||
type Secret = { id: string; service: string; label: string; active: boolean; mask: string }
|
||||
type Entry = { id: string; keys: string[]; content: string; constant: boolean; logic: string }
|
||||
type Cmd = { id: string; name: string; title: string; template: string }
|
||||
|
||||
export function BorrowPanels() {
|
||||
const [secrets, setSecrets] = useState<Secret[]>([])
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [cmds, setCmds] = useState<Cmd[]>([])
|
||||
const [service, setService] = useState<string>(services[0][0])
|
||||
const [label, setLabel] = useState('')
|
||||
const [value, setValue] = useState('')
|
||||
const [keys, setKeys] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [constant, setConstant] = useState(false)
|
||||
const [cmdName, setCmdName] = useState('/summary')
|
||||
const [cmdTitle, setCmdTitle] = useState('总结')
|
||||
const [cmdBody, setCmdBody] = useState('请总结以上内容。\n{{input}}')
|
||||
|
||||
const reload = () => {
|
||||
api.listSecrets().then(setSecrets).catch(() => undefined)
|
||||
api.listContextEntries().then(setEntries).catch(() => undefined)
|
||||
api.listPromptCommands().then(setCmds).catch(() => undefined)
|
||||
}
|
||||
useEffect(() => { reload() }, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsGroup title="密钥">
|
||||
<SettingRow label="新增密钥" desc="每个服务同时只有一把生效。列表只显示掩码。">
|
||||
<select value={service} onChange={e => setService(e.target.value)}>
|
||||
{services.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="标签与密钥" desc="保存后立即生效,并停用同服务的其他密钥。">
|
||||
<input className="search-input" value={label} placeholder="标签" onChange={e => setLabel(e.target.value)} />
|
||||
<input className="search-input" type="password" autoComplete="new-password" value={value} placeholder="密钥" onChange={e => setValue(e.target.value)} />
|
||||
<Btn variant="primary" onClick={async () => {
|
||||
if (!value.trim()) return
|
||||
await api.createSecret(service, label || '未命名', value.trim())
|
||||
setValue(''); setLabel(''); reload()
|
||||
}}>添加</Btn>
|
||||
</SettingRow>
|
||||
{secrets.map(s => (
|
||||
<SettingRow key={s.id} label={s.label} desc={`${s.service} · ${s.mask}${s.active ? ' · 生效' : ''}`}>
|
||||
{!s.active && <Btn onClick={async () => { await api.activateSecret(s.id); reload() }}>启用</Btn>}
|
||||
<Btn onClick={async () => { await api.deleteSecret(s.id); reload() }}>删除</Btn>
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
<SettingsGroup title="条件注入">
|
||||
<SettingRow label="关键词" desc="逗号分隔。用 /正则/ 表示正则。勾选后变为常驻。">
|
||||
<input className="search-input" value={keys} placeholder="关键词" onChange={e => setKeys(e.target.value)} />
|
||||
<label><input type="checkbox" checked={constant} onChange={e => setConstant(e.target.checked)} /> 常驻</label>
|
||||
</SettingRow>
|
||||
<SettingRow label="注入正文" desc="命中后附在上下文末尾,不改系统前缀。">
|
||||
<textarea rows={3} value={content} onChange={e => setContent(e.target.value)} />
|
||||
<Btn variant="primary" onClick={async () => {
|
||||
if (!content.trim()) return
|
||||
await api.saveContextEntry({
|
||||
keys: keys.split(/[,,]/).map(s => s.trim()).filter(Boolean),
|
||||
content: content.trim(), constant, logic: 'and', enabled: true, scanDepth: 4, budgetChars: 2000,
|
||||
})
|
||||
setContent(''); setKeys(''); reload()
|
||||
}}>添加</Btn>
|
||||
</SettingRow>
|
||||
{entries.map(e => (
|
||||
<SettingRow key={e.id} label={e.constant ? '常驻' : (e.keys.join(' / ') || '未命名')} desc={e.content}>
|
||||
<Btn onClick={async () => { await api.deleteContextEntry(e.id); reload() }}>删除</Btn>
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
<SettingsGroup title="用户命令">
|
||||
<SettingRow label="命令" desc="发送 /名称 时展开成提示词。不会自动触发。{{input}} 换成命令后面的文字。">
|
||||
<input className="search-input" value={cmdName} onChange={e => setCmdName(e.target.value)} />
|
||||
<input className="search-input" value={cmdTitle} onChange={e => setCmdTitle(e.target.value)} />
|
||||
</SettingRow>
|
||||
<SettingRow label="模板" desc="名称需为小写字母、数字或连字符,例如 /summary。">
|
||||
<textarea rows={3} value={cmdBody} onChange={e => setCmdBody(e.target.value)} />
|
||||
<Btn variant="primary" onClick={async () => {
|
||||
await api.savePromptCommand({ name: cmdName, title: cmdTitle, template: cmdBody })
|
||||
reload()
|
||||
}}>添加</Btn>
|
||||
</SettingRow>
|
||||
{cmds.map(c => (
|
||||
<SettingRow key={c.id} label={c.name} desc={c.title}>
|
||||
<Btn onClick={async () => { await api.deletePromptCommand(c.id); reload() }}>删除</Btn>
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -197,7 +197,7 @@ function LiveBar({ live }: { live: LiveState }) {
|
||||
// 用户在运行中手动折过的 run:结束后沿用这次选择,不强制再收起。
|
||||
const touchedProcess = new Set<string>()
|
||||
|
||||
function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null }) {
|
||||
function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveState | null; convId?: string }) {
|
||||
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
|
||||
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
|
||||
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
|
||||
@@ -236,7 +236,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
setOpen(next)
|
||||
}
|
||||
return (
|
||||
<div className="msg assistant" data-msg-id={msg.id}>
|
||||
<div className={`msg assistant${msg.excluded ? ' is-excluded' : ''}`} data-msg-id={msg.id}>
|
||||
<div className="msg-role">
|
||||
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
|
||||
<span>助手</span>
|
||||
@@ -266,6 +266,38 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
))}
|
||||
{live && <LiveAgentNotes live={live} />}
|
||||
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
|
||||
{convId && <MessageTools msg={msg} convId={convId} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionNoteField({ convId, note }: { convId: string; note: string }) {
|
||||
const [value, setValue] = useState(note)
|
||||
useEffect(() => { setValue(note) }, [note, convId])
|
||||
return (
|
||||
<input className="search-input session-note" value={value} placeholder="本会话要求"
|
||||
title="附在上下文末尾,不改系统前缀"
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => store.openConversation(convId)) }} />
|
||||
)
|
||||
}
|
||||
|
||||
function MessageTools({ msg, convId }: { msg: Message; convId: string }) {
|
||||
const text = msg.parts.filter(p => p.type === 'text').map(p => p.text ?? '').join('\n')
|
||||
const refresh = () => { void store.openConversation(convId) }
|
||||
return (
|
||||
<div className="msg-tools">
|
||||
<button className="btn ghost" type="button" onClick={() => { void navigator.clipboard.writeText(text) }}>复制</button>
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.excludeMessage(msg.id, !msg.excluded).then(refresh) }}>
|
||||
{msg.excluded ? '纳入上下文' : '排除出上下文'}
|
||||
</button>
|
||||
{msg.role === 'assistant' && (
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.regenerate(convId, msg.id).then(refresh) }}>重新生成</button>
|
||||
)}
|
||||
{msg.variantGroup && msg.excluded && (
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.selectVariant(msg.id).then(refresh) }}>使用此候选</button>
|
||||
)}
|
||||
{msg.variantGroup && <span className="process-collapse-meta">候选 {(msg.variantIndex ?? 0) + 1}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -278,12 +310,18 @@ function ContextObsView({ obs }: { obs: ContextObs }) {
|
||||
trigger={<>
|
||||
<span className="process-collapse-chev">▸</span>
|
||||
<span>🧩 上下文构成</span>
|
||||
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
|
||||
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} token)</span>
|
||||
</>}>
|
||||
<div className="body">
|
||||
<ul className="ui-list">
|
||||
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
|
||||
</ul>
|
||||
{obs.sections && obs.sections.length > 0 && (
|
||||
<div className="process-collapse-meta">
|
||||
{obs.sections.map(s => `${s.name} ${s.estTokens}`).join(' · ')}
|
||||
{obs.prefixHash ? ` · 前缀 ${obs.prefixHash}` : ''}
|
||||
</div>
|
||||
)}
|
||||
{obs.truncations && obs.truncations.length > 0 && (
|
||||
<div style={{ color: 'var(--warn)', fontSize: 'var(--fs-ui-sm)', marginTop: 6 }}>
|
||||
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
|
||||
@@ -508,6 +546,7 @@ export function Conversation() {
|
||||
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
|
||||
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
|
||||
</button>
|
||||
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
|
||||
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||
{!connected && <span className="conn-label">重连中</span>}
|
||||
{showStop && <button className="btn ghost" onClick={() => store.cancelActive()}>停止</button>}
|
||||
@@ -534,11 +573,12 @@ export function Conversation() {
|
||||
// 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。
|
||||
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
|
||||
return (
|
||||
<div className="msg user" key={m.id} data-msg-id={m.id}>
|
||||
<div className={`msg user${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
|
||||
<div className="msg-role">
|
||||
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
|
||||
</div>
|
||||
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
|
||||
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
|
||||
{currentConvId && <RewindControl convId={currentConvId} messageId={m.id} />}
|
||||
{anchored.map(c => (
|
||||
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
||||
@@ -550,7 +590,7 @@ export function Conversation() {
|
||||
)
|
||||
}
|
||||
if (m.role === 'system') return <NoticeMessage key={m.id} msg={m} />
|
||||
return <AssistantMessage key={m.id} msg={m} live={live?.runId === m.runId ? live : null} />
|
||||
return <AssistantMessage key={m.id} msg={m} convId={currentConvId ?? undefined} live={live?.runId === m.runId ? live : null} />
|
||||
})}
|
||||
{showLiveBubble(live?.runId ?? null, messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string)) && live &&
|
||||
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { store, useStore } from '../store'
|
||||
import type { ThemeChoice } from '../theme'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
|
||||
import { BorrowPanels } from './BorrowPanels'
|
||||
|
||||
type Section = 'settings'
|
||||
|
||||
@@ -94,6 +95,7 @@ export function SettingsModal() {
|
||||
</SettingsGroup>
|
||||
)
|
||||
})}
|
||||
{section === 'settings' && <BorrowPanels />}
|
||||
</SettingsShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface Conversation {
|
||||
pinned?: boolean; archived?: boolean;
|
||||
allowCrossRead?: boolean; crossPrivate?: boolean;
|
||||
permissionMode?: 'default' | 'plan' | 'auto_edit' | '';
|
||||
sessionNote?: string;
|
||||
createdAt: string; updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -44,6 +45,9 @@ export interface Part {
|
||||
export interface Message {
|
||||
id: ID; conversationId: ID; runId?: ID; role: Role;
|
||||
parts: Part[]; createdAt: string;
|
||||
excluded?: boolean;
|
||||
variantGroup?: string;
|
||||
variantIndex?: number;
|
||||
}
|
||||
|
||||
export type RunKind = 'conversation' | 'subagent' | 'tool';
|
||||
@@ -110,10 +114,17 @@ export interface RunCompletedPayload {
|
||||
context?: ContextObservation
|
||||
}
|
||||
|
||||
export interface ContextSection {
|
||||
name: string
|
||||
estTokens: number
|
||||
}
|
||||
|
||||
export interface ContextObservation {
|
||||
items: string[]
|
||||
truncations?: string[]
|
||||
estChars: number
|
||||
sections?: ContextSection[]
|
||||
prefixHash?: string
|
||||
}
|
||||
export interface RunCheckPayload { passed: boolean; reason?: string }
|
||||
export interface RunFailedPayload { code: string; message: string }
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ContextObs {
|
||||
items: string[]
|
||||
truncations?: string[]
|
||||
estChars: number
|
||||
sections?: { name: string; estTokens: number }[]
|
||||
prefixHash?: string
|
||||
}
|
||||
|
||||
export interface LiveState {
|
||||
|
||||
@@ -560,5 +560,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
font-size: var(--fs-ui-sm);
|
||||
}
|
||||
.rewind-panel ul { margin: 6px 0; padding-left: 1.2em; }
|
||||
.msg.is-excluded { opacity: 0.55; }
|
||||
.msg-tools { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-top: 4px; }
|
||||
.session-note { width: 12rem; }
|
||||
.rewind-actions { display: flex; gap: 8px; }
|
||||
.rewind-error { color: var(--err); font-size: var(--fs-ui-sm); }
|
||||
|
||||
Reference in New Issue
Block a user