mirror of
https://github.com/hanzoai/build.git
synced 2026-10-23 02:18:08 +00:00
composer: what the gear says is read when it changes; the mic and voice are plain marks on a laptop; a phone gives where its own line
This commit is contained in:
+44
-32
@@ -1,21 +1,21 @@
|
||||
/**
|
||||
* What a message carries beside its words, assembled once for Chat and Dev:
|
||||
* the conversation's gear (gear.ts) drawn as the `+` menu's shelves and the
|
||||
* chips after `+`, and `brief` — the words the gear adds to the message, read
|
||||
* just before it goes.
|
||||
* chips after `+`, and `text` — the words the gear adds to every message, read
|
||||
* again whenever the gear changes.
|
||||
*
|
||||
* skills each picked skill's SKILL.md (the org's own, or the brand's
|
||||
* catalogue document): the platform's completion takes no skills
|
||||
* field, so the text rides the message
|
||||
* memory what Hanzo remembers about the person that bears on the ask
|
||||
* (GET /v1/ai/memory/recall): the platform injects none itself
|
||||
* memory what Hanzo remembers about the person (GET /v1/ai/memory/list):
|
||||
* the platform injects none itself
|
||||
* mode Plan: lay the plan out and wait; Auto says nothing
|
||||
*
|
||||
* A shelf is offered only where its service answers; a surface adds its own
|
||||
* (Chat its agents, Dev its devices) beside these.
|
||||
*/
|
||||
import { BookOpen, ListChecks, ScrollText } from '@hanzogui/lucide-icons-2'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { call, query, unwrap, type Target } from './api/call.ts'
|
||||
import { active, authored, document, nameOf as skillOf } from './api/skills.ts'
|
||||
@@ -27,26 +27,44 @@ import type { Chip } from './prompt.tsx'
|
||||
/** The most of each skill a message carries. */
|
||||
const SKILL_MAX = 16_000
|
||||
|
||||
/** The memories one message carries at most. */
|
||||
const RECALL = 8
|
||||
/** The memories every message carries at most, newest first. */
|
||||
const RECALL = 20
|
||||
|
||||
export interface Carry {
|
||||
gear: GearState
|
||||
skills: Shelf
|
||||
/** The chips after `+`: the skills on, Research, and Plan. */
|
||||
on: Chip[]
|
||||
/** The words the gear adds to a message about `text`, '' when none. */
|
||||
brief: (text: string) => Promise<string>
|
||||
/** The words the gear adds to every message, '' when none. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** `<owner>/<name>` memories that bear on `q`, as lines. */
|
||||
async function recall(t: Target, q: string): Promise<string[]> {
|
||||
const rows = unwrap(await call<unknown>(t, 'GET', `/v1/ai/memory/recall${query({ q: q.slice(0, 500), limit: RECALL })}`))
|
||||
/** What Hanzo remembers about the person, newest first, as lines. */
|
||||
async function remembered(t: Target): Promise<string[]> {
|
||||
const rows = unwrap(await call<unknown>(t, 'GET', `/v1/ai/memory/list${query({ limit: RECALL })}`))
|
||||
return (Array.isArray(rows) ? rows : [])
|
||||
.map((r) => (r && typeof r === 'object' ? String((r as { content?: unknown }).content ?? '') : ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** The words `skills`, `memory` and `mode` add to a message. */
|
||||
async function said(t: Target, skills: readonly string[], memory: boolean, mode: string): Promise<string> {
|
||||
const parts: string[] = []
|
||||
if (skills.length) {
|
||||
const own = await authored(t).catch(() => [])
|
||||
for (const n of skills) {
|
||||
const body = own.find((s) => s.name === n)?.content || (await document(t, n).catch(() => ''))
|
||||
if (body) parts.push(`Use the skill "${n}" where it applies:\n\n${body.slice(0, SKILL_MAX)}`)
|
||||
}
|
||||
}
|
||||
if (memory) {
|
||||
const lines = await remembered(t).catch(() => [])
|
||||
if (lines.length) parts.push(`What you remember about the person:\n${lines.map((l) => `- ${l}`).join('\n')}`)
|
||||
}
|
||||
if (mode === 'plan') parts.push('Plan mode: lay out the plan — the steps, what each changes and why — and stop. Change nothing and produce no final work until the person says go.')
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation `key` names (`''` for one not begun), for target `t`.
|
||||
* `customize(tab)` opens Dev's Customize on that tab.
|
||||
@@ -75,25 +93,19 @@ export function useCarry(t: Target, key: string, customize: (tab: 'skills') => v
|
||||
...(mode === 'plan' ? [{ id: 'plan', label: 'Plan', icon: <ListChecks size={14} />, onRemove: () => gear.set({ mode: 'auto' }) }] : []),
|
||||
]
|
||||
|
||||
const brief = useCallback(
|
||||
async (text: string): Promise<string> => {
|
||||
const parts: string[] = []
|
||||
if (skills.length) {
|
||||
const own = await authored(t).catch(() => [])
|
||||
for (const n of skills) {
|
||||
const body = own.find((s) => s.name === n)?.content || (await document(t, n).catch(() => ''))
|
||||
if (body) parts.push(`Use the skill "${n}" where it applies:\n\n${body.slice(0, SKILL_MAX)}`)
|
||||
}
|
||||
}
|
||||
if (memory && text.trim()) {
|
||||
const lines = await recall(t, text).catch(() => [])
|
||||
if (lines.length) parts.push(`What you remember about the person, where it bears on this:\n${lines.map((l) => `- ${l}`).join('\n')}`)
|
||||
}
|
||||
if (mode === 'plan') parts.push('Plan mode: lay out the plan — the steps, what each changes and why — and stop. Change nothing and produce no final work until the person says go.')
|
||||
return parts.join('\n\n')
|
||||
},
|
||||
[t, skills, memory, mode],
|
||||
)
|
||||
const signedIn = Boolean(t.token())
|
||||
const [text, setText] = useState('')
|
||||
const asked = skills.join('\n')
|
||||
useEffect(() => {
|
||||
if (!signedIn) return setText(mode === 'plan' ? 'Plan mode: lay out the plan and stop until the person says go.' : '')
|
||||
let live = true
|
||||
void said(t, skills, memory, mode).then((x) => live && setText(x))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
// `skills` is read through `asked`, its contents.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [t, signedIn, asked, memory, mode])
|
||||
|
||||
return { gear, skills: shelf, on, brief }
|
||||
return { gear, skills: shelf, on, text }
|
||||
}
|
||||
|
||||
+1
-1
@@ -224,7 +224,7 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
|
||||
setNote('')
|
||||
try {
|
||||
const run = await start(t, {
|
||||
prompt: compose([draft.trim(), await carry.brief(draft)].filter(Boolean).join('\n\n---\n'), files),
|
||||
prompt: compose([draft.trim(), carry.text].filter(Boolean).join('\n\n---\n'), files),
|
||||
// The name alone. The org rides the request, and a slash is not a repo name.
|
||||
// An empty base or place is not sent: the default branch, and the sandbox.
|
||||
repo: repo?.name ?? hub?.full_name,
|
||||
|
||||
+4
-1
@@ -30,6 +30,9 @@ import { LANGUAGES } from './voice.ts'
|
||||
|
||||
const KEY = 'hanzo.mic'
|
||||
|
||||
/** The mic is a plain mark until it is pointed at. */
|
||||
const PLAIN = { bg: 'transparent', color: 'var(--foreground)', hoverStyle: { bg: '$hover' } } as const
|
||||
|
||||
function kept(): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(KEY)
|
||||
@@ -148,7 +151,7 @@ export function Dictation({ voice, disabled = false, language = null, onLanguage
|
||||
hoverStyle={{ borderColor: '$borderColor' }}
|
||||
shrink={0}
|
||||
>
|
||||
<Control asChild size={30}>
|
||||
<Control asChild size={30} {...PLAIN}>
|
||||
<Voice voice={voice} disabled={disabled} says={{ idle: 'Dictate', listening: 'Dictating — click to stop' }}>
|
||||
{(state) => <MicIcon size={16} fill={state === 'idle' ? 'none' : 'currentColor'} />}
|
||||
</Voice>
|
||||
|
||||
+11
-4
@@ -49,6 +49,9 @@ export const ROUND = 32
|
||||
const CORNER = 12
|
||||
const CORNER_PHONE = 22
|
||||
|
||||
/** A round control with no ground of its own until it is pointed at. */
|
||||
const QUIET = { bg: 'transparent', color: 'var(--foreground)', hoverStyle: { bg: '$hover' } } as const
|
||||
|
||||
/** What every Composer placeholder says, unless a surface names who is listening. */
|
||||
export const ASK = 'Type / for skills'
|
||||
|
||||
@@ -294,13 +297,15 @@ export function Tune({ mind, mode, where, onUpgrade }: { mind?: Mind | null; mod
|
||||
const thinks = mind ? reasons(mind.model, mind.models) : false
|
||||
const pace = mind ? EFFORTS.find((e) => e.id === mind.effort)! : null
|
||||
return (
|
||||
<XStack data-slot="tune" width="100%" items="center" gap={2} flexWrap="wrap">
|
||||
<XStack data-slot="tune" width="100%" items="center" gap={2} flexWrap="wrap" rowGap={4}>
|
||||
{where ? (
|
||||
<XStack items="center" gap={2} flexWrap="wrap" minW={0}>
|
||||
// On a phone where the run goes takes its own line, and the model, effort and mode the next.
|
||||
<XStack items="center" gap={2} flexWrap="wrap" minW={0} $max-sm={{ width: '100%' }}>
|
||||
{where}
|
||||
</XStack>
|
||||
) : null}
|
||||
<XStack flex={1} />
|
||||
<XStack flex={1} $max-sm={{ display: 'none' }} />
|
||||
<XStack items="center" gap={2} ml="auto" shrink={0}>
|
||||
{mind ? <Model mind={mind} onUpgrade={onUpgrade} /> : null}
|
||||
{mind && thinks && pace ? (
|
||||
<Opens label={`Effort: ${pace.label}`} word={pace.label} dim>
|
||||
@@ -345,6 +350,7 @@ export function Tune({ mind, mode, where, onUpgrade }: { mind?: Mind | null; mod
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</XStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -535,7 +541,8 @@ export function prompt(o: PromptOptions, state?: { index: number; closed: string
|
||||
<XStack items="center" gap={6} data-slot="prompt-end" shrink={0}>
|
||||
{o.voice ? <Dictation voice={o.voice} disabled={disabled} language={o.language ?? null} {...(o.onLanguage ? { onLanguage: o.onLanguage } : {})} /> : null}
|
||||
{!busy && !lit && o.talk ? (
|
||||
<Control asChild size={ROUND} fill rounded={999}>
|
||||
// Voice mode is a plain mark on a laptop, as the mic is, and the filled round on a phone.
|
||||
<Control asChild size={ROUND} fill={narrow} rounded={999} {...(narrow ? null : QUIET)}>
|
||||
<Voice
|
||||
voice={o.talk}
|
||||
disabled={disabled}
|
||||
|
||||
Reference in New Issue
Block a user